Compare commits
30 Commits
36345b12ee
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d17049d92a | |||
| f7593dc0dc | |||
| 8193eebf13 | |||
| 4c42cf0131 | |||
| bd6656ff31 | |||
| 61225158f0 | |||
| 07aa9908e8 | |||
| 4b6844b85e | |||
| 7d2d6f021d | |||
| 5786cc89e5 | |||
| 4ccc6bcf27 | |||
| 159cdda386 | |||
| 1a890b0c11 | |||
| 6e156037e6 | |||
| 1a478dea8e | |||
| a893a84f16 | |||
| d20989f859 | |||
| 00d4e8d3e0 | |||
| d5ba8ae7f5 | |||
| b59bf4cc5d | |||
| 7827841de6 | |||
| 2d6be1a22c | |||
| 05ab6c0f3e | |||
| c5efe23d01 | |||
| 7b14ed593f | |||
| 45a7dcd8cd | |||
| 823ef15022 | |||
| 1431cd2fbc | |||
| 29e6494e11 | |||
| 7c30f288cd |
@@ -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
@@ -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"
|
||||||
|
|||||||
+47
-2
@@ -176,11 +176,56 @@ pub async fn add_album(
|
|||||||
post_json(&format!("{BASE}/albums"), &body).await
|
post_json(&format!("{BASE}/albums"), &body).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn watch_track(title: &str, mbid: &str) -> Result<WatchTrackResponse, ApiError> {
|
pub async fn watch_track(
|
||||||
let body = serde_json::json!({"title": title, "mbid": mbid}).to_string();
|
artist: Option<&str>,
|
||||||
|
title: &str,
|
||||||
|
mbid: &str,
|
||||||
|
) -> Result<WatchTrackResponse, ApiError> {
|
||||||
|
let body = serde_json::json!({"artist": artist, "title": title, "mbid": mbid}).to_string();
|
||||||
post_json(&format!("{BASE}/tracks/watch"), &body).await
|
post_json(&format!("{BASE}/tracks/watch"), &body).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn unwatch_artist(id: i32) -> Result<serde_json::Value, ApiError> {
|
||||||
|
let resp = Request::delete(&format!("{BASE}/artists/{id}/watch"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError(e.to_string()))?;
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(ApiError(format!("HTTP {}", resp.status())));
|
||||||
|
}
|
||||||
|
resp.json().await.map_err(|e| ApiError(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unwatch_album(mbid: &str) -> Result<serde_json::Value, ApiError> {
|
||||||
|
let resp = Request::delete(&format!("{BASE}/albums/{mbid}/watch"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError(e.to_string()))?;
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(ApiError(format!("HTTP {}", resp.status())));
|
||||||
|
}
|
||||||
|
resp.json().await.map_err(|e| ApiError(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unwatch_track(mbid: &str) -> Result<serde_json::Value, ApiError> {
|
||||||
|
let body = serde_json::json!({"mbid": mbid}).to_string();
|
||||||
|
let resp = Request::delete(&format!("{BASE}/tracks/watch"))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(&body)
|
||||||
|
.map_err(|e| ApiError(e.to_string()))?
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError(e.to_string()))?;
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(ApiError(format!("HTTP {}", resp.status())));
|
||||||
|
}
|
||||||
|
resp.json().await.map_err(|e| ApiError(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_artist(id: i32) -> Result<(), ApiError> {
|
||||||
|
delete(&format!("{BASE}/artists/{id}")).await
|
||||||
|
}
|
||||||
|
|
||||||
// --- Downloads ---
|
// --- Downloads ---
|
||||||
pub async fn get_downloads(status: Option<&str>) -> Result<Vec<DownloadItem>, ApiError> {
|
pub async fn get_downloads(status: Option<&str>) -> Result<Vec<DownloadItem>, ApiError> {
|
||||||
let mut url = format!("{BASE}/downloads/queue");
|
let mut url = format!("{BASE}/downloads/queue");
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub struct Props {
|
|||||||
#[function_component(Navbar)]
|
#[function_component(Navbar)]
|
||||||
pub fn navbar(props: &Props) -> Html {
|
pub fn navbar(props: &Props) -> Html {
|
||||||
let route = use_route::<Route>();
|
let route = use_route::<Route>();
|
||||||
|
let sidebar_open = use_state(|| false);
|
||||||
|
|
||||||
let link = |to: Route, label: &str| {
|
let link = |to: Route, label: &str| {
|
||||||
let active = route.as_ref() == Some(&to);
|
let active = route.as_ref() == Some(&to);
|
||||||
@@ -24,16 +25,56 @@ pub fn navbar(props: &Props) -> Html {
|
|||||||
|
|
||||||
let on_logout = {
|
let on_logout = {
|
||||||
let cb = props.on_logout.clone();
|
let cb = props.on_logout.clone();
|
||||||
|
let sidebar_open = sidebar_open.clone();
|
||||||
Callback::from(move |e: MouseEvent| {
|
Callback::from(move |e: MouseEvent| {
|
||||||
e.prevent_default();
|
e.prevent_default();
|
||||||
|
sidebar_open.set(false);
|
||||||
cb.emit(());
|
cb.emit(());
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let toggle = {
|
||||||
|
let sidebar_open = sidebar_open.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
sidebar_open.set(!*sidebar_open);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let close_overlay = {
|
||||||
|
let sidebar_open = sidebar_open.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
sidebar_open.set(false);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close sidebar when any nav link is clicked
|
||||||
|
let on_nav_click = {
|
||||||
|
let sidebar_open = sidebar_open.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
sidebar_open.set(false);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let sidebar_class = if *sidebar_open {
|
||||||
|
"sidebar open"
|
||||||
|
} else {
|
||||||
|
"sidebar"
|
||||||
|
};
|
||||||
|
let overlay_class = if *sidebar_open {
|
||||||
|
"sidebar-overlay open"
|
||||||
|
} else {
|
||||||
|
"sidebar-overlay"
|
||||||
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class="sidebar">
|
<>
|
||||||
|
<button class="hamburger" onclick={toggle}>
|
||||||
|
{ "\u{2630}" }
|
||||||
|
</button>
|
||||||
|
<div class={overlay_class} onclick={close_overlay}></div>
|
||||||
|
<div class={sidebar_class}>
|
||||||
<h1>{ "Shanty" }</h1>
|
<h1>{ "Shanty" }</h1>
|
||||||
<nav>
|
<nav onclick={on_nav_click}>
|
||||||
{ link(Route::Dashboard, "Dashboard") }
|
{ link(Route::Dashboard, "Dashboard") }
|
||||||
{ link(Route::Search, "Search") }
|
{ link(Route::Search, "Search") }
|
||||||
{ link(Route::Library, "Library") }
|
{ link(Route::Library, "Library") }
|
||||||
@@ -48,5 +89,6 @@ pub fn navbar(props: &Props) -> Html {
|
|||||||
<a href="#" class="text-sm" onclick={on_logout}>{ "Logout" }</a>
|
<a href="#" class="text-sm" onclick={on_logout}>{ "Logout" }</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,13 +122,15 @@ pub fn album_page(props: &Props) -> Html {
|
|||||||
let detail = detail.clone();
|
let detail = detail.clone();
|
||||||
let title = t.title.clone();
|
let title = t.title.clone();
|
||||||
let mbid = t.recording_mbid.clone();
|
let mbid = t.recording_mbid.clone();
|
||||||
|
let artist = d.artist.clone();
|
||||||
Callback::from(move |_: MouseEvent| {
|
Callback::from(move |_: MouseEvent| {
|
||||||
let detail = detail.clone();
|
let detail = detail.clone();
|
||||||
let title = title.clone();
|
let title = title.clone();
|
||||||
let mbid = mbid.clone();
|
let mbid = mbid.clone();
|
||||||
|
let artist = artist.clone();
|
||||||
let idx = idx;
|
let idx = idx;
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
if let Ok(resp) = api::watch_track(&title, &mbid).await {
|
if let Ok(resp) = api::watch_track(artist.as_deref(), &title, &mbid).await {
|
||||||
if let Some(ref d) = *detail {
|
if let Some(ref d) = *detail {
|
||||||
let mut updated = d.clone();
|
let mut updated = d.clone();
|
||||||
if let Some(track) = updated.tracks.get_mut(idx) {
|
if let Some(track) = updated.tracks.get_mut(idx) {
|
||||||
@@ -141,6 +143,27 @@ pub fn album_page(props: &Props) -> Html {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let on_unwatch_click = {
|
||||||
|
let detail = detail.clone();
|
||||||
|
let mbid = t.recording_mbid.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
let detail = detail.clone();
|
||||||
|
let mbid = mbid.clone();
|
||||||
|
let idx = idx;
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
if api::unwatch_track(&mbid).await.is_ok() {
|
||||||
|
if let Some(ref d) = *detail {
|
||||||
|
let mut updated = d.clone();
|
||||||
|
if let Some(track) = updated.tracks.get_mut(idx) {
|
||||||
|
track.status = None;
|
||||||
|
}
|
||||||
|
detail.set(Some(updated));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -160,6 +183,11 @@ pub fn album_page(props: &Props) -> Html {
|
|||||||
onclick={on_watch_click}>
|
onclick={on_watch_click}>
|
||||||
{ "Watch" }
|
{ "Watch" }
|
||||||
</button>
|
</button>
|
||||||
|
} else {
|
||||||
|
<button class="btn btn-sm btn-secondary"
|
||||||
|
onclick={on_unwatch_click}>
|
||||||
|
{ "Unwatch" }
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
<button class="btn btn-sm btn-secondary"
|
<button class="btn btn-sm btn-secondary"
|
||||||
onclick={on_lyrics_click}>
|
onclick={on_lyrics_click}>
|
||||||
|
|||||||
+459
-26
@@ -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
|
||||||
@@ -106,11 +116,18 @@ pub fn artist_page(props: &Props) -> Html {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let watch_all_btn = {
|
let watch_all_btn = {
|
||||||
let artist_status = d.artist_status.clone();
|
let is_watched = d.artist_status == "owned"
|
||||||
let show = artist_status != "owned";
|
|| d.artist_status == "partial"
|
||||||
if show {
|
|| d.artist_status == "wanted";
|
||||||
|
let is_fully_watched = d.artist_status == "owned";
|
||||||
|
|
||||||
|
let watch_btn = if !is_fully_watched {
|
||||||
let artist_name = d.artist.name.clone();
|
let artist_name = d.artist.name.clone();
|
||||||
let artist_mbid = d.artist.musicbrainz_id.clone();
|
let artist_mbid = d
|
||||||
|
.artist_info
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|i| i.mbid.clone())
|
||||||
|
.or_else(|| d.artist.musicbrainz_id.clone());
|
||||||
let message = message.clone();
|
let message = message.clone();
|
||||||
let error = error.clone();
|
let error = error.clone();
|
||||||
let fetch = fetch.clone();
|
let fetch = fetch.clone();
|
||||||
@@ -142,7 +159,41 @@ pub fn artist_page(props: &Props) -> Html {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
html! {}
|
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 = {
|
||||||
@@ -191,12 +242,272 @@ pub fn artist_page(props: &Props) -> Html {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let remove_btn = {
|
||||||
|
let artist_id_num = d.artist.id;
|
||||||
|
if artist_id_num > 0 {
|
||||||
|
let error = error.clone();
|
||||||
html! {
|
html! {
|
||||||
<div>
|
<button class="btn btn-sm btn-danger"
|
||||||
if let Some(ref banner) = d.artist_banner {
|
onclick={Callback::from(move |_: MouseEvent| {
|
||||||
<div class="artist-banner" style={format!("background-image: url('{banner}')")}>
|
let error = error.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
if let Err(e) = api::delete_artist(artist_id_num).await {
|
||||||
|
error.set(Some(e.0));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Navigate back to library
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
let _ = window.location().set_href("/library");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})}>
|
||||||
|
{ "Remove" }
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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>
|
</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! {
|
||||||
|
<div>
|
||||||
|
<div class={if d.artist_banner.is_some() { "artist-banner-wrap" } else { "" }}
|
||||||
|
style={d.artist_banner.as_ref().map(|b| format!("background-image: url('{b}')")).unwrap_or_default()}>
|
||||||
<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">
|
||||||
@@ -249,6 +560,7 @@ pub fn artist_page(props: &Props) -> Html {
|
|||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
{ watch_all_btn }
|
{ watch_all_btn }
|
||||||
{ monitor_btn }
|
{ monitor_btn }
|
||||||
|
{ remove_btn }
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
if let Some(ref bio) = d.artist_bio {
|
if let Some(ref bio) = d.artist_bio {
|
||||||
@@ -268,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);">
|
||||||
@@ -275,11 +588,19 @@ 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>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group albums by type
|
// Group albums by type (primary credit only)
|
||||||
{ for ["Album", "EP", "Single"].iter().map(|release_type| {
|
{ for ["Album", "EP", "Single"].iter().map(|release_type| {
|
||||||
let type_albums: Vec<_> = d.albums.iter()
|
let type_albums: Vec<_> = d.albums.iter()
|
||||||
.filter(|a| a.release_type.as_deref().unwrap_or("Album") == *release_type)
|
.filter(|a| a.release_type.as_deref().unwrap_or("Album") == *release_type)
|
||||||
@@ -315,8 +636,40 @@ pub fn artist_page(props: &Props) -> Html {
|
|||||||
|
|
||||||
let tc = album.track_count;
|
let tc = album.track_count;
|
||||||
|
|
||||||
// Watch button for unwatched 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();
|
||||||
@@ -325,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();
|
||||||
@@ -350,10 +704,42 @@ pub fn artist_page(props: &Props) -> Html {
|
|||||||
{ "Watch" }
|
{ "Watch" }
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
html! {}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let album_unwatch_btn = {
|
||||||
|
let album_title = album.title.clone();
|
||||||
|
let album_mbid = album.mbid.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 btn-fixed"
|
||||||
|
style={if !is_album_watched { "visibility:hidden;" } else { "" }}
|
||||||
|
onclick={Callback::from(move |_: MouseEvent| {
|
||||||
|
let album_title = album_title.clone();
|
||||||
|
let album_mbid = album_mbid.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_album(&album_mbid).await {
|
||||||
|
Ok(_) => {
|
||||||
|
message.set(Some(format!("Unwatched '{album_title}'")));
|
||||||
|
fetch.emit(artist_id);
|
||||||
|
}
|
||||||
|
Err(e) => error.set(Some(e.0)),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})}>
|
||||||
|
{ "Unwatch" }
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
@@ -369,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>
|
||||||
@@ -397,6 +778,58 @@ pub fn artist_page(props: &Props) -> Html {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
// Featured releases (collapsible, pre-collapsed)
|
||||||
|
{ for ["Album", "EP", "Single"].iter().map(|release_type| {
|
||||||
|
let featured: Vec<_> = d.featured_albums.iter()
|
||||||
|
.filter(|a| a.release_type.as_deref().unwrap_or("Album") == *release_type)
|
||||||
|
.collect();
|
||||||
|
if featured.is_empty() {
|
||||||
|
return html! {};
|
||||||
|
}
|
||||||
|
html! {
|
||||||
|
<details class="mb-2">
|
||||||
|
<summary class="text-muted" style="cursor: pointer;">
|
||||||
|
{ format!("Featured {}s ({})", release_type, featured.len()) }
|
||||||
|
</summary>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 60px;"></th>
|
||||||
|
<th>{ "Title" }</th>
|
||||||
|
<th>{ "Date" }</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ for featured.iter().map(|album| {
|
||||||
|
let cover_url = format!("https://coverartarchive.org/release/{}/front-250", album.mbid);
|
||||||
|
html! {
|
||||||
|
<tr style="opacity: 0.6;">
|
||||||
|
<td>
|
||||||
|
<img class="album-art" src={cover_url}
|
||||||
|
loading="lazy"
|
||||||
|
onerror={Callback::from(|e: web_sys::Event| {
|
||||||
|
if let Some(el) = e.target_dyn_into::<web_sys::HtmlElement>() {
|
||||||
|
el.set_attribute("style", "display:none").ok();
|
||||||
|
}
|
||||||
|
})} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Link<Route> to={Route::Album { mbid: album.mbid.clone() }}>
|
||||||
|
{ &album.title }
|
||||||
|
</Link<Route>>
|
||||||
|
</td>
|
||||||
|
<td class="text-muted">{ album.date.as_deref().unwrap_or("") }</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
|
||||||
|
} // close else (discography tab)
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+155
-57
@@ -34,6 +34,8 @@ pub fn dashboard() -> Html {
|
|||||||
let status = use_state(|| None::<Status>);
|
let status = use_state(|| None::<Status>);
|
||||||
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 pipeline_was_active = use_state(|| false);
|
||||||
|
let pipeline_complete = use_state(|| false);
|
||||||
|
|
||||||
// Fetch status function
|
// Fetch status function
|
||||||
let fetch_status = {
|
let fetch_status = {
|
||||||
@@ -240,12 +242,8 @@ pub fn dashboard() -> Html {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|t| t.status == "Pending" || t.status == "Running");
|
.any(|t| t.status == "Pending" || t.status == "Running");
|
||||||
|
|
||||||
// Pre-compute scheduled task rows
|
// Skip callbacks for scheduler
|
||||||
let scheduled_rows = {
|
let on_skip_pipeline = {
|
||||||
let mut rows = Vec::new();
|
|
||||||
if let Some(ref sched) = s.scheduled {
|
|
||||||
if let Some(ref next) = sched.next_pipeline {
|
|
||||||
let on_skip = {
|
|
||||||
let message = message.clone();
|
let message = message.clone();
|
||||||
let error = error.clone();
|
let error = error.clone();
|
||||||
let fetch = fetch_status.clone();
|
let fetch = fetch_status.clone();
|
||||||
@@ -264,17 +262,7 @@ pub fn dashboard() -> Html {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
rows.push(html! {
|
let on_skip_monitor = {
|
||||||
<tr>
|
|
||||||
<td>{ "Auto Pipeline" }</td>
|
|
||||||
<td><span class="badge badge-pending">{ "Scheduled" }</span></td>
|
|
||||||
<td class="text-sm text-muted">{ format!("Next run: {}", format_next_run(next)) }</td>
|
|
||||||
<td><button class="btn btn-sm btn-danger" onclick={on_skip}>{ "Skip" }</button></td>
|
|
||||||
</tr>
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if let Some(ref next) = sched.next_monitor {
|
|
||||||
let on_skip = {
|
|
||||||
let message = message.clone();
|
let message = message.clone();
|
||||||
let error = error.clone();
|
let error = error.clone();
|
||||||
let fetch = fetch_status.clone();
|
let fetch = fetch_status.clone();
|
||||||
@@ -293,19 +281,143 @@ pub fn dashboard() -> Html {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
rows.push(html! {
|
|
||||||
|
let pipeline_progress_html = if let Some(ref wq) = s.work_queue {
|
||||||
|
let active = wq.download.pending
|
||||||
|
+ wq.download.running
|
||||||
|
+ wq.index.pending
|
||||||
|
+ wq.index.running
|
||||||
|
+ wq.tag.pending
|
||||||
|
+ wq.tag.running
|
||||||
|
+ wq.organize.pending
|
||||||
|
+ wq.organize.running
|
||||||
|
+ wq.enrich.pending
|
||||||
|
+ wq.enrich.running;
|
||||||
|
|
||||||
|
// Track pipeline active→inactive transition
|
||||||
|
if active > 0 {
|
||||||
|
if !*pipeline_was_active {
|
||||||
|
pipeline_was_active.set(true);
|
||||||
|
pipeline_complete.set(false);
|
||||||
|
}
|
||||||
|
} else if *pipeline_was_active {
|
||||||
|
pipeline_was_active.set(false);
|
||||||
|
pipeline_complete.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if active > 0 {
|
||||||
|
html! {
|
||||||
|
<div class="card">
|
||||||
|
<h3>{ "Pipeline Progress" }</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>{ "Step" }</th><th>{ "Pending" }</th><th>{ "Running" }</th><th>{ "Done" }</th><th>{ "Failed" }</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ for [("Download", &wq.download), ("Index", &wq.index), ("Tag", &wq.tag), ("Organize", &wq.organize), ("Enrich", &wq.enrich)].iter().map(|(name, c)| {
|
||||||
|
html! {
|
||||||
|
<tr>
|
||||||
|
<td>{ name }</td>
|
||||||
|
<td>{ c.pending }</td>
|
||||||
|
<td>{ if c.running > 0 { html! { <span class="badge badge-accent">{ c.running }</span> } } else { html! { { "0" } } } }</td>
|
||||||
|
<td>{ c.completed }</td>
|
||||||
|
<td>{ if c.failed > 0 { html! { <span class="badge badge-danger">{ c.failed }</span> } } else { html! { { "0" } } } }</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else if *pipeline_complete {
|
||||||
|
html! {
|
||||||
|
<div class="card" style="border-color: var(--success);">
|
||||||
|
<p style="color: var(--success);">{ "Pipeline run complete!" }</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
} else if *pipeline_complete {
|
||||||
|
html! {
|
||||||
|
<div class="card" style="border-color: var(--success);">
|
||||||
|
<p style="color: var(--success);">{ "Pipeline run complete!" }</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
};
|
||||||
|
|
||||||
|
let scheduled_jobs_html = {
|
||||||
|
let next_pipeline = s
|
||||||
|
.scheduled
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|sc| sc.next_pipeline.as_ref());
|
||||||
|
let next_monitor = s.scheduled.as_ref().and_then(|sc| sc.next_monitor.as_ref());
|
||||||
|
let pipeline_next_str = next_pipeline
|
||||||
|
.map(|n| format_next_run(n))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let monitor_next_str = next_monitor.map(|n| format_next_run(n)).unwrap_or_default();
|
||||||
|
let pipeline_last = s
|
||||||
|
.scheduler
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|sc| sc.get("pipeline"))
|
||||||
|
.and_then(|j| j.get("last_result"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let monitor_last = s
|
||||||
|
.scheduler
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|sc| sc.get("monitor"))
|
||||||
|
.and_then(|j| j.get("last_result"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="card">
|
||||||
|
<h3>{ "Scheduled Jobs" }</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>{ "Job" }</th><th>{ "Status" }</th><th>{ "Next Run" }</th><th>{ "Last Result" }</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>{ "Auto Pipeline" }</td>
|
||||||
|
<td>{ if next_pipeline.is_some() {
|
||||||
|
html! { <span class="badge badge-pending">{ "Scheduled" }</span> }
|
||||||
|
} else {
|
||||||
|
html! { <span class="text-muted text-sm">{ "Idle" }</span> }
|
||||||
|
}}</td>
|
||||||
|
<td class="text-sm text-muted">{ pipeline_next_str }</td>
|
||||||
|
<td class="text-sm text-muted">{ pipeline_last }</td>
|
||||||
|
<td>{ if next_pipeline.is_some() {
|
||||||
|
html! { <button class="btn btn-sm btn-danger" onclick={on_skip_pipeline}>{ "Skip" }</button> }
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}}</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{ "Monitor Check" }</td>
|
<td>{ "Monitor Check" }</td>
|
||||||
<td><span class="badge badge-pending">{ "Scheduled" }</span></td>
|
<td>{ if next_monitor.is_some() {
|
||||||
<td class="text-sm text-muted">{ format!("Next run: {}", format_next_run(next)) }</td>
|
html! { <span class="badge badge-pending">{ "Scheduled" }</span> }
|
||||||
<td><button class="btn btn-sm btn-danger" onclick={on_skip}>{ "Skip" }</button></td>
|
} else {
|
||||||
|
html! { <span class="text-muted text-sm">{ "Idle" }</span> }
|
||||||
|
}}</td>
|
||||||
|
<td class="text-sm text-muted">{ monitor_next_str }</td>
|
||||||
|
<td class="text-sm text-muted">{ monitor_last }</td>
|
||||||
|
<td>{ if next_monitor.is_some() {
|
||||||
|
html! { <button class="btn btn-sm btn-danger" onclick={on_skip_monitor}>{ "Skip" }</button> }
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
});
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
}
|
|
||||||
rows
|
|
||||||
};
|
};
|
||||||
let has_scheduled = !scheduled_rows.is_empty();
|
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div>
|
<div>
|
||||||
@@ -366,36 +478,13 @@ pub fn dashboard() -> Html {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Work Queue Progress
|
// Work Queue Progress
|
||||||
if let Some(ref wq) = s.work_queue {
|
{ pipeline_progress_html }
|
||||||
if wq.download.pending + wq.download.running + wq.tag.pending + wq.tag.running + wq.organize.pending + wq.organize.running > 0
|
|
||||||
|| wq.download.completed + wq.tag.completed + wq.organize.completed > 0
|
|
||||||
{
|
|
||||||
<div class="card">
|
|
||||||
<h3>{ "Pipeline Progress" }</h3>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>{ "Step" }</th><th>{ "Pending" }</th><th>{ "Running" }</th><th>{ "Done" }</th><th>{ "Failed" }</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{ for [("Download", &wq.download), ("Index", &wq.index), ("Tag", &wq.tag), ("Organize", &wq.organize)].iter().map(|(name, c)| {
|
|
||||||
html! {
|
|
||||||
<tr>
|
|
||||||
<td>{ name }</td>
|
|
||||||
<td>{ c.pending }</td>
|
|
||||||
<td>{ if c.running > 0 { html! { <span class="badge badge-accent">{ c.running }</span> } } else { html! { { "0" } } } }</td>
|
|
||||||
<td>{ c.completed }</td>
|
|
||||||
<td>{ if c.failed > 0 { html! { <span class="badge badge-danger">{ c.failed }</span> } } else { html! { { "0" } } } }</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Background Tasks (always show if there are tasks or scheduled items)
|
// Scheduled Jobs (always visible)
|
||||||
if !s.tasks.is_empty() || has_scheduled {
|
{ scheduled_jobs_html }
|
||||||
|
|
||||||
|
// Background Tasks (one-off tasks like MB import)
|
||||||
|
if !s.tasks.is_empty() {
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>{ "Background Tasks" }</h3>
|
<h3>{ "Background Tasks" }</h3>
|
||||||
<table class="tasks-table">
|
<table class="tasks-table">
|
||||||
@@ -403,7 +492,6 @@ pub fn dashboard() -> Html {
|
|||||||
<tr><th>{ "Type" }</th><th>{ "Status" }</th><th>{ "Progress" }</th><th>{ "Result" }</th></tr>
|
<tr><th>{ "Type" }</th><th>{ "Status" }</th><th>{ "Progress" }</th><th>{ "Result" }</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{ for scheduled_rows.into_iter() }
|
|
||||||
{ for s.tasks.iter().map(|t| {
|
{ for s.tasks.iter().map(|t| {
|
||||||
let progress_html = if let Some(ref p) = t.progress {
|
let progress_html = if let Some(ref p) = t.progress {
|
||||||
if p.total > 0 {
|
if p.total > 0 {
|
||||||
@@ -446,7 +534,7 @@ pub fn dashboard() -> Html {
|
|||||||
<tr><th>{ "Query" }</th><th>{ "Status" }</th><th>{ "Error" }</th></tr>
|
<tr><th>{ "Query" }</th><th>{ "Status" }</th><th>{ "Error" }</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{ for s.queue.items.iter().map(|item| html! {
|
{ for s.queue.items.iter().take(10).map(|item| html! {
|
||||||
<tr>
|
<tr>
|
||||||
<td>{ &item.query }</td>
|
<td>{ &item.query }</td>
|
||||||
<td><StatusBadge status={item.status.clone()} /></td>
|
<td><StatusBadge status={item.status.clone()} /></td>
|
||||||
@@ -455,6 +543,11 @@ pub fn dashboard() -> Html {
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
if s.queue.items.len() > 10 {
|
||||||
|
<p class="text-sm text-muted mt-1">
|
||||||
|
{ format!("and {} more...", s.queue.items.len() - 10) }
|
||||||
|
</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
} else if s.queue.pending > 0 || s.queue.downloading > 0 {
|
} else if s.queue.pending > 0 || s.queue.downloading > 0 {
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -474,7 +567,7 @@ pub fn dashboard() -> Html {
|
|||||||
<tr><th>{ "Title" }</th><th>{ "Artist" }</th><th>{ "Album" }</th><th>{ "MBID" }</th></tr>
|
<tr><th>{ "Title" }</th><th>{ "Artist" }</th><th>{ "Album" }</th><th>{ "MBID" }</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{ for tagging.items.iter().map(|t| html! {
|
{ for tagging.items.iter().take(10).map(|t| html! {
|
||||||
<tr>
|
<tr>
|
||||||
<td>{ t.title.as_deref().unwrap_or("Unknown") }</td>
|
<td>{ t.title.as_deref().unwrap_or("Unknown") }</td>
|
||||||
<td>{ t.artist.as_deref().unwrap_or("") }</td>
|
<td>{ t.artist.as_deref().unwrap_or("") }</td>
|
||||||
@@ -490,6 +583,11 @@ pub fn dashboard() -> Html {
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
if tagging.items.len() > 10 {
|
||||||
|
<p class="text-sm text-muted mt-1">
|
||||||
|
{ format!("and {} more...", tagging.items.len() - 10) }
|
||||||
|
</p>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
+338
-27
@@ -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::*;
|
||||||
|
|
||||||
@@ -10,16 +13,131 @@ pub fn library_page() -> Html {
|
|||||||
let artists = use_state(|| None::<Vec<ArtistListItem>>);
|
let artists = use_state(|| None::<Vec<ArtistListItem>>);
|
||||||
let error = use_state(|| None::<String>);
|
let error = use_state(|| None::<String>);
|
||||||
|
|
||||||
{
|
let fetch_artists = {
|
||||||
|
let artists = artists.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
Callback::from(move |_: ()| {
|
||||||
let artists = artists.clone();
|
let artists = artists.clone();
|
||||||
let error = error.clone();
|
let error = error.clone();
|
||||||
use_effect_with((), move |_| {
|
|
||||||
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)),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let fetch = fetch_artists.clone();
|
||||||
|
use_effect_with((), move |_| {
|
||||||
|
fetch.emit(());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,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">
|
||||||
@@ -41,19 +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>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{ for artists.iter().map(|a| html! {
|
{ for artists.iter().map(|a| {
|
||||||
<tr>
|
let anchor_id =
|
||||||
|
first_of_letter.get(&a.id).map(|c| format!("letter-{c}"));
|
||||||
|
let artist_id = a.id;
|
||||||
|
let artist_name = a.name.clone();
|
||||||
|
let artist_mbid = a.musicbrainz_id.clone();
|
||||||
|
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 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::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! {
|
||||||
|
<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 }
|
||||||
@@ -64,36 +336,75 @@ pub fn library_page() -> Html {
|
|||||||
<span style="color: var(--success);" title="Monitored">{ "\u{2713}" }</span>
|
<span style="color: var(--success);" title="Monitored">{ "\u{2713}" }</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
|
if a.enriched {
|
||||||
<td>
|
<td>
|
||||||
if a.total_items > 0 {
|
<div class="progress-bar-wrap">
|
||||||
<span class="text-sm" style={
|
<div class="progress-bar-fill" style={owned_bar_style.clone()}></div>
|
||||||
if a.total_owned >= a.total_watched && a.total_watched > 0 { "color: var(--success);" }
|
<span class="progress-bar-text">{ format!("{}/{}", a.total_owned, a.total_watched) }</span>
|
||||||
else if a.total_owned > 0 { "color: var(--warning);" }
|
</div>
|
||||||
else { "color: var(--text-muted);" }
|
|
||||||
}>
|
|
||||||
{ format!("{}/{}", a.total_owned, a.total_watched) }
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
if a.total_items > 0 {
|
<div class="progress-bar-wrap">
|
||||||
<span class="text-sm" style={
|
<div class="progress-bar-fill" style={watched_bar_style.clone()}></div>
|
||||||
if a.total_watched > 0 { "color: var(--accent);" }
|
<span class="progress-bar-text">{ format!("{}/{}", a.total_watched, a.total_items) }</span>
|
||||||
else { "color: var(--text-muted);" }
|
</div>
|
||||||
}>
|
</td>
|
||||||
{ format!("{}/{}", a.total_watched, a.total_items) }
|
} else {
|
||||||
</span>
|
<td colspan="2" class="text-sm text-muted loading">
|
||||||
}
|
{ "Awaiting artist enrichment..." }
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted text-sm">
|
|
||||||
if a.total_items > 0 {
|
|
||||||
{ a.total_items }
|
|
||||||
}
|
}
|
||||||
|
<td class="actions">
|
||||||
|
<button class="btn btn-sm btn-lib"
|
||||||
|
onclick={on_watch}
|
||||||
|
style={if is_fully_watched { "visibility:hidden;" } else { "" }}>
|
||||||
|
{ "Watch" }
|
||||||
|
</button>
|
||||||
|
<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" }
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
}
|
||||||
})}
|
})}
|
||||||
</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>
|
||||||
}
|
}
|
||||||
|
|||||||
+183
-10
@@ -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,8 +468,136 @@ pub fn playlists_page() -> Html {
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<label class="tooltip-wrap">
|
||||||
|
{ format!("Discovery Range: {}", *discovery_range) }
|
||||||
|
<span class="text-sm text-muted">{ if *discovery_range <= 3 { " (focused)" } else if *discovery_range >= 7 { " (wide)" } else { "" } }</span>
|
||||||
|
<span class="tooltip-icon">{ "?" }</span>
|
||||||
|
<span class="tooltip-text">{ "How far from your seeds to explore. Low = only the most closely related artists. High = cast a wide net into loosely connected artists." }</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label class="tooltip-wrap">
|
||||||
|
{ format!("Seed Weight: {}", *seed_weight) }
|
||||||
|
<span class="text-sm text-muted">{ if *seed_weight == 0 { " (exclude seeds)" } else if *seed_weight >= 8 { " (heavy)" } else { "" } }</span>
|
||||||
|
<span class="tooltip-icon">{ "?" }</span>
|
||||||
|
<span class="tooltip-text">{ "How much your seed artists themselves should appear in the playlist. 0 = only discoveries (seeds excluded), 5 = normal, 10 = seeds appear heavily." }</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
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">
|
<div class="form-group">
|
||||||
<label>{ "Track Order" }</label>
|
<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={{
|
<select onchange={{
|
||||||
let ord = ordering.clone();
|
let ord = ordering.clone();
|
||||||
Callback::from(move |e: Event| {
|
Callback::from(move |e: Event| {
|
||||||
@@ -437,12 +605,13 @@ pub fn playlists_page() -> Html {
|
|||||||
ord.set(select.value());
|
ord.set(select.value());
|
||||||
})
|
})
|
||||||
}}>
|
}}>
|
||||||
<option value="interleave" selected={*ordering == "interleave"}>{ "Interleave (spread artists)" }</option>
|
<option value="interleave" selected={*ordering == "interleave"}>{ "Interleave" }</option>
|
||||||
<option value="score" selected={*ordering == "score"}>{ "By Score (best first)" }</option>
|
<option value="score" selected={*ordering == "score"}>{ "By Score" }</option>
|
||||||
<option value="random" selected={*ordering == "random"}>{ "Random" }</option>
|
<option value="random" selected={*ordering == "random"}>{ "Random" }</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -453,6 +453,18 @@ pub fn settings_page() -> Html {
|
|||||||
}
|
}
|
||||||
})} />
|
})} />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{ "Concurrency" }</label>
|
||||||
|
<input type="number" min="1" max="16" value={c.tagging.concurrency.to_string()}
|
||||||
|
oninput={let config = config.clone(); Callback::from(move |e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
if let Ok(v) = input.value().parse::<usize>() {
|
||||||
|
let mut cfg = (*config).clone().unwrap();
|
||||||
|
cfg.tagging.concurrency = v;
|
||||||
|
config.set(Some(cfg));
|
||||||
|
}
|
||||||
|
})} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Downloads
|
// Downloads
|
||||||
|
|||||||
+45
-2
@@ -30,9 +30,13 @@ pub struct ArtistListItem {
|
|||||||
pub musicbrainz_id: Option<String>,
|
pub musicbrainz_id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub monitored: bool,
|
pub monitored: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub enriched: bool,
|
||||||
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)]
|
||||||
@@ -54,6 +58,8 @@ pub struct FullAlbumInfo {
|
|||||||
pub struct FullArtistDetail {
|
pub struct FullArtistDetail {
|
||||||
pub artist: Artist,
|
pub artist: Artist,
|
||||||
pub albums: Vec<FullAlbumInfo>,
|
pub albums: Vec<FullAlbumInfo>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub featured_albums: Vec<FullAlbumInfo>,
|
||||||
pub artist_status: String,
|
pub artist_status: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub total_available_tracks: u32,
|
pub total_available_tracks: u32,
|
||||||
@@ -73,12 +79,27 @@ 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)]
|
||||||
pub struct ArtistInfoFe {
|
pub struct ArtistInfoFe {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub mbid: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub disambiguation: Option<String>,
|
pub disambiguation: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub country: Option<String>,
|
pub country: Option<String>,
|
||||||
@@ -120,6 +141,8 @@ pub struct Track {
|
|||||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
pub struct MbAlbumDetail {
|
pub struct MbAlbumDetail {
|
||||||
pub mbid: String,
|
pub mbid: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub artist: Option<String>,
|
||||||
pub tracks: Vec<MbAlbumTrack>,
|
pub tracks: Vec<MbAlbumTrack>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +284,7 @@ pub struct ScheduledTasks {
|
|||||||
pub next_monitor: Option<String>,
|
pub next_monitor: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Default, Deserialize)]
|
||||||
pub struct WorkQueueCounts {
|
pub struct WorkQueueCounts {
|
||||||
pub pending: u64,
|
pub pending: u64,
|
||||||
pub running: u64,
|
pub running: u64,
|
||||||
@@ -275,6 +298,8 @@ pub struct WorkQueueStats {
|
|||||||
pub index: WorkQueueCounts,
|
pub index: WorkQueueCounts,
|
||||||
pub tag: WorkQueueCounts,
|
pub tag: WorkQueueCounts,
|
||||||
pub organize: WorkQueueCounts,
|
pub organize: WorkQueueCounts,
|
||||||
|
#[serde(default)]
|
||||||
|
pub enrich: WorkQueueCounts,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
@@ -337,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 {
|
||||||
@@ -457,6 +494,12 @@ pub struct TaggingConfigFe {
|
|||||||
pub write_tags: bool,
|
pub write_tags: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub confidence: f64,
|
pub confidence: f64,
|
||||||
|
#[serde(default = "default_tag_concurrency")]
|
||||||
|
pub concurrency: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_tag_concurrency() -> usize {
|
||||||
|
4
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||||
|
|||||||
+193
-7
@@ -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 {
|
||||||
@@ -344,3 +390,143 @@ tr[draggable="true"]:active { cursor: grabbing; }
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hamburger menu button — hidden on desktop */
|
||||||
|
.hamburger {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0.75rem;
|
||||||
|
left: 0.75rem;
|
||||||
|
z-index: 101;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar overlay — hidden on desktop */
|
||||||
|
.sidebar-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 99;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.hamburger { display: block; }
|
||||||
|
.sidebar-overlay.open { display: block; }
|
||||||
|
.sidebar {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
z-index: 100;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.sidebar.open { transform: translateX(0); }
|
||||||
|
.main-content {
|
||||||
|
margin-left: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
padding-top: 3.5rem;
|
||||||
|
}
|
||||||
|
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
table { display: block; overflow-x: auto; }
|
||||||
|
.form-group input[type="text"],
|
||||||
|
.form-group input[type="number"],
|
||||||
|
.form-group input[type="password"],
|
||||||
|
.form-group select {
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
}
|
||||||
|
.tab-bar { overflow-x: auto; }
|
||||||
|
.album-art-lg { width: 120px; height: 120px; }
|
||||||
|
.album-header { flex-direction: column; }
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,10 +11,7 @@ pub async fn run_refresh() -> Result<String, String> {
|
|||||||
let cookies_path = shanty_config::data_dir().join("cookies.txt");
|
let cookies_path = shanty_config::data_dir().join("cookies.txt");
|
||||||
|
|
||||||
if !profile_dir.exists() {
|
if !profile_dir.exists() {
|
||||||
return Err(format!(
|
return Err(format!("no Firefox profile at {}", profile_dir.display()));
|
||||||
"no Firefox profile at {}",
|
|
||||||
profile_dir.display()
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let script = find_script()?;
|
let script = find_script()?;
|
||||||
|
|||||||
+15
-1
@@ -16,6 +16,9 @@ pub async fn trigger_pipeline(state: &web::Data<AppState>) -> Result<String, Api
|
|||||||
let pipeline_id = uuid::Uuid::new_v4().to_string();
|
let pipeline_id = uuid::Uuid::new_v4().to_string();
|
||||||
let conn = state.db.conn();
|
let conn = state.db.conn();
|
||||||
|
|
||||||
|
// Clear completed/failed items from previous pipeline runs (not standalone tasks)
|
||||||
|
let _ = queries::work_queue::clear_all_pipelines(conn).await;
|
||||||
|
|
||||||
// Step 1: Sync wanted items to download queue (fast, just DB inserts)
|
// Step 1: Sync wanted items to download queue (fast, just DB inserts)
|
||||||
let sync_stats = shanty_dl::sync_wanted_to_queue(conn, false).await?;
|
let sync_stats = shanty_dl::sync_wanted_to_queue(conn, false).await?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -42,10 +45,21 @@ pub async fn trigger_pipeline(state: &web::Data<AppState>) -> Result<String, Api
|
|||||||
state.workers.notify(WorkTaskType::Download);
|
state.workers.notify(WorkTaskType::Download);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 3: Scan library for existing files (import pipeline)
|
||||||
|
let index_payload = serde_json::json!({"scan_all": true});
|
||||||
|
queries::work_queue::enqueue(
|
||||||
|
conn,
|
||||||
|
WorkTaskType::Index,
|
||||||
|
&index_payload.to_string(),
|
||||||
|
Some(&pipeline_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
state.workers.notify(WorkTaskType::Index);
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
download_items = pending.len(),
|
download_items = pending.len(),
|
||||||
pipeline_id = %pipeline_id,
|
pipeline_id = %pipeline_id,
|
||||||
"pipeline work items created"
|
"pipeline work items created (including library scan)"
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(pipeline_id)
|
Ok(pipeline_id)
|
||||||
|
|||||||
+52
-6
@@ -44,6 +44,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.route(web::get().to(list_albums))
|
.route(web::get().to(list_albums))
|
||||||
.route(web::post().to(add_album)),
|
.route(web::post().to(add_album)),
|
||||||
)
|
)
|
||||||
|
.service(web::resource("/albums/{mbid}/watch").route(web::delete().to(unwatch_album)))
|
||||||
.service(web::resource("/albums/{mbid}").route(web::get().to(get_album)));
|
.service(web::resource("/albums/{mbid}").route(web::get().to(get_album)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,17 +70,30 @@ async fn get_album(
|
|||||||
let mbid = path.into_inner();
|
let mbid = path.into_inner();
|
||||||
|
|
||||||
// Try fetching as a release first
|
// Try fetching as a release first
|
||||||
let mb_tracks = match state.mb_client.get_release_tracks(&mbid).await {
|
let (mb_tracks, _release_mbid) = match state.mb_client.get_release_tracks(&mbid).await {
|
||||||
Ok(tracks) => tracks,
|
Ok(tracks) => (tracks, mbid.clone()),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// Probably a release-group MBID. Browse releases for this group.
|
// Probably a release-group MBID. Browse releases for this group.
|
||||||
let release_mbid = resolve_release_from_group(&state, &mbid).await?;
|
let resolved = resolve_release_from_group(&state, &mbid).await?;
|
||||||
|
let tracks = state
|
||||||
|
.mb_client
|
||||||
|
.get_release_tracks(&resolved)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("MusicBrainz error: {e}")))?;
|
||||||
|
(tracks, resolved)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the album artist from the release's recording credits
|
||||||
|
let album_artist = if let Some(first_track) = mb_tracks.first() {
|
||||||
state
|
state
|
||||||
.mb_client
|
.mb_client
|
||||||
.get_release_tracks(&release_mbid)
|
.get_recording(&first_track.recording_mbid)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("MusicBrainz error: {e}")))?
|
.ok()
|
||||||
}
|
.map(|r| r.artist)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get all wanted items to check local status
|
// Get all wanted items to check local status
|
||||||
@@ -112,6 +126,7 @@ async fn get_album(
|
|||||||
|
|
||||||
Ok(HttpResponse::Ok().json(serde_json::json!({
|
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||||
"mbid": mbid,
|
"mbid": mbid,
|
||||||
|
"artist": album_artist,
|
||||||
"tracks": tracks,
|
"tracks": tracks,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
@@ -180,3 +195,34 @@ async fn add_album(
|
|||||||
"errors": summary.errors,
|
"errors": summary.errors,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn unwatch_album(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
auth::require_auth(&session)?;
|
||||||
|
let mbid = path.into_inner();
|
||||||
|
let conn = state.db.conn();
|
||||||
|
|
||||||
|
// Get the album's tracks from MB to find their recording MBIDs
|
||||||
|
let tracks = match state.mb_client.get_release_tracks(&mbid).await {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(_) => {
|
||||||
|
// Try as release-group
|
||||||
|
let release_mbid = resolve_release_from_group(&state, &mbid).await?;
|
||||||
|
state
|
||||||
|
.mb_client
|
||||||
|
.get_release_tracks(&release_mbid)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("MusicBrainz error: {e}")))?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut removed = 0u64;
|
||||||
|
for track in &tracks {
|
||||||
|
removed += queries::wanted::remove_by_mbid(conn, &track.recording_mbid).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({"removed": removed})))
|
||||||
|
}
|
||||||
|
|||||||
+241
-21
@@ -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;
|
||||||
@@ -34,9 +34,11 @@ struct ArtistListItem {
|
|||||||
name: String,
|
name: String,
|
||||||
musicbrainz_id: Option<String>,
|
musicbrainz_id: Option<String>,
|
||||||
monitored: bool,
|
monitored: bool,
|
||||||
|
enriched: bool,
|
||||||
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)]
|
||||||
@@ -78,6 +80,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.route(web::post().to(set_monitored))
|
.route(web::post().to(set_monitored))
|
||||||
.route(web::delete().to(unset_monitored)),
|
.route(web::delete().to(unset_monitored)),
|
||||||
)
|
)
|
||||||
|
.service(web::resource("/artists/{id}/watch").route(web::delete().to(unwatch_artist)))
|
||||||
.service(
|
.service(
|
||||||
web::resource("/artists/{id}")
|
web::resource("/artists/{id}")
|
||||||
.route(web::get().to(get_artist))
|
.route(web::get().to(get_artist))
|
||||||
@@ -91,17 +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 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 {
|
||||||
let artist_wanted: Vec<_> = wanted
|
// Get total_items from enrichment cache (total available tracks from MB)
|
||||||
.iter()
|
|
||||||
.filter(|w| w.artist_id == Some(a.id))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Check if we have cached artist-level totals from a prior detail page load
|
|
||||||
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 {
|
||||||
@@ -110,27 +113,43 @@ async fn list_artists(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let (total_watched, total_owned, total_items) =
|
let enriched = cached_totals.is_some();
|
||||||
if let Some((avail, watched, owned)) = cached_totals {
|
let total_items = cached_totals
|
||||||
(watched as usize, owned as usize, avail as usize)
|
.map(|(avail, _, _)| avail as usize)
|
||||||
} else {
|
.unwrap_or(0);
|
||||||
// Fall back to wanted item counts
|
|
||||||
let total_items = artist_wanted.len();
|
// 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
|
let total_owned = artist_wanted
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|w| w.status == WantedStatus::Owned)
|
.filter(|w| w.status == WantedStatus::Owned)
|
||||||
.count();
|
.filter_map(|w| w.musicbrainz_id.as_deref())
|
||||||
(total_items, total_owned, total_items)
|
.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,
|
||||||
name: a.name.clone(),
|
name: a.name.clone(),
|
||||||
musicbrainz_id: a.musicbrainz_id.clone(),
|
musicbrainz_id: a.musicbrainz_id.clone(),
|
||||||
monitored: a.monitored,
|
monitored: a.monitored,
|
||||||
|
enriched,
|
||||||
total_watched,
|
total_watched,
|
||||||
total_owned,
|
total_owned,
|
||||||
total_items,
|
total_items,
|
||||||
|
local_tracks,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,6 +376,11 @@ pub async fn enrich_artist(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Backfill artist MBID if the DB record doesn't have one yet (e.g., from import)
|
||||||
|
if artist.musicbrainz_id.is_none() && id.is_some() {
|
||||||
|
let _ = queries::artists::upsert(state.db.conn(), &artist.name, Some(&mbid)).await;
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch artist photo + bio + banner (cached, provider-aware)
|
// Fetch artist photo + bio + banner (cached, provider-aware)
|
||||||
let config = state.config.read().await;
|
let config = state.config.read().await;
|
||||||
let image_source = config.metadata.artist_image_source.clone();
|
let image_source = config.metadata.artist_image_source.clone();
|
||||||
@@ -376,14 +400,123 @@ 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 release groups and filter by allowed secondary types
|
// 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
|
||||||
let all_release_groups = state
|
let all_release_groups = state
|
||||||
.search
|
.search
|
||||||
.get_release_groups(&mbid)
|
.get_release_groups(&mbid)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||||
let allowed = state.config.read().await.allowed_secondary_types.clone();
|
let allowed = state.config.read().await.allowed_secondary_types.clone();
|
||||||
let release_groups: Vec<_> = all_release_groups
|
|
||||||
|
let (primary_rgs, featured_rgs): (Vec<_>, Vec<_>) =
|
||||||
|
all_release_groups.into_iter().partition(|rg| !rg.featured);
|
||||||
|
|
||||||
|
let release_groups: Vec<_> = primary_rgs
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|rg| {
|
.filter(|rg| {
|
||||||
if rg.secondary_types.is_empty() {
|
if rg.secondary_types.is_empty() {
|
||||||
@@ -395,6 +528,31 @@ pub async fn enrich_artist(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
// Featured release groups — just pass through with type filtering
|
||||||
|
let featured_albums: Vec<FullAlbumInfo> = featured_rgs
|
||||||
|
.iter()
|
||||||
|
.filter(|rg| {
|
||||||
|
if rg.secondary_types.is_empty() {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
rg.secondary_types.iter().all(|st| allowed.contains(st))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map(|rg| FullAlbumInfo {
|
||||||
|
mbid: rg.first_release_id.clone().unwrap_or_else(|| rg.id.clone()),
|
||||||
|
title: rg.title.clone(),
|
||||||
|
release_type: rg.primary_type.clone(),
|
||||||
|
date: rg.first_release_date.clone(),
|
||||||
|
track_count: 0,
|
||||||
|
local_album_id: None,
|
||||||
|
watched_tracks: 0,
|
||||||
|
owned_tracks: 0,
|
||||||
|
downloaded_tracks: 0,
|
||||||
|
total_local_tracks: 0,
|
||||||
|
status: "featured".to_string(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Get all wanted items for this artist
|
// Get all wanted items for this artist
|
||||||
let all_wanted = queries::wanted::list(state.db.conn(), None, None).await?;
|
let all_wanted = queries::wanted::list(state.db.conn(), None, None).await?;
|
||||||
let artist_wanted: Vec<_> = all_wanted
|
let artist_wanted: Vec<_> = all_wanted
|
||||||
@@ -427,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 {
|
||||||
@@ -493,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());
|
||||||
|
|
||||||
@@ -555,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 {
|
||||||
@@ -609,6 +790,7 @@ pub async fn enrich_artist(
|
|||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"artist": artist,
|
"artist": artist,
|
||||||
"albums": albums,
|
"albums": albums,
|
||||||
|
"featured_albums": featured_albums,
|
||||||
"artist_status": artist_status,
|
"artist_status": artist_status,
|
||||||
"total_available_tracks": total_available_tracks,
|
"total_available_tracks": total_available_tracks,
|
||||||
"total_watched_tracks": total_artist_watched,
|
"total_watched_tracks": total_artist_watched,
|
||||||
@@ -619,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,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -777,11 +961,13 @@ async fn add_artist(
|
|||||||
if body.name.is_none() && body.mbid.is_none() {
|
if body.name.is_none() && body.mbid.is_none() {
|
||||||
return Err(ApiError::BadRequest("provide name or mbid".into()));
|
return Err(ApiError::BadRequest("provide name or mbid".into()));
|
||||||
}
|
}
|
||||||
|
let allowed = state.config.read().await.allowed_secondary_types.clone();
|
||||||
let summary = shanty_watch::add_artist(
|
let summary = shanty_watch::add_artist(
|
||||||
state.db.conn(),
|
state.db.conn(),
|
||||||
body.name.as_deref(),
|
body.name.as_deref(),
|
||||||
body.mbid.as_deref(),
|
body.mbid.as_deref(),
|
||||||
&state.mb_client,
|
&state.mb_client,
|
||||||
|
&allowed,
|
||||||
Some(user_id),
|
Some(user_id),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -807,12 +993,46 @@ async fn delete_artist(
|
|||||||
session: Session,
|
session: Session,
|
||||||
path: web::Path<i32>,
|
path: web::Path<i32>,
|
||||||
) -> Result<HttpResponse, ApiError> {
|
) -> Result<HttpResponse, ApiError> {
|
||||||
auth::require_admin(&session)?;
|
auth::require_auth(&session)?;
|
||||||
let id = path.into_inner();
|
let id = path.into_inner();
|
||||||
queries::artists::delete(state.db.conn(), id).await?;
|
let conn = state.db.conn();
|
||||||
|
|
||||||
|
// 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::tracks::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;
|
||||||
|
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())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn unwatch_artist(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<i32>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
auth::require_auth(&session)?;
|
||||||
|
let id = path.into_inner();
|
||||||
|
let removed = queries::wanted::remove_by_artist(state.db.conn(), id).await?;
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({"removed": removed})))
|
||||||
|
}
|
||||||
|
|
||||||
async fn set_monitored(
|
async fn set_monitored(
|
||||||
state: web::Data<AppState>,
|
state: web::Data<AppState>,
|
||||||
session: Session,
|
session: Session,
|
||||||
|
|||||||
+60
-4
@@ -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()))?
|
||||||
|
|||||||
@@ -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(
|
||||||
¶ms.format,
|
¶ms,
|
||||||
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(¶ms.format, response::ERROR_NOT_FOUND, "invalid id");
|
return response::error(¶ms, 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(¶ms.format, serde_json::json!({}))
|
response::ok(¶ms, 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(¶ms, 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(¶ms, 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(
|
||||||
|
¶ms,
|
||||||
|
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(
|
||||||
|
¶ms,
|
||||||
|
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(¶ms, serde_json::json!({}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
+743
-94
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||||
¶ms.format,
|
¶ms,
|
||||||
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(
|
||||||
¶ms.format,
|
¶ms,
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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(
|
||||||
¶ms.format,
|
¶ms,
|
||||||
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(¶ms.format, response::ERROR_NOT_FOUND, "invalid song id");
|
return response::error(¶ms, 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(¶ms.format, response::ERROR_NOT_FOUND, "song not found");
|
return response::error(¶ms, 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(
|
||||||
¶ms.format,
|
¶ms,
|
||||||
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(
|
||||||
¶ms.format,
|
¶ms,
|
||||||
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(¶ms, response::ERROR_NOT_FOUND, "file not found"),
|
||||||
response::error(¶ms.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(¶ms, response::ERROR_NOT_FOUND, "file not found on disk")
|
||||||
¶ms.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(
|
||||||
¶ms.format,
|
¶ms,
|
||||||
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(¶ms.format, response::ERROR_NOT_FOUND, "invalid song id");
|
return response::error(¶ms, 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(¶ms.format, response::ERROR_NOT_FOUND, "song not found");
|
return response::error(¶ms, 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(¶ms, response::ERROR_NOT_FOUND, "file not found on disk")
|
||||||
¶ms.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(
|
||||||
¶ms.format,
|
¶ms,
|
||||||
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(¶ms, response::ERROR_NOT_FOUND, "invalid cover art id");
|
||||||
¶ms.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(¶ms, response::ERROR_NOT_FOUND, "album not found");
|
||||||
¶ms.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 {
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_ => HttpResponse::NotFound().finish(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+103
-71
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
¶ms.format,
|
¶ms,
|
||||||
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(
|
||||||
¶ms.format,
|
¶ms,
|
||||||
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(¶ms, response::ERROR_NOT_FOUND, "invalid playlist id");
|
||||||
¶ms.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(¶ms, response::ERROR_NOT_FOUND, "playlist not found");
|
||||||
¶ms.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(
|
||||||
¶ms.format,
|
¶ms,
|
||||||
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(
|
||||||
¶ms.format,
|
¶ms,
|
||||||
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(
|
||||||
¶ms.format,
|
¶ms,
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"playlist": pl_json,
|
"playlist": pl_json,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Err(e) => response::error(
|
Err(e) => response::error(
|
||||||
¶ms.format,
|
¶ms,
|
||||||
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(
|
||||||
¶ms.format,
|
¶ms,
|
||||||
|
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(¶ms, 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(¶ms, 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(
|
||||||
|
¶ms,
|
||||||
|
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(
|
||||||
|
¶ms,
|
||||||
|
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(
|
||||||
|
¶ms,
|
||||||
|
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(
|
||||||
|
¶ms,
|
||||||
|
response::ERROR_GENERIC,
|
||||||
|
&format!("failed to update playlist metadata: {e}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
response::ok(¶ms, 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(
|
||||||
|
¶ms,
|
||||||
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(¶ms, response::ERROR_NOT_FOUND, "invalid playlist id");
|
||||||
¶ms.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(¶ms.format, serde_json::json!({})),
|
Ok(()) => response::ok(¶ms, serde_json::json!({})),
|
||||||
Err(e) => response::error(
|
Err(e) => response::error(
|
||||||
¶ms.format,
|
¶ms,
|
||||||
response::ERROR_GENERIC,
|
response::ERROR_GENERIC,
|
||||||
&format!("failed to delete playlist: {e}"),
|
&format!("failed to delete playlist: {e}"),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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(¶ms.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(¶ms.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 {
|
||||||
|
|||||||
@@ -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") {
|
|
||||||
Some(q) => q,
|
|
||||||
None => {
|
|
||||||
return response::error(
|
|
||||||
¶ms.format,
|
|
||||||
response::ERROR_MISSING_PARAM,
|
|
||||||
"missing required parameter: query",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
let artist_count: u64 = get_query_param(&req, "artistCount")
|
async fn run_search(
|
||||||
.and_then(|v| v.parse().ok())
|
args: &SubsonicArgs,
|
||||||
.unwrap_or(20);
|
state: &web::Data<AppState>,
|
||||||
let album_count: u64 = get_query_param(&req, "albumCount")
|
) -> Result<SearchResults, &'static str> {
|
||||||
.and_then(|v| v.parse().ok())
|
let query = args.get("query").ok_or("query")?;
|
||||||
.unwrap_or(20);
|
let artist_count: u64 = args.get_parsed("artistCount").unwrap_or(20);
|
||||||
let song_count: u64 = get_query_param(&req, "songCount")
|
let album_count: u64 = args.get_parsed("albumCount").unwrap_or(20);
|
||||||
.and_then(|v| v.parse().ok())
|
let song_count: u64 = args.get_parsed("songCount").unwrap_or(20);
|
||||||
.unwrap_or(20);
|
|
||||||
|
|
||||||
// Search tracks (which gives us artists and albums too)
|
let tracks = queries::tracks::search(state.db.conn(), query)
|
||||||
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(
|
||||||
|
¶ms,
|
||||||
|
response::ERROR_MISSING_PARAM,
|
||||||
|
&format!("missing required parameter: {name}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
response::ok(
|
response::ok(
|
||||||
¶ms.format,
|
¶ms,
|
||||||
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(
|
||||||
|
¶ms,
|
||||||
|
response::ERROR_MISSING_PARAM,
|
||||||
|
&format!("missing required parameter: {name}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
response::ok(
|
||||||
|
¶ms,
|
||||||
|
serde_json::json!({
|
||||||
|
"searchResult2": {
|
||||||
|
"artist": results.artists,
|
||||||
|
"album": results.albums,
|
||||||
|
"song": results.songs,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(¶ms, $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(¶ms, 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(¶ms, response::ERROR_NOT_AUTHORIZED, "jukebox disabled")
|
||||||
|
}
|
||||||
@@ -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(¶ms.format, serde_json::json!({}))
|
response::ok(¶ms, 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(
|
||||||
¶ms.format,
|
¶ms,
|
||||||
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(
|
||||||
|
¶ms,
|
||||||
|
serde_json::json!({
|
||||||
|
"openSubsonicExtensions": []
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
¶ms.format,
|
¶ms,
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"user": {
|
"user": {
|
||||||
"username": user.username,
|
"username": user.username,
|
||||||
|
|||||||
@@ -52,13 +52,15 @@ async fn get_status(
|
|||||||
queue_items.extend(pending_items.iter().cloned());
|
queue_items.extend(pending_items.iter().cloned());
|
||||||
queue_items.extend(failed_items.iter().take(5).cloned());
|
queue_items.extend(failed_items.iter().take(5).cloned());
|
||||||
|
|
||||||
let needs_tagging = queries::tracks::get_needing_metadata(conn).await?;
|
let needs_tagging = queries::tracks::get_untagged(conn).await?;
|
||||||
|
|
||||||
// Work queue counts
|
// Work queue counts
|
||||||
let work_queue = queries::work_queue::counts_all(conn).await.ok();
|
let work_queue = queries::work_queue::counts_all(conn).await.ok();
|
||||||
|
|
||||||
// Scheduler state from DB
|
// Scheduler state from DB
|
||||||
let scheduler_jobs = queries::scheduler_state::list_all(conn).await.unwrap_or_default();
|
let scheduler_jobs = queries::scheduler_state::list_all(conn)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
let scheduler_json: serde_json::Value = scheduler_jobs
|
let scheduler_json: serde_json::Value = scheduler_jobs
|
||||||
.iter()
|
.iter()
|
||||||
.map(|j| {
|
.map(|j| {
|
||||||
@@ -158,12 +160,7 @@ async fn trigger_organize(
|
|||||||
}
|
}
|
||||||
for track in &tracks {
|
for track in &tracks {
|
||||||
let payload = serde_json::json!({"track_id": track.id});
|
let payload = serde_json::json!({"track_id": track.id});
|
||||||
queries::work_queue::enqueue(
|
queries::work_queue::enqueue(conn, WorkTaskType::Organize, &payload.to_string(), None)
|
||||||
conn,
|
|
||||||
WorkTaskType::Organize,
|
|
||||||
&payload.to_string(),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await?;
|
.await?;
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-1
@@ -29,7 +29,11 @@ pub struct WatchTrackRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||||
cfg.service(web::resource("/tracks/watch").route(web::post().to(watch_track)))
|
cfg.service(
|
||||||
|
web::resource("/tracks/watch")
|
||||||
|
.route(web::post().to(watch_track))
|
||||||
|
.route(web::delete().to(unwatch_track)),
|
||||||
|
)
|
||||||
.service(web::resource("/tracks").route(web::get().to(list_tracks)))
|
.service(web::resource("/tracks").route(web::get().to(list_tracks)))
|
||||||
.service(web::resource("/tracks/{id}").route(web::get().to(get_track)));
|
.service(web::resource("/tracks/{id}").route(web::get().to(get_track)));
|
||||||
}
|
}
|
||||||
@@ -87,3 +91,17 @@ async fn watch_track(
|
|||||||
"artist_name": entry.artist_name,
|
"artist_name": entry.artist_name,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn unwatch_track(
|
||||||
|
state: web::Data<AppState>,
|
||||||
|
session: Session,
|
||||||
|
body: web::Json<WatchTrackRequest>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
auth::require_auth(&session)?;
|
||||||
|
let mbid = body
|
||||||
|
.mbid
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| ApiError::BadRequest("provide recording mbid".into()))?;
|
||||||
|
let removed = queries::wanted::remove_by_mbid(state.db.conn(), mbid).await?;
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({"removed": removed})))
|
||||||
|
}
|
||||||
|
|||||||
+21
-8
@@ -15,14 +15,29 @@ use crate::state::AppState;
|
|||||||
/// Spawn the unified scheduler background loop.
|
/// Spawn the unified scheduler background loop.
|
||||||
pub fn spawn(state: web::Data<AppState>) {
|
pub fn spawn(state: web::Data<AppState>) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// Initialize scheduler state rows in DB
|
// Initialize scheduler state rows in DB with next_run_at pre-populated
|
||||||
for job_name in ["pipeline", "monitor", "cookie_refresh"] {
|
for job_name in ["pipeline", "monitor", "cookie_refresh"] {
|
||||||
if let Err(e) =
|
match queries::scheduler_state::get_or_create(state.db.conn(), job_name).await {
|
||||||
queries::scheduler_state::get_or_create(state.db.conn(), job_name).await
|
Ok(job) => {
|
||||||
{
|
if job.next_run_at.is_none() {
|
||||||
|
let (enabled, interval_secs) = read_job_config(&state, job_name).await;
|
||||||
|
if enabled {
|
||||||
|
let next =
|
||||||
|
Utc::now().naive_utc() + chrono::Duration::seconds(interval_secs);
|
||||||
|
let _ = queries::scheduler_state::update_next_run(
|
||||||
|
state.db.conn(),
|
||||||
|
job_name,
|
||||||
|
Some(next),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
tracing::error!(job = job_name, error = %e, "failed to init scheduler state");
|
tracing::error!(job = job_name, error = %e, "failed to init scheduler state");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Check each job
|
// Check each job
|
||||||
@@ -55,8 +70,7 @@ where
|
|||||||
// If config says disabled, ensure DB state reflects it
|
// If config says disabled, ensure DB state reflects it
|
||||||
if !config_enabled {
|
if !config_enabled {
|
||||||
if job.enabled {
|
if job.enabled {
|
||||||
let _ =
|
let _ = queries::scheduler_state::set_enabled(state.db.conn(), job_name, false).await;
|
||||||
queries::scheduler_state::set_enabled(state.db.conn(), job_name, false).await;
|
|
||||||
let _ =
|
let _ =
|
||||||
queries::scheduler_state::update_next_run(state.db.conn(), job_name, None).await;
|
queries::scheduler_state::update_next_run(state.db.conn(), job_name, None).await;
|
||||||
}
|
}
|
||||||
@@ -103,8 +117,7 @@ where
|
|||||||
// Update last run and schedule next
|
// Update last run and schedule next
|
||||||
let _ = queries::scheduler_state::update_last_run(state.db.conn(), job_name, &result_str).await;
|
let _ = queries::scheduler_state::update_last_run(state.db.conn(), job_name, &result_str).await;
|
||||||
let next = Utc::now().naive_utc() + chrono::Duration::seconds(interval_secs);
|
let next = Utc::now().naive_utc() + chrono::Duration::seconds(interval_secs);
|
||||||
let _ =
|
let _ = queries::scheduler_state::update_next_run(state.db.conn(), job_name, Some(next)).await;
|
||||||
queries::scheduler_state::update_next_run(state.db.conn(), job_name, Some(next)).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn read_job_config(state: &web::Data<AppState>, job_name: &str) -> (bool, i64) {
|
async fn read_job_config(state: &web::Data<AppState>, job_name: &str) -> (bool, i64) {
|
||||||
|
|||||||
+220
-29
@@ -36,6 +36,7 @@ impl WorkerManager {
|
|||||||
notifiers.insert(WorkTaskType::Index, Arc::new(Notify::new()));
|
notifiers.insert(WorkTaskType::Index, Arc::new(Notify::new()));
|
||||||
notifiers.insert(WorkTaskType::Tag, Arc::new(Notify::new()));
|
notifiers.insert(WorkTaskType::Tag, Arc::new(Notify::new()));
|
||||||
notifiers.insert(WorkTaskType::Organize, Arc::new(Notify::new()));
|
notifiers.insert(WorkTaskType::Organize, Arc::new(Notify::new()));
|
||||||
|
notifiers.insert(WorkTaskType::Enrich, Arc::new(Notify::new()));
|
||||||
Self { notifiers }
|
Self { notifiers }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,16 +65,27 @@ impl WorkerManager {
|
|||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(6 * 3600)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(6 * 3600)).await;
|
||||||
let _ = queries::work_queue::cleanup_completed(cleanup_state.db.conn(), 7).await;
|
let _ =
|
||||||
|
queries::work_queue::cleanup_completed(cleanup_state.db.conn(), 7).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// Spawn each worker type
|
// Read config for concurrency settings and spawn workers
|
||||||
spawn_worker(state.clone(), WorkTaskType::Download, 1);
|
let cfg = state_clone.config.read().await.clone();
|
||||||
spawn_worker(state.clone(), WorkTaskType::Index, 4);
|
spawn_worker(state_clone.clone(), WorkTaskType::Download, 1);
|
||||||
spawn_worker(state.clone(), WorkTaskType::Tag, 2);
|
spawn_worker(
|
||||||
spawn_worker(state.clone(), WorkTaskType::Organize, 4);
|
state_clone.clone(),
|
||||||
|
WorkTaskType::Index,
|
||||||
|
cfg.indexing.concurrency,
|
||||||
|
);
|
||||||
|
spawn_worker(
|
||||||
|
state_clone.clone(),
|
||||||
|
WorkTaskType::Tag,
|
||||||
|
cfg.tagging.concurrency,
|
||||||
|
);
|
||||||
|
spawn_worker(state_clone.clone(), WorkTaskType::Organize, 4);
|
||||||
|
spawn_worker(state_clone.clone(), WorkTaskType::Enrich, 2);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,12 +99,6 @@ fn spawn_worker(state: web::Data<AppState>, task_type: WorkTaskType, concurrency
|
|||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
// Wait for notification or poll timeout
|
|
||||||
tokio::select! {
|
|
||||||
_ = notify.notified() => {}
|
|
||||||
_ = tokio::time::sleep(std::time::Duration::from_secs(5)) => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Claim pending items
|
// Claim pending items
|
||||||
let items = match queries::work_queue::claim_next(
|
let items = match queries::work_queue::claim_next(
|
||||||
state.db.conn(),
|
state.db.conn(),
|
||||||
@@ -110,6 +116,11 @@ fn spawn_worker(state: web::Data<AppState>, task_type: WorkTaskType, concurrency
|
|||||||
};
|
};
|
||||||
|
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
|
// Nothing to do — wait for notification or poll timeout
|
||||||
|
tokio::select! {
|
||||||
|
_ = notify.notified() => {}
|
||||||
|
_ = tokio::time::sleep(std::time::Duration::from_secs(5)) => {}
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +142,7 @@ fn spawn_worker(state: web::Data<AppState>, task_type: WorkTaskType, concurrency
|
|||||||
WorkTaskType::Index => process_index(&state, &item).await,
|
WorkTaskType::Index => process_index(&state, &item).await,
|
||||||
WorkTaskType::Tag => process_tag(&state, &item).await,
|
WorkTaskType::Tag => process_tag(&state, &item).await,
|
||||||
WorkTaskType::Organize => process_organize(&state, &item).await,
|
WorkTaskType::Organize => process_organize(&state, &item).await,
|
||||||
|
WorkTaskType::Enrich => process_enrich(&state, &item).await,
|
||||||
};
|
};
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
@@ -141,6 +153,7 @@ fn spawn_worker(state: web::Data<AppState>, task_type: WorkTaskType, concurrency
|
|||||||
tracing::error!(id = item_id, error = %e, "failed to mark work item complete");
|
tracing::error!(id = item_id, error = %e, "failed to mark work item complete");
|
||||||
}
|
}
|
||||||
// Enqueue downstream items
|
// Enqueue downstream items
|
||||||
|
let has_downstream = !downstream.is_empty();
|
||||||
for (task_type, payload) in downstream {
|
for (task_type, payload) in downstream {
|
||||||
if let Err(e) = queries::work_queue::enqueue(
|
if let Err(e) = queries::work_queue::enqueue(
|
||||||
state.db.conn(),
|
state.db.conn(),
|
||||||
@@ -157,6 +170,21 @@ fn spawn_worker(state: web::Data<AppState>, task_type: WorkTaskType, concurrency
|
|||||||
}
|
}
|
||||||
state.workers.notify(task_type);
|
state.workers.notify(task_type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check pipeline completion when this item had no downstream
|
||||||
|
// (terminal step or empty result). Any worker can trigger this.
|
||||||
|
if !has_downstream
|
||||||
|
&& item_task_type != WorkTaskType::Enrich
|
||||||
|
&& let Some(ref pid) = pipeline_id
|
||||||
|
&& let Ok(true) = queries::work_queue::pipeline_is_complete(
|
||||||
|
state.db.conn(),
|
||||||
|
pid,
|
||||||
|
None, // item already marked complete
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
trigger_pipeline_completion(&state, pid).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -316,7 +344,11 @@ async fn process_index(
|
|||||||
let cfg = state.config.read().await.clone();
|
let cfg = state.config.read().await.clone();
|
||||||
let mut downstream = Vec::new();
|
let mut downstream = Vec::new();
|
||||||
|
|
||||||
if payload.get("scan_all").and_then(|v| v.as_bool()).unwrap_or(false) {
|
if payload
|
||||||
|
.get("scan_all")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
// Full library scan
|
// Full library scan
|
||||||
let scan_config = shanty_index::ScanConfig {
|
let scan_config = shanty_index::ScanConfig {
|
||||||
root: cfg.library_path.clone(),
|
root: cfg.library_path.clone(),
|
||||||
@@ -327,8 +359,8 @@ async fn process_index(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// Create Tag work items for all untagged tracks
|
// Create Tag work items for tracks not yet tagged by shanty
|
||||||
let untagged = queries::tracks::get_needing_metadata(conn)
|
let untagged = queries::tracks::get_untagged(conn)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
for track in &untagged {
|
for track in &untagged {
|
||||||
@@ -360,8 +392,7 @@ async fn process_tag(
|
|||||||
let track_id = payload
|
let track_id = payload
|
||||||
.get("track_id")
|
.get("track_id")
|
||||||
.and_then(|v| v.as_i64())
|
.and_then(|v| v.as_i64())
|
||||||
.ok_or("missing track_id in payload")?
|
.ok_or("missing track_id in payload")? as i32;
|
||||||
as i32;
|
|
||||||
|
|
||||||
let conn = state.db.conn();
|
let conn = state.db.conn();
|
||||||
let cfg = state.config.read().await.clone();
|
let cfg = state.config.read().await.clone();
|
||||||
@@ -380,6 +411,67 @@ async fn process_tag(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Re-read the track to get the MBID set by tagging
|
||||||
|
let track = queries::tracks::get_by_id(conn, track_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Ensure a wanted_item exists for this track (marks imported files as Owned).
|
||||||
|
// Check by MBID first, then by name+artist to avoid duplicates from MBID mismatches.
|
||||||
|
// 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
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
} 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(
|
||||||
|
conn,
|
||||||
|
queries::wanted::AddWantedItem {
|
||||||
|
item_type: shanty_db::entities::wanted_item::ItemType::Track,
|
||||||
|
name: track.title.as_deref().unwrap_or("Unknown"),
|
||||||
|
musicbrainz_id: track.musicbrainz_id.as_deref(),
|
||||||
|
artist_id: track.artist_id,
|
||||||
|
album_id: track.album_id,
|
||||||
|
track_id: Some(track.id),
|
||||||
|
user_id: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Mark as Owned immediately since the file already exists
|
||||||
|
let _ = queries::wanted::update_status(
|
||||||
|
conn,
|
||||||
|
item.id,
|
||||||
|
shanty_db::entities::wanted_item::WantedStatus::Owned,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
// Create Organize work item
|
// Create Organize work item
|
||||||
let org_payload = serde_json::json!({"track_id": track_id});
|
let org_payload = serde_json::json!({"track_id": track_id});
|
||||||
Ok(vec![(WorkTaskType::Organize, org_payload.to_string())])
|
Ok(vec![(WorkTaskType::Organize, org_payload.to_string())])
|
||||||
@@ -394,8 +486,7 @@ async fn process_organize(
|
|||||||
let track_id = payload
|
let track_id = payload
|
||||||
.get("track_id")
|
.get("track_id")
|
||||||
.and_then(|v| v.as_i64())
|
.and_then(|v| v.as_i64())
|
||||||
.ok_or("missing track_id in payload")?
|
.ok_or("missing track_id in payload")? as i32;
|
||||||
as i32;
|
|
||||||
|
|
||||||
let conn = state.db.conn();
|
let conn = state.db.conn();
|
||||||
let cfg = state.config.read().await.clone();
|
let cfg = state.config.read().await.clone();
|
||||||
@@ -414,18 +505,118 @@ async fn process_organize(
|
|||||||
// Promote this track's wanted item from Downloaded to Owned
|
// Promote this track's wanted item from Downloaded to Owned
|
||||||
let _ = queries::wanted::promote_downloaded_to_owned(conn).await;
|
let _ = queries::wanted::promote_downloaded_to_owned(conn).await;
|
||||||
|
|
||||||
// Check if pipeline is complete and trigger enrichment
|
Ok(vec![])
|
||||||
if let Some(ref pipeline_id) = item.pipeline_id
|
}
|
||||||
&& let Ok(true) = queries::work_queue::pipeline_is_complete(conn, pipeline_id).await
|
|
||||||
|
/// Called when all non-Enrich items in a pipeline are complete.
|
||||||
|
/// Runs cleanup and creates Enrich work items for each watched artist.
|
||||||
|
async fn trigger_pipeline_completion(state: &web::Data<AppState>, pipeline_id: &str) {
|
||||||
|
let conn = state.db.conn();
|
||||||
|
tracing::info!(pipeline_id = %pipeline_id, "pipeline complete, running cleanup");
|
||||||
|
|
||||||
|
// Cleanup: remove unwanted tracks (files + DB), orphaned records, empty dirs
|
||||||
|
let library_path = state.config.read().await.library_path.clone();
|
||||||
|
|
||||||
|
// Delete files for tracks that went through tagging but aren't wanted
|
||||||
|
match queries::tracks::get_unwanted(conn).await {
|
||||||
|
Ok(unwanted) if !unwanted.is_empty() => {
|
||||||
|
let count = unwanted.len();
|
||||||
|
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);
|
||||||
|
if path.exists() {
|
||||||
|
if let Err(e) = std::fs::remove_file(path) {
|
||||||
|
tracing::warn!(path = %track.file_path, error = %e, "failed to delete unwanted file");
|
||||||
|
} else {
|
||||||
|
// Clean up empty parent dirs
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
shanty_org::organizer::cleanup_empty_dirs(parent, &library_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = queries::tracks::delete(conn, track.id).await;
|
||||||
|
}
|
||||||
|
tracing::info!(count, "cleaned up unwanted tracks and files");
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!(error = %e, "failed to find unwanted tracks"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove DB records for files that no longer exist on disk
|
||||||
|
match queries::tracks::delete_orphaned(conn).await {
|
||||||
|
Ok(n) if n > 0 => tracing::info!(count = n, "cleaned up orphaned tracks"),
|
||||||
|
Err(e) => tracing::warn!(error = %e, "failed to clean orphaned tracks"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
match queries::albums::delete_empty(conn).await {
|
||||||
|
Ok(n) if n > 0 => tracing::info!(count = n, "cleaned up empty albums"),
|
||||||
|
Err(e) => tracing::warn!(error = %e, "failed to clean empty albums"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
match queries::artists::delete_unused(conn).await {
|
||||||
|
Ok(n) if n > 0 => tracing::info!(count = n, "cleaned up unused artists"),
|
||||||
|
Err(e) => tracing::warn!(error = %e, "failed to clean unused artists"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Enrich work items for each artist that has wanted items
|
||||||
|
let all_wanted = queries::wanted::list(conn, None, None)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut artist_ids: Vec<i32> = all_wanted.iter().filter_map(|w| w.artist_id).collect();
|
||||||
|
artist_ids.sort();
|
||||||
|
artist_ids.dedup();
|
||||||
|
|
||||||
|
for artist_id in &artist_ids {
|
||||||
|
let payload = serde_json::json!({"artist_id": artist_id});
|
||||||
|
if let Err(e) = queries::work_queue::enqueue(
|
||||||
|
conn,
|
||||||
|
WorkTaskType::Enrich,
|
||||||
|
&payload.to_string(),
|
||||||
|
Some(pipeline_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
tracing::info!(pipeline_id = %pipeline_id, "pipeline complete, triggering enrichment");
|
tracing::error!(error = %e, "failed to enqueue enrich work item");
|
||||||
let state = state.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Err(e) = crate::routes::artists::enrich_all_watched_artists(&state).await {
|
|
||||||
tracing::error!(error = %e, "post-pipeline enrichment failed");
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
state.workers.notify(WorkTaskType::Enrich);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_enrich(
|
||||||
|
state: &web::Data<AppState>,
|
||||||
|
item: &shanty_db::entities::work_queue::Model,
|
||||||
|
) -> WorkResult {
|
||||||
|
let payload: serde_json::Value =
|
||||||
|
serde_json::from_str(&item.payload_json).map_err(|e| e.to_string())?;
|
||||||
|
let artist_id = payload
|
||||||
|
.get("artist_id")
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.ok_or("missing artist_id in payload")? as i32;
|
||||||
|
|
||||||
|
crate::routes::artists::enrich_artist(state, &artist_id.to_string(), false)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
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}'], "\"")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user