Compare commits

..

18 Commits

Author SHA1 Message Date
connor d17049d92a Now better matching of the airsonic spec 2026-04-09 11:49:17 -04:00
Connor Johnstone f7593dc0dc several small ui updates, plus hopefully a track-matching fix for real this time 2026-04-01 22:41:46 -04:00
Connor Johnstone 8193eebf13 several small ui updates, plus hopefully a track-matching fix 2026-04-01 22:32:52 -04:00
Connor Johnstone 4c42cf0131 update to the playlists. testing 2026-04-01 22:12:58 -04:00
Connor Johnstone bd6656ff31 fleshed out subsonic more 2026-04-01 19:36:24 -04:00
Connor Johnstone 61225158f0 finally have a proper scroll bar 2026-04-01 12:25:59 -04:00
Connor Johnstone 07aa9908e8 added the scroll bar 2026-03-31 13:18:18 -04:00
Connor Johnstone 4b6844b85e test fixes 2026-03-26 18:06:21 -04:00
Connor Johnstone 7d2d6f021d Unified the track logic. Seems to work much better 2026-03-26 17:38:16 -04:00
Connor Johnstone 5786cc89e5 Attempt to fix playlist gen dropdown 2026-03-26 13:20:20 -04:00
Connor Johnstone 4ccc6bcf27 Top ten button 2026-03-26 11:21:48 -04:00
Connor Johnstone 159cdda386 I **think** I've at least 99% fixed the top songs mismatch 2026-03-25 21:50:09 -04:00
Connor Johnstone 1a890b0c11 synced top tracks with status 2026-03-25 16:50:52 -04:00
Connor Johnstone 6e156037e6 top songs flow 2026-03-25 15:48:37 -04:00
Connor Johnstone 1a478dea8e progress bars 2026-03-25 15:20:12 -04:00
Connor Johnstone a893a84f16 added the watch/unwatch buttons for artists/albums 2026-03-25 15:08:37 -04:00
Connor Johnstone d20989f859 better artist banner heights 2026-03-25 14:40:17 -04:00
Connor Johnstone 00d4e8d3e0 proper fix plus delete artist actually removes files 2026-03-25 14:32:17 -04:00
23 changed files with 3003 additions and 556 deletions
+1
View File
@@ -37,6 +37,7 @@ serde_json = "1"
serde_yaml = "0.9" serde_yaml = "0.9"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["io"] } tokio-util = { version = "0.7", features = ["io"] }
futures-util = "0.3"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-actix-web = "0.7" tracing-actix-web = "0.7"
+1 -1
View File
@@ -14,5 +14,5 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
wasm-bindgen = "0.2" wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4" wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["HtmlInputElement", "HtmlSelectElement", "Window", "DragEvent", "DataTransfer"] } web-sys = { version = "0.3", features = ["HtmlInputElement", "HtmlSelectElement", "Window", "DragEvent", "DataTransfer", "DomRect", "DomTokenList", "NodeList", "Element", "HtmlElement", "CssStyleDeclaration", "DomStringMap"] }
js-sys = "0.3" js-sys = "0.3"
+349 -54
View File
@@ -5,6 +5,7 @@ use yew::prelude::*;
use yew_router::prelude::*; use yew_router::prelude::*;
use crate::api; use crate::api;
use crate::components::status_badge::StatusBadge;
use crate::pages::Route; use crate::pages::Route;
use crate::types::FullArtistDetail; use crate::types::FullArtistDetail;
@@ -18,6 +19,15 @@ pub fn artist_page(props: &Props) -> Html {
let detail = use_state(|| None::<FullArtistDetail>); let detail = use_state(|| None::<FullArtistDetail>);
let error = use_state(|| None::<String>); let error = use_state(|| None::<String>);
let message = use_state(|| None::<String>); let message = use_state(|| None::<String>);
let active_tab = use_state(|| "discography".to_string());
let top_songs_limit = use_state(|| 25usize);
let watch_top_count = use_state(|| 10usize);
// Track top song status overrides — use Rc<RefCell> so async callbacks always see latest state
let top_song_overrides: Rc<
std::cell::RefCell<std::collections::HashMap<usize, Option<String>>>,
> = use_mut_ref(std::collections::HashMap::new);
// Counter to force re-renders when overrides change
let overrides_version = use_state(|| 0u32);
let id = props.id.clone(); let id = props.id.clone();
// Flag to prevent the background enrichment from overwriting a user-triggered refresh // Flag to prevent the background enrichment from overwriting a user-triggered refresh
@@ -109,37 +119,9 @@ pub fn artist_page(props: &Props) -> Html {
let is_watched = d.artist_status == "owned" let is_watched = d.artist_status == "owned"
|| d.artist_status == "partial" || d.artist_status == "partial"
|| d.artist_status == "wanted"; || d.artist_status == "wanted";
if is_watched { let is_fully_watched = d.artist_status == "owned";
// Unwatch All
let artist_id_num = d.artist.id; let watch_btn = if !is_fully_watched {
let artist_name = d.artist.name.clone();
let message = message.clone();
let error = error.clone();
let fetch = fetch.clone();
let artist_id = id.clone();
html! {
<button class="btn btn-sm btn-secondary"
onclick={Callback::from(move |_: MouseEvent| {
let artist_name = artist_name.clone();
let message = message.clone();
let error = error.clone();
let fetch = fetch.clone();
let artist_id = artist_id.clone();
wasm_bindgen_futures::spawn_local(async move {
match api::unwatch_artist(artist_id_num).await {
Ok(_) => {
message.set(Some(format!("Unwatched {artist_name}")));
fetch.emit(artist_id);
}
Err(e) => error.set(Some(e.0)),
}
});
})}>
{ "Unwatch All" }
</button>
}
} else {
// Watch All — prefer enrichment MBID over DB record (import may not have set it)
let artist_name = d.artist.name.clone(); let artist_name = d.artist.name.clone();
let artist_mbid = d let artist_mbid = d
.artist_info .artist_info
@@ -175,7 +157,43 @@ pub fn artist_page(props: &Props) -> Html {
{ "Watch All" } { "Watch All" }
</button> </button>
} }
} } else {
html! {}
};
let unwatch_btn = if is_watched {
let artist_id_num = d.artist.id;
let artist_name = d.artist.name.clone();
let message = message.clone();
let error = error.clone();
let fetch = fetch.clone();
let artist_id = id.clone();
html! {
<button class="btn btn-sm btn-secondary"
onclick={Callback::from(move |_: MouseEvent| {
let artist_name = artist_name.clone();
let message = message.clone();
let error = error.clone();
let fetch = fetch.clone();
let artist_id = artist_id.clone();
wasm_bindgen_futures::spawn_local(async move {
match api::unwatch_artist(artist_id_num).await {
Ok(_) => {
message.set(Some(format!("Unwatched {artist_name}")));
fetch.emit(artist_id);
}
Err(e) => error.set(Some(e.0)),
}
});
})}>
{ "Unwatch All" }
</button>
}
} else {
html! {}
};
html! { <> { watch_btn } { unwatch_btn } </> }
}; };
let monitor_btn = { let monitor_btn = {
@@ -251,12 +269,245 @@ pub fn artist_page(props: &Props) -> Html {
} }
}; };
let tab_bar_html = {
let has_top_songs = d.lastfm_available && !d.top_songs.is_empty();
let disco_active = *active_tab == "discography";
let top_active = *active_tab == "top_songs";
let on_disco = {
let tab = active_tab.clone();
Callback::from(move |_: MouseEvent| tab.set("discography".to_string()))
};
let on_top = {
let tab = active_tab.clone();
Callback::from(move |_: MouseEvent| tab.set("top_songs".to_string()))
};
html! {
<div class="tab-bar">
<button class={if disco_active { "tab-btn active" } else { "tab-btn" }}
onclick={on_disco}>{ "Discography" }</button>
if has_top_songs {
<button class={if top_active { "tab-btn active" } else { "tab-btn" }}
onclick={on_top}>{ "Top Songs" }</button>
}
</div>
}
};
let top_songs_html = {
let limit = *top_songs_limit;
let visible: Vec<_> = d.top_songs.iter().take(limit).collect();
let has_more = d.top_songs.len() > limit;
let artist_name = d.artist.name.clone();
let on_show_more = {
let top_songs_limit = top_songs_limit.clone();
Callback::from(move |_: MouseEvent| {
top_songs_limit.set(*top_songs_limit + 25);
})
};
let on_count_change = {
let watch_top_count = watch_top_count.clone();
Callback::from(move |e: Event| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
if let Ok(v) = input.value().parse::<usize>() {
if v >= 1 {
watch_top_count.set(v);
}
}
})
};
let on_watch_top = {
let overrides = top_song_overrides.clone();
let version = overrides_version.clone();
let songs: Vec<_> = d.top_songs.iter().take(*watch_top_count).cloned().collect();
let artist = artist_name.clone();
Callback::from(move |_: MouseEvent| {
// Collect indices of unwatched songs
let to_watch: Vec<(usize, String, Option<String>)> = songs
.iter()
.enumerate()
.filter(|(i, song)| {
let has_override = overrides.borrow().get(i).is_some();
!has_override && song.status.is_none()
})
.map(|(i, song)| (i, song.name.clone(), song.mbid.clone()))
.collect();
// Mark all as pending immediately
for (i, _, _) in &to_watch {
overrides
.borrow_mut()
.insert(*i, Some("pending".to_string()));
}
version.set(*version + 1);
// Watch sequentially so the user sees progress
let overrides = overrides.clone();
let version = version.clone();
let artist = artist.clone();
wasm_bindgen_futures::spawn_local(async move {
for (i, name, mbid) in to_watch {
let status = match api::watch_track(
Some(&artist),
&name,
mbid.as_deref().unwrap_or(""),
)
.await
{
Ok(resp) => resp.status,
Err(_) => "wanted".to_string(),
};
overrides.borrow_mut().insert(i, Some(status));
version.set(*version + 1);
}
});
})
};
let watch_count_val = *watch_top_count;
html! {
<div>
<div style="display:flex;gap:0.5rem;align-items:center;margin-bottom:0.5rem;">
<button class="btn btn-sm" onclick={on_watch_top}>
{ format!("Watch Top {}", watch_count_val) }
</button>
<input type="number" min="1"
value={watch_count_val.to_string()}
onchange={on_count_change}
style="width:4rem;" />
</div>
<table>
<thead>
<tr>
<th style="width:3rem;">{ "#" }</th>
<th>{ "Title" }</th>
<th>{ "Plays" }</th>
<th>{ "Status" }</th>
<th></th>
</tr>
</thead>
<tbody>
{ for visible.iter().enumerate().map(|(i, song)| {
let rank = i + 1;
// Use override status if set, otherwise use the original
let effective_status = top_song_overrides
.borrow()
.get(&i)
.cloned()
.unwrap_or_else(|| song.status.clone());
let has_status = effective_status.is_some();
// Check if this song is in-flight (override set to exactly "pending")
let is_pending = top_song_overrides
.borrow()
.get(&i)
.map(|s| s.as_deref() == Some("pending"))
.unwrap_or(false);
let on_watch = {
let overrides = top_song_overrides.clone();
let version = overrides_version.clone();
let name = song.name.clone();
let mbid = song.mbid.clone();
let artist = artist_name.clone();
Callback::from(move |_: MouseEvent| {
// Immediately mark as pending
overrides.borrow_mut().insert(i, Some("pending".to_string()));
version.set(*version + 1);
let overrides = overrides.clone();
let version = version.clone();
let name = name.clone();
let mbid = mbid.clone();
let artist = artist.clone();
wasm_bindgen_futures::spawn_local(async move {
let status = match api::watch_track(Some(&artist), &name, mbid.as_deref().unwrap_or("")).await {
Ok(resp) => resp.status,
Err(_) => "wanted".to_string(),
};
overrides.borrow_mut().insert(i, Some(status));
version.set(*version + 1);
});
})
};
let on_unwatch = {
let overrides = top_song_overrides.clone();
let version = overrides_version.clone();
let mbid = song.mbid.clone();
Callback::from(move |_: MouseEvent| {
// Immediately mark as pending
overrides.borrow_mut().insert(i, Some("pending".to_string()));
version.set(*version + 1);
let overrides = overrides.clone();
let version = version.clone();
let mbid = mbid.clone();
wasm_bindgen_futures::spawn_local(async move {
if let Some(ref m) = mbid {
let _ = api::unwatch_track(m).await;
}
overrides.borrow_mut().insert(i, None);
version.set(*version + 1);
});
})
};
let plays = if song.playcount >= 1_000_000 {
format!("{}M", song.playcount / 1_000_000)
} else if song.playcount >= 1_000 {
format!("{}K", song.playcount / 1_000)
} else {
song.playcount.to_string()
};
html! {
<tr>
<td class="text-muted">{ rank }</td>
<td>{ &song.name }</td>
<td class="text-muted text-sm">{ plays }</td>
<td>
if let Some(ref status) = effective_status {
if status != "pending" {
<StatusBadge status={status.clone()} />
}
}
</td>
<td class="actions">
if is_pending {
<button class="btn btn-sm" disabled={true}>{ "..." }</button>
} else if !has_status {
<button class="btn btn-sm" onclick={on_watch}>
{ "Watch" }
</button>
} else {
<button class="btn btn-sm btn-secondary" onclick={on_unwatch}>
{ "Unwatch" }
</button>
}
</td>
</tr>
}
})}
</tbody>
</table>
if has_more {
<div style="text-align:center;margin-top:0.5rem;">
<button class="btn btn-sm btn-secondary" onclick={on_show_more}>
{ format!("Show More ({} total)", d.top_songs.len()) }
</button>
</div>
}
</div>
}
};
html! { html! {
<div> <div>
if let Some(ref banner) = d.artist_banner { <div class={if d.artist_banner.is_some() { "artist-banner-wrap" } else { "" }}
<div class="artist-banner" style={format!("background-image: url('{banner}')")}> style={d.artist_banner.as_ref().map(|b| format!("background-image: url('{b}')")).unwrap_or_default()}>
</div>
}
<div class="page-header"> <div class="page-header">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
@@ -329,6 +580,7 @@ pub fn artist_page(props: &Props) -> Html {
<p class="text-sm text-muted loading">{ "Loading track counts..." }</p> <p class="text-sm text-muted loading">{ "Loading track counts..." }</p>
} }
</div> </div>
</div> // close artist-banner-wrap
if let Some(ref msg) = *message { if let Some(ref msg) = *message {
<div class="card" style="border-color: var(--success);"> <div class="card" style="border-color: var(--success);">
@@ -336,6 +588,14 @@ pub fn artist_page(props: &Props) -> Html {
</div> </div>
} }
// Tab bar
{ tab_bar_html }
if *active_tab == "top_songs" && !d.top_songs.is_empty() {
// Top Songs tab content
{ top_songs_html }
} else {
if d.albums.is_empty() { if d.albums.is_empty() {
<p class="text-muted">{ "No releases found on MusicBrainz." }</p> <p class="text-muted">{ "No releases found on MusicBrainz." }</p>
} }
@@ -376,8 +636,40 @@ pub fn artist_page(props: &Props) -> Html {
let tc = album.track_count; let tc = album.track_count;
// Watch/Unwatch toggle for albums // Progress bar styles
let watch_btn = if is_unwatched { let owned_pct = if tc > 0 {
(album.owned_tracks as f64 / tc as f64 * 100.0) as u32
} else {
0
};
let owned_color = if album.owned_tracks >= tc && tc > 0 {
"var(--success)"
} else if album.owned_tracks > 0 {
"var(--warning)"
} else {
"var(--text-muted)"
};
let owned_bar_style =
format!("width:{owned_pct}%;background:{owned_color};");
let watched_pct = if tc > 0 {
(album.watched_tracks as f64 / tc as f64 * 100.0) as u32
} else {
0
};
let watched_color = if album.watched_tracks > 0 {
"var(--accent)"
} else {
"var(--text-muted)"
};
let watched_bar_style =
format!("width:{watched_pct}%;background:{watched_color};");
// Watch/Unwatch buttons for albums — show both when partially watched
let is_fully_owned = album.status == "owned";
let is_album_watched = album.status != "unwatched";
let album_watch_btn = {
let artist_name = d.artist.name.clone(); let artist_name = d.artist.name.clone();
let album_title = album.title.clone(); let album_title = album.title.clone();
let album_mbid = album.mbid.clone(); let album_mbid = album.mbid.clone();
@@ -386,7 +678,8 @@ pub fn artist_page(props: &Props) -> Html {
let fetch = fetch.clone(); let fetch = fetch.clone();
let artist_id = id.clone(); let artist_id = id.clone();
html! { html! {
<button class="btn btn-sm btn-primary" <button class="btn btn-sm btn-primary btn-fixed"
style={if is_fully_owned { "visibility:hidden;" } else { "" }}
onclick={Callback::from(move |_: MouseEvent| { onclick={Callback::from(move |_: MouseEvent| {
let artist_name = artist_name.clone(); let artist_name = artist_name.clone();
let album_title = album_title.clone(); let album_title = album_title.clone();
@@ -411,7 +704,9 @@ pub fn artist_page(props: &Props) -> Html {
{ "Watch" } { "Watch" }
</button> </button>
} }
} else { };
let album_unwatch_btn = {
let album_title = album.title.clone(); let album_title = album.title.clone();
let album_mbid = album.mbid.clone(); let album_mbid = album.mbid.clone();
let message = message.clone(); let message = message.clone();
@@ -419,7 +714,8 @@ pub fn artist_page(props: &Props) -> Html {
let fetch = fetch.clone(); let fetch = fetch.clone();
let artist_id = id.clone(); let artist_id = id.clone();
html! { html! {
<button class="btn btn-sm btn-secondary" <button class="btn btn-sm btn-secondary btn-fixed"
style={if !is_album_watched { "visibility:hidden;" } else { "" }}
onclick={Callback::from(move |_: MouseEvent| { onclick={Callback::from(move |_: MouseEvent| {
let album_title = album_title.clone(); let album_title = album_title.clone();
let album_mbid = album_mbid.clone(); let album_mbid = album_mbid.clone();
@@ -442,6 +738,8 @@ pub fn artist_page(props: &Props) -> Html {
} }
}; };
let watch_btn = html! { <span style="display:inline-flex;gap:0.5rem;">{ album_watch_btn } { album_unwatch_btn }</span> };
html! { html! {
<tr style={row_style}> <tr style={row_style}>
<td> <td>
@@ -457,23 +755,18 @@ pub fn artist_page(props: &Props) -> Html {
<td class="text-muted">{ album.date.as_deref().unwrap_or("") }</td> <td class="text-muted">{ album.date.as_deref().unwrap_or("") }</td>
<td> <td>
if tc > 0 { if tc > 0 {
<span class="text-sm" style={ <div class="progress-bar-wrap">
if album.owned_tracks >= tc { "color: var(--success);" } <div class="progress-bar-fill" style={owned_bar_style}></div>
else if album.owned_tracks > 0 { "color: var(--warning);" } <span class="progress-bar-text">{ format!("{}/{}", album.owned_tracks, tc) }</span>
else { "color: var(--text-muted);" } </div>
}>
{ format!("{}/{}", album.owned_tracks, tc) }
</span>
} }
</td> </td>
<td> <td>
if tc > 0 { if tc > 0 {
<span class="text-sm" style={ <div class="progress-bar-wrap">
if album.watched_tracks > 0 { "color: var(--accent);" } <div class="progress-bar-fill" style={watched_bar_style}></div>
else { "color: var(--text-muted);" } <span class="progress-bar-text">{ format!("{}/{}", album.watched_tracks, tc) }</span>
}> </div>
{ format!("{}/{}", album.watched_tracks, tc) }
</span>
} }
</td> </td>
<td>{ watch_btn }</td> <td>{ watch_btn }</td>
@@ -535,6 +828,8 @@ pub fn artist_page(props: &Props) -> Html {
</details> </details>
} }
})} })}
} // close else (discography tab)
</div> </div>
} }
} }
+314 -35
View File
@@ -1,3 +1,6 @@
use std::collections::{HashMap, HashSet};
use wasm_bindgen::JsCast;
use yew::prelude::*; use yew::prelude::*;
use yew_router::prelude::*; use yew_router::prelude::*;
@@ -17,7 +20,7 @@ pub fn library_page() -> Html {
let artists = artists.clone(); let artists = artists.clone();
let error = error.clone(); let error = error.clone();
wasm_bindgen_futures::spawn_local(async move { wasm_bindgen_futures::spawn_local(async move {
match api::list_artists(200, 0).await { match api::list_artists(0, 0).await {
Ok(a) => artists.set(Some(a)), Ok(a) => artists.set(Some(a)),
Err(e) => error.set(Some(e.0)), Err(e) => error.set(Some(e.0)),
} }
@@ -32,6 +35,112 @@ pub fn library_page() -> Html {
}); });
} }
// Measure DOM heights after render and set spacer flex-grow values directly.
// Must be called before any early returns to maintain consistent hook order.
const SCROLL_TRACK_FONT_PX: f64 = 18.0;
{
let artist_count = artists.as_ref().map(|a| a.len()).unwrap_or(0);
use_effect_with(artist_count, move |_| {
if let Some(window) = web_sys::window() {
if let Some(doc) = window.document() {
// a. height of the first row
let row_h = doc
.query_selector("#library-table tbody tr")
.ok()
.flatten()
.and_then(|el| el.dyn_into::<web_sys::HtmlElement>().ok())
.map(|el| el.offset_height() as f64)
.unwrap_or(0.0);
// b. px from top of page to first artist row
let header_h = doc
.query_selector("[id^='letter-']")
.map(|el| {
el.map(|el| {
let rect = el.get_bounding_client_rect();
rect.top() + window.scroll_y().unwrap_or(0.0)
})
.unwrap_or(0.0)
})
.unwrap_or(0.0);
// c. footer = distance from last row bottom to document bottom
let doc_h = doc
.document_element()
.map(|el| el.scroll_height() as f64)
.unwrap_or(0.0);
let last_row_bottom = doc
.query_selector("#library-table tbody tr:last-child")
.ok()
.flatten()
.map(|el| {
let rect = el.get_bounding_client_rect();
rect.bottom() + window.scroll_y().unwrap_or(0.0)
})
.unwrap_or(doc_h);
let footer_h = (doc_h - last_row_bottom).max(0.0);
// d. Compute percentage heights for all track elements.
// Total page = header + table rows + footer
let num_rows = artist_count.max(1) as f64;
let table_h = num_rows * row_h;
let total_h = header_h + table_h + footer_h;
if total_h <= 0.0 {
return;
}
let header_pct = header_h / total_h * 100.0;
let footer_pct = footer_h / total_h * 100.0;
let table_pct = table_h / total_h * 100.0;
// Set header spacer height
if let Some(el) = doc.get_element_by_id("scroll-track-header-spacer") {
if let Ok(el) = el.dyn_into::<web_sys::HtmlElement>() {
let _ = el
.style()
.set_property("height", &format!("{header_pct:.2}%"));
}
}
// Set footer spacer height
if let Some(el) = doc.get_element_by_id("scroll-track-footer-spacer") {
if let Ok(el) = el.dyn_into::<web_sys::HtmlElement>() {
let _ = el
.style()
.set_property("height", &format!("{footer_pct:.2}%"));
}
}
// Set each letter's height (letter_fraction * table_pct)
let track_h = window
.inner_height()
.ok()
.and_then(|v| v.as_f64())
.unwrap_or(800.0);
if let Ok(letters) = doc.query_selector_all(".scroll-track-letter") {
for i in 0..letters.length() {
if let Some(el) = letters.item(i) {
if let Ok(el) = el.dyn_into::<web_sys::HtmlElement>() {
if let Some(grow_str) = el.dataset().get("grow") {
if let Ok(grow) = grow_str.parse::<f64>() {
let pct = grow * table_pct;
let px = pct / 100.0 * track_h;
let _ = el
.style()
.set_property("height", &format!("{pct:.2}%"));
// Hide text if cell is too short to fit it
if px < SCROLL_TRACK_FONT_PX {
let _ = el.style().set_property("font-size", "0");
}
}
}
}
}
}
}
}
}
});
}
if let Some(ref err) = *error { if let Some(ref err) = *error {
return html! { <div class="error">{ format!("Error: {err}") }</div> }; return html! { <div class="error">{ format!("Error: {err}") }</div> };
} }
@@ -40,6 +149,53 @@ pub fn library_page() -> Html {
return html! { <p class="loading">{ "Loading..." }</p> }; return html! { <p class="loading">{ "Loading..." }</p> };
}; };
// Pre-compute which artist IDs are first in their letter group (for anchor IDs)
let mut seen_letters = HashSet::new();
let first_of_letter: HashMap<i32, char> = artists
.iter()
.filter_map(|a| {
let first = a.name.chars().next().unwrap_or('#').to_ascii_uppercase();
let letter = if first.is_ascii_alphabetic() {
first
} else {
'#'
};
if seen_letters.insert(letter) {
Some((a.id, letter))
} else {
None
}
})
.collect();
// Count artists per letter for proportional sizing
let mut letter_counts: HashMap<char, usize> = HashMap::new();
for a in artists.iter() {
let first = a.name.chars().next().unwrap_or('#').to_ascii_uppercase();
let letter = if first.is_ascii_alphabetic() {
first
} else {
'#'
};
*letter_counts.entry(letter).or_default() += 1;
}
let total_artists = artists.len().max(1);
let all_letters: Vec<char> = "#ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars().collect();
let scroll_track_items: Vec<(String, f64)> = all_letters
.iter()
.filter_map(|&c| {
let count = letter_counts.get(&c).copied().unwrap_or(0);
if count > 0 {
// Percentage of total artists — will be scaled to percentage of track
// by the effect that measures header/footer
Some((c.to_string(), count as f64 / total_artists as f64))
} else {
None
}
})
.collect();
html! { html! {
<div> <div>
<div class="page-header"> <div class="page-header">
@@ -50,34 +206,126 @@ pub fn library_page() -> Html {
if artists.is_empty() { if artists.is_empty() {
<p class="text-muted">{ "No artists in library. Use Search to add some!" }</p> <p class="text-muted">{ "No artists in library. Use Search to add some!" }</p>
} else { } else {
<table> <table id="library-table">
<thead> <thead>
<tr> <tr>
<th>{ "Name" }</th> <th>{ "Name" }</th>
<th>{ "Monitored" }</th> <th>{ "Monitored" }</th>
<th>{ "Owned" }</th> <th>{ "Owned" }</th>
<th>{ "Watched" }</th> <th>{ "Watched" }</th>
<th>{ "Tracks" }</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{ for artists.iter().map(|a| { { for artists.iter().map(|a| {
let anchor_id =
first_of_letter.get(&a.id).map(|c| format!("letter-{c}"));
let artist_id = a.id; let artist_id = a.id;
let error = error.clone(); let artist_name = a.name.clone();
let fetch = fetch_artists.clone(); let artist_mbid = a.musicbrainz_id.clone();
let on_remove = Callback::from(move |_: MouseEvent| { let is_watched = a.total_watched > 0;
let is_fully_watched =
a.enriched && a.total_watched >= a.total_items && a.total_items > 0;
let is_monitored = a.monitored;
let on_remove = {
let error = error.clone(); let error = error.clone();
let fetch = fetch.clone(); let fetch = fetch_artists.clone();
wasm_bindgen_futures::spawn_local(async move { Callback::from(move |_: MouseEvent| {
match api::delete_artist(artist_id).await { let error = error.clone();
Ok(_) => fetch.emit(()), let fetch = fetch.clone();
Err(e) => error.set(Some(e.0)), wasm_bindgen_futures::spawn_local(async move {
} match api::delete_artist(artist_id).await {
}); Ok(_) => fetch.emit(()),
}); Err(e) => error.set(Some(e.0)),
}
});
})
};
let on_watch = {
let error = error.clone();
let fetch = fetch_artists.clone();
let name = artist_name.clone();
let mbid = artist_mbid.clone();
Callback::from(move |_: MouseEvent| {
let error = error.clone();
let fetch = fetch.clone();
let name = name.clone();
let mbid = mbid.clone();
wasm_bindgen_futures::spawn_local(async move {
match api::add_artist(&name, mbid.as_deref()).await {
Ok(_) => fetch.emit(()),
Err(e) => error.set(Some(e.0)),
}
});
})
};
let on_unwatch = {
let error = error.clone();
let fetch = fetch_artists.clone();
Callback::from(move |_: MouseEvent| {
let error = error.clone();
let fetch = fetch.clone();
wasm_bindgen_futures::spawn_local(async move {
match api::unwatch_artist(artist_id).await {
Ok(_) => fetch.emit(()),
Err(e) => error.set(Some(e.0)),
}
});
})
};
let on_monitor_toggle = {
let error = error.clone();
let fetch = fetch_artists.clone();
Callback::from(move |_: MouseEvent| {
let error = error.clone();
let fetch = fetch.clone();
wasm_bindgen_futures::spawn_local(async move {
match api::set_artist_monitored(artist_id, !is_monitored)
.await
{
Ok(_) => fetch.emit(()),
Err(e) => error.set(Some(e.0)),
}
});
})
};
// Pre-compute progress bar styles
let owned_pct = if a.total_watched > 0 {
(a.total_owned as f64 / a.total_watched as f64 * 100.0) as u32
} else {
0
};
let owned_color =
if a.total_owned >= a.total_watched && a.total_watched > 0 {
"var(--success)"
} else if a.total_owned > 0 {
"var(--warning)"
} else {
"var(--text-muted)"
};
let owned_bar_style =
format!("width:{owned_pct}%;background:{owned_color};");
let watched_pct = if a.total_items > 0 {
(a.total_watched as f64 / a.total_items as f64 * 100.0) as u32
} else {
0
};
let watched_color = if a.total_watched > 0 {
"var(--accent)"
} else {
"var(--text-muted)"
};
let watched_bar_style =
format!("width:{watched_pct}%;background:{watched_color};");
html! { html! {
<tr> <tr id={anchor_id}>
<td> <td>
<Link<Route> to={Route::Artist { id: a.id.to_string() }}> <Link<Route> to={Route::Artist { id: a.id.to_string() }}>
{ &a.name } { &a.name }
@@ -90,34 +338,38 @@ pub fn library_page() -> Html {
</td> </td>
if a.enriched { if a.enriched {
<td> <td>
<span class="text-sm" style={ <div class="progress-bar-wrap">
if a.total_owned >= a.total_watched && a.total_watched > 0 { "color: var(--success);" } <div class="progress-bar-fill" style={owned_bar_style.clone()}></div>
else if a.total_owned > 0 { "color: var(--warning);" } <span class="progress-bar-text">{ format!("{}/{}", a.total_owned, a.total_watched) }</span>
else { "color: var(--text-muted);" } </div>
}>
{ format!("{}/{}", a.total_owned, a.total_watched) }
</span>
</td> </td>
<td> <td>
<span class="text-sm" style={ <div class="progress-bar-wrap">
if a.total_watched > 0 { "color: var(--accent);" } <div class="progress-bar-fill" style={watched_bar_style.clone()}></div>
else { "color: var(--text-muted);" } <span class="progress-bar-text">{ format!("{}/{}", a.total_watched, a.total_items) }</span>
}> </div>
{ format!("{}/{}", a.total_watched, a.total_items) }
</span>
</td> </td>
} else { } else {
<td colspan="2" class="text-sm text-muted loading"> <td colspan="2" class="text-sm text-muted loading">
{ "Awaiting artist enrichment..." } { "Awaiting artist enrichment..." }
</td> </td>
} }
<td class="text-muted text-sm"> <td class="actions">
if a.enriched && a.total_items > 0 { <button class="btn btn-sm btn-lib"
{ a.total_items } onclick={on_watch}
} style={if is_fully_watched { "visibility:hidden;" } else { "" }}>
</td> { "Watch" }
<td> </button>
<button class="btn btn-sm btn-danger" onclick={on_remove}> <button class="btn btn-sm btn-secondary btn-lib"
onclick={on_unwatch}
style={if !is_watched { "visibility:hidden;" } else { "" }}>
{ "Unwatch" }
</button>
<button class={if is_monitored { "btn btn-sm btn-success btn-lib" } else { "btn btn-sm btn-secondary btn-lib" }}
onclick={on_monitor_toggle}>
{ if is_monitored { "Monitored" } else { "Monitor" } }
</button>
<button class="btn btn-sm btn-danger btn-lib" onclick={on_remove}>
{ "Remove" } { "Remove" }
</button> </button>
</td> </td>
@@ -126,6 +378,33 @@ pub fn library_page() -> Html {
})} })}
</tbody> </tbody>
</table> </table>
<div class="scroll-track">
<div id="scroll-track-header-spacer"></div>
{ for scroll_track_items.iter().map(|(letter, grow)| {
let letter_c = letter.clone();
let data_grow = format!("{grow:.6}");
let onclick = Callback::from(move |_: MouseEvent| {
if let Some(window) = web_sys::window() {
if let Some(doc) = window.document() {
if let Some(el) = doc.get_element_by_id(&format!("letter-{}", letter_c)) {
let rect = el.get_bounding_client_rect();
let scroll_y = window.scroll_y().unwrap_or(0.0);
window.scroll_to_with_x_and_y(
0.0,
rect.top() + scroll_y - 60.0,
);
}
}
}
});
html! {
<div class="scroll-track-letter" data-grow={data_grow} {onclick}>
{ letter }
</div>
}
})}
<div id="scroll-track-footer-spacer"></div>
</div>
} }
</div> </div>
} }
+192 -19
View File
@@ -17,7 +17,7 @@ pub fn playlists_page() -> Html {
let all_artists = all_artists.clone(); let all_artists = all_artists.clone();
use_effect_with((), move |_| { use_effect_with((), move |_| {
wasm_bindgen_futures::spawn_local(async move { wasm_bindgen_futures::spawn_local(async move {
if let Ok(artists) = api::list_artists(500, 0).await { if let Ok(artists) = api::list_artists(0, 0).await {
all_artists.set(artists); all_artists.set(artists);
} }
}); });
@@ -41,9 +41,15 @@ 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 global_popularity = use_state(|| 0u8);
let country_filter = use_state(|| false);
let seed_weight = use_state(|| 5u8);
let max_tracks_per_artist = use_state(|| 0u8); // 0 = auto
let max_artists = use_state(|| 0u8); // 0 = unlimited
// Results // Results
let generated = use_state(|| None::<GeneratedPlaylist>); let generated = use_state(|| None::<GeneratedPlaylist>);
@@ -85,6 +91,12 @@ pub fn playlists_page() -> Html {
let count = count.clone(); let count = count.clone();
let popularity_bias = popularity_bias.clone(); let popularity_bias = popularity_bias.clone();
let ordering = ordering.clone(); let ordering = ordering.clone();
let discovery_range = discovery_range.clone();
let global_popularity = global_popularity.clone();
let country_filter = country_filter.clone();
let seed_weight = seed_weight.clone();
let max_tracks_per_artist = max_tracks_per_artist.clone();
let max_artists = max_artists.clone();
let generated = generated.clone(); let generated = generated.clone();
let error = error.clone(); let error = error.clone();
let loading = loading.clone(); let loading = loading.clone();
@@ -94,6 +106,12 @@ pub fn playlists_page() -> Html {
let count = *count; let count = *count;
let popularity_bias = *popularity_bias; let popularity_bias = *popularity_bias;
let ordering_val = (*ordering).clone(); let ordering_val = (*ordering).clone();
let discovery_range_val = *discovery_range;
let global_popularity_val = *global_popularity;
let country_filter_val = *country_filter;
let seed_weight_val = *seed_weight;
let max_tpa_val = *max_tracks_per_artist;
let max_artists_val = *max_artists;
let generated = generated.clone(); let generated = generated.clone();
let error = error.clone(); let error = error.clone();
let loading = loading.clone(); let loading = loading.clone();
@@ -110,6 +128,24 @@ pub fn playlists_page() -> Html {
popularity_bias, popularity_bias,
ordering: ordering_val, ordering: ordering_val,
rules: None, rules: None,
discovery_range: Some(discovery_range_val),
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
},
}; };
match api::generate_playlist(&req).await { match api::generate_playlist(&req).await {
@@ -348,7 +384,7 @@ pub fn playlists_page() -> Html {
current.is_none() current.is_none()
}) })
.filter(|a| !seeds_c.contains(&a.name)) .filter(|a| !seeds_c.contains(&a.name))
.take(15) .take(50)
.cloned() .cloned()
.collect(); .collect();
html! { html! {
@@ -385,7 +421,7 @@ pub fn playlists_page() -> Html {
let name = a.name.clone(); let name = a.name.clone();
let seeds = seeds.clone(); let seeds = seeds.clone();
let si = seed_input.clone(); let si = seed_input.clone();
let track_count = a.total_items; let track_count = a.local_tracks;
html! { html! {
<div class="autocomplete-item" onclick={Callback::from(move |_: MouseEvent| { <div class="autocomplete-item" onclick={Callback::from(move |_: MouseEvent| {
let mut v = (*seeds).clone(); let mut v = (*seeds).clone();
@@ -413,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"
@@ -428,19 +468,148 @@ pub fn playlists_page() -> Html {
}) })
}} }}
/> />
<div class="form-group"> <label class="tooltip-wrap">
<label>{ "Track Order" }</label> { format!("Discovery Range: {}", *discovery_range) }
<select onchange={{ <span class="text-sm text-muted">{ if *discovery_range <= 3 { " (focused)" } else if *discovery_range >= 7 { " (wide)" } else { "" } }</span>
let ord = ordering.clone(); <span class="tooltip-icon">{ "?" }</span>
Callback::from(move |e: Event| { <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>
let select: HtmlSelectElement = e.target_unchecked_into(); </label>
ord.set(select.value()); <input
type="range"
min="0" max="10"
value={discovery_range.to_string()}
oninput={{
let dr = discovery_range.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
if let Ok(v) = input.value().parse() {
dr.set(v);
}
}) })
}}> }}
<option value="interleave" selected={*ordering == "interleave"}>{ "Interleave (spread artists)" }</option> />
<option value="score" selected={*ordering == "score"}>{ "By Score (best first)" }</option> <label class="tooltip-wrap">
<option value="random" selected={*ordering == "random"}>{ "Random" }</option> { format!("Seed Weight: {}", *seed_weight) }
</select> <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"
min="0" max="10"
value={seed_weight.to_string()}
oninput={{
let sw = seed_weight.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
if let Ok(v) = input.value().parse() {
sw.set(v);
}
})
}}
/>
<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"
min="0" max="10"
value={global_popularity.to_string()}
oninput={{
let gp = global_popularity.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
if let Ok(v) = input.value().parse() {
gp.set(v);
}
})
}}
/>
<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| {
let select: HtmlSelectElement = e.target_unchecked_into();
if let Ok(v) = select.value().parse() {
mtpa.set(v);
}
})
}}>
<option value="0" selected={*max_tracks_per_artist == 0}>{ "Auto" }</option>
{ for [1u8, 2, 3, 5, 10, 15, 20, 25, 30, 50].iter().map(|n| html! {
<option value={n.to_string()} selected={*max_tracks_per_artist == *n}>
{ n.to_string() }
</option>
})}
</select>
</div>
<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| {
let select: HtmlSelectElement = e.target_unchecked_into();
if let Ok(v) = select.value().parse() {
ma.set(v);
}
})
}}>
<option value="0" selected={*max_artists == 0}>{ "Unlimited" }</option>
{ for [5u8, 10, 15, 20, 25, 30, 50, 75, 100].iter().map(|n| html! {
<option value={n.to_string()} selected={*max_artists == *n}>
{ n.to_string() }
</option>
})}
</select>
</div>
<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>
</div> </div>
} }
@@ -502,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();
+28 -1
View File
@@ -35,6 +35,8 @@ pub struct ArtistListItem {
pub total_watched: usize, pub total_watched: usize,
pub total_owned: usize, pub total_owned: usize,
pub total_items: usize, pub total_items: usize,
#[serde(default)]
pub local_tracks: u64,
} }
#[derive(Debug, Clone, PartialEq, Deserialize)] #[derive(Debug, Clone, PartialEq, Deserialize)]
@@ -77,6 +79,19 @@ pub struct FullArtistDetail {
pub artist_bio: Option<String>, pub artist_bio: Option<String>,
#[serde(default)] #[serde(default)]
pub artist_banner: Option<String>, pub artist_banner: Option<String>,
#[serde(default)]
pub top_songs: Vec<TopSongFe>,
#[serde(default)]
pub lastfm_available: bool,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct TopSongFe {
pub name: String,
#[serde(default)]
pub playcount: u64,
pub mbid: Option<String>,
pub status: Option<String>,
} }
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
@@ -347,10 +362,22 @@ pub struct GenerateRequest {
pub ordering: String, pub ordering: String,
#[serde(default)] #[serde(default)]
pub rules: Option<SmartRulesInput>, pub rules: Option<SmartRulesInput>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub discovery_range: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub global_popularity: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub country_filter: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seed_weight: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tracks_per_artist: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_artists: Option<u8>,
} }
fn default_playlist_count() -> usize { fn default_playlist_count() -> usize {
50 30
} }
fn default_popularity_bias() -> u8 { fn default_popularity_bias() -> u8 {
+137 -7
View File
@@ -148,13 +148,27 @@ a:hover { color: var(--accent-hover); }
color: var(--text-secondary); color: var(--text-secondary);
} }
.artist-links a:hover { color: var(--accent); } .artist-links a:hover { color: var(--accent); }
.artist-banner { .artist-banner-wrap {
width: 100%; background-size: 100% auto;
height: 200px; background-position: top center;
background-size: cover; background-repeat: no-repeat;
background-position: center;
border-radius: var(--radius); border-radius: var(--radius);
margin-bottom: 1rem; margin: -2rem -2rem 1rem -2rem;
padding: 2rem;
padding-top: 30%;
position: relative;
}
.artist-banner-wrap::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(to bottom, rgba(15,23,42,0.4) 0%, rgba(15,23,42,0.85) 60%, rgba(15,23,42,0.95) 100%);
border-radius: var(--radius);
pointer-events: none;
}
.artist-banner-wrap > * {
position: relative;
z-index: 1;
} }
.artist-photo { .artist-photo {
width: 120px; width: 120px;
@@ -214,7 +228,7 @@ a:hover { color: var(--accent-hover); }
/* Tables */ /* Tables */
table { width: 100%; border-collapse: collapse; } table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 0.75rem; border-bottom: 1px solid var(--border); } th, td { text-align: left; padding: 0.75rem; border-bottom: 1px solid var(--border); }
th { color: var(--text-secondary); font-weight: 600; font-size: 0.85rem; text-transform: uppercase; } th { color: var(--text-secondary); font-weight: 600; font-size: 0.85rem; text-transform: uppercase; text-align: center; }
tr:hover { background: var(--bg-card); } tr:hover { background: var(--bg-card); }
/* Buttons */ /* Buttons */
@@ -234,6 +248,38 @@ tr:hover { background: var(--bg-card); }
.btn-danger { background: var(--danger); color: white; } .btn-danger { background: var(--danger); color: white; }
.btn-secondary { background: var(--bg-card); color: var(--text-primary); border: 1px solid var(--border); } .btn-secondary { background: var(--bg-card); color: var(--text-primary); border: 1px solid var(--border); }
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.8rem; } .btn-sm { padding: 0.25rem 0.5rem; font-size: 0.8rem; }
/* Progress bars for library columns */
.progress-bar-wrap {
position: relative;
background: var(--bg-card);
border-radius: 4px;
height: 1.4rem;
min-width: 4.5rem;
overflow: hidden;
}
.progress-bar-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
border-radius: 4px;
opacity: 0.3;
transition: width 0.3s ease;
}
.progress-bar-text {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 0.8rem;
font-weight: 600;
color: var(--text-primary);
}
.btn-fixed { min-width: 5.5rem; text-align: center; }
.btn-lib { min-width: 4.5rem; text-align: center; padding-left: 0.25rem; padding-right: 0.25rem; }
/* Forms */ /* Forms */
input, select { input, select {
@@ -399,4 +445,88 @@ tr[draggable="true"]:active { cursor: grabbing; }
.album-art-lg { width: 120px; height: 120px; } .album-art-lg { width: 120px; height: 120px; }
.album-header { flex-direction: column; } .album-header { flex-direction: column; }
.artist-photo { width: 80px; height: 80px; } .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 */
.scroll-track {
position: fixed;
right: 18px;
top: 0;
bottom: 0;
width: 28px;
z-index: 100;
pointer-events: none;
overflow: hidden;
}
.scroll-track-letter {
pointer-events: auto;
font-size: 18px;
font-weight: 600;
color: var(--text-secondary);
cursor: pointer;
padding: 0 4px;
line-height: 1;
user-select: none;
display: flex;
align-items: flex-start;
justify-content: center;
overflow: hidden;
}
.scroll-track-letter:hover {
color: var(--accent);
} }
+182 -10
View File
@@ -2,7 +2,7 @@ use actix_session::Session;
use actix_web::{HttpResponse, web}; use actix_web::{HttpResponse, web};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use shanty_data::{ArtistBioFetcher, ArtistImageFetcher, MetadataFetcher}; use shanty_data::{ArtistBioFetcher, ArtistImageFetcher, MetadataFetcher, SimilarArtistFetcher};
use shanty_db::entities::wanted_item::WantedStatus; use shanty_db::entities::wanted_item::WantedStatus;
use shanty_db::queries; use shanty_db::queries;
use shanty_search::SearchProvider; use shanty_search::SearchProvider;
@@ -38,6 +38,7 @@ struct ArtistListItem {
total_watched: usize, total_watched: usize,
total_owned: usize, total_owned: usize,
total_items: usize, total_items: usize,
local_tracks: u64,
} }
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
@@ -93,11 +94,17 @@ async fn list_artists(
query: web::Query<PaginationParams>, query: web::Query<PaginationParams>,
) -> Result<HttpResponse, ApiError> { ) -> Result<HttpResponse, ApiError> {
auth::require_auth(&session)?; auth::require_auth(&session)?;
let artists = queries::artists::list(state.db.conn(), query.limit, query.offset).await?; let artists = if query.limit == 0 {
queries::artists::list_all(state.db.conn()).await?
} else {
queries::artists::list(state.db.conn(), query.limit, query.offset).await?
};
let wanted = queries::wanted::list(state.db.conn(), None, None).await?;
let mut items: Vec<ArtistListItem> = Vec::new(); let mut items: Vec<ArtistListItem> = Vec::new();
for a in &artists { for a in &artists {
// Check if we have cached artist-level totals from a prior detail page load // Get total_items from enrichment cache (total available tracks from MB)
let cache_key = format!("artist_totals:{}", a.id); let cache_key = format!("artist_totals:{}", a.id);
let cached_totals: Option<(u32, u32, u32)> = let cached_totals: Option<(u32, u32, u32)> =
if let Ok(Some(json)) = queries::cache::get(state.db.conn(), &cache_key).await { if let Ok(Some(json)) = queries::cache::get(state.db.conn(), &cache_key).await {
@@ -107,12 +114,31 @@ async fn list_artists(
}; };
let enriched = cached_totals.is_some(); let enriched = cached_totals.is_some();
let (total_watched, total_owned, total_items) = let total_items = cached_totals
if let Some((avail, watched, owned)) = cached_totals { .map(|(avail, _, _)| avail as usize)
(watched as usize, owned as usize, avail as usize) .unwrap_or(0);
} else {
(0, 0, 0) // Compute watched/owned live from wanted_items (always current)
}; let artist_wanted: Vec<_> = wanted
.iter()
.filter(|w| w.artist_id == Some(a.id))
.collect();
// Deduplicate by MBID to match the detail page's counting logic
let total_watched = artist_wanted
.iter()
.filter_map(|w| w.musicbrainz_id.as_deref())
.collect::<std::collections::HashSet<_>>()
.len();
let total_owned = artist_wanted
.iter()
.filter(|w| w.status == WantedStatus::Owned)
.filter_map(|w| w.musicbrainz_id.as_deref())
.collect::<std::collections::HashSet<_>>()
.len();
let local_tracks = queries::tracks::count_by_artist(state.db.conn(), a.id)
.await
.unwrap_or(0);
items.push(ArtistListItem { items.push(ArtistListItem {
id: a.id, id: a.id,
@@ -123,6 +149,7 @@ async fn list_artists(
total_watched, total_watched,
total_owned, total_owned,
total_items, total_items,
local_tracks,
}); });
} }
@@ -373,6 +400,111 @@ pub async fn enrich_artist(
.await; .await;
tracing::debug!(mbid = %mbid, has_photo = artist_photo.is_some(), has_bio = artist_bio.is_some(), has_banner = artist_banner.is_some(), "artist enrichment data"); tracing::debug!(mbid = %mbid, has_photo = artist_photo.is_some(), has_bio = artist_bio.is_some(), has_banner = artist_banner.is_some(), "artist enrichment data");
// Fetch top songs from Last.fm (cached 7 days)
let top_songs: Vec<serde_json::Value> = if let Some(ref key) = lastfm_api_key {
let cache_key = format!("lastfm_top_tracks:{mbid}");
let cached: Option<Vec<shanty_data::PopularTrack>> =
if let Ok(Some(json)) = queries::cache::get(state.db.conn(), &cache_key).await {
serde_json::from_str(&json).ok()
} else {
None
};
let tracks = if let Some(cached) = cached {
cached
} else {
match shanty_data::LastFmSimilarFetcher::new(key.clone()) {
Ok(fetcher) => {
match fetcher.get_top_tracks(&artist.name, Some(&mbid)).await {
Ok(tracks) => {
// Cache for 7 days
if let Ok(json) = serde_json::to_string(&tracks) {
let _ = queries::cache::set(
state.db.conn(),
&cache_key,
"lastfm",
&json,
7 * 86400,
)
.await;
// Also persist on artist record
if let Some(local_id) = id {
let _ = queries::artists::update_top_songs(
state.db.conn(),
local_id,
&json,
)
.await;
}
}
tracks
}
Err(e) => {
tracing::warn!(error = %e, "failed to fetch Last.fm top tracks");
vec![]
}
}
}
Err(e) => {
tracing::warn!(error = %e, "failed to create Last.fm fetcher");
vec![]
}
}
};
// Cross-reference with wanted items to add status.
// Resolve each top song title → discography recording MBID → wanted_item.
// This uses the same fuzzy-match + album-preference logic as add_track,
// so the MBID is guaranteed to match a recording on the discography page.
let all_wanted = queries::wanted::list(state.db.conn(), None, None).await?;
let wanted_by_mbid: std::collections::HashMap<
&str,
&shanty_db::entities::wanted_item::Model,
> = all_wanted
.iter()
.filter(|w| id.is_some() && w.artist_id == id)
.filter_map(|w| w.musicbrainz_id.as_deref().map(|m| (m, w)))
.collect();
// Load the discography cache for fuzzy title → MBID resolution
let disc_recordings: Vec<shanty_watch::DiscRecording> =
if let Some(ref artist_mbid) = artist.musicbrainz_id {
let cache_key = format!("artist_known_recordings:{artist_mbid}");
if let Ok(Some(json)) = queries::cache::get(state.db.conn(), &cache_key).await {
serde_json::from_str(&json).unwrap_or_default()
} else {
vec![]
}
} else {
vec![]
};
tracks
.iter()
.map(|t| {
// Resolve the top song title to a discography MBID, then look up the wanted item
let matched = shanty_watch::resolve_from_discography(&t.name, &disc_recordings)
.and_then(|disc| wanted_by_mbid.get(disc.mbid.as_str()).copied());
let status = matched.map(|w| match w.status {
WantedStatus::Owned => "owned",
WantedStatus::Downloaded => "downloaded",
WantedStatus::Wanted => "wanted",
WantedStatus::Available => "available",
});
serde_json::json!({
"name": t.name,
"playcount": t.playcount,
"mbid": t.mbid,
"status": status,
})
})
.collect()
} else {
vec![]
};
let lastfm_available = lastfm_api_key.is_some();
// Fetch release groups and split into primary vs featured // Fetch release groups and split into primary vs featured
let all_release_groups = state let all_release_groups = state
.search .search
@@ -453,6 +585,7 @@ pub async fn enrich_artist(
let mut seen_watched: std::collections::HashSet<String> = std::collections::HashSet::new(); let mut seen_watched: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut seen_owned: std::collections::HashSet<String> = std::collections::HashSet::new(); let mut seen_owned: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut albums: Vec<FullAlbumInfo> = Vec::new(); let mut albums: Vec<FullAlbumInfo> = Vec::new();
let mut disc_recordings: Vec<shanty_watch::DiscRecording> = Vec::new();
for rg in &release_groups { for rg in &release_groups {
if skip_track_fetch { if skip_track_fetch {
@@ -519,9 +652,20 @@ pub async fn enrich_artist(
let mut owned: u32 = 0; let mut owned: u32 = 0;
let mut downloaded: u32 = 0; let mut downloaded: u32 = 0;
let rg_type = rg.primary_type.clone().unwrap_or_default();
let rg_date = rg.first_release_date.clone();
for track in &cached.tracks { for track in &cached.tracks {
let rec_id = &track.recording_mbid; let rec_id = &track.recording_mbid;
// Collect for known_recordings cache rebuild
disc_recordings.push(shanty_watch::DiscRecording {
mbid: rec_id.clone(),
title: track.title.clone(),
rg_type: rg_type.clone(),
rg_date: rg_date.clone(),
});
// Add to artist-level unique available set // Add to artist-level unique available set
seen_available.insert(rec_id.clone()); seen_available.insert(rec_id.clone());
@@ -581,6 +725,17 @@ pub async fn enrich_artist(
}); });
} }
// Rebuild the known_recordings cache from the detail page's actual track data.
// This ensures add_track's fast path uses MBIDs that match the displayed release groups.
if !skip_track_fetch
&& !disc_recordings.is_empty()
&& let Ok(json) = serde_json::to_string(&disc_recordings)
{
let cache_key = format!("artist_known_recordings:{mbid}");
let _ =
queries::cache::set(state.db.conn(), &cache_key, "computed", &json, 7 * 86400).await;
}
// Sort: owned first, then partial, then wanted, then unwatched; within each by date // Sort: owned first, then partial, then wanted, then unwatched; within each by date
albums.sort_by(|a, b| { albums.sort_by(|a, b| {
let order = |s: &str| match s { let order = |s: &str| match s {
@@ -646,6 +801,8 @@ pub async fn enrich_artist(
"artist_photo": artist_photo, "artist_photo": artist_photo,
"artist_bio": artist_bio, "artist_bio": artist_bio,
"artist_banner": artist_banner, "artist_banner": artist_banner,
"top_songs": top_songs,
"lastfm_available": lastfm_available,
})) }))
} }
@@ -840,13 +997,28 @@ async fn delete_artist(
let id = path.into_inner(); let id = path.into_inner();
let conn = state.db.conn(); let conn = state.db.conn();
// Cascade: remove wanted items, tracks (DB only), albums, cache, then artist // Get tracks before deleting so we can remove files from disk
let tracks = queries::tracks::get_by_artist(conn, id).await?;
let library_path = state.config.read().await.library_path.clone();
// Cascade: remove wanted items, tracks, albums, cache, then artist
queries::wanted::remove_by_artist(conn, id).await?; queries::wanted::remove_by_artist(conn, id).await?;
queries::tracks::delete_by_artist(conn, id).await?; queries::tracks::delete_by_artist(conn, id).await?;
queries::albums::delete_by_artist(conn, id).await?; queries::albums::delete_by_artist(conn, id).await?;
let _ = queries::cache::purge_prefix(conn, &format!("artist_totals:{id}")).await; let _ = queries::cache::purge_prefix(conn, &format!("artist_totals:{id}")).await;
queries::artists::delete(conn, id).await?; queries::artists::delete(conn, id).await?;
// Delete files from disk and clean up empty directories
for track in &tracks {
let path = std::path::Path::new(&track.file_path);
if path.exists() {
let _ = std::fs::remove_file(path);
if let Some(parent) = path.parent() {
shanty_org::cleanup_empty_dirs(parent, &library_path);
}
}
}
Ok(HttpResponse::NoContent().finish()) Ok(HttpResponse::NoContent().finish())
} }
+60 -4
View File
@@ -1,14 +1,62 @@
use std::future::Future;
use std::pin::Pin;
use actix_session::Session; use actix_session::Session;
use actix_web::{HttpResponse, web}; use actix_web::{HttpResponse, web};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sea_orm::DatabaseConnection;
use shanty_data::HybridMusicBrainzFetcher;
use shanty_db::queries; use shanty_db::queries;
use shanty_playlist::{self, PlaylistRequest}; use shanty_playlist::{self, CountryLookup, PlaylistRequest, SimilarConfig};
use crate::auth; use crate::auth;
use crate::error::ApiError; use crate::error::ApiError;
use crate::state::AppState; use crate::state::AppState;
/// Country lookup backed by search_cache + local MusicBrainz DB.
/// Never hits the remote MB API — unknown artists pass through the filter.
struct CachedCountryLookup<'a> {
conn: &'a DatabaseConnection,
mb: &'a HybridMusicBrainzFetcher,
}
impl CountryLookup for CachedCountryLookup<'_> {
fn get_country<'a>(
&'a self,
mbid: &'a str,
) -> Pin<Box<dyn Future<Output = Option<String>> + Send + 'a>> {
Box::pin(async move {
// 1. Check search_cache
let cache_key = format!("mb_artist_country:{mbid}");
if let Ok(Some(cached)) = queries::cache::get(self.conn, &cache_key).await {
return if cached == "__none__" {
None
} else {
Some(cached)
};
}
// 2. Try local MB DB only (no remote API)
let info = self.mb.get_artist_info_local(mbid)?;
let country = info.country;
// Cache the result (30 days)
let cache_val = country.as_deref().unwrap_or("__none__");
let _ = queries::cache::set(
self.conn,
&cache_key,
"musicbrainz",
cache_val,
30 * 24 * 3600,
)
.await;
country
})
}
}
pub fn configure(cfg: &mut web::ServiceConfig) { pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(web::resource("/playlists/generate").route(web::post().to(generate_playlist))) cfg.service(web::resource("/playlists/generate").route(web::post().to(generate_playlist)))
.service( .service(
@@ -58,13 +106,21 @@ async fn generate_playlist(
} }
let fetcher = shanty_data::LastFmSimilarFetcher::new(api_key) let fetcher = shanty_data::LastFmSimilarFetcher::new(api_key)
.map_err(|e| ApiError::Internal(e.to_string()))?; .map_err(|e| ApiError::Internal(e.to_string()))?;
let similar_config = SimilarConfig::from_request(&req);
let country_lookup = if similar_config.country_filter {
Some(CachedCountryLookup {
conn,
mb: &state.mb_client,
})
} else {
None
};
shanty_playlist::similar_artists( shanty_playlist::similar_artists(
conn, conn,
&fetcher, &fetcher,
req.seed_artists, req.seed_artists,
req.count, &similar_config,
req.popularity_bias, country_lookup.as_ref().map(|c| c as &dyn CountryLookup),
&req.ordering,
) )
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))? .map_err(|e| ApiError::Internal(e.to_string()))?
+81 -11
View File
@@ -1,36 +1,37 @@
use actix_web::{HttpRequest, HttpResponse, web}; use actix_web::{HttpResponse, web};
use crate::state::AppState; use crate::state::AppState;
use super::helpers::{authenticate, get_query_param, parse_subsonic_id}; use super::SubsonicArgs;
use super::helpers::{authenticate, parse_subsonic_id};
use super::response; use super::response;
/// GET /rest/scrobble[.view] /// /rest/scrobble[.view] — currently logs only; full scrobble persistence
pub async fn scrobble(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse { /// (last.fm session keys, listen history table) is a follow-up.
let (params, user) = match authenticate(&req, &state).await { pub async fn scrobble(args: SubsonicArgs, state: web::Data<AppState>) -> HttpResponse {
let (params, user) = match authenticate(&args, &state).await {
Ok(v) => v, Ok(v) => v,
Err(resp) => return resp, Err(resp) => return resp,
}; };
let id_str = match get_query_param(&req, "id") { let id_str = match args.get("id") {
Some(id) => id, Some(id) => id,
None => { None => {
return response::error( return response::error(
&params.format, &params,
response::ERROR_MISSING_PARAM, response::ERROR_MISSING_PARAM,
"missing required parameter: id", "missing required parameter: id",
); );
} }
}; };
let (prefix, entity_id) = match parse_subsonic_id(&id_str) { let (prefix, entity_id) = match parse_subsonic_id(id_str) {
Some(v) => v, Some(v) => v,
None => { None => {
return response::error(&params.format, response::ERROR_NOT_FOUND, "invalid id"); return response::error(&params, response::ERROR_NOT_FOUND, "invalid id");
} }
}; };
// Log the scrobble for now; full play tracking can be added later
tracing::info!( tracing::info!(
user = %user.username, user = %user.username,
id_type = prefix, id_type = prefix,
@@ -38,5 +39,74 @@ pub async fn scrobble(req: HttpRequest, state: web::Data<AppState>) -> HttpRespo
"subsonic scrobble" "subsonic scrobble"
); );
response::ok(&params.format, serde_json::json!({})) response::ok(&params, serde_json::json!({}))
}
/// /rest/star[.view] — no-op stub. Persisting stars requires a `starred_items`
/// table; tracked as a follow-up.
pub async fn star(args: SubsonicArgs, state: web::Data<AppState>) -> HttpResponse {
let (params, _user) = match authenticate(&args, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
response::ok(&params, serde_json::json!({}))
}
/// /rest/unstar[.view] — no-op stub.
pub async fn unstar(args: SubsonicArgs, state: web::Data<AppState>) -> HttpResponse {
let (params, _user) = match authenticate(&args, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
response::ok(&params, serde_json::json!({}))
}
/// /rest/getStarred2[.view] — returns empty starred lists.
pub async fn get_starred2(args: SubsonicArgs, state: web::Data<AppState>) -> HttpResponse {
let (params, _user) = match authenticate(&args, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
response::ok(
&params,
serde_json::json!({
"starred2": {
"artist": [],
"album": [],
"song": [],
}
}),
)
}
/// /rest/getStarred[.view] — folder-based mirror of getStarred2.
pub async fn get_starred(args: SubsonicArgs, state: web::Data<AppState>) -> HttpResponse {
let (params, _user) = match authenticate(&args, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
response::ok(
&params,
serde_json::json!({
"starred": {
"artist": [],
"album": [],
"song": [],
}
}),
)
}
/// /rest/setRating[.view] — no-op stub.
pub async fn set_rating(args: SubsonicArgs, state: web::Data<AppState>) -> HttpResponse {
let (params, _user) = match authenticate(&args, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
response::ok(&params, serde_json::json!({}))
} }
+190
View File
@@ -0,0 +1,190 @@
//! Subsonic request parameter extractor.
//!
//! Implements a custom actix-web `FromRequest` extractor that reads parameters
//! from the URL query string AND, when the request is a form-encoded POST, from
//! the body. This is required by the OpenSubsonic spec which mandates support
//! for `application/x-www-form-urlencoded` POST bodies (used by clients like
//! mopidy-subidy and to overcome URL length limits on `updatePlaylist`-style
//! calls with many `songIdToAdd` parameters).
//!
//! Note: this extractor consumes the request payload via `payload.take()`. Do
//! NOT combine it with another body-consuming extractor (`web::Bytes`,
//! `web::Form`, `web::Json`, `web::Payload`) in the same handler — they will
//! conflict. Combining with `HttpRequest` is fine; that only reads headers.
use actix_web::{
FromRequest, HttpRequest, dev::Payload, error::ErrorPayloadTooLarge, http::Method, web,
};
use futures_util::StreamExt;
use futures_util::future::LocalBoxFuture;
const MAX_FORM_BODY_BYTES: usize = 1024 * 1024; // 1 MiB
/// Merged parameter bag from a Subsonic request.
///
/// Holds all `(key, value)` pairs from the URL query string and (for
/// form-encoded POSTs) the request body. Lookups preserve insertion order, so
/// query-string params come first, then body params.
#[derive(Debug, Clone, Default)]
pub struct SubsonicArgs(Vec<(String, String)>);
impl SubsonicArgs {
/// Build directly from a list of pairs (used in tests).
#[cfg(test)]
pub fn from_pairs(pairs: Vec<(String, String)>) -> Self {
Self(pairs)
}
/// Get the first value for a given key.
pub fn get(&self, name: &str) -> Option<&str> {
self.0
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| v.as_str())
}
/// Get all values for a given key (for repeated params like `songId`).
pub fn get_all(&self, name: &str) -> Vec<&str> {
self.0
.iter()
.filter(|(k, _)| k == name)
.map(|(_, v)| v.as_str())
.collect()
}
/// Parse a single param via `FromStr`.
pub fn get_parsed<T: std::str::FromStr>(&self, name: &str) -> Option<T> {
self.get(name).and_then(|v| v.parse().ok())
}
}
impl FromRequest for SubsonicArgs {
type Error = actix_web::Error;
type Future = LocalBoxFuture<'static, Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future {
let qs = req.query_string().to_string();
let is_form_post = req.method() == Method::POST
&& req
.headers()
.get(actix_web::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|s| s.starts_with("application/x-www-form-urlencoded"))
.unwrap_or(false);
let mut payload = payload.take();
Box::pin(async move {
let mut params: Vec<(String, String)> =
serde_urlencoded::from_str(&qs).unwrap_or_default();
if is_form_post {
let mut body = web::BytesMut::new();
while let Some(chunk) = payload.next().await {
let chunk = chunk?;
if body.len() + chunk.len() > MAX_FORM_BODY_BYTES {
return Err(ErrorPayloadTooLarge("subsonic form body too large"));
}
body.extend_from_slice(&chunk);
}
if let Ok(body_params) =
serde_urlencoded::from_bytes::<Vec<(String, String)>>(&body)
{
params.extend(body_params);
}
}
Ok(SubsonicArgs(params))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use actix_web::test::TestRequest;
async fn extract(req: TestRequest) -> SubsonicArgs {
let (req, mut pl) = req.to_http_parts();
SubsonicArgs::from_request(&req, &mut pl).await.unwrap()
}
#[actix_web::test]
async fn parses_query_string_on_get() {
let args =
extract(TestRequest::get().uri("/rest/ping?u=alice&p=secret&v=1.14.0&c=test")).await;
assert_eq!(args.get("u"), Some("alice"));
assert_eq!(args.get("p"), Some("secret"));
assert_eq!(args.get("v"), Some("1.14.0"));
assert_eq!(args.get("c"), Some("test"));
}
#[actix_web::test]
async fn parses_form_body_on_post() {
let args = extract(
TestRequest::post()
.uri("/rest/ping")
.insert_header(("content-type", "application/x-www-form-urlencoded"))
.set_payload("u=alice&p=secret&v=1.14.0"),
)
.await;
assert_eq!(args.get("u"), Some("alice"));
assert_eq!(args.get("p"), Some("secret"));
assert_eq!(args.get("v"), Some("1.14.0"));
}
#[actix_web::test]
async fn merges_query_and_body_on_post() {
let args = extract(
TestRequest::post()
.uri("/rest/updatePlaylist?u=alice&p=secret&v=1.14.0&c=test&f=json")
.insert_header(("content-type", "application/x-www-form-urlencoded"))
.set_payload("playlistId=pl-1&songIdToAdd=tr-2&songIdToAdd=tr-3"),
)
.await;
assert_eq!(args.get("u"), Some("alice"));
assert_eq!(args.get("playlistId"), Some("pl-1"));
assert_eq!(args.get_all("songIdToAdd"), vec!["tr-2", "tr-3"]);
}
#[actix_web::test]
async fn ignores_body_when_content_type_is_wrong() {
let args = extract(
TestRequest::post()
.uri("/rest/ping?u=alice")
.insert_header(("content-type", "application/json"))
.set_payload(r#"{"p":"secret"}"#),
)
.await;
assert_eq!(args.get("u"), Some("alice"));
assert_eq!(args.get("p"), None);
}
#[actix_web::test]
async fn get_all_returns_repeated_params() {
let args = extract(
TestRequest::get()
.uri("/rest/createPlaylist?name=mix&songId=tr-1&songId=tr-2&songId=tr-3"),
)
.await;
assert_eq!(args.get_all("songId"), vec!["tr-1", "tr-2", "tr-3"]);
}
#[actix_web::test]
async fn get_parsed_works() {
let args = extract(TestRequest::get().uri("/rest/getRandomSongs?size=42")).await;
assert_eq!(args.get_parsed::<u32>("size"), Some(42));
assert_eq!(args.get_parsed::<u32>("missing"), None);
}
#[actix_web::test]
async fn rejects_oversized_body() {
let huge = "x=".to_string() + &"a".repeat(MAX_FORM_BODY_BYTES + 10);
let (req, mut pl) = TestRequest::post()
.uri("/rest/ping")
.insert_header(("content-type", "application/x-www-form-urlencoded"))
.set_payload(huge)
.to_http_parts();
let result = SubsonicArgs::from_request(&req, &mut pl).await;
assert!(result.is_err());
}
}
+44 -30
View File
@@ -1,10 +1,11 @@
use actix_web::HttpRequest;
use md5::{Digest, Md5}; use md5::{Digest, Md5};
use sea_orm::DatabaseConnection; use sea_orm::DatabaseConnection;
use shanty_db::entities::user::Model as User; use shanty_db::entities::user::Model as User;
use shanty_db::queries; use shanty_db::queries;
use super::SubsonicArgs;
/// Subsonic authentication method. /// Subsonic authentication method.
pub enum AuthMethod { pub enum AuthMethod {
/// Modern: token = md5(password + salt) /// Modern: token = md5(password + salt)
@@ -13,22 +14,27 @@ pub enum AuthMethod {
Password(String), Password(String),
/// Legacy: hex-encoded password (p=enc:hexstring) /// Legacy: hex-encoded password (p=enc:hexstring)
HexPassword(String), HexPassword(String),
/// OpenSubsonic API key. We currently store these in the same column as
/// `subsonic_password` so they share the same verification path.
ApiKey(String),
} }
/// Common Subsonic API parameters extracted from the query string. /// Common Subsonic API parameters extracted from the request args.
pub struct SubsonicParams { pub struct SubsonicParams {
/// Username /// Username
pub username: String, pub username: String,
/// Authentication method + credentials /// Authentication method + credentials
pub auth: AuthMethod, pub auth: AuthMethod,
/// API version requested /// API version requested by the client (whatever they sent in `v`).
#[allow(dead_code)] #[allow(dead_code)]
pub version: String, pub version: Option<String>,
/// Client name /// Client name (whatever they sent in `c`).
#[allow(dead_code)] #[allow(dead_code)]
pub client: String, pub client: String,
/// Response format: "xml" or "json" /// Response format: "xml", "json", or "jsonp".
pub format: String, pub format: String,
/// JSONP callback name (only meaningful when `format == "jsonp"`).
pub callback: Option<String>,
} }
pub enum SubsonicAuthError { pub enum SubsonicAuthError {
@@ -37,35 +43,36 @@ pub enum SubsonicAuthError {
} }
impl SubsonicParams { impl SubsonicParams {
/// Extract Subsonic params from the query string. /// Build from the merged request args. Auth params may come from the
pub fn from_request(req: &HttpRequest) -> Result<Self, SubsonicAuthError> { /// query string or (for form-encoded POSTs) the body — `SubsonicArgs`
let qs = req.query_string(); /// merges both, and we accept either location.
let params: Vec<(String, String)> = serde_urlencoded::from_str(qs).unwrap_or_default(); pub fn from_args(args: &SubsonicArgs) -> Result<Self, SubsonicAuthError> {
let username = args
.get("u")
.ok_or_else(|| SubsonicAuthError::MissingParam("u".into()))?
.to_string();
let version = args.get("v").map(|s| s.to_string());
let client = args.get("c").unwrap_or("unknown").to_string();
let format = args.get("f").unwrap_or("xml").to_string();
let callback = args.get("callback").map(|s| s.to_string());
let get = |name: &str| -> Option<String> { // Auth precedence: apiKey > token > legacy password.
params let auth = if let Some(key) = args.get("apiKey") {
.iter() AuthMethod::ApiKey(key.to_string())
.find(|(k, _)| k == name) } else if let (Some(token), Some(salt)) = (args.get("t"), args.get("s")) {
.map(|(_, v)| v.clone()) AuthMethod::Token {
}; token: token.to_string(),
salt: salt.to_string(),
let username = get("u").ok_or_else(|| SubsonicAuthError::MissingParam("u".into()))?; }
let version = get("v").unwrap_or_else(|| "1.16.1".into()); } else if let Some(p) = args.get("p") {
let client = get("c").unwrap_or_else(|| "unknown".into());
let format = get("f").unwrap_or_else(|| "xml".into());
// Try token auth first (modern), then legacy password
let auth = if let (Some(token), Some(salt)) = (get("t"), get("s")) {
AuthMethod::Token { token, salt }
} else if let Some(p) = get("p") {
if let Some(hex_str) = p.strip_prefix("enc:") { if let Some(hex_str) = p.strip_prefix("enc:") {
AuthMethod::HexPassword(hex_str.to_string()) AuthMethod::HexPassword(hex_str.to_string())
} else { } else {
AuthMethod::Password(p) AuthMethod::Password(p.to_string())
} }
} else { } else {
return Err(SubsonicAuthError::MissingParam( return Err(SubsonicAuthError::MissingParam(
"authentication required (t+s or p)".into(), "authentication required (apiKey, t+s, or p)".into(),
)); ));
}; };
@@ -75,6 +82,7 @@ impl SubsonicParams {
version, version,
client, client,
format, format,
callback,
}) })
} }
} }
@@ -107,19 +115,25 @@ pub async fn verify_auth(
} }
} }
AuthMethod::Password(password) => { AuthMethod::Password(password) => {
// Direct plaintext comparison
if password != subsonic_password { if password != subsonic_password {
return Err(SubsonicAuthError::AuthFailed); return Err(SubsonicAuthError::AuthFailed);
} }
} }
AuthMethod::HexPassword(hex_str) => { AuthMethod::HexPassword(hex_str) => {
// Decode hex to string, compare
let decoded = hex::decode(hex_str).map_err(|_| SubsonicAuthError::AuthFailed)?; let decoded = hex::decode(hex_str).map_err(|_| SubsonicAuthError::AuthFailed)?;
let password = String::from_utf8(decoded).map_err(|_| SubsonicAuthError::AuthFailed)?; let password = String::from_utf8(decoded).map_err(|_| SubsonicAuthError::AuthFailed)?;
if password != subsonic_password { if password != subsonic_password {
return Err(SubsonicAuthError::AuthFailed); return Err(SubsonicAuthError::AuthFailed);
} }
} }
AuthMethod::ApiKey(key) => {
// Until we have a dedicated apiKey column, accept the same value
// stored in subsonic_password. This is enough to make API-key
// capable clients connect; we can split the storage later.
if key != subsonic_password {
return Err(SubsonicAuthError::AuthFailed);
}
}
} }
Ok(user) Ok(user)
File diff suppressed because it is too large Load Diff
+8 -23
View File
@@ -1,31 +1,24 @@
use actix_web::{HttpRequest, HttpResponse, web}; use actix_web::{HttpResponse, web};
use shanty_db::entities::user::Model as User; use shanty_db::entities::user::Model as User;
use crate::state::AppState; use crate::state::AppState;
use super::SubsonicArgs;
use super::auth::{SubsonicAuthError, SubsonicParams, verify_auth}; use super::auth::{SubsonicAuthError, SubsonicParams, verify_auth};
use super::response; use super::response;
/// Extract and authenticate subsonic params, returning an error HttpResponse on failure. /// Extract and authenticate subsonic params, returning an error HttpResponse on failure.
pub async fn authenticate( pub async fn authenticate(
req: &HttpRequest, args: &SubsonicArgs,
state: &web::Data<AppState>, state: &web::Data<AppState>,
) -> Result<(SubsonicParams, User), HttpResponse> { ) -> Result<(SubsonicParams, User), HttpResponse> {
tracing::debug!( let params = SubsonicParams::from_args(args).map_err(|e| match e {
path = req.path(), SubsonicAuthError::MissingParam(name) => response::auth_error(
query = req.query_string(),
"subsonic request"
);
let params = SubsonicParams::from_request(req).map_err(|e| match e {
SubsonicAuthError::MissingParam(name) => response::error(
"xml",
response::ERROR_MISSING_PARAM, response::ERROR_MISSING_PARAM,
&format!("missing required parameter: {name}"), &format!("missing required parameter: {name}"),
), ),
SubsonicAuthError::AuthFailed => response::error( SubsonicAuthError::AuthFailed => response::auth_error(
"xml",
response::ERROR_NOT_AUTHENTICATED, response::ERROR_NOT_AUTHENTICATED,
"wrong username or password", "wrong username or password",
), ),
@@ -35,12 +28,12 @@ pub async fn authenticate(
.await .await
.map_err(|e| match e { .map_err(|e| match e {
SubsonicAuthError::AuthFailed => response::error( SubsonicAuthError::AuthFailed => response::error(
&params.format, &params,
response::ERROR_NOT_AUTHENTICATED, response::ERROR_NOT_AUTHENTICATED,
"wrong username or password", "wrong username or password",
), ),
SubsonicAuthError::MissingParam(name) => response::error( SubsonicAuthError::MissingParam(name) => response::error(
&params.format, &params,
response::ERROR_MISSING_PARAM, response::ERROR_MISSING_PARAM,
&format!("missing required parameter: {name}"), &format!("missing required parameter: {name}"),
), ),
@@ -56,15 +49,7 @@ pub fn parse_subsonic_id(id: &str) -> Option<(&str, i32)> {
let num = num_str.parse().ok()?; let num = num_str.parse().ok()?;
Some((prefix, num)) Some((prefix, num))
} else { } else {
// Plain number — no prefix
let num = id.parse().ok()?; let num = id.parse().ok()?;
Some(("unknown", num)) Some(("unknown", num))
} }
} }
/// Get a query parameter by name from the request.
pub fn get_query_param(req: &HttpRequest, name: &str) -> Option<String> {
let qs = req.query_string();
let params: Vec<(String, String)> = serde_urlencoded::from_str(qs).unwrap_or_default();
params.into_iter().find(|(k, _)| k == name).map(|(_, v)| v)
}
+85 -74
View File
@@ -6,38 +6,43 @@ use shanty_db::queries;
use crate::state::AppState; use crate::state::AppState;
use super::helpers::{authenticate, get_query_param, parse_subsonic_id}; use super::SubsonicArgs;
use super::helpers::{authenticate, parse_subsonic_id};
use super::response; use super::response;
/// GET /rest/stream[.view] /// /rest/stream[.view]
pub async fn stream(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse { pub async fn stream(
let (params, _user) = match authenticate(&req, &state).await { args: SubsonicArgs,
req: HttpRequest,
state: web::Data<AppState>,
) -> HttpResponse {
let (params, _user) = match authenticate(&args, &state).await {
Ok(v) => v, Ok(v) => v,
Err(resp) => return resp, Err(resp) => return resp,
}; };
let id_str = match get_query_param(&req, "id") { let id_str = match args.get("id") {
Some(id) => id, Some(id) => id,
None => { None => {
return response::error( return response::error(
&params.format, &params,
response::ERROR_MISSING_PARAM, response::ERROR_MISSING_PARAM,
"missing required parameter: id", "missing required parameter: id",
); );
} }
}; };
let (_prefix, track_id) = match parse_subsonic_id(&id_str) { let (_prefix, track_id) = match parse_subsonic_id(id_str) {
Some(v) => v, Some(v) => v,
None => { None => {
return response::error(&params.format, response::ERROR_NOT_FOUND, "invalid song id"); return response::error(&params, response::ERROR_NOT_FOUND, "invalid song id");
} }
}; };
let track = match queries::tracks::get_by_id(state.db.conn(), track_id).await { let track = match queries::tracks::get_by_id(state.db.conn(), track_id).await {
Ok(t) => t, Ok(t) => t,
Err(_) => { Err(_) => {
return response::error(&params.format, response::ERROR_NOT_FOUND, "song not found"); return response::error(&params, response::ERROR_NOT_FOUND, "song not found");
} }
}; };
@@ -47,29 +52,21 @@ pub async fn stream(req: HttpRequest, state: web::Data<AppState>) -> HttpRespons
.unwrap_or("") .unwrap_or("")
.to_lowercase(); .to_lowercase();
let requested_format = get_query_param(&req, "format"); let requested_format = args.get("format").map(|s| s.to_string());
let max_bit_rate = get_query_param(&req, "maxBitRate") let max_bit_rate: u32 = args.get_parsed("maxBitRate").unwrap_or(0);
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(0);
// Determine if transcoding is needed:
// - Client explicitly requests a different format
// - File is opus/ogg (many mobile clients can't play these natively)
// - Client requests a specific bitrate
let needs_transcode = match requested_format.as_deref() { let needs_transcode = match requested_format.as_deref() {
Some("raw") => false, // Explicitly asked for no transcoding Some("raw") => false,
Some(fmt) if fmt != file_ext => true, // Different format requested Some(fmt) if fmt != file_ext => true,
_ => { _ => {
// Auto-transcode opus/ogg to mp3 since many clients don't support them
matches!(file_ext.as_str(), "opus" | "ogg") || (max_bit_rate > 0 && max_bit_rate < 320) matches!(file_ext.as_str(), "opus" | "ogg") || (max_bit_rate > 0 && max_bit_rate < 320)
} }
}; };
// Check file exists before doing anything
if !std::path::Path::new(&track.file_path).exists() { if !std::path::Path::new(&track.file_path).exists() {
tracing::error!(path = %track.file_path, "track file not found on disk"); tracing::error!(path = %track.file_path, "track file not found on disk");
return response::error( return response::error(
&params.format, &params,
response::ERROR_NOT_FOUND, response::ERROR_NOT_FOUND,
&format!("file not found: {}", track.file_path), &format!("file not found: {}", track.file_path),
); );
@@ -80,11 +77,7 @@ pub async fn stream(req: HttpRequest, state: web::Data<AppState>) -> HttpRespons
.as_deref() .as_deref()
.filter(|f| *f != "raw") .filter(|f| *f != "raw")
.unwrap_or("mp3"); .unwrap_or("mp3");
let bitrate = if max_bit_rate > 0 { let bitrate = if max_bit_rate > 0 { max_bit_rate } else { 192 };
max_bit_rate
} else {
192 // Default transcoding bitrate
};
let content_type = match target_format { let content_type = match target_format {
"mp3" => "audio/mpeg", "mp3" => "audio/mpeg",
@@ -143,7 +136,7 @@ pub async fn stream(req: HttpRequest, state: web::Data<AppState>) -> HttpRespons
match NamedFile::open_async(&track.file_path).await { match NamedFile::open_async(&track.file_path).await {
Ok(file) => file.into_response(&req), Ok(file) => file.into_response(&req),
Err(_) => response::error( Err(_) => response::error(
&params.format, &params,
response::ERROR_NOT_FOUND, response::ERROR_NOT_FOUND,
"transcoding failed", "transcoding failed",
), ),
@@ -154,57 +147,54 @@ pub async fn stream(req: HttpRequest, state: web::Data<AppState>) -> HttpRespons
tracing::error!(error = %e, "failed to start ffmpeg"); tracing::error!(error = %e, "failed to start ffmpeg");
match NamedFile::open_async(&track.file_path).await { match NamedFile::open_async(&track.file_path).await {
Ok(file) => file.into_response(&req), Ok(file) => file.into_response(&req),
Err(_) => { Err(_) => response::error(&params, response::ERROR_NOT_FOUND, "file not found"),
response::error(&params.format, response::ERROR_NOT_FOUND, "file not found")
}
} }
} }
} }
} else { } else {
// Serve the file directly with Range request support
match NamedFile::open_async(&track.file_path).await { match NamedFile::open_async(&track.file_path).await {
Ok(file) => file.into_response(&req), Ok(file) => file.into_response(&req),
Err(e) => { Err(e) => {
tracing::error!(path = %track.file_path, error = %e, "failed to open track file for streaming"); tracing::error!(path = %track.file_path, error = %e, "failed to open track file for streaming");
response::error( response::error(&params, response::ERROR_NOT_FOUND, "file not found on disk")
&params.format,
response::ERROR_NOT_FOUND,
"file not found on disk",
)
} }
} }
} }
} }
/// GET /rest/download[.view] /// /rest/download[.view]
pub async fn download(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse { pub async fn download(
let (params, _user) = match authenticate(&req, &state).await { args: SubsonicArgs,
req: HttpRequest,
state: web::Data<AppState>,
) -> HttpResponse {
let (params, _user) = match authenticate(&args, &state).await {
Ok(v) => v, Ok(v) => v,
Err(resp) => return resp, Err(resp) => return resp,
}; };
let id_str = match get_query_param(&req, "id") { let id_str = match args.get("id") {
Some(id) => id, Some(id) => id,
None => { None => {
return response::error( return response::error(
&params.format, &params,
response::ERROR_MISSING_PARAM, response::ERROR_MISSING_PARAM,
"missing required parameter: id", "missing required parameter: id",
); );
} }
}; };
let (_prefix, track_id) = match parse_subsonic_id(&id_str) { let (_prefix, track_id) = match parse_subsonic_id(id_str) {
Some(v) => v, Some(v) => v,
None => { None => {
return response::error(&params.format, response::ERROR_NOT_FOUND, "invalid song id"); return response::error(&params, response::ERROR_NOT_FOUND, "invalid song id");
} }
}; };
let track = match queries::tracks::get_by_id(state.db.conn(), track_id).await { let track = match queries::tracks::get_by_id(state.db.conn(), track_id).await {
Ok(t) => t, Ok(t) => t,
Err(_) => { Err(_) => {
return response::error(&params.format, response::ERROR_NOT_FOUND, "song not found"); return response::error(&params, response::ERROR_NOT_FOUND, "song not found");
} }
}; };
@@ -224,42 +214,37 @@ pub async fn download(req: HttpRequest, state: web::Data<AppState>) -> HttpRespo
} }
Err(e) => { Err(e) => {
tracing::error!(path = %track.file_path, error = %e, "failed to open track file for download"); tracing::error!(path = %track.file_path, error = %e, "failed to open track file for download");
response::error( response::error(&params, response::ERROR_NOT_FOUND, "file not found on disk")
&params.format,
response::ERROR_NOT_FOUND,
"file not found on disk",
)
} }
} }
} }
/// GET /rest/getCoverArt[.view] /// /rest/getCoverArt[.view]
pub async fn get_cover_art(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse { pub async fn get_cover_art(
let (params, _user) = match authenticate(&req, &state).await { args: SubsonicArgs,
req: HttpRequest,
state: web::Data<AppState>,
) -> HttpResponse {
let (params, _user) = match authenticate(&args, &state).await {
Ok(v) => v, Ok(v) => v,
Err(resp) => return resp, Err(resp) => return resp,
}; };
let id_str = match get_query_param(&req, "id") { let id_str = match args.get("id") {
Some(id) => id, Some(id) => id,
None => { None => {
return response::error( return response::error(
&params.format, &params,
response::ERROR_MISSING_PARAM, response::ERROR_MISSING_PARAM,
"missing required parameter: id", "missing required parameter: id",
); );
} }
}; };
// Cover art IDs can be album IDs (al-N) or artist IDs (ar-N) let (prefix, entity_id) = match parse_subsonic_id(id_str) {
let (prefix, entity_id) = match parse_subsonic_id(&id_str) {
Some(v) => v, Some(v) => v,
None => { None => {
return response::error( return response::error(&params, response::ERROR_NOT_FOUND, "invalid cover art id");
&params.format,
response::ERROR_NOT_FOUND,
"invalid cover art id",
);
} }
}; };
@@ -268,23 +253,17 @@ pub async fn get_cover_art(req: HttpRequest, state: web::Data<AppState>) -> Http
let album = match queries::albums::get_by_id(state.db.conn(), entity_id).await { let album = match queries::albums::get_by_id(state.db.conn(), entity_id).await {
Ok(a) => a, Ok(a) => a,
Err(_) => { Err(_) => {
return response::error( return response::error(&params, response::ERROR_NOT_FOUND, "album not found");
&params.format,
response::ERROR_NOT_FOUND,
"album not found",
);
} }
}; };
if let Some(ref cover_art_path) = album.cover_art_path { if let Some(ref cover_art_path) = album.cover_art_path {
// If it's a URL, redirect to it
if cover_art_path.starts_with("http://") || cover_art_path.starts_with("https://") { if cover_art_path.starts_with("http://") || cover_art_path.starts_with("https://") {
return HttpResponse::TemporaryRedirect() return HttpResponse::TemporaryRedirect()
.append_header(("Location", cover_art_path.as_str())) .append_header(("Location", cover_art_path.as_str()))
.finish(); .finish();
} }
// Otherwise try to serve as a local file
match NamedFile::open_async(cover_art_path).await { match NamedFile::open_async(cover_art_path).await {
Ok(file) => return file.into_response(&req), Ok(file) => return file.into_response(&req),
Err(e) => { Err(e) => {
@@ -293,7 +272,6 @@ pub async fn get_cover_art(req: HttpRequest, state: web::Data<AppState>) -> Http
} }
} }
// If album has a MusicBrainz ID, redirect to Cover Art Archive
if let Some(ref mbid) = album.musicbrainz_id { if let Some(ref mbid) = album.musicbrainz_id {
let url = format!("https://coverartarchive.org/release/{mbid}/front-250"); let url = format!("https://coverartarchive.org/release/{mbid}/front-250");
return HttpResponse::TemporaryRedirect() return HttpResponse::TemporaryRedirect()
@@ -301,12 +279,45 @@ pub async fn get_cover_art(req: HttpRequest, state: web::Data<AppState>) -> Http
.finish(); .finish();
} }
// No cover art available
HttpResponse::NotFound().finish() HttpResponse::NotFound().finish()
} }
_ => { "ar" => {
// For other types, no cover art let artist = match queries::artists::get_by_id(state.db.conn(), entity_id).await {
HttpResponse::NotFound().finish() Ok(a) => a,
Err(_) => return HttpResponse::NotFound().finish(),
};
// Look up the cached artist image — same lookup pattern that
// browsing::get_artist_info2 uses.
let mbid = artist.musicbrainz_id.as_deref().unwrap_or("");
if mbid.is_empty() {
return HttpResponse::NotFound().finish();
}
let config = state.config.read().await;
let image_source = config.metadata.artist_image_source.clone();
drop(config);
let key = format!("artist_image:{image_source}:{mbid}");
let cached = queries::cache::get(state.db.conn(), &key)
.await
.ok()
.flatten()
.filter(|s| !s.is_empty());
if let Some(url) = cached {
if url.starts_with("http://") || url.starts_with("https://") {
HttpResponse::TemporaryRedirect()
.append_header(("Location", url))
.finish()
} else {
match NamedFile::open_async(&url).await {
Ok(file) => file.into_response(&req),
Err(_) => HttpResponse::NotFound().finish(),
}
}
} else {
HttpResponse::NotFound().finish()
}
} }
_ => HttpResponse::NotFound().finish(),
} }
} }
+103 -71
View File
@@ -1,4 +1,5 @@
mod annotation; mod annotation;
mod args;
mod auth; mod auth;
mod browsing; mod browsing;
mod helpers; mod helpers;
@@ -6,81 +7,112 @@ mod media;
mod playlists; mod playlists;
mod response; mod response;
mod search; mod search;
mod stubs;
mod system; mod system;
mod user; mod user;
use actix_web::web; pub(crate) use args::SubsonicArgs;
use actix_web::{Route, guard, web};
/// Construct a route that accepts both GET and POST. Per the OpenSubsonic
/// spec, every Subsonic endpoint must accept `application/x-www-form-urlencoded`
/// POST bodies as well as the legacy GET-with-query-string form, and the
/// `SubsonicArgs` extractor handles both transparently.
fn rest_route() -> Route {
web::route().guard(guard::Any(guard::Get()).or(guard::Post()))
}
/// Register `name` and `name.view` aliases for the same handler. The `.view`
/// suffix is the historical Subsonic spelling and many clients still use it.
macro_rules! register {
($scope:expr, $name:literal, $handler:path) => {{
$scope
.route(concat!("/", $name), rest_route().to($handler))
.route(concat!("/", $name, ".view"), rest_route().to($handler))
}};
}
pub fn configure(cfg: &mut web::ServiceConfig) { pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service( let scope = web::scope("/rest");
web::scope("/rest") let scope = register!(scope, "ping", system::ping);
// System let scope = register!(scope, "getLicense", system::get_license);
.route("/ping", web::get().to(system::ping)) let scope = register!(
.route("/ping.view", web::get().to(system::ping)) scope,
.route("/getLicense", web::get().to(system::get_license)) "getOpenSubsonicExtensions",
.route("/getLicense.view", web::get().to(system::get_license)) system::get_open_subsonic_extensions
// Browsing
.route(
"/getMusicFolders",
web::get().to(browsing::get_music_folders),
)
.route(
"/getMusicFolders.view",
web::get().to(browsing::get_music_folders),
)
.route("/getIndexes", web::get().to(browsing::get_indexes))
.route("/getIndexes.view", web::get().to(browsing::get_indexes))
.route(
"/getMusicDirectory",
web::get().to(browsing::get_music_directory),
)
.route(
"/getMusicDirectory.view",
web::get().to(browsing::get_music_directory),
)
.route("/getArtists", web::get().to(browsing::get_artists))
.route("/getArtists.view", web::get().to(browsing::get_artists))
.route("/getArtist", web::get().to(browsing::get_artist))
.route("/getArtist.view", web::get().to(browsing::get_artist))
.route("/getAlbum", web::get().to(browsing::get_album))
.route("/getAlbum.view", web::get().to(browsing::get_album))
.route("/getSong", web::get().to(browsing::get_song))
.route("/getSong.view", web::get().to(browsing::get_song))
.route("/getGenres", web::get().to(browsing::get_genres))
.route("/getGenres.view", web::get().to(browsing::get_genres))
// Search
.route("/search3", web::get().to(search::search3))
.route("/search3.view", web::get().to(search::search3))
// Media
.route("/stream", web::get().to(media::stream))
.route("/stream.view", web::get().to(media::stream))
.route("/download", web::get().to(media::download))
.route("/download.view", web::get().to(media::download))
.route("/getCoverArt", web::get().to(media::get_cover_art))
.route("/getCoverArt.view", web::get().to(media::get_cover_art))
// Playlists
.route("/getPlaylists", web::get().to(playlists::get_playlists))
.route(
"/getPlaylists.view",
web::get().to(playlists::get_playlists),
)
.route("/getPlaylist", web::get().to(playlists::get_playlist))
.route("/getPlaylist.view", web::get().to(playlists::get_playlist))
.route("/createPlaylist", web::get().to(playlists::create_playlist))
.route(
"/createPlaylist.view",
web::get().to(playlists::create_playlist),
)
.route("/deletePlaylist", web::get().to(playlists::delete_playlist))
.route(
"/deletePlaylist.view",
web::get().to(playlists::delete_playlist),
)
// Annotation
.route("/scrobble", web::get().to(annotation::scrobble))
.route("/scrobble.view", web::get().to(annotation::scrobble))
// User
.route("/getUser", web::get().to(user::get_user))
.route("/getUser.view", web::get().to(user::get_user)),
); );
// Browsing
let scope = register!(scope, "getMusicFolders", browsing::get_music_folders);
let scope = register!(scope, "getIndexes", browsing::get_indexes);
let scope = register!(scope, "getMusicDirectory", browsing::get_music_directory);
let scope = register!(scope, "getArtists", browsing::get_artists);
let scope = register!(scope, "getArtist", browsing::get_artist);
let scope = register!(scope, "getAlbum", browsing::get_album);
let scope = register!(scope, "getSong", browsing::get_song);
let scope = register!(scope, "getGenres", browsing::get_genres);
let scope = register!(scope, "getRandomSongs", browsing::get_random_songs);
let scope = register!(scope, "getSongsByGenre", browsing::get_songs_by_genre);
let scope = register!(scope, "getAlbumList", browsing::get_album_list);
let scope = register!(scope, "getAlbumList2", browsing::get_album_list2);
let scope = register!(scope, "getTopSongs", browsing::get_top_songs);
let scope = register!(scope, "getArtistInfo", browsing::get_artist_info);
let scope = register!(scope, "getArtistInfo2", browsing::get_artist_info2);
let scope = register!(scope, "getAlbumInfo", browsing::get_album_info);
let scope = register!(scope, "getAlbumInfo2", browsing::get_album_info2);
let scope = register!(scope, "getSimilarSongs", browsing::get_similar_songs);
let scope = register!(scope, "getSimilarSongs2", browsing::get_similar_songs2);
let scope = register!(scope, "getNowPlaying", browsing::get_now_playing);
let scope = register!(scope, "getLyrics", browsing::get_lyrics);
let scope = register!(scope, "getLyricsBySongId", browsing::get_lyrics_by_song_id);
// Search
let scope = register!(scope, "search2", search::search2);
let scope = register!(scope, "search3", search::search3);
// Media
let scope = register!(scope, "stream", media::stream);
let scope = register!(scope, "download", media::download);
let scope = register!(scope, "getCoverArt", media::get_cover_art);
// Playlists
let scope = register!(scope, "getPlaylists", playlists::get_playlists);
let scope = register!(scope, "getPlaylist", playlists::get_playlist);
let scope = register!(scope, "createPlaylist", playlists::create_playlist);
let scope = register!(scope, "updatePlaylist", playlists::update_playlist);
let scope = register!(scope, "deletePlaylist", playlists::delete_playlist);
// Annotation
let scope = register!(scope, "scrobble", annotation::scrobble);
let scope = register!(scope, "star", annotation::star);
let scope = register!(scope, "unstar", annotation::unstar);
let scope = register!(scope, "setRating", annotation::set_rating);
let scope = register!(scope, "getStarred", annotation::get_starred);
let scope = register!(scope, "getStarred2", annotation::get_starred2);
// User
let scope = register!(scope, "getUser", user::get_user);
// Stubs (best-effort no-ops keeping strict clients happy)
let scope = register!(scope, "getPlayQueue", stubs::get_play_queue);
let scope = register!(scope, "savePlayQueue", stubs::save_play_queue);
let scope = register!(scope, "getBookmarks", stubs::get_bookmarks);
let scope = register!(scope, "createBookmark", stubs::create_bookmark);
let scope = register!(scope, "deleteBookmark", stubs::delete_bookmark);
let scope = register!(scope, "getScanStatus", stubs::get_scan_status);
let scope = register!(scope, "startScan", stubs::start_scan);
let scope = register!(scope, "getShares", stubs::get_shares);
let scope = register!(scope, "getPodcasts", stubs::get_podcasts);
let scope = register!(scope, "getNewestPodcasts", stubs::get_newest_podcasts);
let scope = register!(
scope,
"getInternetRadioStations",
stubs::get_internet_radio_stations
);
let scope = register!(scope, "getChatMessages", stubs::get_chat_messages);
let scope = register!(scope, "getAvatar", stubs::get_avatar);
let scope = register!(scope, "jukeboxControl", stubs::jukebox_control);
cfg.service(scope);
} }
+145 -51
View File
@@ -1,15 +1,16 @@
use actix_web::{HttpRequest, HttpResponse, web}; use actix_web::{HttpResponse, web};
use shanty_db::queries; use shanty_db::queries;
use crate::state::AppState; use crate::state::AppState;
use super::helpers::{authenticate, get_query_param, parse_subsonic_id}; use super::SubsonicArgs;
use super::helpers::{authenticate, parse_subsonic_id};
use super::response::{self, SubsonicChild}; use super::response::{self, SubsonicChild};
/// GET /rest/getPlaylists[.view] /// /rest/getPlaylists[.view]
pub async fn get_playlists(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse { pub async fn get_playlists(args: SubsonicArgs, state: web::Data<AppState>) -> HttpResponse {
let (params, user) = match authenticate(&req, &state).await { let (params, user) = match authenticate(&args, &state).await {
Ok(v) => v, Ok(v) => v,
Err(resp) => return resp, Err(resp) => return resp,
}; };
@@ -24,7 +25,6 @@ pub async fn get_playlists(req: HttpRequest, state: web::Data<AppState>) -> Http
.await .await
.unwrap_or(0); .unwrap_or(0);
// Calculate total duration
let tracks = queries::playlists::get_tracks(state.db.conn(), pl.id) let tracks = queries::playlists::get_tracks(state.db.conn(), pl.id)
.await .await
.unwrap_or_default(); .unwrap_or_default();
@@ -50,7 +50,7 @@ pub async fn get_playlists(req: HttpRequest, state: web::Data<AppState>) -> Http
} }
response::ok( response::ok(
&params.format, &params,
serde_json::json!({ serde_json::json!({
"playlists": { "playlists": {
"playlist": playlist_list, "playlist": playlist_list,
@@ -59,43 +59,35 @@ pub async fn get_playlists(req: HttpRequest, state: web::Data<AppState>) -> Http
) )
} }
/// GET /rest/getPlaylist[.view] /// /rest/getPlaylist[.view]
pub async fn get_playlist(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse { pub async fn get_playlist(args: SubsonicArgs, state: web::Data<AppState>) -> HttpResponse {
let (params, user) = match authenticate(&req, &state).await { let (params, user) = match authenticate(&args, &state).await {
Ok(v) => v, Ok(v) => v,
Err(resp) => return resp, Err(resp) => return resp,
}; };
let id_str = match get_query_param(&req, "id") { let id_str = match args.get("id") {
Some(id) => id, Some(id) => id,
None => { None => {
return response::error( return response::error(
&params.format, &params,
response::ERROR_MISSING_PARAM, response::ERROR_MISSING_PARAM,
"missing required parameter: id", "missing required parameter: id",
); );
} }
}; };
let (_prefix, playlist_id) = match parse_subsonic_id(&id_str) { let (_prefix, playlist_id) = match parse_subsonic_id(id_str) {
Some(v) => v, Some(v) => v,
None => { None => {
return response::error( return response::error(&params, response::ERROR_NOT_FOUND, "invalid playlist id");
&params.format,
response::ERROR_NOT_FOUND,
"invalid playlist id",
);
} }
}; };
let playlist = match queries::playlists::get_by_id(state.db.conn(), playlist_id).await { let playlist = match queries::playlists::get_by_id(state.db.conn(), playlist_id).await {
Ok(p) => p, Ok(p) => p,
Err(_) => { Err(_) => {
return response::error( return response::error(&params, response::ERROR_NOT_FOUND, "playlist not found");
&params.format,
response::ERROR_NOT_FOUND,
"playlist not found",
);
} }
}; };
@@ -129,38 +121,35 @@ pub async fn get_playlist(req: HttpRequest, state: web::Data<AppState>) -> HttpR
} }
response::ok( response::ok(
&params.format, &params,
serde_json::json!({ serde_json::json!({
"playlist": pl_json, "playlist": pl_json,
}), }),
) )
} }
/// GET /rest/createPlaylist[.view] /// /rest/createPlaylist[.view]
pub async fn create_playlist(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse { pub async fn create_playlist(args: SubsonicArgs, state: web::Data<AppState>) -> HttpResponse {
let (params, user) = match authenticate(&req, &state).await { let (params, user) = match authenticate(&args, &state).await {
Ok(v) => v, Ok(v) => v,
Err(resp) => return resp, Err(resp) => return resp,
}; };
let name = match get_query_param(&req, "name") { let name = match args.get("name") {
Some(n) => n, Some(n) => n.to_string(),
None => { None => {
return response::error( return response::error(
&params.format, &params,
response::ERROR_MISSING_PARAM, response::ERROR_MISSING_PARAM,
"missing required parameter: name", "missing required parameter: name",
); );
} }
}; };
// Collect songId params (can be repeated) let track_ids: Vec<i32> = args
let qs = req.query_string(); .get_all("songId")
let query_params: Vec<(String, String)> = serde_urlencoded::from_str(qs).unwrap_or_default();
let track_ids: Vec<i32> = query_params
.iter() .iter()
.filter(|(k, _)| k == "songId") .filter_map(|v| parse_subsonic_id(v).map(|(_, id)| id))
.filter_map(|(_, v)| parse_subsonic_id(v).map(|(_, id)| id))
.collect(); .collect();
match queries::playlists::create(state.db.conn(), &name, None, Some(user.id), &track_ids).await match queries::playlists::create(state.db.conn(), &name, None, Some(user.id), &track_ids).await
@@ -196,53 +185,158 @@ pub async fn create_playlist(req: HttpRequest, state: web::Data<AppState>) -> Ht
} }
response::ok( response::ok(
&params.format, &params,
serde_json::json!({ serde_json::json!({
"playlist": pl_json, "playlist": pl_json,
}), }),
) )
} }
Err(e) => response::error( Err(e) => response::error(
&params.format, &params,
response::ERROR_GENERIC, response::ERROR_GENERIC,
&format!("failed to create playlist: {e}"), &format!("failed to create playlist: {e}"),
), ),
} }
} }
/// GET /rest/deletePlaylist[.view] /// /rest/updatePlaylist[.view]
pub async fn delete_playlist(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse { ///
let (params, _user) = match authenticate(&req, &state).await { /// Spec params:
/// - `playlistId` (required)
/// - `name`, `comment` (optional metadata changes)
/// - `public` (ignored — we don't model public playlists yet)
/// - `songIdToAdd` (repeatable; appended to end)
/// - `songIndexToRemove` (repeatable; 0-based indices into the *current*
/// track list, resolved before any mutation)
pub async fn update_playlist(args: SubsonicArgs, state: web::Data<AppState>) -> HttpResponse {
let (params, user) = match authenticate(&args, &state).await {
Ok(v) => v, Ok(v) => v,
Err(resp) => return resp, Err(resp) => return resp,
}; };
let id_str = match get_query_param(&req, "id") { let id_str = match args.get("playlistId") {
Some(id) => id, Some(id) => id,
None => { None => {
return response::error( return response::error(
&params.format, &params,
response::ERROR_MISSING_PARAM,
"missing required parameter: playlistId",
);
}
};
let (_prefix, playlist_id) = match parse_subsonic_id(id_str) {
Some(v) => v,
None => {
return response::error(&params, response::ERROR_NOT_FOUND, "invalid playlist id");
}
};
let playlist = match queries::playlists::get_by_id(state.db.conn(), playlist_id).await {
Ok(p) => p,
Err(_) => {
return response::error(&params, response::ERROR_NOT_FOUND, "playlist not found");
}
};
// Ownership check — never let one user modify another's playlist.
if playlist.user_id != Some(user.id) {
return response::error(
&params,
response::ERROR_NOT_AUTHORIZED,
"you do not own this playlist",
);
}
// Resolve songIndexToRemove against the current track ordering BEFORE any
// additions, so the indices match what the client sees in getPlaylist.
let removal_indices: Vec<usize> = args
.get_all("songIndexToRemove")
.iter()
.filter_map(|v| v.parse().ok())
.collect();
if !removal_indices.is_empty() {
let current = queries::playlists::get_tracks(state.db.conn(), playlist_id)
.await
.unwrap_or_default();
let track_ids_to_remove: Vec<i32> = removal_indices
.iter()
.filter_map(|&idx| current.get(idx).map(|t| t.id))
.collect();
for tid in track_ids_to_remove {
if let Err(e) =
queries::playlists::remove_track(state.db.conn(), playlist_id, tid).await
{
return response::error(
&params,
response::ERROR_GENERIC,
&format!("failed to remove track: {e}"),
);
}
}
}
// Append additions in the order the client sent them.
for raw in args.get_all("songIdToAdd") {
if let Some((_, track_id)) = parse_subsonic_id(raw)
&& let Err(e) =
queries::playlists::add_track(state.db.conn(), playlist_id, track_id).await
{
return response::error(
&params,
response::ERROR_GENERIC,
&format!("failed to add track: {e}"),
);
}
}
// Metadata updates last so they bump updated_at after any track edits.
let new_name = args.get("name");
let new_comment = args.get("comment");
if (new_name.is_some() || new_comment.is_some())
&& let Err(e) =
queries::playlists::update(state.db.conn(), playlist_id, new_name, new_comment).await
{
return response::error(
&params,
response::ERROR_GENERIC,
&format!("failed to update playlist metadata: {e}"),
);
}
response::ok(&params, serde_json::json!({}))
}
/// /rest/deletePlaylist[.view]
pub async fn delete_playlist(args: SubsonicArgs, state: web::Data<AppState>) -> HttpResponse {
let (params, _user) = match authenticate(&args, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
let id_str = match args.get("id") {
Some(id) => id,
None => {
return response::error(
&params,
response::ERROR_MISSING_PARAM, response::ERROR_MISSING_PARAM,
"missing required parameter: id", "missing required parameter: id",
); );
} }
}; };
let (_prefix, playlist_id) = match parse_subsonic_id(&id_str) { let (_prefix, playlist_id) = match parse_subsonic_id(id_str) {
Some(v) => v, Some(v) => v,
None => { None => {
return response::error( return response::error(&params, response::ERROR_NOT_FOUND, "invalid playlist id");
&params.format,
response::ERROR_NOT_FOUND,
"invalid playlist id",
);
} }
}; };
match queries::playlists::delete(state.db.conn(), playlist_id).await { match queries::playlists::delete(state.db.conn(), playlist_id).await {
Ok(()) => response::ok(&params.format, serde_json::json!({})), Ok(()) => response::ok(&params, serde_json::json!({})),
Err(e) => response::error( Err(e) => response::error(
&params.format, &params,
response::ERROR_GENERIC, response::ERROR_GENERIC,
&format!("failed to delete playlist: {e}"), &format!("failed to delete playlist: {e}"),
), ),
+91 -14
View File
@@ -1,55 +1,89 @@
use actix_web::HttpResponse; use actix_web::HttpResponse;
use serde::Serialize; use serde::Serialize;
use super::auth::SubsonicParams;
const SUBSONIC_VERSION: &str = "1.16.1"; const SUBSONIC_VERSION: &str = "1.16.1";
const XMLNS: &str = "http://subsonic.org/restapi"; const XMLNS: &str = "http://subsonic.org/restapi";
/// Server name reported via the OpenSubsonic `type` field. Sourced from
/// Cargo metadata so it tracks the actual binary.
const SERVER_TYPE: &str = env!("CARGO_PKG_NAME");
/// Actual server version reported via OpenSubsonic `serverVersion`.
const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
/// Build a successful Subsonic response in the requested format. /// Build a successful Subsonic response, honouring the requested format and
pub fn ok(format: &str, body: serde_json::Value) -> HttpResponse { /// (for `f=jsonp`) the callback name.
format_response(format, "ok", body, None) pub fn ok(params: &SubsonicParams, body: serde_json::Value) -> HttpResponse {
format_response(&params.format, "ok", body, params.callback.as_deref())
} }
/// Build a Subsonic error response. /// Build a Subsonic error response for an authenticated request.
pub fn error(format: &str, code: u32, message: &str) -> HttpResponse { pub fn error(params: &SubsonicParams, code: u32, message: &str) -> HttpResponse {
let err = serde_json::json!({ let err = serde_json::json!({
"error": { "error": {
"code": code, "code": code,
"message": message, "message": message,
} }
}); });
format_response(format, "failed", err, None) format_response(&params.format, "failed", err, params.callback.as_deref())
} }
/// Subsonic error codes. /// Build an error response when authentication itself failed and we have no
/// `SubsonicParams` available. Always uses XML (the spec default) — clients
/// in this state are usually broken anyway and cannot reliably negotiate.
pub fn auth_error(code: u32, message: &str) -> HttpResponse {
let err = serde_json::json!({
"error": {
"code": code,
"message": message,
}
});
format_response("xml", "failed", err, None)
}
/// Subsonic error codes (per OpenSubsonic spec).
pub const ERROR_GENERIC: u32 = 0; pub const ERROR_GENERIC: u32 = 0;
pub const ERROR_MISSING_PARAM: u32 = 10; pub const ERROR_MISSING_PARAM: u32 = 10;
#[allow(dead_code)]
pub const ERROR_CLIENT_MUST_UPGRADE: u32 = 20;
#[allow(dead_code)]
pub const ERROR_SERVER_MUST_UPGRADE: u32 = 30;
pub const ERROR_NOT_AUTHENTICATED: u32 = 40; pub const ERROR_NOT_AUTHENTICATED: u32 = 40;
#[allow(dead_code)] #[allow(dead_code)]
pub const ERROR_TOKEN_AUTH_NOT_SUPPORTED_FOR_LDAP: u32 = 41;
#[allow(dead_code)]
pub const ERROR_AUTH_MECHANISM_NOT_SUPPORTED: u32 = 42;
#[allow(dead_code)]
pub const ERROR_MULTIPLE_AUTH_MECHANISMS: u32 = 43;
#[allow(dead_code)]
pub const ERROR_INVALID_API_KEY: u32 = 44;
pub const ERROR_NOT_AUTHORIZED: u32 = 50; pub const ERROR_NOT_AUTHORIZED: u32 = 50;
#[allow(dead_code)]
pub const ERROR_TRIAL_EXPIRED: u32 = 60;
pub const ERROR_NOT_FOUND: u32 = 70; pub const ERROR_NOT_FOUND: u32 = 70;
fn format_response( fn format_response(
format: &str, format: &str,
status: &str, status: &str,
body: serde_json::Value, body: serde_json::Value,
_type_attr: Option<&str>, callback: Option<&str>,
) -> HttpResponse { ) -> HttpResponse {
match format { match format {
"json" => format_json(status, body), "json" => format_json(status, body),
"jsonp" => format_jsonp(status, body, callback),
_ => format_xml(status, body), _ => format_xml(status, body),
} }
} }
fn format_json(status: &str, body: serde_json::Value) -> HttpResponse { fn build_response_object(status: &str, body: serde_json::Value) -> serde_json::Value {
let mut response = serde_json::json!({ let mut response = serde_json::json!({
"status": status, "status": status,
"version": SUBSONIC_VERSION, "version": SUBSONIC_VERSION,
"type": "shanty", "type": SERVER_TYPE,
"serverVersion": "0.1.0", "serverVersion": SERVER_VERSION,
"openSubsonic": true, "openSubsonic": true,
}); });
// Merge body into response
if let serde_json::Value::Object(map) = body if let serde_json::Value::Object(map) = body
&& let serde_json::Value::Object(ref mut resp_map) = response && let serde_json::Value::Object(ref mut resp_map) = response
{ {
@@ -58,8 +92,12 @@ fn format_json(status: &str, body: serde_json::Value) -> HttpResponse {
} }
} }
response
}
fn format_json(status: &str, body: serde_json::Value) -> HttpResponse {
let wrapper = serde_json::json!({ let wrapper = serde_json::json!({
"subsonic-response": response, "subsonic-response": build_response_object(status, body),
}); });
HttpResponse::Ok() HttpResponse::Ok()
@@ -67,10 +105,49 @@ fn format_json(status: &str, body: serde_json::Value) -> HttpResponse {
.json(wrapper) .json(wrapper)
} }
fn format_jsonp(status: &str, body: serde_json::Value, callback: Option<&str>) -> HttpResponse {
let Some(cb) = callback else {
// Per spec, missing callback for f=jsonp is a missing-param error.
return format_json(
"failed",
serde_json::json!({
"error": {
"code": ERROR_MISSING_PARAM,
"message": "missing required parameter: callback (required for f=jsonp)",
}
}),
);
};
// Sanity-check the callback name to avoid trivial XSS via reflected JS.
if !cb
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$' || c == '.')
|| cb.is_empty()
{
return format_json(
"failed",
serde_json::json!({
"error": {
"code": ERROR_GENERIC,
"message": "invalid callback name",
}
}),
);
}
let wrapper = serde_json::json!({
"subsonic-response": build_response_object(status, body),
});
let body = format!("{cb}({wrapper});");
HttpResponse::Ok()
.content_type("application/javascript; charset=UTF-8")
.body(body)
}
fn format_xml(status: &str, body: serde_json::Value) -> HttpResponse { fn format_xml(status: &str, body: serde_json::Value) -> HttpResponse {
let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"); let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.push_str(&format!( xml.push_str(&format!(
"<subsonic-response xmlns=\"{XMLNS}\" status=\"{status}\" version=\"{SUBSONIC_VERSION}\" type=\"shanty\" serverVersion=\"0.1.0\" openSubsonic=\"true\">" "<subsonic-response xmlns=\"{XMLNS}\" status=\"{status}\" version=\"{SUBSONIC_VERSION}\" type=\"{SERVER_TYPE}\" serverVersion=\"{SERVER_VERSION}\" openSubsonic=\"true\">"
)); ));
if let serde_json::Value::Object(map) = &body { if let serde_json::Value::Object(map) = &body {
+78 -38
View File
@@ -1,46 +1,33 @@
use actix_web::{HttpRequest, HttpResponse, web}; use actix_web::{HttpResponse, web};
use shanty_db::queries; use shanty_db::queries;
use crate::state::AppState; use crate::state::AppState;
use super::helpers::{authenticate, get_query_param}; use super::SubsonicArgs;
use super::helpers::authenticate;
use super::response::{self, SubsonicChild}; use super::response::{self, SubsonicChild};
/// GET /rest/search3[.view] /// Container for the searched results, used by both `search2` and `search3`.
pub async fn search3(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse { struct SearchResults {
let (params, _user) = match authenticate(&req, &state).await { artists: Vec<serde_json::Value>,
Ok(v) => v, albums: Vec<serde_json::Value>,
Err(resp) => return resp, songs: Vec<serde_json::Value>,
}; }
let query = match get_query_param(&req, "query") { async fn run_search(
Some(q) => q, args: &SubsonicArgs,
None => { state: &web::Data<AppState>,
return response::error( ) -> Result<SearchResults, &'static str> {
&params.format, let query = args.get("query").ok_or("query")?;
response::ERROR_MISSING_PARAM, let artist_count: u64 = args.get_parsed("artistCount").unwrap_or(20);
"missing required parameter: query", let album_count: u64 = args.get_parsed("albumCount").unwrap_or(20);
); let song_count: u64 = args.get_parsed("songCount").unwrap_or(20);
}
};
let artist_count: u64 = get_query_param(&req, "artistCount") let tracks = queries::tracks::search(state.db.conn(), query)
.and_then(|v| v.parse().ok())
.unwrap_or(20);
let album_count: u64 = get_query_param(&req, "albumCount")
.and_then(|v| v.parse().ok())
.unwrap_or(20);
let song_count: u64 = get_query_param(&req, "songCount")
.and_then(|v| v.parse().ok())
.unwrap_or(20);
// Search tracks (which gives us artists and albums too)
let tracks = queries::tracks::search(state.db.conn(), &query)
.await .await
.unwrap_or_default(); .unwrap_or_default();
// Collect unique artists from tracks
let mut seen_artists = std::collections::HashSet::new(); let mut seen_artists = std::collections::HashSet::new();
let mut artist_results: Vec<serde_json::Value> = Vec::new(); let mut artist_results: Vec<serde_json::Value> = Vec::new();
for track in &tracks { for track in &tracks {
@@ -61,7 +48,6 @@ pub async fn search3(req: HttpRequest, state: web::Data<AppState>) -> HttpRespon
} }
} }
// Also search artists by name directly
let all_artists = queries::artists::list(state.db.conn(), 10000, 0) let all_artists = queries::artists::list(state.db.conn(), 10000, 0)
.await .await
.unwrap_or_default(); .unwrap_or_default();
@@ -83,7 +69,6 @@ pub async fn search3(req: HttpRequest, state: web::Data<AppState>) -> HttpRespon
} }
} }
// Collect unique albums from tracks
let mut seen_albums = std::collections::HashSet::new(); let mut seen_albums = std::collections::HashSet::new();
let mut album_results: Vec<serde_json::Value> = Vec::new(); let mut album_results: Vec<serde_json::Value> = Vec::new();
for track in &tracks { for track in &tracks {
@@ -104,20 +89,75 @@ pub async fn search3(req: HttpRequest, state: web::Data<AppState>) -> HttpRespon
} }
} }
// Song results
let song_results: Vec<serde_json::Value> = tracks let song_results: Vec<serde_json::Value> = tracks
.iter() .iter()
.take(song_count as usize) .take(song_count as usize)
.map(|track| serde_json::to_value(SubsonicChild::from_track(track)).unwrap_or_default()) .map(|track| serde_json::to_value(SubsonicChild::from_track(track)).unwrap_or_default())
.collect(); .collect();
Ok(SearchResults {
artists: artist_results,
albums: album_results,
songs: song_results,
})
}
/// /rest/search3[.view]
pub async fn search3(args: SubsonicArgs, state: web::Data<AppState>) -> HttpResponse {
let (params, _user) = match authenticate(&args, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
let results = match run_search(&args, &state).await {
Ok(r) => r,
Err(name) => {
return response::error(
&params,
response::ERROR_MISSING_PARAM,
&format!("missing required parameter: {name}"),
);
}
};
response::ok( response::ok(
&params.format, &params,
serde_json::json!({ serde_json::json!({
"searchResult3": { "searchResult3": {
"artist": artist_results, "artist": results.artists,
"album": album_results, "album": results.albums,
"song": song_results, "song": results.songs,
}
}),
)
}
/// /rest/search2[.view] — same data as `search3`, different response wrapper
/// key. Older clients (and mopidy-subidy in some configurations) call this.
pub async fn search2(args: SubsonicArgs, state: web::Data<AppState>) -> HttpResponse {
let (params, _user) = match authenticate(&args, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
let results = match run_search(&args, &state).await {
Ok(r) => r,
Err(name) => {
return response::error(
&params,
response::ERROR_MISSING_PARAM,
&format!("missing required parameter: {name}"),
);
}
};
response::ok(
&params,
serde_json::json!({
"searchResult2": {
"artist": results.artists,
"album": results.albums,
"song": results.songs,
} }
}), }),
) )
+82
View File
@@ -0,0 +1,82 @@
//! Stub implementations of Subsonic endpoints we don't actually back with
//! real data. They satisfy strict clients that probe for these endpoints by
//! returning a successful response with empty payloads — better than 404.
//!
//! These will get real implementations as the relevant features land.
use actix_web::{HttpResponse, web};
use crate::state::AppState;
use super::SubsonicArgs;
use super::helpers::authenticate;
use super::response;
macro_rules! stub_handler {
($name:ident, $body:expr) => {
pub async fn $name(args: SubsonicArgs, state: web::Data<AppState>) -> HttpResponse {
let (params, _user) = match authenticate(&args, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
response::ok(&params, $body)
}
};
}
stub_handler!(
get_play_queue,
serde_json::json!({ "playQueue": { "entry": [] } })
);
stub_handler!(save_play_queue, serde_json::json!({}));
stub_handler!(
get_bookmarks,
serde_json::json!({ "bookmarks": { "bookmark": [] } })
);
stub_handler!(create_bookmark, serde_json::json!({}));
stub_handler!(delete_bookmark, serde_json::json!({}));
stub_handler!(
get_scan_status,
serde_json::json!({ "scanStatus": { "scanning": false, "count": 0 } })
);
stub_handler!(
start_scan,
serde_json::json!({ "scanStatus": { "scanning": false, "count": 0 } })
);
stub_handler!(get_shares, serde_json::json!({ "shares": { "share": [] } }));
stub_handler!(
get_podcasts,
serde_json::json!({ "podcasts": { "channel": [] } })
);
stub_handler!(
get_newest_podcasts,
serde_json::json!({ "newestPodcasts": { "episode": [] } })
);
stub_handler!(
get_internet_radio_stations,
serde_json::json!({ "internetRadioStations": { "internetRadioStation": [] } })
);
stub_handler!(
get_chat_messages,
serde_json::json!({ "chatMessages": { "chatMessage": [] } })
);
/// `getAvatar` returns a 404. The spec allows servers without avatar storage
/// to return an error, and we don't track per-user avatars.
pub async fn get_avatar(args: SubsonicArgs, state: web::Data<AppState>) -> HttpResponse {
let (params, _user) = match authenticate(&args, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
response::error(&params, response::ERROR_NOT_FOUND, "no avatar")
}
/// `jukeboxControl` is unsupported — return a not-authorized error so clients
/// don't keep retrying.
pub async fn jukebox_control(args: SubsonicArgs, state: web::Data<AppState>) -> HttpResponse {
let (params, _user) = match authenticate(&args, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
response::error(&params, response::ERROR_NOT_AUTHORIZED, "jukebox disabled")
}
+32 -9
View File
@@ -1,29 +1,30 @@
use actix_web::{HttpRequest, HttpResponse, web}; use actix_web::{HttpResponse, web};
use crate::state::AppState; use crate::state::AppState;
use super::SubsonicArgs;
use super::helpers::authenticate; use super::helpers::authenticate;
use super::response; use super::response;
/// GET /rest/ping[.view] /// /rest/ping[.view]
pub async fn ping(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse { pub async fn ping(args: SubsonicArgs, state: web::Data<AppState>) -> HttpResponse {
let (params, _user) = match authenticate(&req, &state).await { let (params, _user) = match authenticate(&args, &state).await {
Ok(v) => v, Ok(v) => v,
Err(resp) => return resp, Err(resp) => return resp,
}; };
response::ok(&params.format, serde_json::json!({})) response::ok(&params, serde_json::json!({}))
} }
/// GET /rest/getLicense[.view] /// /rest/getLicense[.view]
pub async fn get_license(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse { pub async fn get_license(args: SubsonicArgs, state: web::Data<AppState>) -> HttpResponse {
let (params, _user) = match authenticate(&req, &state).await { let (params, _user) = match authenticate(&args, &state).await {
Ok(v) => v, Ok(v) => v,
Err(resp) => return resp, Err(resp) => return resp,
}; };
response::ok( response::ok(
&params.format, &params,
serde_json::json!({ serde_json::json!({
"license": { "license": {
"valid": true, "valid": true,
@@ -33,3 +34,25 @@ pub async fn get_license(req: HttpRequest, state: web::Data<AppState>) -> HttpRe
}), }),
) )
} }
/// /rest/getOpenSubsonicExtensions[.view]
///
/// Required by the OpenSubsonic spec since we advertise `openSubsonic: true`.
/// We support no optional extensions yet, so the array is empty — that's a
/// valid response and is enough to keep extension-aware clients happy.
pub async fn get_open_subsonic_extensions(
args: SubsonicArgs,
state: web::Data<AppState>,
) -> HttpResponse {
let (params, _user) = match authenticate(&args, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
response::ok(
&params,
serde_json::json!({
"openSubsonicExtensions": []
}),
)
}
+6 -5
View File
@@ -1,15 +1,16 @@
use actix_web::{HttpRequest, HttpResponse, web}; use actix_web::{HttpResponse, web};
use shanty_db::entities::user::UserRole; use shanty_db::entities::user::UserRole;
use crate::state::AppState; use crate::state::AppState;
use super::SubsonicArgs;
use super::helpers::authenticate; use super::helpers::authenticate;
use super::response; use super::response;
/// GET /rest/getUser[.view] /// /rest/getUser[.view]
pub async fn get_user(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse { pub async fn get_user(args: SubsonicArgs, state: web::Data<AppState>) -> HttpResponse {
let (params, user) = match authenticate(&req, &state).await { let (params, user) = match authenticate(&args, &state).await {
Ok(v) => v, Ok(v) => v,
Err(resp) => return resp, Err(resp) => return resp,
}; };
@@ -17,7 +18,7 @@ pub async fn get_user(req: HttpRequest, state: web::Data<AppState>) -> HttpRespo
let is_admin = user.role == UserRole::Admin; let is_admin = user.role == UserRole::Admin;
response::ok( response::ok(
&params.format, &params,
serde_json::json!({ serde_json::json!({
"user": { "user": {
"username": user.username, "username": user.username,
+51 -5
View File
@@ -416,19 +416,44 @@ async fn process_tag(
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
// Ensure a wanted_item exists for this track (marks imported files as Owned) // Ensure a wanted_item exists for this track (marks imported files as Owned).
if let Some(ref mbid) = track.musicbrainz_id // Check by MBID first, then by name+artist to avoid duplicates from MBID mismatches.
&& queries::wanted::find_by_mbid(conn, mbid) // When found, link the wanted_item to this track via track_id so cleanup never
// deletes it (track_id is a direct link that can't break from MBID mismatches).
let found_wanted = if let Some(ref mbid) = track.musicbrainz_id {
queries::wanted::find_by_mbid(conn, mbid)
.await .await
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.is_none() } else {
None
};
let found_wanted = if found_wanted.is_some() {
found_wanted
} else {
// Also check by name + artist_id (normalize unicode dashes and case)
let all_wanted = queries::wanted::list(conn, None, None)
.await
.unwrap_or_default();
let title_norm = normalize_for_match(track.title.as_deref().unwrap_or(""));
all_wanted
.into_iter()
.find(|w| w.artist_id == track.artist_id && normalize_for_match(&w.name) == title_norm)
};
// Link the wanted_item to this track so get_unwanted() won't delete it
if let Some(ref wanted) = found_wanted
&& wanted.track_id != Some(track.id)
{ {
let _ = queries::wanted::update_track_id(conn, wanted.id, track.id).await;
}
if found_wanted.is_none() {
let item = queries::wanted::add( let item = queries::wanted::add(
conn, conn,
queries::wanted::AddWantedItem { queries::wanted::AddWantedItem {
item_type: shanty_db::entities::wanted_item::ItemType::Track, item_type: shanty_db::entities::wanted_item::ItemType::Track,
name: track.title.as_deref().unwrap_or("Unknown"), name: track.title.as_deref().unwrap_or("Unknown"),
musicbrainz_id: Some(mbid), musicbrainz_id: track.musicbrainz_id.as_deref(),
artist_id: track.artist_id, artist_id: track.artist_id,
album_id: track.album_id, album_id: track.album_id,
track_id: Some(track.id), track_id: Some(track.id),
@@ -497,6 +522,14 @@ async fn trigger_pipeline_completion(state: &web::Data<AppState>, pipeline_id: &
Ok(unwanted) if !unwanted.is_empty() => { Ok(unwanted) if !unwanted.is_empty() => {
let count = unwanted.len(); let count = unwanted.len();
for track in &unwanted { for track in &unwanted {
tracing::info!(
id = track.id,
path = %track.file_path,
artist = ?track.artist,
title = ?track.title,
mbid = ?track.musicbrainz_id,
"deleting unwanted track"
);
let path = std::path::Path::new(&track.file_path); let path = std::path::Path::new(&track.file_path);
if path.exists() { if path.exists() {
if let Err(e) = std::fs::remove_file(path) { if let Err(e) = std::fs::remove_file(path) {
@@ -574,3 +607,16 @@ async fn process_enrich(
Ok(vec![]) Ok(vec![])
} }
/// Normalize a string for fuzzy matching: lowercase, replace unicode dashes/quotes with ASCII.
fn normalize_for_match(s: &str) -> String {
s.to_lowercase()
.replace(
[
'\u{2010}', '\u{2011}', '\u{2012}', '\u{2013}', '\u{2014}', '\u{2015}',
],
"-",
)
.replace(['\u{2018}', '\u{2019}'], "'")
.replace(['\u{201C}', '\u{201D}'], "\"")
}