several small ui updates, plus hopefully a track-matching fix

This commit is contained in:
Connor Johnstone
2026-04-01 22:32:52 -04:00
parent 4c42cf0131
commit 8193eebf13
5 changed files with 268 additions and 45 deletions
+64 -21
View File
@@ -41,7 +41,7 @@ pub fn playlists_page() -> Html {
let seed_input = use_state(String::new);
let seed_focused = use_state(|| false);
let seeds = use_state(Vec::<String>::new);
let count = use_state(|| 50usize);
let count = use_state(|| 30usize);
let popularity_bias = use_state(|| 5u8);
let ordering = use_state(|| "interleave".to_string());
let discovery_range = use_state(|| 5u8);
@@ -129,11 +129,23 @@ pub fn playlists_page() -> Html {
ordering: ordering_val,
rules: None,
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 },
seed_weight: Some(seed_weight_val),
max_tracks_per_artist: if max_tpa_val > 0 { Some(max_tpa_val) } else { None },
max_artists: if max_artists_val > 0 { Some(max_artists_val) } else { None },
max_tracks_per_artist: if max_tpa_val > 0 {
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 {
@@ -437,7 +449,11 @@ pub fn playlists_page() -> Html {
}
})}
</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
type="range"
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="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>
<input
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="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>
<input
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="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>
<input
type="range"
@@ -503,8 +528,9 @@ pub fn playlists_page() -> Html {
})
}}
/>
<div class="playlist-controls-row">
<div class="form-group">
<label>
<label class="tooltip-wrap" style="white-space: nowrap;">
<input type="checkbox"
checked={*country_filter}
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>
</div>
<div style="display: flex; gap: 1rem;">
<div class="form-group" style="flex: 1;">
<label>{ "Max Per Artist" }</label>
<div class="form-group">
<label class="tooltip-wrap">
{ "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={{
let mtpa = max_tracks_per_artist.clone();
Callback::from(move |e: Event| {
@@ -538,8 +569,12 @@ pub fn playlists_page() -> Html {
})}
</select>
</div>
<div class="form-group" style="flex: 1;">
<label>{ "Max Artists" }</label>
<div class="form-group">
<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={{
let ma = max_artists.clone();
Callback::from(move |e: Event| {
@@ -557,9 +592,12 @@ pub fn playlists_page() -> Html {
})}
</select>
</div>
</div>
<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={{
let ord = ordering.clone();
Callback::from(move |e: Event| {
@@ -567,12 +605,13 @@ pub fn playlists_page() -> Html {
ord.set(select.value());
})
}}>
<option value="interleave" selected={*ordering == "interleave"}>{ "Interleave (spread artists)" }</option>
<option value="score" selected={*ordering == "score"}>{ "By Score (best first)" }</option>
<option value="interleave" selected={*ordering == "interleave"}>{ "Interleave" }</option>
<option value="score" selected={*ordering == "score"}>{ "By Score" }</option>
<option value="random" selected={*ordering == "random"}>{ "Random" }</option>
</select>
</div>
</div>
</div>
}
};
@@ -632,8 +671,12 @@ pub fn playlists_page() -> Html {
{ strategy_inputs }
<div class="form-group">
<label>{ format!("Count: {}", *count) }</label>
<input type="range" min="10" max="200" step="10"
<label class="tooltip-wrap">
{ 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()}
oninput={{
let count = count.clone();
+1 -1
View File
@@ -377,7 +377,7 @@ pub struct GenerateRequest {
}
fn default_playlist_count() -> usize {
50
30
}
fn default_popularity_bias() -> u8 {
+53
View File
@@ -447,6 +447,59 @@ tr[draggable="true"]:active { cursor: grabbing; }
.artist-photo { width: 80px; height: 80px; }
.scroll-track { width: 18px; }
.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 */
+119
View File
@@ -2,6 +2,7 @@ use actix_web::{HttpRequest, HttpResponse, web};
use std::collections::BTreeMap;
use shanty_db::queries;
use shanty_playlist::SimilarConfig;
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(
&params.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(
&params.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(
&params.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(
&params.format,
serde_json::json!({ "similarSongs2": { "song": [] } }),
);
}
};
let fetcher = match shanty_data::LastFmSimilarFetcher::new(api_key) {
Ok(f) => f,
Err(_) => {
return response::ok(
&params.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(
&params.format,
serde_json::json!({
"similarSongs2": {
"song": songs,
}
}),
)
}
+8
View File
@@ -73,6 +73,14 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
"/getArtistInfo2.view",
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
.route("/search3", web::get().to(search::search3))
.route("/search3.view", web::get().to(search::search3))