Compare commits
1 Commits
4c42cf0131
...
8193eebf13
| Author | SHA1 | Date | |
|---|---|---|---|
| 8193eebf13 |
@@ -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,24 +528,30 @@ pub fn playlists_page() -> Html {
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
checked={*country_filter}
|
||||
onchange={{
|
||||
let cf = country_filter.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
cf.set(input.checked());
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{ " Same countries as seed artists" }
|
||||
</label>
|
||||
</div>
|
||||
<div style="display: flex; gap: 1rem;">
|
||||
<div class="form-group" style="flex: 1;">
|
||||
<label>{ "Max Per Artist" }</label>
|
||||
<div class="playlist-controls-row">
|
||||
<div class="form-group">
|
||||
<label class="tooltip-wrap" style="white-space: nowrap;">
|
||||
<input type="checkbox"
|
||||
checked={*country_filter}
|
||||
onchange={{
|
||||
let cf = country_filter.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
cf.set(input.checked());
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{ " 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 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,20 +592,24 @@ pub fn playlists_page() -> Html {
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{ "Track Order" }</label>
|
||||
<select onchange={{
|
||||
let ord = ordering.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||
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="random" selected={*ordering == "random"}>{ "Random" }</option>
|
||||
</select>
|
||||
<div class="form-group">
|
||||
<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| {
|
||||
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||
ord.set(select.value());
|
||||
})
|
||||
}}>
|
||||
<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();
|
||||
|
||||
@@ -377,7 +377,7 @@ pub struct GenerateRequest {
|
||||
}
|
||||
|
||||
fn default_playlist_count() -> usize {
|
||||
50
|
||||
30
|
||||
}
|
||||
|
||||
fn default_popularity_bias() -> u8 {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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(
|
||||
¶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",
|
||||
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))
|
||||
|
||||
Reference in New Issue
Block a user