Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8193eebf13 |
@@ -41,7 +41,7 @@ pub fn playlists_page() -> Html {
|
|||||||
let seed_input = use_state(String::new);
|
let seed_input = use_state(String::new);
|
||||||
let seed_focused = use_state(|| false);
|
let seed_focused = use_state(|| false);
|
||||||
let seeds = use_state(Vec::<String>::new);
|
let seeds = use_state(Vec::<String>::new);
|
||||||
let count = use_state(|| 50usize);
|
let count = use_state(|| 30usize);
|
||||||
let popularity_bias = use_state(|| 5u8);
|
let popularity_bias = use_state(|| 5u8);
|
||||||
let ordering = use_state(|| "interleave".to_string());
|
let ordering = use_state(|| "interleave".to_string());
|
||||||
let discovery_range = use_state(|| 5u8);
|
let discovery_range = use_state(|| 5u8);
|
||||||
@@ -129,11 +129,23 @@ pub fn playlists_page() -> Html {
|
|||||||
ordering: ordering_val,
|
ordering: ordering_val,
|
||||||
rules: None,
|
rules: None,
|
||||||
discovery_range: Some(discovery_range_val),
|
discovery_range: Some(discovery_range_val),
|
||||||
global_popularity: if global_popularity_val > 0 { Some(global_popularity_val) } else { None },
|
global_popularity: if global_popularity_val > 0 {
|
||||||
|
Some(global_popularity_val)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
country_filter: if country_filter_val { Some(true) } else { None },
|
country_filter: if country_filter_val { Some(true) } else { None },
|
||||||
seed_weight: Some(seed_weight_val),
|
seed_weight: Some(seed_weight_val),
|
||||||
max_tracks_per_artist: if max_tpa_val > 0 { Some(max_tpa_val) } else { None },
|
max_tracks_per_artist: if max_tpa_val > 0 {
|
||||||
max_artists: if max_artists_val > 0 { Some(max_artists_val) } else { None },
|
Some(max_tpa_val)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
max_artists: if max_artists_val > 0 {
|
||||||
|
Some(max_artists_val)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
match api::generate_playlist(&req).await {
|
match api::generate_playlist(&req).await {
|
||||||
@@ -437,7 +449,11 @@ pub fn playlists_page() -> Html {
|
|||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<label>{ format!("Popularity Bias: {}", *popularity_bias) }</label>
|
<label class="tooltip-wrap">
|
||||||
|
{ format!("Popularity Bias: {}", *popularity_bias) }
|
||||||
|
<span class="tooltip-icon">{ "?" }</span>
|
||||||
|
<span class="tooltip-text">{ "Controls how much to prefer an artist's popular tracks over deep cuts. 0 = all tracks equally likely, 10 = heavily favor their most-played songs." }</span>
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="0" max="10"
|
min="0" max="10"
|
||||||
@@ -452,8 +468,11 @@ pub fn playlists_page() -> Html {
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<label>{ format!("Discovery Range: {}", *discovery_range) }
|
<label class="tooltip-wrap">
|
||||||
|
{ format!("Discovery Range: {}", *discovery_range) }
|
||||||
<span class="text-sm text-muted">{ if *discovery_range <= 3 { " (focused)" } else if *discovery_range >= 7 { " (wide)" } else { "" } }</span>
|
<span class="text-sm text-muted">{ if *discovery_range <= 3 { " (focused)" } else if *discovery_range >= 7 { " (wide)" } else { "" } }</span>
|
||||||
|
<span class="tooltip-icon">{ "?" }</span>
|
||||||
|
<span class="tooltip-text">{ "How far from your seeds to explore. Low = only the most closely related artists. High = cast a wide net into loosely connected artists." }</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -469,8 +488,11 @@ pub fn playlists_page() -> Html {
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<label>{ format!("Seed Weight: {}", *seed_weight) }
|
<label class="tooltip-wrap">
|
||||||
|
{ format!("Seed Weight: {}", *seed_weight) }
|
||||||
<span class="text-sm text-muted">{ if *seed_weight == 0 { " (exclude seeds)" } else if *seed_weight >= 8 { " (heavy)" } else { "" } }</span>
|
<span class="text-sm text-muted">{ if *seed_weight == 0 { " (exclude seeds)" } else if *seed_weight >= 8 { " (heavy)" } else { "" } }</span>
|
||||||
|
<span class="tooltip-icon">{ "?" }</span>
|
||||||
|
<span class="tooltip-text">{ "How much your seed artists themselves should appear in the playlist. 0 = only discoveries (seeds excluded), 5 = normal, 10 = seeds appear heavily." }</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -486,8 +508,11 @@ pub fn playlists_page() -> Html {
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<label>{ format!("Global Popularity: {}", *global_popularity) }
|
<label class="tooltip-wrap">
|
||||||
|
{ format!("Global Popularity: {}", *global_popularity) }
|
||||||
<span class="text-sm text-muted">{ if *global_popularity == 0 { " (off)" } else { "" } }</span>
|
<span class="text-sm text-muted">{ if *global_popularity == 0 { " (off)" } else { "" } }</span>
|
||||||
|
<span class="tooltip-icon">{ "?" }</span>
|
||||||
|
<span class="tooltip-text">{ "Boost tracks that are globally popular across all artists. Unlike Popularity Bias (which is per-artist), this favors well-known songs regardless of which artist they belong to." }</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -503,8 +528,9 @@ pub fn playlists_page() -> Html {
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<div class="playlist-controls-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>
|
<label class="tooltip-wrap" style="white-space: nowrap;">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
checked={*country_filter}
|
checked={*country_filter}
|
||||||
onchange={{
|
onchange={{
|
||||||
@@ -515,12 +541,17 @@ pub fn playlists_page() -> Html {
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{ " Same countries as seed artists" }
|
{ " Same countries" }
|
||||||
|
<span class="tooltip-icon">{ "?" }</span>
|
||||||
|
<span class="tooltip-text">{ "Only include artists from the same countries as your seed artists. Useful for getting music in a particular language. Requires a local MusicBrainz database for best results." }</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; gap: 1rem;">
|
<div class="form-group">
|
||||||
<div class="form-group" style="flex: 1;">
|
<label class="tooltip-wrap">
|
||||||
<label>{ "Max Per Artist" }</label>
|
{ "Max Per Artist" }
|
||||||
|
<span class="tooltip-icon">{ "?" }</span>
|
||||||
|
<span class="tooltip-text">{ "Maximum number of tracks from any single artist. 'Auto' ties this to the Popularity Bias setting." }</span>
|
||||||
|
</label>
|
||||||
<select onchange={{
|
<select onchange={{
|
||||||
let mtpa = max_tracks_per_artist.clone();
|
let mtpa = max_tracks_per_artist.clone();
|
||||||
Callback::from(move |e: Event| {
|
Callback::from(move |e: Event| {
|
||||||
@@ -538,8 +569,12 @@ pub fn playlists_page() -> Html {
|
|||||||
})}
|
})}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="flex: 1;">
|
<div class="form-group">
|
||||||
<label>{ "Max Artists" }</label>
|
<label class="tooltip-wrap">
|
||||||
|
{ "Max Artists" }
|
||||||
|
<span class="tooltip-icon">{ "?" }</span>
|
||||||
|
<span class="tooltip-text">{ "Limit how many different artists appear in the playlist. Lower values give a tighter, more focused mix." }</span>
|
||||||
|
</label>
|
||||||
<select onchange={{
|
<select onchange={{
|
||||||
let ma = max_artists.clone();
|
let ma = max_artists.clone();
|
||||||
Callback::from(move |e: Event| {
|
Callback::from(move |e: Event| {
|
||||||
@@ -557,9 +592,12 @@ pub fn playlists_page() -> Html {
|
|||||||
})}
|
})}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{ "Track Order" }</label>
|
<label class="tooltip-wrap">
|
||||||
|
{ "Track Order" }
|
||||||
|
<span class="tooltip-icon">{ "?" }</span>
|
||||||
|
<span class="tooltip-text">{ "How to arrange tracks in the final playlist. Interleave spreads artists evenly, By Score puts the best matches first, Random shuffles everything." }</span>
|
||||||
|
</label>
|
||||||
<select onchange={{
|
<select onchange={{
|
||||||
let ord = ordering.clone();
|
let ord = ordering.clone();
|
||||||
Callback::from(move |e: Event| {
|
Callback::from(move |e: Event| {
|
||||||
@@ -567,12 +605,13 @@ pub fn playlists_page() -> Html {
|
|||||||
ord.set(select.value());
|
ord.set(select.value());
|
||||||
})
|
})
|
||||||
}}>
|
}}>
|
||||||
<option value="interleave" selected={*ordering == "interleave"}>{ "Interleave (spread artists)" }</option>
|
<option value="interleave" selected={*ordering == "interleave"}>{ "Interleave" }</option>
|
||||||
<option value="score" selected={*ordering == "score"}>{ "By Score (best first)" }</option>
|
<option value="score" selected={*ordering == "score"}>{ "By Score" }</option>
|
||||||
<option value="random" selected={*ordering == "random"}>{ "Random" }</option>
|
<option value="random" selected={*ordering == "random"}>{ "Random" }</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -632,8 +671,12 @@ pub fn playlists_page() -> Html {
|
|||||||
{ strategy_inputs }
|
{ strategy_inputs }
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{ format!("Count: {}", *count) }</label>
|
<label class="tooltip-wrap">
|
||||||
<input type="range" min="10" max="200" step="10"
|
{ format!("Count: {}", *count) }
|
||||||
|
<span class="tooltip-icon">{ "?" }</span>
|
||||||
|
<span class="tooltip-text">{ "Total number of tracks to include in the generated playlist." }</span>
|
||||||
|
</label>
|
||||||
|
<input type="range" min="10" max="100" step="5"
|
||||||
value={count.to_string()}
|
value={count.to_string()}
|
||||||
oninput={{
|
oninput={{
|
||||||
let count = count.clone();
|
let count = count.clone();
|
||||||
|
|||||||
@@ -377,7 +377,7 @@ pub struct GenerateRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn default_playlist_count() -> usize {
|
fn default_playlist_count() -> usize {
|
||||||
50
|
30
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_popularity_bias() -> u8 {
|
fn default_popularity_bias() -> u8 {
|
||||||
|
|||||||
@@ -447,6 +447,59 @@ tr[draggable="true"]:active { cursor: grabbing; }
|
|||||||
.artist-photo { width: 80px; height: 80px; }
|
.artist-photo { width: 80px; height: 80px; }
|
||||||
.scroll-track { width: 18px; }
|
.scroll-track { width: 18px; }
|
||||||
.scroll-track-letter { font-size: 0.7rem; }
|
.scroll-track-letter { font-size: 0.7rem; }
|
||||||
|
.playlist-controls-row { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltips */
|
||||||
|
.tooltip-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.tooltip-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: help;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.tooltip-text {
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
width: 240px;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
z-index: 50;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.tooltip-wrap:hover .tooltip-text {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Playlist controls row */
|
||||||
|
.playlist-controls-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Alphabetical scroll track — fixed to right edge next to browser scrollbar */
|
/* Alphabetical scroll track — fixed to right edge next to browser scrollbar */
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use actix_web::{HttpRequest, HttpResponse, web};
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use shanty_db::queries;
|
use shanty_db::queries;
|
||||||
|
use shanty_playlist::SimilarConfig;
|
||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -995,3 +996,121 @@ pub async fn get_artist_info2(req: HttpRequest, state: web::Data<AppState>) -> H
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /rest/getSimilarSongs2[.view]
|
||||||
|
pub async fn get_similar_songs2(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
|
||||||
|
let (params, _user) = match authenticate(&req, &state).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(resp) => return resp,
|
||||||
|
};
|
||||||
|
|
||||||
|
let id_str = match get_query_param(&req, "id") {
|
||||||
|
Some(id) => id,
|
||||||
|
None => {
|
||||||
|
return response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_MISSING_PARAM,
|
||||||
|
"missing required parameter: id",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (_prefix, artist_id) = match parse_subsonic_id(&id_str) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
return response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_NOT_FOUND,
|
||||||
|
"invalid artist id",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let artist = match queries::artists::get_by_id(state.db.conn(), artist_id).await {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(_) => {
|
||||||
|
return response::error(
|
||||||
|
¶ms.format,
|
||||||
|
response::ERROR_NOT_FOUND,
|
||||||
|
"artist not found",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let count: usize = get_query_param(&req, "count")
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(50);
|
||||||
|
|
||||||
|
// Need a Last.fm API key for similar artist lookups
|
||||||
|
let config = state.config.read().await;
|
||||||
|
let lastfm_key = config.metadata.lastfm_api_key.clone();
|
||||||
|
drop(config);
|
||||||
|
|
||||||
|
let api_key = match lastfm_key {
|
||||||
|
Some(k) if !k.is_empty() => k,
|
||||||
|
_ => {
|
||||||
|
return response::ok(
|
||||||
|
¶ms.format,
|
||||||
|
serde_json::json!({ "similarSongs2": { "song": [] } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let fetcher = match shanty_data::LastFmSimilarFetcher::new(api_key) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(_) => {
|
||||||
|
return response::ok(
|
||||||
|
¶ms.format,
|
||||||
|
serde_json::json!({ "similarSongs2": { "song": [] } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let similar_config = SimilarConfig {
|
||||||
|
count,
|
||||||
|
popularity_bias: 5,
|
||||||
|
ordering: "random".to_string(),
|
||||||
|
discovery_range: 5,
|
||||||
|
global_popularity: 0,
|
||||||
|
country_filter: false,
|
||||||
|
seed_weight: 3,
|
||||||
|
max_tracks_per_artist: None,
|
||||||
|
max_artists: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = shanty_playlist::similar_artists(
|
||||||
|
state.db.conn(),
|
||||||
|
&fetcher,
|
||||||
|
vec![artist.name],
|
||||||
|
&similar_config,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let songs: Vec<serde_json::Value> = match result {
|
||||||
|
Ok(playlist) => {
|
||||||
|
let mut songs = Vec::new();
|
||||||
|
for pt in &playlist.tracks {
|
||||||
|
if let Ok(track) =
|
||||||
|
queries::tracks::get_by_id(state.db.conn(), pt.track_id).await
|
||||||
|
{
|
||||||
|
songs.push(
|
||||||
|
serde_json::to_value(SubsonicChild::from_track(&track))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
songs
|
||||||
|
}
|
||||||
|
Err(_) => vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
response::ok(
|
||||||
|
¶ms.format,
|
||||||
|
serde_json::json!({
|
||||||
|
"similarSongs2": {
|
||||||
|
"song": songs,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -73,6 +73,14 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
"/getArtistInfo2.view",
|
"/getArtistInfo2.view",
|
||||||
web::get().to(browsing::get_artist_info2),
|
web::get().to(browsing::get_artist_info2),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/getSimilarSongs2",
|
||||||
|
web::get().to(browsing::get_similar_songs2),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/getSimilarSongs2.view",
|
||||||
|
web::get().to(browsing::get_similar_songs2),
|
||||||
|
)
|
||||||
// Search
|
// Search
|
||||||
.route("/search3", web::get().to(search::search3))
|
.route("/search3", web::get().to(search::search3))
|
||||||
.route("/search3.view", web::get().to(search::search3))
|
.route("/search3.view", web::get().to(search::search3))
|
||||||
|
|||||||
Reference in New Issue
Block a user