Compare commits

..

5 Commits

Author SHA1 Message Date
Connor Johnstone
3dba620c9b Added the mb db download. Big upsides and downsides 2026-03-21 23:22:49 -04:00
Connor Johnstone
75f3b4f704 Sped up artist enrichment at least somewhat 2026-03-21 15:08:28 -04:00
Connor Johnstone
621355e352 Minimal subsonic functionality 2026-03-20 20:04:35 -04:00
Connor Johnstone
abe321a317 Added the playlist editor 2026-03-20 18:36:59 -04:00
Connor Johnstone
ea6a6410f3 Added the playlist generator 2026-03-20 18:09:47 -04:00
33 changed files with 4025 additions and 143 deletions

View File

@@ -15,6 +15,7 @@ shanty-tag = { path = "../shanty-tag" }
shanty-org = { path = "../shanty-org" } shanty-org = { path = "../shanty-org" }
shanty-watch = { path = "../shanty-watch" } shanty-watch = { path = "../shanty-watch" }
shanty-dl = { path = "../shanty-dl" } shanty-dl = { path = "../shanty-dl" }
shanty-playlist = { path = "../shanty-playlist" }
shanty-search = { path = "../shanty-search" } shanty-search = { path = "../shanty-search" }
sea-orm = { version = "1", features = ["sqlx-sqlite", "runtime-tokio-native-tls"] } sea-orm = { version = "1", features = ["sqlx-sqlite", "runtime-tokio-native-tls"] }
actix-web = "4" actix-web = "4"
@@ -22,6 +23,10 @@ actix-cors = "0.7"
actix-files = "0.6" actix-files = "0.6"
actix-session = { version = "0.10", features = ["cookie-session"] } actix-session = { version = "0.10", features = ["cookie-session"] }
argon2 = "0.5" argon2 = "0.5"
md-5 = "0.10"
hex = "0.4"
quick-xml = { version = "0.37", features = ["serialize"] }
serde_urlencoded = "0.7"
rand = "0.9" rand = "0.9"
thiserror = "2" thiserror = "2"
anyhow = "1" anyhow = "1"
@@ -31,6 +36,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" 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"] }
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"

View File

@@ -14,5 +14,5 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
wasm-bindgen = "0.2" wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4" wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["HtmlInputElement", "HtmlSelectElement", "Window"] } web-sys = { version = "0.3", features = ["HtmlInputElement", "HtmlSelectElement", "Window", "DragEvent", "DataTransfer"] }
js-sys = "0.3" js-sys = "0.3"

View File

@@ -44,6 +44,20 @@ async fn post_empty<T: DeserializeOwned>(url: &str) -> Result<T, ApiError> {
resp.json().await.map_err(|e| ApiError(e.to_string())) resp.json().await.map_err(|e| ApiError(e.to_string()))
} }
async fn put_json<T: DeserializeOwned>(url: &str, body: &str) -> Result<T, ApiError> {
let resp = Request::put(url)
.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()))
}
async fn delete(url: &str) -> Result<(), ApiError> { async fn delete(url: &str) -> Result<(), ApiError> {
let resp = Request::delete(url) let resp = Request::delete(url)
.send() .send()
@@ -227,6 +241,15 @@ pub async fn trigger_monitor_check() -> Result<TaskRef, ApiError> {
post_empty(&format!("{BASE}/monitor/check")).await post_empty(&format!("{BASE}/monitor/check")).await
} }
// --- Scheduler ---
pub async fn skip_scheduled_pipeline() -> Result<serde_json::Value, ApiError> {
post_empty(&format!("{BASE}/scheduler/skip-pipeline")).await
}
pub async fn skip_scheduled_monitor() -> Result<serde_json::Value, ApiError> {
post_empty(&format!("{BASE}/scheduler/skip-monitor")).await
}
// --- System --- // --- System ---
pub async fn trigger_index() -> Result<TaskRef, ApiError> { pub async fn trigger_index() -> Result<TaskRef, ApiError> {
post_empty(&format!("{BASE}/index")).await post_empty(&format!("{BASE}/index")).await
@@ -259,6 +282,77 @@ pub async fn save_config(config: &AppConfig) -> Result<AppConfig, ApiError> {
resp.json().await.map_err(|e| ApiError(e.to_string())) resp.json().await.map_err(|e| ApiError(e.to_string()))
} }
// --- Playlists ---
pub async fn generate_playlist(req: &GenerateRequest) -> Result<GeneratedPlaylist, ApiError> {
let body = serde_json::to_string(req).map_err(|e| ApiError(e.to_string()))?;
post_json(&format!("{BASE}/playlists/generate"), &body).await
}
pub async fn save_playlist(
name: &str,
description: Option<&str>,
track_ids: &[i32],
) -> Result<serde_json::Value, ApiError> {
let body = serde_json::json!({
"name": name,
"description": description,
"track_ids": track_ids,
})
.to_string();
post_json(&format!("{BASE}/playlists"), &body).await
}
pub async fn list_playlists() -> Result<Vec<PlaylistSummary>, ApiError> {
get_json(&format!("{BASE}/playlists")).await
}
pub async fn get_playlist(id: i32) -> Result<PlaylistDetail, ApiError> {
get_json(&format!("{BASE}/playlists/{id}")).await
}
pub async fn delete_playlist(id: i32) -> Result<(), ApiError> {
delete(&format!("{BASE}/playlists/{id}")).await
}
pub fn export_m3u_url(id: i32) -> String {
format!("{BASE}/playlists/{id}/m3u")
}
pub async fn add_track_to_playlist(
playlist_id: i32,
track_id: i32,
) -> Result<serde_json::Value, ApiError> {
let body = serde_json::json!({"track_id": track_id}).to_string();
post_json(&format!("{BASE}/playlists/{playlist_id}/tracks"), &body).await
}
pub async fn remove_track_from_playlist(playlist_id: i32, track_id: i32) -> Result<(), ApiError> {
delete(&format!("{BASE}/playlists/{playlist_id}/tracks/{track_id}")).await
}
pub async fn reorder_playlist_tracks(
playlist_id: i32,
track_ids: &[i32],
) -> Result<serde_json::Value, ApiError> {
let body = serde_json::json!({"track_ids": track_ids}).to_string();
put_json(&format!("{BASE}/playlists/{playlist_id}/tracks"), &body).await
}
pub async fn search_tracks(query: &str) -> Result<Vec<Track>, ApiError> {
get_json(&format!("{BASE}/tracks?q={query}&limit=50")).await
}
// --- Subsonic ---
pub async fn get_subsonic_password_status() -> Result<SubsonicPasswordStatus, ApiError> {
get_json(&format!("{BASE}/auth/subsonic-password-status")).await
}
pub async fn set_subsonic_password(password: &str) -> Result<serde_json::Value, ApiError> {
let body = serde_json::json!({"password": password}).to_string();
put_json(&format!("{BASE}/auth/subsonic-password"), &body).await
}
// --- YouTube Auth --- // --- YouTube Auth ---
pub async fn get_ytauth_status() -> Result<YtAuthStatus, ApiError> { pub async fn get_ytauth_status() -> Result<YtAuthStatus, ApiError> {
@@ -280,3 +374,13 @@ pub async fn ytauth_refresh() -> Result<serde_json::Value, ApiError> {
pub async fn ytauth_clear_cookies() -> Result<(), ApiError> { pub async fn ytauth_clear_cookies() -> Result<(), ApiError> {
delete(&format!("{BASE}/ytauth/cookies")).await delete(&format!("{BASE}/ytauth/cookies")).await
} }
// --- MusicBrainz Local DB ---
pub async fn get_mb_status() -> Result<MbStatus, ApiError> {
get_json(&format!("{BASE}/mb-status")).await
}
pub async fn trigger_mb_import() -> Result<TaskRef, ApiError> {
post_empty(&format!("{BASE}/mb-import")).await
}

View File

@@ -37,6 +37,7 @@ pub fn navbar(props: &Props) -> Html {
{ link(Route::Dashboard, "Dashboard") } { link(Route::Dashboard, "Dashboard") }
{ link(Route::Search, "Search") } { link(Route::Search, "Search") }
{ link(Route::Library, "Library") } { link(Route::Library, "Library") }
{ link(Route::Playlists, "Playlists") }
{ link(Route::Downloads, "Downloads") } { link(Route::Downloads, "Downloads") }
if props.role == "admin" { if props.role == "admin" {
{ link(Route::Settings, "Settings") } { link(Route::Settings, "Settings") }

View File

@@ -245,22 +245,60 @@ pub fn dashboard() -> Html {
let mut rows = Vec::new(); let mut rows = Vec::new();
if let Some(ref sched) = s.scheduled { if let Some(ref sched) = s.scheduled {
if let Some(ref next) = sched.next_pipeline { if let Some(ref next) = sched.next_pipeline {
let on_skip = {
let message = message.clone();
let error = error.clone();
let fetch = fetch_status.clone();
Callback::from(move |_: MouseEvent| {
let message = message.clone();
let error = error.clone();
let fetch = fetch.clone();
wasm_bindgen_futures::spawn_local(async move {
match api::skip_scheduled_pipeline().await {
Ok(_) => {
message.set(Some("Next pipeline run skipped".into()));
fetch.emit(());
}
Err(e) => error.set(Some(e.0)),
}
});
})
};
rows.push(html! { rows.push(html! {
<tr> <tr>
<td>{ "Auto Pipeline" }</td> <td>{ "Auto Pipeline" }</td>
<td><span class="badge badge-pending">{ "Scheduled" }</span></td> <td><span class="badge badge-pending">{ "Scheduled" }</span></td>
<td></td>
<td class="text-sm text-muted">{ format!("Next run: {}", format_next_run(next)) }</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> </tr>
}); });
} }
if let Some(ref next) = sched.next_monitor { if let Some(ref next) = sched.next_monitor {
let on_skip = {
let message = message.clone();
let error = error.clone();
let fetch = fetch_status.clone();
Callback::from(move |_: MouseEvent| {
let message = message.clone();
let error = error.clone();
let fetch = fetch.clone();
wasm_bindgen_futures::spawn_local(async move {
match api::skip_scheduled_monitor().await {
Ok(_) => {
message.set(Some("Next monitor check skipped".into()));
fetch.emit(());
}
Err(e) => error.set(Some(e.0)),
}
});
})
};
rows.push(html! { rows.push(html! {
<tr> <tr>
<td>{ "Monitor Check" }</td> <td>{ "Monitor Check" }</td>
<td><span class="badge badge-pending">{ "Scheduled" }</span></td> <td><span class="badge badge-pending">{ "Scheduled" }</span></td>
<td></td>
<td class="text-sm text-muted">{ format!("Next run: {}", format_next_run(next)) }</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> </tr>
}); });
} }

View File

@@ -4,6 +4,7 @@ pub mod dashboard;
pub mod downloads; pub mod downloads;
pub mod library; pub mod library;
pub mod login; pub mod login;
pub mod playlists;
pub mod search; pub mod search;
pub mod settings; pub mod settings;
pub mod setup; pub mod setup;
@@ -23,6 +24,8 @@ pub enum Route {
Artist { id: String }, Artist { id: String },
#[at("/albums/:mbid")] #[at("/albums/:mbid")]
Album { mbid: String }, Album { mbid: String },
#[at("/playlists")]
Playlists,
#[at("/downloads")] #[at("/downloads")]
Downloads, Downloads,
#[at("/settings")] #[at("/settings")]
@@ -39,6 +42,7 @@ pub fn switch(route: Route) -> Html {
Route::Library => html! { <library::LibraryPage /> }, Route::Library => html! { <library::LibraryPage /> },
Route::Artist { id } => html! { <artist::ArtistPage {id} /> }, Route::Artist { id } => html! { <artist::ArtistPage {id} /> },
Route::Album { mbid } => html! { <album::AlbumPage {mbid} /> }, Route::Album { mbid } => html! { <album::AlbumPage {mbid} /> },
Route::Playlists => html! { <playlists::PlaylistsPage /> },
Route::Downloads => html! { <downloads::DownloadsPage /> }, Route::Downloads => html! { <downloads::DownloadsPage /> },
Route::Settings => html! { <settings::SettingsPage /> }, Route::Settings => html! { <settings::SettingsPage /> },
Route::NotFound => html! { <h2>{ "404 — Not Found" }</h2> }, Route::NotFound => html! { <h2>{ "404 — Not Found" }</h2> },

View File

@@ -0,0 +1,851 @@
use web_sys::HtmlInputElement;
use web_sys::HtmlSelectElement;
use yew::prelude::*;
use crate::api;
use crate::types::*;
#[function_component(PlaylistsPage)]
pub fn playlists_page() -> Html {
// Tab state: "saved" | "generate" | "edit"
let active_tab = use_state(|| "saved".to_string());
let editing_playlist = use_state(|| None::<PlaylistDetail>);
// All local artists (for seed picker)
let all_artists = use_state(Vec::<ArtistListItem>::new);
{
let all_artists = all_artists.clone();
use_effect_with((), move |_| {
wasm_bindgen_futures::spawn_local(async move {
if let Ok(artists) = api::list_artists(500, 0).await {
all_artists.set(artists);
}
});
});
}
// All tracks (for add-track search in edit tab)
let all_tracks = use_state(Vec::<Track>::new);
{
let all_tracks = all_tracks.clone();
use_effect_with((), move |_| {
wasm_bindgen_futures::spawn_local(async move {
if let Ok(tracks) = api::search_tracks("").await {
all_tracks.set(tracks);
}
});
});
}
// Generate form state
let seed_input = use_state(String::new);
let seed_focused = use_state(|| false);
let seeds = use_state(Vec::<String>::new);
let count = use_state(|| 50usize);
let popularity_bias = use_state(|| 5u8);
let ordering = use_state(|| "interleave".to_string());
// Results
let generated = use_state(|| None::<GeneratedPlaylist>);
let saved_playlists = use_state(|| None::<Vec<PlaylistSummary>>);
let error = use_state(|| None::<String>);
let message = use_state(|| None::<String>);
let loading = use_state(|| false);
let save_name = use_state(String::new);
// Edit tab state
let edit_name = use_state(String::new);
let track_search_input = use_state(String::new);
let track_search_focused = use_state(|| false);
let dragging_index = use_state(|| None::<usize>);
// Load saved playlists on mount
let refresh_playlists = {
let saved_playlists = saved_playlists.clone();
Callback::from(move |_: ()| {
let saved_playlists = saved_playlists.clone();
wasm_bindgen_futures::spawn_local(async move {
if let Ok(list) = api::list_playlists().await {
saved_playlists.set(Some(list));
}
});
})
};
{
let refresh = refresh_playlists.clone();
use_effect_with((), move |_| {
refresh.emit(());
});
}
// Generate handler
let on_generate = {
let seeds = seeds.clone();
let count = count.clone();
let popularity_bias = popularity_bias.clone();
let ordering = ordering.clone();
let generated = generated.clone();
let error = error.clone();
let loading = loading.clone();
let message = message.clone();
Callback::from(move |_: MouseEvent| {
let seeds = (*seeds).clone();
let count = *count;
let popularity_bias = *popularity_bias;
let ordering_val = (*ordering).clone();
let generated = generated.clone();
let error = error.clone();
let loading = loading.clone();
let message = message.clone();
loading.set(true);
error.set(None);
message.set(None);
wasm_bindgen_futures::spawn_local(async move {
let req = GenerateRequest {
strategy: "similar".to_string(),
seed_artists: seeds,
genres: vec![],
count,
popularity_bias,
ordering: ordering_val,
rules: None,
};
match api::generate_playlist(&req).await {
Ok(result) => {
let n = result.tracks.len();
message.set(Some(format!("Generated {n} tracks")));
generated.set(Some(result));
}
Err(e) => error.set(Some(e.0)),
}
loading.set(false);
});
})
};
// Save handler
let on_save = {
let generated = generated.clone();
let save_name = save_name.clone();
let error = error.clone();
let message = message.clone();
let refresh = refresh_playlists.clone();
let active_tab = active_tab.clone();
Callback::from(move |_: MouseEvent| {
let generated = generated.clone();
let name = (*save_name).clone();
let error = error.clone();
let message = message.clone();
let refresh = refresh.clone();
let active_tab = active_tab.clone();
if name.is_empty() {
error.set(Some("Enter a playlist name".to_string()));
return;
}
wasm_bindgen_futures::spawn_local(async move {
if let Some(ref gen) = *generated {
let track_ids: Vec<i32> = gen.tracks.iter().map(|t| t.track_id).collect();
match api::save_playlist(&name, None, &track_ids).await {
Ok(_) => {
message.set(Some(format!("Saved playlist: {name}")));
refresh.emit(());
active_tab.set("saved".to_string());
}
Err(e) => error.set(Some(e.0)),
}
}
});
})
};
// --- Tab bar ---
let tab_bar = {
let active = (*active_tab).clone();
let tab = active_tab.clone();
let editing = editing_playlist.clone();
html! {
<div class="tab-bar">
<button
class={classes!("tab-btn", (active == "saved").then_some("active"))}
onclick={{
let tab = tab.clone();
Callback::from(move |_: MouseEvent| tab.set("saved".to_string()))
}}
>{ "Saved Playlists" }</button>
<button
class={classes!("tab-btn", (active == "generate").then_some("active"))}
onclick={{
let tab = tab.clone();
Callback::from(move |_: MouseEvent| tab.set("generate".to_string()))
}}
>{ "Generate" }</button>
if let Some(ref detail) = *editing {
<button
class={classes!("tab-btn", (active == "edit").then_some("active"))}
onclick={{
let tab = tab.clone();
Callback::from(move |_: MouseEvent| tab.set("edit".to_string()))
}}
>{ format!("Edit: {}", detail.playlist.name) }</button>
}
</div>
}
};
// --- Tab 1: Saved Playlists ---
let saved_tab = {
let active_tab = active_tab.clone();
let editing_playlist = editing_playlist.clone();
let edit_name = edit_name.clone();
let saved_playlists = saved_playlists.clone();
let refresh_playlists = refresh_playlists.clone();
let saved_list = if let Some(ref playlists) = *saved_playlists {
if playlists.is_empty() {
html! { <p class="text-muted">{ "No saved playlists." }</p> }
} else {
html! {
<table>
<thead>
<tr>
<th>{ "Name" }</th>
<th>{ "Tracks" }</th>
<th>{ "Created" }</th>
<th></th>
</tr>
</thead>
<tbody>
{ for playlists.iter().map(|p| {
let id = p.id;
let refresh = refresh_playlists.clone();
let editing = editing_playlist.clone();
let tab = active_tab.clone();
let en = edit_name.clone();
html! {
<tr>
<td>{ &p.name }</td>
<td>{ p.track_count }</td>
<td class="text-sm text-muted">{ &p.created_at }</td>
<td>
<div class="actions">
<button class="btn btn-sm btn-secondary" onclick={{
let editing = editing.clone();
let tab = tab.clone();
let en = en.clone();
Callback::from(move |_: MouseEvent| {
let editing = editing.clone();
let tab = tab.clone();
let en = en.clone();
wasm_bindgen_futures::spawn_local(async move {
if let Ok(detail) = api::get_playlist(id).await {
en.set(detail.playlist.name.clone());
editing.set(Some(detail));
tab.set("edit".to_string());
}
});
})
}}>{ "Edit" }</button>
<a href={api::export_m3u_url(id)}
class="btn btn-sm btn-secondary"
download="true">{ "M3U" }</a>
<button class="btn btn-sm btn-danger" onclick={{
let refresh = refresh.clone();
let editing = editing.clone();
Callback::from(move |_: MouseEvent| {
let refresh = refresh.clone();
let editing = editing.clone();
wasm_bindgen_futures::spawn_local(async move {
let _ = api::delete_playlist(id).await;
// If we're editing this playlist, clear it
if let Some(ref d) = *editing {
if d.playlist.id == id {
editing.set(None);
}
}
refresh.emit(());
});
})
}}>{ "Delete" }</button>
</div>
</td>
</tr>
}
})}
</tbody>
</table>
}
}
} else {
html! { <p class="loading">{ "Loading..." }</p> }
};
html! {
<div>
<div style="margin-bottom: 1rem;">
<button class="btn btn-primary" onclick={{
let tab = active_tab.clone();
Callback::from(move |_: MouseEvent| tab.set("generate".to_string()))
}}>{ "Generate" }</button>
<button class="btn btn-secondary" onclick={{
let editing = editing_playlist.clone();
let edit_name = edit_name.clone();
let tab = active_tab.clone();
let error = error.clone();
let refresh = refresh_playlists.clone();
Callback::from(move |_: MouseEvent| {
let editing = editing.clone();
let edit_name = edit_name.clone();
let tab = tab.clone();
let error = error.clone();
let refresh = refresh.clone();
wasm_bindgen_futures::spawn_local(async move {
match api::save_playlist("New Playlist", None, &[]).await {
Ok(resp) => {
// Extract the new playlist id and open it for editing
if let Some(id) = resp.get("id").and_then(|v| v.as_i64()) {
let id = id as i32;
edit_name.set("New Playlist".to_string());
if let Ok(detail) = api::get_playlist(id).await {
editing.set(Some(detail));
tab.set("edit".to_string());
}
refresh.emit(());
}
}
Err(e) => error.set(Some(e.0)),
}
});
})
}}>{ "New" }</button>
</div>
{ saved_list }
</div>
}
};
// --- Tab 2: Generate ---
let generate_tab = {
let strategy_inputs = {
let seed_input_c = seed_input.clone();
let seeds_c = seeds.clone();
let query = (*seed_input_c).to_lowercase();
let filtered: Vec<_> = (*all_artists)
.iter()
.filter(|a| {
if query.is_empty() {
return true;
}
let name_lower = a.name.to_lowercase();
let mut chars = query.chars();
let mut current = chars.next();
for c in name_lower.chars() {
if current == Some(c) {
current = chars.next();
}
}
current.is_none()
})
.filter(|a| !seeds_c.contains(&a.name))
.take(15)
.cloned()
.collect();
html! {
<div class="form-group">
<label>{ "Seed Artists" }</label>
<div style="position:relative;">
<input
type="text"
placeholder="Search your library..."
value={(*seed_input_c).clone()}
oninput={{
let si = seed_input.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
si.set(input.value());
})
}}
onfocus={{
let f = seed_focused.clone();
Callback::from(move |_: FocusEvent| f.set(true))
}}
onblur={{
let f = seed_focused.clone();
Callback::from(move |_: FocusEvent| {
let f = f.clone();
gloo_timers::callback::Timeout::new(200, move || f.set(false))
.forget();
})
}}
/>
if *seed_focused && !filtered.is_empty() {
<div class="autocomplete-dropdown">
{ for filtered.iter().map(|a| {
let name = a.name.clone();
let seeds = seeds.clone();
let si = seed_input.clone();
let track_count = a.total_items;
html! {
<div class="autocomplete-item" onclick={Callback::from(move |_: MouseEvent| {
let mut v = (*seeds).clone();
v.push(name.clone());
seeds.set(v);
si.set(String::new());
})}>
<span>{ &a.name }</span>
<span class="text-muted text-sm">{ format!("{} tracks", track_count) }</span>
</div>
}
})}
</div>
}
</div>
<div class="tag-list">
{ for seeds_c.iter().enumerate().map(|(i, s)| {
let seeds = seeds_c.clone();
html! {
<span class="tag" onclick={Callback::from(move |_: MouseEvent| {
let mut v = (*seeds).clone();
v.remove(i);
seeds.set(v);
})}>{s}{ " \u{00d7}" }</span>
}
})}
</div>
<label>{ format!("Popularity Bias: {}", *popularity_bias) }</label>
<input
type="range"
min="0" max="10"
value={popularity_bias.to_string()}
oninput={{
let pb = popularity_bias.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
if let Ok(v) = input.value().parse() {
pb.set(v);
}
})
}}
/>
<div class="form-group">
<label>{ "Track Order" }</label>
<select onchange={{
let ord = ordering.clone();
Callback::from(move |e: Event| {
let select: HtmlSelectElement = e.target_unchecked_into();
ord.set(select.value());
})
}}>
<option value="interleave" selected={*ordering == "interleave"}>{ "Interleave (spread artists)" }</option>
<option value="score" selected={*ordering == "score"}>{ "By Score (best first)" }</option>
<option value="random" selected={*ordering == "random"}>{ "Random" }</option>
</select>
</div>
</div>
}
};
let track_list = if let Some(ref gen) = *generated {
html! {
<div class="card" style="margin-top: 1rem;">
<h3>{ format!("Generated Tracks ({})", gen.tracks.len()) }</h3>
<div style="display: flex; gap: 0.5rem; margin-bottom: 1rem;">
<input
type="text"
placeholder="Playlist name..."
value={(*save_name).clone()}
oninput={{
let sn = save_name.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
sn.set(input.value());
})
}}
/>
<button class="btn btn-primary" onclick={on_save.clone()}>{ "Save Playlist" }</button>
</div>
<table>
<thead>
<tr>
<th>{ "#" }</th>
<th>{ "Title" }</th>
<th>{ "Artist" }</th>
<th>{ "Album" }</th>
<th>{ "Score" }</th>
</tr>
</thead>
<tbody>
{ for gen.tracks.iter().enumerate().map(|(i, t)| html! {
<tr>
<td>{ i + 1 }</td>
<td>{ t.title.as_deref().unwrap_or("Unknown") }</td>
<td>{ t.artist.as_deref().unwrap_or("Unknown") }</td>
<td class="text-muted">{ t.album.as_deref().unwrap_or("") }</td>
<td class="text-sm text-muted">{ format!("{:.3}", t.score) }</td>
</tr>
})}
</tbody>
</table>
</div>
}
} else {
html! {}
};
html! {
<div>
<div class="card">
<h3>{ "Generate Playlist" }</h3>
<p class="text-muted text-sm">{ "Build a playlist from similar artists in your library using Last.fm data." }</p>
{ strategy_inputs }
<div class="form-group">
<label>{ format!("Count: {}", *count) }</label>
<input type="range" min="10" max="200" step="10"
value={count.to_string()}
oninput={{
let count = count.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
if let Ok(v) = input.value().parse() {
count.set(v);
}
})
}}
/>
</div>
<button
class="btn btn-primary"
onclick={on_generate}
disabled={*loading}
>
{ if *loading { "Generating..." } else { "Generate" } }
</button>
</div>
{ track_list }
</div>
}
};
// --- Tab 3: Edit ---
let edit_tab = {
let editing = editing_playlist.clone();
let active_tab_c = active_tab.clone();
let edit_name_c = edit_name.clone();
let refresh = refresh_playlists.clone();
let error_c = error.clone();
let message_c = message.clone();
let track_search_input_c = track_search_input.clone();
let track_search_focused_c = track_search_focused.clone();
let all_tracks_c = all_tracks.clone();
let dragging_index_c = dragging_index.clone();
if let Some(ref detail) = *editing {
let playlist_id = detail.playlist.id;
let tracks = detail.tracks.clone();
// Filter tracks for add-track search
let search_query = (*track_search_input_c).to_lowercase();
let existing_ids: Vec<i32> = tracks.iter().map(|t| t.id).collect();
let filtered_tracks: Vec<_> = if search_query.is_empty() {
vec![]
} else {
(*all_tracks_c)
.iter()
.filter(|t| {
if existing_ids.contains(&t.id) {
return false;
}
let title_lower = t.title.as_deref().unwrap_or("").to_lowercase();
let artist_lower = t.artist.as_deref().unwrap_or("").to_lowercase();
// Subsequence match on title or artist
let matches_field = |field: &str| {
let mut chars = search_query.chars();
let mut current = chars.next();
for c in field.chars() {
if current == Some(c) {
current = chars.next();
}
}
current.is_none()
};
matches_field(&title_lower) || matches_field(&artist_lower)
})
.take(15)
.cloned()
.collect()
};
html! {
<div>
<button class="btn btn-secondary" style="margin-bottom: 1rem;" onclick={{
let tab = active_tab_c.clone();
let editing = editing.clone();
Callback::from(move |_: MouseEvent| {
editing.set(None);
tab.set("saved".to_string());
})
}}>{ "Back to Playlists" }</button>
<div class="card">
<div class="form-group">
<label>{ "Playlist Name" }</label>
<div style="display: flex; gap: 0.5rem;">
<input
type="text"
value={(*edit_name_c).clone()}
oninput={{
let en = edit_name_c.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
en.set(input.value());
})
}}
/>
<button class="btn btn-primary" onclick={{
let en = edit_name_c.clone();
let editing = editing.clone();
let refresh = refresh.clone();
let message = message_c.clone();
let error = error_c.clone();
let tab = active_tab_c.clone();
Callback::from(move |_: MouseEvent| {
let name = (*en).clone();
let editing = editing.clone();
let refresh = refresh.clone();
let message = message.clone();
let error = error.clone();
let tab = tab.clone();
if name.is_empty() {
error.set(Some("Name cannot be empty".to_string()));
return;
}
wasm_bindgen_futures::spawn_local(async move {
match api::get_playlist(playlist_id).await {
Ok(_) => {
let body = serde_json::json!({"name": name}).to_string();
let resp = gloo_net::http::Request::put(
&format!("/api/playlists/{playlist_id}"),
)
.header("Content-Type", "application/json")
.body(&body)
.unwrap()
.send()
.await;
match resp {
Ok(r) if r.ok() => {
message.set(Some("Playlist saved".to_string()));
refresh.emit(());
editing.set(None);
tab.set("saved".to_string());
}
Ok(r) => error.set(Some(format!("HTTP {}", r.status()))),
Err(e) => error.set(Some(e.to_string())),
}
}
Err(e) => error.set(Some(e.0)),
}
});
})
}}>{ "Save" }</button>
</div>
</div>
</div>
<div class="card">
<h3>{ format!("Tracks ({})", tracks.len()) }</h3>
<table>
<thead>
<tr>
<th style="width: 2rem;"></th>
<th style="width: 2rem;">{ "#" }</th>
<th>{ "Title" }</th>
<th>{ "Artist" }</th>
<th>{ "Album" }</th>
<th style="width: 3rem;"></th>
</tr>
</thead>
<tbody>
{ for tracks.iter().enumerate().map(|(i, t)| {
let track_id = t.id;
let editing = editing.clone();
let dragging = dragging_index_c.clone();
let idx = i;
let ondragstart = {
let dragging = dragging.clone();
Callback::from(move |e: DragEvent| {
dragging.set(Some(idx));
if let Some(dt) = e.data_transfer() {
let _ = dt.set_data("text/plain", &idx.to_string());
}
})
};
let ondragover = Callback::from(move |e: DragEvent| {
e.prevent_default();
});
let ondrop = {
let editing = editing.clone();
let dragging = dragging.clone();
Callback::from(move |e: DragEvent| {
e.prevent_default();
let target_idx = idx;
if let Some(source_idx) = *dragging {
if source_idx != target_idx {
let editing_inner = editing.clone();
let dragging_inner = dragging.clone();
// Reorder locally
if let Some(ref detail) = *editing {
let mut new_tracks = detail.tracks.clone();
let item = new_tracks.remove(source_idx);
new_tracks.insert(target_idx, item);
let track_ids: Vec<i32> = new_tracks.iter().map(|t| t.id).collect();
let new_detail = PlaylistDetail {
playlist: detail.playlist.clone(),
tracks: new_tracks,
};
editing_inner.set(Some(new_detail));
dragging_inner.set(None);
// Call reorder API
wasm_bindgen_futures::spawn_local(async move {
let _ = api::reorder_playlist_tracks(playlist_id, &track_ids).await;
});
}
}
}
dragging.set(None);
})
};
let on_remove = {
let editing = editing.clone();
Callback::from(move |_: MouseEvent| {
let editing = editing.clone();
wasm_bindgen_futures::spawn_local(async move {
let _ = api::remove_track_from_playlist(playlist_id, track_id).await;
// Refresh the playlist
if let Ok(detail) = api::get_playlist(playlist_id).await {
editing.set(Some(detail));
}
});
})
};
html! {
<tr draggable="true"
{ondragstart}
{ondragover}
{ondrop}
>
<td><span class="drag-handle">{ "\u{2261}" }</span></td>
<td>{ i + 1 }</td>
<td>{ t.title.as_deref().unwrap_or("Unknown") }</td>
<td>{ t.artist.as_deref().unwrap_or("Unknown") }</td>
<td class="text-muted">{ t.album.as_deref().unwrap_or("") }</td>
<td>
<button class="btn btn-sm btn-danger" onclick={on_remove}>
{ "\u{00d7}" }
</button>
</td>
</tr>
}
})}
</tbody>
</table>
</div>
// Add track section
<div class="card">
<h3>{ "Add Track" }</h3>
<div style="position:relative;">
<input
type="text"
placeholder="Search tracks in your library..."
value={(*track_search_input_c).clone()}
oninput={{
let tsi = track_search_input_c.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
tsi.set(input.value());
})
}}
onfocus={{
let f = track_search_focused_c.clone();
Callback::from(move |_: FocusEvent| f.set(true))
}}
onblur={{
let f = track_search_focused_c.clone();
Callback::from(move |_: FocusEvent| {
let f = f.clone();
gloo_timers::callback::Timeout::new(200, move || f.set(false)).forget();
})
}}
/>
if *track_search_focused_c && !filtered_tracks.is_empty() {
<div class="autocomplete-dropdown">
{ for filtered_tracks.iter().map(|t| {
let tid = t.id;
let title = t.title.clone().unwrap_or_else(|| "Unknown".to_string());
let artist = t.artist.clone().unwrap_or_else(|| "Unknown".to_string());
let editing = editing.clone();
let tsi = track_search_input_c.clone();
html! {
<div class="autocomplete-item" onclick={Callback::from(move |_: MouseEvent| {
let editing = editing.clone();
let tsi = tsi.clone();
tsi.set(String::new());
wasm_bindgen_futures::spawn_local(async move {
let _ = api::add_track_to_playlist(playlist_id, tid).await;
if let Ok(detail) = api::get_playlist(playlist_id).await {
editing.set(Some(detail));
}
});
})}>
<span>{ format!("{} - {}", title, artist) }</span>
</div>
}
})}
</div>
}
</div>
</div>
</div>
}
} else {
html! { <p class="text-muted">{ "No playlist selected for editing." }</p> }
}
};
html! {
<div>
<div class="page-header">
<h2>{ "Playlists" }</h2>
</div>
if let Some(ref msg) = *message {
<div class="card" style="border-color: var(--success);">{ msg }</div>
}
if let Some(ref err) = *error {
<div class="card error">{ err }</div>
}
{ tab_bar }
if *active_tab == "saved" {
{ saved_tab }
}
if *active_tab == "generate" {
{ generate_tab }
}
if *active_tab == "edit" {
{ edit_tab }
}
</div>
}
}

View File

@@ -3,7 +3,7 @@ use web_sys::HtmlSelectElement;
use yew::prelude::*; use yew::prelude::*;
use crate::api; use crate::api;
use crate::types::{AppConfig, YtAuthStatus}; use crate::types::{AppConfig, MbStatus, SubsonicPasswordStatus, YtAuthStatus};
#[function_component(SettingsPage)] #[function_component(SettingsPage)]
pub fn settings_page() -> Html { pub fn settings_page() -> Html {
@@ -12,11 +12,18 @@ pub fn settings_page() -> Html {
let message = use_state(|| None::<String>); let message = use_state(|| None::<String>);
let ytauth = use_state(|| None::<YtAuthStatus>); let ytauth = use_state(|| None::<YtAuthStatus>);
let ytauth_loading = use_state(|| false); let ytauth_loading = use_state(|| false);
let subsonic_status = use_state(|| None::<SubsonicPasswordStatus>);
let subsonic_password = use_state(String::new);
let subsonic_saving = use_state(|| false);
let mb_status = use_state(|| None::<MbStatus>);
let mb_importing = use_state(|| false);
{ {
let config = config.clone(); let config = config.clone();
let error = error.clone(); let error = error.clone();
let ytauth = ytauth.clone(); let ytauth = ytauth.clone();
let subsonic_status = subsonic_status.clone();
let mb_status = mb_status.clone();
use_effect_with((), move |_| { use_effect_with((), move |_| {
wasm_bindgen_futures::spawn_local(async move { wasm_bindgen_futures::spawn_local(async move {
match api::get_config().await { match api::get_config().await {
@@ -29,6 +36,16 @@ pub fn settings_page() -> Html {
ytauth.set(Some(status)); ytauth.set(Some(status));
} }
}); });
wasm_bindgen_futures::spawn_local(async move {
if let Ok(status) = api::get_subsonic_password_status().await {
subsonic_status.set(Some(status));
}
});
wasm_bindgen_futures::spawn_local(async move {
if let Ok(status) = api::get_mb_status().await {
mb_status.set(Some(status));
}
});
}); });
} }
@@ -500,6 +517,179 @@ pub fn settings_page() -> Html {
{ ytauth_html } { ytauth_html }
</div> </div>
// Subsonic API
<div class="card">
<h3>{ "Subsonic API" }</h3>
<p class="text-sm text-muted mb-1">
{ "Connect Subsonic-compatible apps (DSub, Symfonium, Feishin) to stream your library. " }
{ "This is a minimal Subsonic implementation for basic browsing and playback. " }
{ "For a full-featured Subsonic server, consider " }
<a href="https://www.navidrome.org" target="_blank">{ "Navidrome" }</a>
{ " pointed at the same library." }
</p>
<div class="form-group">
<label>{ "Server URL (most clients add /rest automatically)" }</label>
<input type="text" readonly=true value={
format!("http://{}:{}",
c.web.bind.clone(),
c.web.port)
} />
</div>
{
if let Some(ref status) = *subsonic_status {
if status.set {
html! {
<p class="text-sm">
<span class="badge badge-success">{ "Password set" }</span>
</p>
}
} else {
html! {
<p class="text-sm text-muted">{ "No Subsonic password set. Set one below to enable access." }</p>
}
}
} else {
html! { <p class="text-sm text-muted">{ "Loading..." }</p> }
}
}
<div class="card" style="border-color: var(--warning); background: rgba(234, 179, 8, 0.08); margin: 0.75rem 0;">
<p class="text-sm" style="margin:0;">
<strong style="color: var(--warning);">{ "Warning: " }</strong>
{ "This password is stored in plaintext per the Subsonic protocol. Do " }
<strong>{ "not" }</strong>
{ " reuse a password from another account." }
</p>
</div>
<div class="form-group">
<label>{ "Subsonic Password" }</label>
<input type="password" placeholder="Enter Subsonic password"
value={(*subsonic_password).clone()}
oninput={let subsonic_password = subsonic_password.clone(); Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
subsonic_password.set(input.value());
})} />
</div>
<button type="button" class="btn btn-primary"
disabled={*subsonic_saving || subsonic_password.is_empty()}
onclick={{
let subsonic_password = subsonic_password.clone();
let subsonic_saving = subsonic_saving.clone();
let subsonic_status = subsonic_status.clone();
let message = message.clone();
let error = error.clone();
Callback::from(move |_: MouseEvent| {
let pw = (*subsonic_password).clone();
let subsonic_saving = subsonic_saving.clone();
let subsonic_status = subsonic_status.clone();
let subsonic_password = subsonic_password.clone();
let message = message.clone();
let error = error.clone();
subsonic_saving.set(true);
wasm_bindgen_futures::spawn_local(async move {
match api::set_subsonic_password(&pw).await {
Ok(_) => {
message.set(Some("Subsonic password saved".into()));
subsonic_password.set(String::new());
if let Ok(s) = api::get_subsonic_password_status().await {
subsonic_status.set(Some(s));
}
}
Err(e) => error.set(Some(e.0)),
}
subsonic_saving.set(false);
});
})
}}>
{ if *subsonic_saving { "Saving..." } else { "Save Subsonic Password" } }
</button>
</div>
// MusicBrainz Local Database
<div class="card">
<h3>{ "MusicBrainz Database" }</h3>
<p class="text-sm text-muted mb-1">
{ "Import the MusicBrainz database locally for instant artist/album lookups instead of rate-limited API calls. " }
{ "Makes browsing and watching artists dramatically faster." }
</p>
<div class="card" style="border-color: var(--warning); background: rgba(234, 179, 8, 0.08); margin: 0.5rem 0;">
<p class="text-sm" style="margin:0;">
<strong style="color: var(--warning);">{ "Heads up: " }</strong>
{ "This downloads ~24 GB of data and builds a ~10 GB local database. " }
{ "The initial import can take 3-6 hours depending on your hardware. " }
{ "Total disk usage: ~35 GB (downloads + database). " }
{ "After the initial import, the database is automatically refreshed weekly to stay current." }
</p>
</div>
{
if let Some(ref status) = *mb_status {
if status.has_local_db {
if let Some(ref stats) = status.stats {
let import_date = stats.last_import_date.clone().unwrap_or_else(|| "unknown".into());
html! {
<>
<p>
<span class="badge badge-success">{ "Loaded" }</span>
<span class="text-muted text-sm" style="margin-left: 0.5rem;">
{ format!("imported {}", import_date) }
</span>
</p>
<p class="text-sm">
{ format!("{} artists, {} release groups, {} releases, {} recordings",
stats.artists, stats.release_groups, stats.releases, stats.recordings) }
</p>
</>
}
} else {
html! {
<p><span class="badge badge-success">{ "Loaded" }</span></p>
}
}
} else {
html! {
<p class="text-sm text-muted">
{ "Not configured. Import data to enable instant lookups." }
</p>
}
}
} else {
html! { <p class="text-sm text-muted">{ "Loading..." }</p> }
}
}
<button type="button" class="btn btn-primary"
disabled={*mb_importing}
onclick={{
let mb_importing = mb_importing.clone();
let mb_status = mb_status.clone();
let message = message.clone();
let error = error.clone();
Callback::from(move |_: MouseEvent| {
let mb_importing = mb_importing.clone();
let mb_status = mb_status.clone();
let message = message.clone();
let error = error.clone();
mb_importing.set(true);
wasm_bindgen_futures::spawn_local(async move {
match api::trigger_mb_import().await {
Ok(task_ref) => {
message.set(Some(format!(
"MusicBrainz import started (task {}). This will take a while.",
task_ref.task_id
)));
// Refresh status after a short delay
if let Ok(s) = api::get_mb_status().await {
mb_status.set(Some(s));
}
}
Err(e) => error.set(Some(e.0)),
}
mb_importing.set(false);
});
})
}}>
{ if *mb_importing { "Starting import..." } else { "Import MusicBrainz Data" } }
</button>
</div>
// Metadata Providers // Metadata Providers
<div class="card"> <div class="card">
<h3>{ "Metadata Providers" }</h3> <h3>{ "Metadata Providers" }</h3>

View File

@@ -289,6 +289,95 @@ pub struct SyncStats {
pub skipped: u64, pub skipped: u64,
} }
// --- Playlists ---
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GenerateRequest {
pub strategy: String,
#[serde(default)]
pub seed_artists: Vec<String>,
#[serde(default)]
pub genres: Vec<String>,
#[serde(default = "default_playlist_count")]
pub count: usize,
#[serde(default = "default_popularity_bias")]
pub popularity_bias: u8,
#[serde(default = "default_ordering")]
pub ordering: String,
#[serde(default)]
pub rules: Option<SmartRulesInput>,
}
fn default_playlist_count() -> usize {
50
}
fn default_popularity_bias() -> u8 {
5
}
fn default_ordering() -> String {
"interleave".into()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct SmartRulesInput {
#[serde(default)]
pub genres: Vec<String>,
pub added_within_days: Option<u32>,
pub year_range: Option<(i32, i32)>,
#[serde(default)]
pub artists: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct GeneratedPlaylist {
pub tracks: Vec<GeneratedTrack>,
pub strategy: String,
#[serde(default)]
pub resolved_seeds: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct GeneratedTrack {
pub track_id: i32,
pub file_path: String,
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub score: f64,
pub duration: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct PlaylistSummary {
pub id: i32,
pub name: String,
pub description: Option<String>,
pub track_count: u64,
pub created_at: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct PlaylistDetail {
pub playlist: SavedPlaylist,
pub tracks: Vec<Track>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct SavedPlaylist {
pub id: i32,
pub name: String,
pub description: Option<String>,
}
// --- Subsonic ---
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct SubsonicPasswordStatus {
pub set: bool,
}
// --- Config --- // --- Config ---
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -311,6 +400,8 @@ pub struct AppConfig {
pub metadata: MetadataConfigFe, pub metadata: MetadataConfigFe,
#[serde(default)] #[serde(default)]
pub scheduling: SchedulingConfigFe, pub scheduling: SchedulingConfigFe,
#[serde(default)]
pub musicbrainz: MusicBrainzConfigFe,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
@@ -424,3 +515,36 @@ fn default_lyrics_source() -> String {
fn default_cover_art_source() -> String { fn default_cover_art_source() -> String {
"coverartarchive".into() "coverartarchive".into()
} }
// --- MusicBrainz local DB ---
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct MusicBrainzConfigFe {
#[serde(default)]
pub local_db_path: Option<String>,
#[serde(default)]
pub auto_update: bool,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct MbStatus {
pub has_local_db: bool,
#[serde(default)]
pub stats: Option<MbLocalStats>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct MbLocalStats {
#[serde(default)]
pub artists: u64,
#[serde(default)]
pub release_groups: u64,
#[serde(default)]
pub releases: u64,
#[serde(default)]
pub recordings: u64,
#[serde(default)]
pub tracks: u64,
#[serde(default)]
pub last_import_date: Option<String>,
}

View File

@@ -169,6 +169,35 @@ a:hover { color: var(--accent-hover); }
max-width: 800px; max-width: 800px;
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.autocomplete-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
z-index: 10;
max-height: 250px;
overflow-y: auto;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.autocomplete-item {
padding: 0.5rem 0.75rem;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.autocomplete-item:hover {
background: var(--bg-hover);
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.5rem;
}
.lyrics { .lyrics {
font-family: inherit; font-family: inherit;
font-size: 0.85rem; font-size: 0.85rem;
@@ -294,6 +323,18 @@ table.tasks-table td { overflow: hidden; text-overflow: ellipsis; }
.auth-card h1 { font-size: 1.8rem; color: var(--accent); margin-bottom: 0.25rem; } .auth-card h1 { font-size: 1.8rem; color: var(--accent); margin-bottom: 0.25rem; }
.auth-card p { margin-bottom: 1.5rem; } .auth-card p { margin-bottom: 1.5rem; }
/* Tab bar */
.tab-bar { display: flex; gap: 0; margin-bottom: 1rem; border-bottom: 2px solid var(--border); }
.tab-btn { padding: 0.5rem 1rem; background: none; border: none; color: var(--text-secondary); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -2px; font-size: 0.9rem; }
.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab-btn:hover { color: var(--text-primary); }
/* Drag and drop */
.drag-handle { cursor: grab; user-select: none; color: var(--text-muted); padding-right: 0.5rem; font-size: 1.2rem; }
tr.drag-over { background: rgba(59, 130, 246, 0.1); }
tr[draggable="true"] { cursor: grab; }
tr[draggable="true"]:active { cursor: grabbing; }
/* Sidebar user section */ /* Sidebar user section */
.sidebar-user { .sidebar-user {
margin-top: auto; margin-top: auto;

View File

@@ -8,6 +8,7 @@ pub mod auth;
pub mod config; pub mod config;
pub mod cookie_refresh; pub mod cookie_refresh;
pub mod error; pub mod error;
pub mod mb_update;
pub mod monitor; pub mod monitor;
pub mod pipeline; pub mod pipeline;
pub mod pipeline_scheduler; pub mod pipeline_scheduler;

View File

@@ -5,8 +5,8 @@ use clap::Parser;
use tracing_actix_web::TracingLogger; use tracing_actix_web::TracingLogger;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use shanty_data::MusicBrainzFetcher;
use shanty_data::WikipediaFetcher; use shanty_data::WikipediaFetcher;
use shanty_data::{HybridMusicBrainzFetcher, LocalMusicBrainzFetcher, MusicBrainzFetcher};
use shanty_db::Database; use shanty_db::Database;
use shanty_search::MusicBrainzSearch; use shanty_search::MusicBrainzSearch;
@@ -54,8 +54,23 @@ async fn main() -> anyhow::Result<()> {
tracing::info!(url = %config.database_url, "connecting to database"); tracing::info!(url = %config.database_url, "connecting to database");
let db = Database::new(&config.database_url).await?; let db = Database::new(&config.database_url).await?;
let mb_client = MusicBrainzFetcher::new()?; let mb_remote = MusicBrainzFetcher::new()?;
let search = MusicBrainzSearch::new()?; let search = MusicBrainzSearch::with_limiter(mb_remote.limiter())?;
// Set up local MB database if configured
let local_mb = create_local_mb_fetcher(&config);
let mb_client = HybridMusicBrainzFetcher::new(local_mb, mb_remote);
if mb_client.has_local_db()
&& let Some(stats) = mb_client.local_stats()
{
tracing::info!(
artists = stats.artists,
release_groups = stats.release_groups,
"local MusicBrainz database loaded"
);
}
let wiki_fetcher = WikipediaFetcher::new()?; let wiki_fetcher = WikipediaFetcher::new()?;
let bind = format!("{}:{}", config.web.bind, config.web.port); let bind = format!("{}:{}", config.web.bind, config.web.port);
@@ -74,6 +89,8 @@ async fn main() -> anyhow::Result<()> {
scheduler: tokio::sync::Mutex::new(shanty_web::state::SchedulerInfo { scheduler: tokio::sync::Mutex::new(shanty_web::state::SchedulerInfo {
next_pipeline: None, next_pipeline: None,
next_monitor: None, next_monitor: None,
skip_pipeline: false,
skip_monitor: false,
}), }),
}); });
@@ -83,6 +100,7 @@ async fn main() -> anyhow::Result<()> {
// Start pipeline and monitor schedulers // Start pipeline and monitor schedulers
shanty_web::pipeline_scheduler::spawn(state.clone()); shanty_web::pipeline_scheduler::spawn(state.clone());
shanty_web::monitor::spawn(state.clone()); shanty_web::monitor::spawn(state.clone());
shanty_web::mb_update::spawn(state.clone());
// Resolve static files directory relative to the binary location // Resolve static files directory relative to the binary location
let static_dir = std::env::current_exe() let static_dir = std::env::current_exe()
@@ -124,15 +142,24 @@ async fn main() -> anyhow::Result<()> {
.service( .service(
actix_files::Files::new("/", static_dir.clone()) actix_files::Files::new("/", static_dir.clone())
.index_file("index.html") .index_file("index.html")
.prefer_utf8(true), .prefer_utf8(true)
.guard(actix_web::guard::fn_guard(|ctx| {
!ctx.head().uri.path().starts_with("/rest")
})),
) )
// SPA fallback: serve index.html for any route not matched // SPA fallback: serve index.html for any route not matched
// by API or static files, so client-side routing works on refresh // by API or static files, so client-side routing works on refresh.
// /rest/* paths get a Subsonic error instead of index.html.
.default_service(web::to({ .default_service(web::to({
let index_path = static_dir.join("index.html"); let index_path = static_dir.join("index.html");
move |req: actix_web::HttpRequest| { move |req: actix_web::HttpRequest| {
let index_path = index_path.clone(); let index_path = index_path.clone();
async move { async move {
if req.path().starts_with("/rest") {
return Ok(actix_web::HttpResponse::NotFound()
.content_type("application/json")
.body(r#"{"subsonic-response":{"status":"failed","version":"1.16.1","error":{"code":0,"message":"Unknown endpoint"}}}"#));
}
actix_files::NamedFile::open_async(index_path) actix_files::NamedFile::open_async(index_path)
.await .await
.map(|f| f.into_response(&req)) .map(|f| f.into_response(&req))
@@ -146,3 +173,36 @@ async fn main() -> anyhow::Result<()> {
Ok(()) Ok(())
} }
/// Create a LocalMusicBrainzFetcher from config if available.
fn create_local_mb_fetcher(config: &AppConfig) -> Option<LocalMusicBrainzFetcher> {
let db_path = config
.musicbrainz
.local_db_path
.as_ref()
.map(|p| p.to_string_lossy().to_string())
.or_else(|| {
let default_path = shanty_config::data_dir().join("shanty-mb.db");
if default_path.exists() {
Some(default_path.to_string_lossy().to_string())
} else {
None
}
})?;
match LocalMusicBrainzFetcher::new(&db_path) {
Ok(fetcher) => {
if fetcher.is_available() {
tracing::info!(path = %db_path, "opened local MusicBrainz database");
Some(fetcher)
} else {
tracing::debug!(path = %db_path, "local MB database exists but has no data");
None
}
}
Err(e) => {
tracing::warn!(path = %db_path, error = %e, "failed to open local MB database");
None
}
}
}

120
src/mb_update.rs Normal file
View File

@@ -0,0 +1,120 @@
//! Background task that periodically re-imports the MusicBrainz database.
use std::time::Duration;
use actix_web::web;
use crate::state::AppState;
/// Spawn the weekly MB database update loop.
///
/// Only runs if a local MB database exists (meaning the user has done an initial import).
/// Downloads fresh dumps and re-imports weekly.
pub fn spawn(state: web::Data<AppState>) {
tokio::spawn(async move {
// Wait 1 hour after startup before first check
tokio::time::sleep(Duration::from_secs(3600)).await;
loop {
// Check if local DB exists and auto-update is desired
let has_local = state.mb_client.has_local_db();
if !has_local {
// No local DB — sleep a day and check again
tokio::time::sleep(Duration::from_secs(86400)).await;
continue;
}
// Check how old the import is
let needs_update = state
.mb_client
.local_stats()
.and_then(|s| s.last_import_date)
.map(|date| {
// Parse the date and check if it's older than 7 days
chrono::NaiveDate::parse_from_str(&date, "%Y-%m-%d")
.map(|d| {
let age = chrono::Utc::now().naive_utc().date() - d;
age.num_days() >= 7
})
.unwrap_or(true) // If we can't parse the date, update
})
.unwrap_or(false); // No stats = no local DB = skip
if !needs_update {
// Check again in 24 hours
tokio::time::sleep(Duration::from_secs(86400)).await;
continue;
}
tracing::info!("starting weekly MusicBrainz database update");
let data_dir = shanty_config::data_dir().join("mb-dumps");
let db_path = state
.config
.read()
.await
.musicbrainz
.local_db_path
.clone()
.unwrap_or_else(|| shanty_config::data_dir().join("shanty-mb.db"));
// Download fresh dumps
if let Err(e) = std::fs::create_dir_all(&data_dir) {
tracing::error!(error = %e, "failed to create dump dir for MB update");
tokio::time::sleep(Duration::from_secs(86400)).await;
continue;
}
let timestamp = match shanty_data::mb_import::discover_latest_dump_folder().await {
Ok(t) => t,
Err(e) => {
tracing::error!(error = %e, "failed to discover latest MB dump");
tokio::time::sleep(Duration::from_secs(86400)).await;
continue;
}
};
let mut download_failed = false;
for filename in shanty_data::mb_import::DUMP_FILES {
if let Err(e) =
shanty_data::mb_import::download_dump(filename, &timestamp, &data_dir, |msg| {
tracing::info!("{msg}");
})
.await
{
tracing::error!(file = filename, error = %e, "MB dump download failed");
download_failed = true;
break;
}
}
if download_failed {
tokio::time::sleep(Duration::from_secs(86400)).await;
continue;
}
// Run import in blocking task
let result = tokio::task::spawn_blocking(move || {
shanty_data::mb_import::run_import_at_path(&db_path, &data_dir, |msg| {
tracing::info!("{msg}");
})
})
.await;
match result {
Ok(Ok(stats)) => {
tracing::info!(%stats, "weekly MusicBrainz update complete");
}
Ok(Err(e)) => {
tracing::error!(error = %e, "weekly MusicBrainz import failed");
}
Err(e) => {
tracing::error!(error = %e, "weekly MusicBrainz import task panicked");
}
}
// Sleep 7 days before next check
tokio::time::sleep(Duration::from_secs(7 * 86400)).await;
}
});
}

View File

@@ -114,15 +114,12 @@ pub async fn check_monitored_artists(
let track_mbids = if let Some(mbids) = cached_tracks { let track_mbids = if let Some(mbids) = cached_tracks {
mbids mbids
} else { } else {
// Not cached — resolve release and fetch tracks // Not cached — resolve release and fetch tracks (rate limited by shared MB client)
// Rate limit: sleep 1.1s between MB requests
tokio::time::sleep(Duration::from_millis(1100)).await;
let release_mbid = if let Some(ref rid) = rg.first_release_id { let release_mbid = if let Some(ref rid) = rg.first_release_id {
rid.clone() rid.clone()
} else { } else {
// Need to resolve from release group // Resolve from release group (goes through shared rate limiter)
match resolve_release_from_group(&rg.id).await { match state.mb_client.resolve_release_from_group(&rg.id).await {
Ok(rid) => rid, Ok(rid) => rid,
Err(e) => { Err(e) => {
tracing::debug!(rg_id = %rg.id, error = %e, "skipping release group"); tracing::debug!(rg_id = %rg.id, error = %e, "skipping release group");
@@ -131,8 +128,6 @@ pub async fn check_monitored_artists(
} }
}; };
tokio::time::sleep(Duration::from_millis(1100)).await;
match state.mb_client.get_release_tracks(&release_mbid).await { match state.mb_client.get_release_tracks(&release_mbid).await {
Ok(tracks) => tracks.into_iter().map(|t| t.recording_mbid).collect(), Ok(tracks) => tracks.into_iter().map(|t| t.recording_mbid).collect(),
Err(e) => { Err(e) => {
@@ -203,35 +198,6 @@ pub async fn check_monitored_artists(
Ok(stats) Ok(stats)
} }
/// Given a release-group MBID, find the first release MBID.
async fn resolve_release_from_group(release_group_mbid: &str) -> Result<String, ApiError> {
let client = reqwest::Client::builder()
.user_agent("Shanty/0.1.0 (shanty-music-app)")
.build()
.map_err(|e| ApiError::Internal(e.to_string()))?;
let url = format!(
"https://musicbrainz.org/ws/2/release?release-group={release_group_mbid}&fmt=json&limit=1"
);
let resp: serde_json::Value = client
.get(&url)
.send()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.json()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
resp.get("releases")
.and_then(|r| r.as_array())
.and_then(|arr| arr.first())
.and_then(|r| r.get("id"))
.and_then(|id| id.as_str())
.map(String::from)
.ok_or_else(|| ApiError::NotFound(format!("no releases for group {release_group_mbid}")))
}
/// Spawn the monitor scheduler background loop. /// Spawn the monitor scheduler background loop.
/// ///
/// Sleeps for the configured interval, then checks monitored artists if enabled. /// Sleeps for the configured interval, then checks monitored artists if enabled.
@@ -268,10 +234,15 @@ pub fn spawn(state: web::Data<AppState>) {
continue; continue;
} }
// Clear next-run while running // Check if this run was skipped
{ {
let mut sched = state.scheduler.lock().await; let mut sched = state.scheduler.lock().await;
sched.next_monitor = None; sched.next_monitor = None;
if sched.skip_monitor {
sched.skip_monitor = false;
tracing::info!("scheduled monitor check skipped (user cancelled)");
continue;
}
} }
tracing::info!("scheduled monitor check starting"); tracing::info!("scheduled monitor check starting");

View File

@@ -40,10 +40,15 @@ pub fn spawn(state: web::Data<AppState>) {
continue; continue;
} }
// Clear next-run while running // Check if this run was skipped
{ {
let mut sched = state.scheduler.lock().await; let mut sched = state.scheduler.lock().await;
sched.next_pipeline = None; sched.next_pipeline = None;
if sched.skip_pipeline {
sched.skip_pipeline = false;
tracing::info!("scheduled pipeline skipped (user cancelled)");
continue;
}
} }
tracing::info!("scheduled pipeline starting"); tracing::info!("scheduled pipeline starting");

View File

@@ -116,51 +116,18 @@ async fn get_album(
}))) })))
} }
/// Given a release-group MBID, find the first release MBID via the MB API. /// Given a release-group MBID, find the first release MBID via the shared MB client.
async fn resolve_release_from_group( async fn resolve_release_from_group(
_state: &web::Data<AppState>, state: &web::Data<AppState>,
release_group_mbid: &str, release_group_mbid: &str,
) -> Result<String, ApiError> { ) -> Result<String, ApiError> {
// Use the MB client's get_json (it's private, so we go through search) state
// The approach: search for releases by this release group .mb_client
// MB API: /ws/2/release?release-group={mbid}&fmt=json&limit=1 .resolve_release_from_group(release_group_mbid)
// Since we can't call get_json directly, use the artist_releases approach
// to find a release that matches this group.
//
// Actually, the simplest: the MetadataFetcher trait has get_artist_releases
// which returns releases, but we need releases for a release GROUP.
// Let's add a direct HTTP call here via reqwest.
let url = format!(
"https://musicbrainz.org/ws/2/release?release-group={release_group_mbid}&fmt=json&limit=1"
);
// Respect rate limiting by going through a small delay
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
let client = reqwest::Client::builder()
.user_agent("Shanty/0.1.0 (shanty-music-app)")
.build()
.map_err(|e| ApiError::Internal(e.to_string()))?;
let resp: serde_json::Value = client
.get(&url)
.send()
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))? .map_err(|e| {
.json()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
resp.get("releases")
.and_then(|r| r.as_array())
.and_then(|arr| arr.first())
.and_then(|r| r.get("id"))
.and_then(|id| id.as_str())
.map(String::from)
.ok_or_else(|| {
ApiError::NotFound(format!( ApiError::NotFound(format!(
"no releases found for release group {release_group_mbid}" "no releases found for release group {release_group_mbid}: {e}"
)) ))
}) })
} }

View File

@@ -193,8 +193,29 @@ async fn get_cached_album_tracks(
let release_mbid = if let Some(rid) = first_release_id { let release_mbid = if let Some(rid) = first_release_id {
rid.to_string() rid.to_string()
} else { } else {
// Browse releases for this release group // Check DB cache for previously resolved release MBID
resolve_release_from_group(rg_id).await? let resolve_cache_key = format!("release_for_rg:{rg_id}");
if let Ok(Some(cached_rid)) = queries::cache::get(state.db.conn(), &resolve_cache_key).await
{
cached_rid
} else {
// Browse releases for this release group (through shared rate limiter)
let rid = state
.mb_client
.resolve_release_from_group(rg_id)
.await
.map_err(|e| ApiError::Internal(format!("MB error for group {rg_id}: {e}")))?;
// Cache the resolved release MBID for 365 days — it never changes
let _ = queries::cache::set(
state.db.conn(),
&resolve_cache_key,
"musicbrainz",
&rid,
365 * 86400,
)
.await;
rid
}
}; };
let mb_tracks = state let mb_tracks = state
@@ -228,37 +249,6 @@ async fn get_cached_album_tracks(
Ok(cached) Ok(cached)
} }
/// Given a release-group MBID, find the first release MBID.
async fn resolve_release_from_group(release_group_mbid: &str) -> Result<String, ApiError> {
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
let client = reqwest::Client::builder()
.user_agent("Shanty/0.1.0 (shanty-music-app)")
.build()
.map_err(|e| ApiError::Internal(e.to_string()))?;
let url = format!(
"https://musicbrainz.org/ws/2/release?release-group={release_group_mbid}&fmt=json&limit=1"
);
let resp: serde_json::Value = client
.get(&url)
.send()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.json()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
resp.get("releases")
.and_then(|r| r.as_array())
.and_then(|arr| arr.first())
.and_then(|r| r.get("id"))
.and_then(|id| id.as_str())
.map(String::from)
.ok_or_else(|| ApiError::NotFound(format!("no releases for group {release_group_mbid}")))
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ArtistFullParams { pub struct ArtistFullParams {
#[serde(default)] #[serde(default)]
@@ -286,7 +276,8 @@ pub async fn enrich_artist(
quick_mode: bool, quick_mode: bool,
) -> Result<serde_json::Value, ApiError> { ) -> Result<serde_json::Value, ApiError> {
// Resolve artist: local ID or MBID // Resolve artist: local ID or MBID
let (artist, id, mbid) = if let Ok(local_id) = id_or_mbid.parse() { // Track whether we already fetched artist info during resolution to avoid a duplicate API call
let (artist, id, mbid, prefetched_info) = if let Ok(local_id) = id_or_mbid.parse() {
let artist = queries::artists::get_by_id(state.db.conn(), local_id).await?; let artist = queries::artists::get_by_id(state.db.conn(), local_id).await?;
let mbid = match &artist.musicbrainz_id { let mbid = match &artist.musicbrainz_id {
Some(m) => m.clone(), Some(m) => m.clone(),
@@ -301,7 +292,7 @@ pub async fn enrich_artist(
})? })?
} }
}; };
(artist, Some(local_id), mbid) (artist, Some(local_id), mbid, None)
} else { } else {
let mbid = id_or_mbid.to_string(); let mbid = id_or_mbid.to_string();
@@ -315,9 +306,10 @@ pub async fn enrich_artist(
if let Some(a) = local { if let Some(a) = local {
let local_id = a.id; let local_id = a.id;
(a, Some(local_id), mbid) (a, Some(local_id), mbid, None)
} else { } else {
// Look up artist info from MusicBrainz by MBID — don't create a local record // Look up artist info from MusicBrainz by MBID — don't create a local record
// This fetches url-rels too, so we reuse it below instead of calling get_artist_info() again
let info = let info =
state.mb_client.get_artist_info(&mbid).await.map_err(|e| { state.mb_client.get_artist_info(&mbid).await.map_err(|e| {
ApiError::NotFound(format!("artist MBID {mbid} not found: {e}")) ApiError::NotFound(format!("artist MBID {mbid} not found: {e}"))
@@ -334,24 +326,34 @@ pub async fn enrich_artist(
monitored: false, monitored: false,
last_checked_at: None, last_checked_at: None,
}; };
(synthetic, None, mbid) (synthetic, None, mbid, Some(info))
} }
}; };
// Fetch detailed artist info (country, type, URLs) — best-effort // Fetch detailed artist info (country, type, URLs) — reuse if already fetched during resolution
let artist_info = match state.mb_client.get_artist_info(&mbid).await { let artist_info = if let Some(info) = prefetched_info {
Ok(info) => { tracing::debug!(
tracing::debug!( mbid = %mbid,
mbid = %mbid, urls = info.urls.len(),
urls = info.urls.len(), country = ?info.country,
country = ?info.country, "reusing prefetched artist info"
"fetched artist info" );
); Some(info)
Some(info) } else {
} match state.mb_client.get_artist_info(&mbid).await {
Err(e) => { Ok(info) => {
tracing::warn!(mbid = %mbid, error = %e, "failed to fetch artist info"); tracing::debug!(
None mbid = %mbid,
urls = info.urls.len(),
country = ?info.country,
"fetched artist info"
);
Some(info)
}
Err(e) => {
tracing::warn!(mbid = %mbid, error = %e, "failed to fetch artist info");
None
}
} }
}; };

View File

@@ -20,7 +20,14 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.route(web::get().to(list_users)) .route(web::get().to(list_users))
.route(web::post().to(create_user)), .route(web::post().to(create_user)),
) )
.service(web::resource("/auth/users/{id}").route(web::delete().to(delete_user))); .service(web::resource("/auth/users/{id}").route(web::delete().to(delete_user)))
.service(
web::resource("/auth/subsonic-password").route(web::put().to(set_subsonic_password)),
)
.service(
web::resource("/auth/subsonic-password-status")
.route(web::get().to(subsonic_password_status)),
);
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -41,6 +48,11 @@ struct CreateUserRequest {
password: String, password: String,
} }
#[derive(Deserialize)]
struct SubsonicPasswordRequest {
password: String,
}
/// Check if initial setup is required (no users in database). /// Check if initial setup is required (no users in database).
async fn setup_required(state: web::Data<AppState>) -> Result<HttpResponse, ApiError> { async fn setup_required(state: web::Data<AppState>) -> Result<HttpResponse, ApiError> {
let count = queries::users::count(state.db.conn()).await?; let count = queries::users::count(state.db.conn()).await?;
@@ -205,3 +217,34 @@ async fn delete_user(
tracing::info!(user_id = user_id, "user deleted by admin"); tracing::info!(user_id = user_id, "user deleted by admin");
Ok(HttpResponse::NoContent().finish()) Ok(HttpResponse::NoContent().finish())
} }
/// Set the Subsonic password for the current user.
async fn set_subsonic_password(
state: web::Data<AppState>,
session: Session,
body: web::Json<SubsonicPasswordRequest>,
) -> Result<HttpResponse, ApiError> {
let (user_id, _, _) = auth::require_auth(&session)?;
if body.password.len() < 4 {
return Err(ApiError::BadRequest(
"password must be at least 4 characters".into(),
));
}
queries::users::set_subsonic_password(state.db.conn(), user_id, &body.password).await?;
tracing::info!(user_id = user_id, "subsonic password set");
Ok(HttpResponse::Ok().json(serde_json::json!({ "status": "ok" })))
}
/// Check whether the current user has a Subsonic password set.
async fn subsonic_password_status(
state: web::Data<AppState>,
session: Session,
) -> Result<HttpResponse, ApiError> {
let (user_id, _, _) = auth::require_auth(&session)?;
let user = queries::users::get_by_id(state.db.conn(), user_id).await?;
Ok(HttpResponse::Ok().json(serde_json::json!({
"set": user.subsonic_password.is_some(),
})))
}

View File

@@ -3,7 +3,9 @@ pub mod artists;
pub mod auth; pub mod auth;
pub mod downloads; pub mod downloads;
pub mod lyrics; pub mod lyrics;
pub mod playlists;
pub mod search; pub mod search;
pub mod subsonic;
pub mod system; pub mod system;
pub mod tracks; pub mod tracks;
pub mod ytauth; pub mod ytauth;
@@ -21,6 +23,9 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.configure(downloads::configure) .configure(downloads::configure)
.configure(lyrics::configure) .configure(lyrics::configure)
.configure(system::configure) .configure(system::configure)
.configure(ytauth::configure), .configure(ytauth::configure)
.configure(playlists::configure),
); );
// Subsonic API at /rest/*
subsonic::configure(cfg);
} }

291
src/routes/playlists.rs Normal file
View File

@@ -0,0 +1,291 @@
use actix_session::Session;
use actix_web::{HttpResponse, web};
use serde::{Deserialize, Serialize};
use shanty_db::queries;
use shanty_playlist::{self, PlaylistRequest};
use crate::auth;
use crate::error::ApiError;
use crate::state::AppState;
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(web::resource("/playlists/generate").route(web::post().to(generate_playlist)))
.service(
web::resource("/playlists")
.route(web::get().to(list_playlists))
.route(web::post().to(save_playlist)),
)
.service(
web::resource("/playlists/{id}")
.route(web::get().to(get_playlist))
.route(web::put().to(update_playlist))
.route(web::delete().to(delete_playlist)),
)
.service(web::resource("/playlists/{id}/m3u").route(web::get().to(export_m3u)))
.service(
web::resource("/playlists/{id}/tracks")
.route(web::post().to(add_track))
.route(web::put().to(reorder_tracks)),
)
.service(
web::resource("/playlists/{id}/tracks/{track_id}")
.route(web::delete().to(remove_track)),
);
}
/// POST /api/playlists/generate — generate a playlist without saving.
async fn generate_playlist(
state: web::Data<AppState>,
session: Session,
body: web::Json<PlaylistRequest>,
) -> Result<HttpResponse, ApiError> {
auth::require_auth(&session)?;
let req = body.into_inner();
let conn = state.db.conn();
let config = state.config.read().await;
let lastfm_key = config.metadata.lastfm_api_key.clone();
drop(config);
let result = match req.strategy.as_str() {
"similar" => {
let api_key = lastfm_key.unwrap_or_default();
if api_key.is_empty() {
return Err(ApiError::BadRequest(
"SHANTY_LASTFM_API_KEY is required for similar-artist playlists".into(),
));
}
let fetcher = shanty_data::LastFmSimilarFetcher::new(api_key)
.map_err(|e| ApiError::Internal(e.to_string()))?;
shanty_playlist::similar_artists(
conn,
&fetcher,
req.seed_artists,
req.count,
req.popularity_bias,
&req.ordering,
)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
}
"genre" => shanty_playlist::genre_based(conn, req.genres, req.count, &req.ordering)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?,
"random" => shanty_playlist::random(conn, req.count, req.ordering != "random")
.await
.map_err(|e| ApiError::Internal(e.to_string()))?,
"smart" => {
let rules = req.rules.unwrap_or_default();
shanty_playlist::smart(conn, rules, req.count)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
}
other => {
return Err(ApiError::BadRequest(format!("unknown strategy: {other}")));
}
};
Ok(HttpResponse::Ok().json(result))
}
#[derive(Deserialize)]
struct SavePlaylistRequest {
name: String,
description: Option<String>,
track_ids: Vec<i32>,
}
/// POST /api/playlists — save a generated playlist.
async fn save_playlist(
state: web::Data<AppState>,
session: Session,
body: web::Json<SavePlaylistRequest>,
) -> Result<HttpResponse, ApiError> {
let (user_id, _, _) = auth::require_auth(&session)?;
let req = body.into_inner();
let playlist = queries::playlists::create(
state.db.conn(),
&req.name,
req.description.as_deref(),
Some(user_id),
&req.track_ids,
)
.await?;
Ok(HttpResponse::Created().json(playlist))
}
/// GET /api/playlists — list saved playlists.
async fn list_playlists(
state: web::Data<AppState>,
session: Session,
) -> Result<HttpResponse, ApiError> {
let (user_id, _, _) = auth::require_auth(&session)?;
let playlists = queries::playlists::list(state.db.conn(), Some(user_id)).await?;
#[derive(Serialize)]
struct PlaylistSummary {
id: i32,
name: String,
description: Option<String>,
track_count: u64,
created_at: String,
}
let mut summaries = Vec::new();
for p in playlists {
let count = queries::playlists::get_track_count(state.db.conn(), p.id).await?;
summaries.push(PlaylistSummary {
id: p.id,
name: p.name,
description: p.description,
track_count: count,
created_at: p.created_at.format("%Y-%m-%d %H:%M").to_string(),
});
}
Ok(HttpResponse::Ok().json(summaries))
}
/// GET /api/playlists/{id} — get playlist with tracks.
async fn get_playlist(
state: web::Data<AppState>,
session: Session,
path: web::Path<i32>,
) -> Result<HttpResponse, ApiError> {
auth::require_auth(&session)?;
let id = path.into_inner();
let playlist = queries::playlists::get_by_id(state.db.conn(), id).await?;
let tracks = queries::playlists::get_tracks(state.db.conn(), id).await?;
Ok(HttpResponse::Ok().json(serde_json::json!({
"playlist": playlist,
"tracks": tracks,
})))
}
#[derive(Deserialize)]
struct UpdatePlaylistRequest {
name: Option<String>,
description: Option<String>,
}
/// PUT /api/playlists/{id} — update playlist name/description.
async fn update_playlist(
state: web::Data<AppState>,
session: Session,
path: web::Path<i32>,
body: web::Json<UpdatePlaylistRequest>,
) -> Result<HttpResponse, ApiError> {
auth::require_auth(&session)?;
let id = path.into_inner();
let req = body.into_inner();
let playlist = queries::playlists::update(
state.db.conn(),
id,
req.name.as_deref(),
req.description.as_deref(),
)
.await?;
Ok(HttpResponse::Ok().json(playlist))
}
/// DELETE /api/playlists/{id} — delete a playlist.
async fn delete_playlist(
state: web::Data<AppState>,
session: Session,
path: web::Path<i32>,
) -> Result<HttpResponse, ApiError> {
auth::require_auth(&session)?;
let id = path.into_inner();
queries::playlists::delete(state.db.conn(), id).await?;
Ok(HttpResponse::NoContent().finish())
}
#[derive(Deserialize)]
struct AddTrackRequest {
track_id: i32,
}
/// POST /api/playlists/{id}/tracks — add a track to playlist.
async fn add_track(
state: web::Data<AppState>,
session: Session,
path: web::Path<i32>,
body: web::Json<AddTrackRequest>,
) -> Result<HttpResponse, ApiError> {
auth::require_auth(&session)?;
let id = path.into_inner();
let req = body.into_inner();
queries::playlists::add_track(state.db.conn(), id, req.track_id).await?;
Ok(HttpResponse::Ok().json(serde_json::json!({"ok": true})))
}
#[derive(Deserialize)]
struct ReorderTracksRequest {
track_ids: Vec<i32>,
}
/// PUT /api/playlists/{id}/tracks — reorder tracks in playlist.
async fn reorder_tracks(
state: web::Data<AppState>,
session: Session,
path: web::Path<i32>,
body: web::Json<ReorderTracksRequest>,
) -> Result<HttpResponse, ApiError> {
auth::require_auth(&session)?;
let id = path.into_inner();
let req = body.into_inner();
queries::playlists::reorder_tracks(state.db.conn(), id, req.track_ids).await?;
Ok(HttpResponse::Ok().json(serde_json::json!({"ok": true})))
}
/// DELETE /api/playlists/{id}/tracks/{track_id} — remove a track from playlist.
async fn remove_track(
state: web::Data<AppState>,
session: Session,
path: web::Path<(i32, i32)>,
) -> Result<HttpResponse, ApiError> {
auth::require_auth(&session)?;
let (id, track_id) = path.into_inner();
queries::playlists::remove_track(state.db.conn(), id, track_id).await?;
Ok(HttpResponse::NoContent().finish())
}
/// GET /api/playlists/{id}/m3u — export as M3U file.
async fn export_m3u(
state: web::Data<AppState>,
session: Session,
path: web::Path<i32>,
) -> Result<HttpResponse, ApiError> {
auth::require_auth(&session)?;
let id = path.into_inner();
let playlist = queries::playlists::get_by_id(state.db.conn(), id).await?;
let tracks = queries::playlists::get_tracks(state.db.conn(), id).await?;
// Convert DB tracks to PlaylistTracks for M3U generation
let playlist_tracks: Vec<shanty_playlist::PlaylistTrack> = tracks
.into_iter()
.map(|t| shanty_playlist::PlaylistTrack {
track_id: t.id,
file_path: t.file_path,
title: t.title,
artist: t.artist,
album: t.album,
score: 0.0,
duration: t.duration,
})
.collect();
let m3u = shanty_playlist::to_m3u(&playlist_tracks);
let filename = format!("{}.m3u", playlist.name.replace(' ', "_"));
Ok(HttpResponse::Ok()
.insert_header(("Content-Type", "audio/x-mpegurl"))
.insert_header((
"Content-Disposition",
format!("attachment; filename=\"{filename}\""),
))
.body(m3u))
}

View File

@@ -0,0 +1,42 @@
use actix_web::{HttpRequest, HttpResponse, web};
use crate::state::AppState;
use super::helpers::{authenticate, get_query_param, parse_subsonic_id};
use super::response;
/// GET /rest/scrobble[.view]
pub async fn scrobble(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
let (params, user) = match authenticate(&req, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
let id_str = match get_query_param(&req, "id") {
Some(id) => id,
None => {
return response::error(
&params.format,
response::ERROR_MISSING_PARAM,
"missing required parameter: id",
);
}
};
let (prefix, entity_id) = match parse_subsonic_id(&id_str) {
Some(v) => v,
None => {
return response::error(&params.format, response::ERROR_NOT_FOUND, "invalid id");
}
};
// Log the scrobble for now; full play tracking can be added later
tracing::info!(
user = %user.username,
id_type = prefix,
id = entity_id,
"subsonic scrobble"
);
response::ok(&params.format, serde_json::json!({}))
}

126
src/routes/subsonic/auth.rs Normal file
View File

@@ -0,0 +1,126 @@
use actix_web::HttpRequest;
use md5::{Digest, Md5};
use sea_orm::DatabaseConnection;
use shanty_db::entities::user::Model as User;
use shanty_db::queries;
/// Subsonic authentication method.
pub enum AuthMethod {
/// Modern: token = md5(password + salt)
Token { token: String, salt: String },
/// Legacy: plaintext password
Password(String),
/// Legacy: hex-encoded password (p=enc:hexstring)
HexPassword(String),
}
/// Common Subsonic API parameters extracted from the query string.
pub struct SubsonicParams {
/// Username
pub username: String,
/// Authentication method + credentials
pub auth: AuthMethod,
/// API version requested
#[allow(dead_code)]
pub version: String,
/// Client name
#[allow(dead_code)]
pub client: String,
/// Response format: "xml" or "json"
pub format: String,
}
pub enum SubsonicAuthError {
MissingParam(String),
AuthFailed,
}
impl SubsonicParams {
/// Extract Subsonic params from the query string.
pub fn from_request(req: &HttpRequest) -> Result<Self, SubsonicAuthError> {
let qs = req.query_string();
let params: Vec<(String, String)> = serde_urlencoded::from_str(qs).unwrap_or_default();
let get = |name: &str| -> Option<String> {
params
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| v.clone())
};
let username = get("u").ok_or_else(|| SubsonicAuthError::MissingParam("u".into()))?;
let version = get("v").unwrap_or_else(|| "1.16.1".into());
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:") {
AuthMethod::HexPassword(hex_str.to_string())
} else {
AuthMethod::Password(p)
}
} else {
return Err(SubsonicAuthError::MissingParam(
"authentication required (t+s or p)".into(),
));
};
Ok(Self {
username,
auth,
version,
client,
format,
})
}
}
/// Verify Subsonic authentication against the stored subsonic_password.
pub async fn verify_auth(
db: &DatabaseConnection,
params: &SubsonicParams,
) -> Result<User, SubsonicAuthError> {
let user = queries::users::find_by_username(db, &params.username)
.await
.map_err(|_| SubsonicAuthError::AuthFailed)?
.ok_or(SubsonicAuthError::AuthFailed)?;
let subsonic_password = user
.subsonic_password
.as_deref()
.ok_or(SubsonicAuthError::AuthFailed)?;
match &params.auth {
AuthMethod::Token { token, salt } => {
// Compute md5(password + salt) and compare
let mut hasher = Md5::new();
hasher.update(subsonic_password.as_bytes());
hasher.update(salt.as_bytes());
let result = hasher.finalize();
let expected = hex::encode(result);
if expected != *token {
return Err(SubsonicAuthError::AuthFailed);
}
}
AuthMethod::Password(password) => {
// Direct plaintext comparison
if password != subsonic_password {
return Err(SubsonicAuthError::AuthFailed);
}
}
AuthMethod::HexPassword(hex_str) => {
// Decode hex to string, compare
let decoded = hex::decode(hex_str).map_err(|_| SubsonicAuthError::AuthFailed)?;
let password = String::from_utf8(decoded).map_err(|_| SubsonicAuthError::AuthFailed)?;
if password != subsonic_password {
return Err(SubsonicAuthError::AuthFailed);
}
}
}
Ok(user)
}

View File

@@ -0,0 +1,589 @@
use actix_web::{HttpRequest, HttpResponse, web};
use std::collections::BTreeMap;
use shanty_db::queries;
use crate::state::AppState;
use super::helpers::{authenticate, get_query_param, parse_subsonic_id};
use super::response::{self, SubsonicChild};
/// GET /rest/getMusicFolders[.view]
pub async fn get_music_folders(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
let (params, _user) = match authenticate(&req, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
response::ok(
&params.format,
serde_json::json!({
"musicFolders": {
"musicFolder": [
{ "id": 1, "name": "Music" }
]
}
}),
)
}
/// GET /rest/getArtists[.view]
pub async fn get_artists(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
let (params, _user) = match authenticate(&req, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
let artists = match queries::artists::list(state.db.conn(), 10000, 0).await {
Ok(a) => a,
Err(e) => {
return response::error(
&params.format,
response::ERROR_GENERIC,
&format!("database error: {e}"),
);
}
};
// Group artists by first letter for the index
let mut index_map: BTreeMap<String, Vec<serde_json::Value>> = BTreeMap::new();
for artist in &artists {
let first_char = artist
.name
.chars()
.next()
.unwrap_or('#')
.to_uppercase()
.next()
.unwrap_or('#');
let key = if first_char.is_alphabetic() {
first_char.to_string()
} else {
"#".to_string()
};
// Count albums for this artist
let album_count = queries::albums::get_by_artist(state.db.conn(), artist.id)
.await
.map(|a| a.len())
.unwrap_or(0);
index_map.entry(key).or_default().push(serde_json::json!({
"id": format!("ar-{}", artist.id),
"name": artist.name,
"albumCount": album_count,
}));
}
let indices: Vec<serde_json::Value> = index_map
.into_iter()
.map(|(name, artists)| {
serde_json::json!({
"name": name,
"artist": artists,
})
})
.collect();
response::ok(
&params.format,
serde_json::json!({
"artists": {
"ignoredArticles": "The El La Los Las Le Les",
"index": indices,
}
}),
)
}
/// GET /rest/getArtist[.view]
pub async fn get_artist(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
let (params, _user) = match authenticate(&req, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
let id_str = match get_query_param(&req, "id") {
Some(id) => id,
None => {
return response::error(
&params.format,
response::ERROR_MISSING_PARAM,
"missing required parameter: id",
);
}
};
let (_prefix, artist_id) = match parse_subsonic_id(&id_str) {
Some(v) => v,
None => {
return response::error(
&params.format,
response::ERROR_NOT_FOUND,
"invalid artist id",
);
}
};
let artist = match queries::artists::get_by_id(state.db.conn(), artist_id).await {
Ok(a) => a,
Err(_) => {
return response::error(
&params.format,
response::ERROR_NOT_FOUND,
"artist not found",
);
}
};
let albums = queries::albums::get_by_artist(state.db.conn(), artist_id)
.await
.unwrap_or_default();
let mut album_list: Vec<serde_json::Value> = Vec::new();
for album in &albums {
let track_count = queries::tracks::get_by_album(state.db.conn(), album.id)
.await
.map(|t| t.len())
.unwrap_or(0);
let duration: i32 = queries::tracks::get_by_album(state.db.conn(), album.id)
.await
.unwrap_or_default()
.iter()
.filter_map(|t| t.duration.map(|d| d as i32))
.sum();
let mut album_json = serde_json::json!({
"id": format!("al-{}", album.id),
"name": album.name,
"title": album.name,
"artist": if album.album_artist.is_empty() { &artist.name } else { &album.album_artist },
"artistId": format!("ar-{}", artist.id),
"coverArt": format!("al-{}", album.id),
"songCount": track_count,
"duration": duration,
"created": "2024-01-01T00:00:00",
});
// Only include year/genre if present (avoid nulls)
if let Some(year) = album.year {
album_json["year"] = serde_json::json!(year);
}
if let Some(ref genre) = album.genre {
album_json["genre"] = serde_json::json!(genre);
}
album_list.push(album_json);
}
response::ok(
&params.format,
serde_json::json!({
"artist": {
"id": format!("ar-{}", artist.id),
"name": artist.name,
"albumCount": albums.len(),
"album": album_list,
}
}),
)
}
/// GET /rest/getAlbum[.view]
pub async fn get_album(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
let (params, _user) = match authenticate(&req, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
let id_str = match get_query_param(&req, "id") {
Some(id) => id,
None => {
return response::error(
&params.format,
response::ERROR_MISSING_PARAM,
"missing required parameter: id",
);
}
};
let (_prefix, album_id) = match parse_subsonic_id(&id_str) {
Some(v) => v,
None => {
return response::error(
&params.format,
response::ERROR_NOT_FOUND,
"invalid album id",
);
}
};
let album = match queries::albums::get_by_id(state.db.conn(), album_id).await {
Ok(a) => a,
Err(_) => {
return response::error(&params.format, response::ERROR_NOT_FOUND, "album not found");
}
};
let tracks = queries::tracks::get_by_album(state.db.conn(), album_id)
.await
.unwrap_or_default();
let total_duration: i32 = tracks
.iter()
.filter_map(|t| t.duration.map(|d| d as i32))
.sum();
let song_list: Vec<serde_json::Value> = tracks
.iter()
.map(|track| serde_json::to_value(SubsonicChild::from_track(track)).unwrap_or_default())
.collect();
let mut album_json = serde_json::json!({
"id": format!("al-{}", album.id),
"name": album.name,
"title": album.name,
"artist": album.album_artist,
"artistId": album.artist_id.map(|id| format!("ar-{id}")),
"coverArt": format!("al-{}", album.id),
"songCount": tracks.len(),
"duration": total_duration,
"created": "2024-01-01T00:00:00",
"song": song_list,
});
if let Some(year) = album.year {
album_json["year"] = serde_json::json!(year);
}
if let Some(ref genre) = album.genre {
album_json["genre"] = serde_json::json!(genre);
}
response::ok(
&params.format,
serde_json::json!({
"album": album_json,
}),
)
}
/// GET /rest/getSong[.view]
pub async fn get_song(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
let (params, _user) = match authenticate(&req, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
let id_str = match get_query_param(&req, "id") {
Some(id) => id,
None => {
return response::error(
&params.format,
response::ERROR_MISSING_PARAM,
"missing required parameter: id",
);
}
};
let (_prefix, track_id) = match parse_subsonic_id(&id_str) {
Some(v) => v,
None => {
return response::error(&params.format, response::ERROR_NOT_FOUND, "invalid song id");
}
};
let track = match queries::tracks::get_by_id(state.db.conn(), track_id).await {
Ok(t) => t,
Err(_) => {
return response::error(&params.format, response::ERROR_NOT_FOUND, "song not found");
}
};
let child = SubsonicChild::from_track(&track);
response::ok(
&params.format,
serde_json::json!({
"song": serde_json::to_value(child).unwrap_or_default(),
}),
)
}
/// GET /rest/getGenres[.view]
pub async fn get_genres(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
let (params, _user) = match authenticate(&req, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
// Get all tracks and extract unique genres
let tracks = queries::tracks::list(state.db.conn(), 100_000, 0)
.await
.unwrap_or_default();
let mut genre_counts: BTreeMap<String, (u64, u64)> = BTreeMap::new();
for track in &tracks {
if let Some(ref genre) = track.genre {
let entry = genre_counts.entry(genre.clone()).or_insert((0, 0));
entry.0 += 1; // song count
// album count is approximated - we count unique album_ids per genre
}
}
// Also count album_ids per genre
let mut genre_albums: BTreeMap<String, std::collections::HashSet<i32>> = BTreeMap::new();
for track in &tracks {
if let Some(ref genre) = track.genre
&& let Some(album_id) = track.album_id
{
genre_albums
.entry(genre.clone())
.or_default()
.insert(album_id);
}
}
let genre_list: Vec<serde_json::Value> = genre_counts
.iter()
.map(|(name, (song_count, _))| {
let album_count = genre_albums.get(name).map(|s| s.len()).unwrap_or(0);
serde_json::json!({
"songCount": song_count,
"albumCount": album_count,
"value": name,
})
})
.collect();
response::ok(
&params.format,
serde_json::json!({
"genres": {
"genre": genre_list,
}
}),
)
}
/// GET /rest/getIndexes[.view] — folder-based browsing (same data as getArtists).
pub async fn get_indexes(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
let (params, _user) = match authenticate(&req, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
let artists = match queries::artists::list(state.db.conn(), 10000, 0).await {
Ok(a) => a,
Err(e) => {
return response::error(
&params.format,
response::ERROR_GENERIC,
&format!("database error: {e}"),
);
}
};
let mut index_map: BTreeMap<String, Vec<serde_json::Value>> = BTreeMap::new();
for artist in &artists {
let first_char = artist
.name
.chars()
.next()
.unwrap_or('#')
.to_uppercase()
.next()
.unwrap_or('#');
let key = if first_char.is_alphabetic() {
first_char.to_string()
} else {
"#".to_string()
};
index_map.entry(key).or_default().push(serde_json::json!({
"id": format!("ar-{}", artist.id),
"name": artist.name,
}));
}
let indices: Vec<serde_json::Value> = index_map
.into_iter()
.map(|(name, artists)| {
serde_json::json!({
"name": name,
"artist": artists,
})
})
.collect();
response::ok(
&params.format,
serde_json::json!({
"indexes": {
"ignoredArticles": "The El La Los Las Le Les",
"index": indices,
}
}),
)
}
/// GET /rest/getMusicDirectory[.view] — returns children of a directory.
/// For artist IDs (ar-N): returns albums as children.
/// For album IDs (al-N): returns tracks as children.
pub async fn get_music_directory(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
let (params, _user) = match authenticate(&req, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
let id_str = match get_query_param(&req, "id") {
Some(id) => id,
None => {
return response::error(
&params.format,
response::ERROR_MISSING_PARAM,
"missing required parameter: id",
);
}
};
let (prefix, db_id) = match parse_subsonic_id(&id_str) {
Some(v) => v,
None => {
return response::error(&params.format, response::ERROR_NOT_FOUND, "invalid id");
}
};
match prefix {
"ar" => {
// Artist directory → list albums
let artist = match queries::artists::get_by_id(state.db.conn(), db_id).await {
Ok(a) => a,
Err(_) => {
return response::error(
&params.format,
response::ERROR_NOT_FOUND,
"artist not found",
);
}
};
let albums = queries::albums::get_by_artist(state.db.conn(), db_id)
.await
.unwrap_or_default();
let children: Vec<serde_json::Value> = albums
.iter()
.map(|album| {
serde_json::json!({
"id": format!("al-{}", album.id),
"parent": format!("ar-{}", artist.id),
"isDir": true,
"title": album.name,
"artist": album.album_artist,
"coverArt": format!("al-{}", album.id),
"year": album.year,
})
})
.collect();
response::ok(
&params.format,
serde_json::json!({
"directory": {
"id": id_str,
"name": artist.name,
"child": children,
}
}),
)
}
"al" => {
// Album directory → list tracks
let album = match queries::albums::get_by_id(state.db.conn(), db_id).await {
Ok(a) => a,
Err(_) => {
return response::error(
&params.format,
response::ERROR_NOT_FOUND,
"album not found",
);
}
};
let tracks = queries::tracks::get_by_album(state.db.conn(), db_id)
.await
.unwrap_or_default();
let children: Vec<serde_json::Value> = tracks
.iter()
.map(|t| serde_json::to_value(SubsonicChild::from_track(t)).unwrap_or_default())
.collect();
response::ok(
&params.format,
serde_json::json!({
"directory": {
"id": id_str,
"name": album.name,
"parent": album.artist_id.map(|id| format!("ar-{id}")),
"child": children,
}
}),
)
}
"unknown" => {
// Plain numeric ID — try artist first, then album
if let Ok(artist) = queries::artists::get_by_id(state.db.conn(), db_id).await {
let albums = queries::albums::get_by_artist(state.db.conn(), db_id)
.await
.unwrap_or_default();
let children: Vec<serde_json::Value> = albums
.iter()
.map(|album| {
serde_json::json!({
"id": format!("al-{}", album.id),
"parent": format!("ar-{}", artist.id),
"isDir": true,
"title": album.name,
"artist": album.album_artist,
"coverArt": format!("al-{}", album.id),
"year": album.year,
})
})
.collect();
response::ok(
&params.format,
serde_json::json!({
"directory": {
"id": id_str,
"name": artist.name,
"child": children,
}
}),
)
} else if let Ok(album) = queries::albums::get_by_id(state.db.conn(), db_id).await {
let tracks = queries::tracks::get_by_album(state.db.conn(), db_id)
.await
.unwrap_or_default();
let children: Vec<serde_json::Value> = tracks
.iter()
.map(|t| serde_json::to_value(SubsonicChild::from_track(t)).unwrap_or_default())
.collect();
response::ok(
&params.format,
serde_json::json!({
"directory": {
"id": id_str,
"name": album.name,
"parent": album.artist_id.map(|id| format!("ar-{id}")),
"child": children,
}
}),
)
} else {
response::error(&params.format, response::ERROR_NOT_FOUND, "not found")
}
}
_ => response::error(&params.format, response::ERROR_NOT_FOUND, "unknown id type"),
}
}

View File

@@ -0,0 +1,70 @@
use actix_web::{HttpRequest, HttpResponse, web};
use shanty_db::entities::user::Model as User;
use crate::state::AppState;
use super::auth::{SubsonicAuthError, SubsonicParams, verify_auth};
use super::response;
/// Extract and authenticate subsonic params, returning an error HttpResponse on failure.
pub async fn authenticate(
req: &HttpRequest,
state: &web::Data<AppState>,
) -> Result<(SubsonicParams, User), HttpResponse> {
tracing::debug!(
path = req.path(),
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,
&format!("missing required parameter: {name}"),
),
SubsonicAuthError::AuthFailed => response::error(
"xml",
response::ERROR_NOT_AUTHENTICATED,
"wrong username or password",
),
})?;
let user = verify_auth(state.db.conn(), &params)
.await
.map_err(|e| match e {
SubsonicAuthError::AuthFailed => response::error(
&params.format,
response::ERROR_NOT_AUTHENTICATED,
"wrong username or password",
),
SubsonicAuthError::MissingParam(name) => response::error(
&params.format,
response::ERROR_MISSING_PARAM,
&format!("missing required parameter: {name}"),
),
})?;
Ok((params, user))
}
/// Parse a Subsonic ID like "ar-123" into (prefix, id).
/// Also accepts plain numbers (e.g., "123") — returns prefix "unknown".
pub fn parse_subsonic_id(id: &str) -> Option<(&str, i32)> {
if let Some((prefix, num_str)) = id.split_once('-') {
let num = num_str.parse().ok()?;
Some((prefix, num))
} else {
// Plain number — no prefix
let num = id.parse().ok()?;
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)
}

View File

@@ -0,0 +1,312 @@
use actix_files::NamedFile;
use actix_web::{HttpRequest, HttpResponse, web};
use tokio::process::Command;
use shanty_db::queries;
use crate::state::AppState;
use super::helpers::{authenticate, get_query_param, parse_subsonic_id};
use super::response;
/// GET /rest/stream[.view]
pub async fn stream(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
let (params, _user) = match authenticate(&req, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
let id_str = match get_query_param(&req, "id") {
Some(id) => id,
None => {
return response::error(
&params.format,
response::ERROR_MISSING_PARAM,
"missing required parameter: id",
);
}
};
let (_prefix, track_id) = match parse_subsonic_id(&id_str) {
Some(v) => v,
None => {
return response::error(&params.format, response::ERROR_NOT_FOUND, "invalid song id");
}
};
let track = match queries::tracks::get_by_id(state.db.conn(), track_id).await {
Ok(t) => t,
Err(_) => {
return response::error(&params.format, response::ERROR_NOT_FOUND, "song not found");
}
};
let file_ext = std::path::Path::new(&track.file_path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let requested_format = get_query_param(&req, "format");
let max_bit_rate = get_query_param(&req, "maxBitRate")
.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() {
Some("raw") => false, // Explicitly asked for no transcoding
Some(fmt) if fmt != file_ext => true, // Different format requested
_ => {
// 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)
}
};
// Check file exists before doing anything
if !std::path::Path::new(&track.file_path).exists() {
tracing::error!(path = %track.file_path, "track file not found on disk");
return response::error(
&params.format,
response::ERROR_NOT_FOUND,
&format!("file not found: {}", track.file_path),
);
}
if needs_transcode {
let target_format = requested_format
.as_deref()
.filter(|f| *f != "raw")
.unwrap_or("mp3");
let bitrate = if max_bit_rate > 0 {
max_bit_rate
} else {
192 // Default transcoding bitrate
};
let content_type = match target_format {
"mp3" => "audio/mpeg",
"opus" => "audio/ogg",
"ogg" => "audio/ogg",
"aac" | "m4a" => "audio/mp4",
"flac" => "audio/flac",
_ => "audio/mpeg",
};
tracing::debug!(
track_id = track_id,
from = %file_ext,
to = target_format,
bitrate = bitrate,
"transcoding stream"
);
match Command::new("ffmpeg")
.args([
"-i",
&track.file_path,
"-map",
"0:a",
"-b:a",
&format!("{bitrate}k"),
"-v",
"0",
"-f",
target_format,
"-",
])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.await
{
Ok(output) => {
if output.status.success() && !output.stdout.is_empty() {
tracing::debug!(
track_id = track_id,
bytes = output.stdout.len(),
"transcoding complete"
);
HttpResponse::Ok()
.content_type(content_type)
.body(output.stdout)
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::error!(
status = ?output.status,
stderr = %stderr,
path = %track.file_path,
"ffmpeg transcoding failed"
);
match NamedFile::open_async(&track.file_path).await {
Ok(file) => file.into_response(&req),
Err(_) => response::error(
&params.format,
response::ERROR_NOT_FOUND,
"transcoding failed",
),
}
}
}
Err(e) => {
tracing::error!(error = %e, "failed to start ffmpeg");
match NamedFile::open_async(&track.file_path).await {
Ok(file) => file.into_response(&req),
Err(_) => {
response::error(&params.format, response::ERROR_NOT_FOUND, "file not found")
}
}
}
}
} else {
// Serve the file directly with Range request support
match NamedFile::open_async(&track.file_path).await {
Ok(file) => file.into_response(&req),
Err(e) => {
tracing::error!(path = %track.file_path, error = %e, "failed to open track file for streaming");
response::error(
&params.format,
response::ERROR_NOT_FOUND,
"file not found on disk",
)
}
}
}
}
/// GET /rest/download[.view]
pub async fn download(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
let (params, _user) = match authenticate(&req, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
let id_str = match get_query_param(&req, "id") {
Some(id) => id,
None => {
return response::error(
&params.format,
response::ERROR_MISSING_PARAM,
"missing required parameter: id",
);
}
};
let (_prefix, track_id) = match parse_subsonic_id(&id_str) {
Some(v) => v,
None => {
return response::error(&params.format, response::ERROR_NOT_FOUND, "invalid song id");
}
};
let track = match queries::tracks::get_by_id(state.db.conn(), track_id).await {
Ok(t) => t,
Err(_) => {
return response::error(&params.format, response::ERROR_NOT_FOUND, "song not found");
}
};
match NamedFile::open_async(&track.file_path).await {
Ok(file) => {
let file = file.set_content_disposition(actix_web::http::header::ContentDisposition {
disposition: actix_web::http::header::DispositionType::Attachment,
parameters: vec![actix_web::http::header::DispositionParam::Filename(
std::path::Path::new(&track.file_path)
.file_name()
.and_then(|f| f.to_str())
.unwrap_or("track")
.to_string(),
)],
});
file.into_response(&req)
}
Err(e) => {
tracing::error!(path = %track.file_path, error = %e, "failed to open track file for download");
response::error(
&params.format,
response::ERROR_NOT_FOUND,
"file not found on disk",
)
}
}
}
/// GET /rest/getCoverArt[.view]
pub async fn get_cover_art(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
let (params, _user) = match authenticate(&req, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
let id_str = match get_query_param(&req, "id") {
Some(id) => id,
None => {
return response::error(
&params.format,
response::ERROR_MISSING_PARAM,
"missing required parameter: id",
);
}
};
// Cover art IDs can be album IDs (al-N) or artist IDs (ar-N)
let (prefix, entity_id) = match parse_subsonic_id(&id_str) {
Some(v) => v,
None => {
return response::error(
&params.format,
response::ERROR_NOT_FOUND,
"invalid cover art id",
);
}
};
match prefix {
"al" => {
let album = match queries::albums::get_by_id(state.db.conn(), entity_id).await {
Ok(a) => a,
Err(_) => {
return response::error(
&params.format,
response::ERROR_NOT_FOUND,
"album not found",
);
}
};
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://") {
return HttpResponse::TemporaryRedirect()
.append_header(("Location", cover_art_path.as_str()))
.finish();
}
// Otherwise try to serve as a local file
match NamedFile::open_async(cover_art_path).await {
Ok(file) => return file.into_response(&req),
Err(e) => {
tracing::warn!(path = %cover_art_path, error = %e, "cover art file not found");
}
}
}
// If album has a MusicBrainz ID, redirect to Cover Art Archive
if let Some(ref mbid) = album.musicbrainz_id {
let url = format!("https://coverartarchive.org/release/{mbid}/front-250");
return HttpResponse::TemporaryRedirect()
.append_header(("Location", url.as_str()))
.finish();
}
// No cover art available
HttpResponse::NotFound().finish()
}
_ => {
// For other types, no cover art
HttpResponse::NotFound().finish()
}
}
}

View File

@@ -0,0 +1,86 @@
mod annotation;
mod auth;
mod browsing;
mod helpers;
mod media;
mod playlists;
mod response;
mod search;
mod system;
mod user;
use actix_web::web;
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/rest")
// System
.route("/ping", web::get().to(system::ping))
.route("/ping.view", web::get().to(system::ping))
.route("/getLicense", web::get().to(system::get_license))
.route("/getLicense.view", web::get().to(system::get_license))
// 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)),
);
}

View File

@@ -0,0 +1,250 @@
use actix_web::{HttpRequest, HttpResponse, web};
use shanty_db::queries;
use crate::state::AppState;
use super::helpers::{authenticate, get_query_param, parse_subsonic_id};
use super::response::{self, SubsonicChild};
/// GET /rest/getPlaylists[.view]
pub async fn get_playlists(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
let (params, user) = match authenticate(&req, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
let playlists = queries::playlists::list(state.db.conn(), Some(user.id))
.await
.unwrap_or_default();
let mut playlist_list: Vec<serde_json::Value> = Vec::new();
for pl in &playlists {
let track_count = queries::playlists::get_track_count(state.db.conn(), pl.id)
.await
.unwrap_or(0);
// Calculate total duration
let tracks = queries::playlists::get_tracks(state.db.conn(), pl.id)
.await
.unwrap_or_default();
let duration: i32 = tracks
.iter()
.filter_map(|t| t.duration.map(|d| d as i32))
.sum();
let mut pl_json = serde_json::json!({
"id": format!("pl-{}", pl.id),
"name": pl.name,
"owner": user.username,
"public": false,
"songCount": track_count,
"duration": duration,
"created": pl.created_at.format("%Y-%m-%dT%H:%M:%S").to_string(),
"changed": pl.updated_at.format("%Y-%m-%dT%H:%M:%S").to_string(),
});
if let Some(ref desc) = pl.description {
pl_json["comment"] = serde_json::json!(desc);
}
playlist_list.push(pl_json);
}
response::ok(
&params.format,
serde_json::json!({
"playlists": {
"playlist": playlist_list,
}
}),
)
}
/// GET /rest/getPlaylist[.view]
pub async fn get_playlist(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
let (params, user) = match authenticate(&req, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
let id_str = match get_query_param(&req, "id") {
Some(id) => id,
None => {
return response::error(
&params.format,
response::ERROR_MISSING_PARAM,
"missing required parameter: id",
);
}
};
let (_prefix, playlist_id) = match parse_subsonic_id(&id_str) {
Some(v) => v,
None => {
return response::error(
&params.format,
response::ERROR_NOT_FOUND,
"invalid playlist id",
);
}
};
let playlist = match queries::playlists::get_by_id(state.db.conn(), playlist_id).await {
Ok(p) => p,
Err(_) => {
return response::error(
&params.format,
response::ERROR_NOT_FOUND,
"playlist not found",
);
}
};
let tracks = queries::playlists::get_tracks(state.db.conn(), playlist_id)
.await
.unwrap_or_default();
let duration: i32 = tracks
.iter()
.filter_map(|t| t.duration.map(|d| d as i32))
.sum();
let entry_list: Vec<serde_json::Value> = tracks
.iter()
.map(|track| serde_json::to_value(SubsonicChild::from_track(track)).unwrap_or_default())
.collect();
let mut pl_json = serde_json::json!({
"id": format!("pl-{}", playlist.id),
"name": playlist.name,
"owner": user.username,
"public": false,
"songCount": tracks.len(),
"duration": duration,
"created": playlist.created_at.format("%Y-%m-%dT%H:%M:%S").to_string(),
"changed": playlist.updated_at.format("%Y-%m-%dT%H:%M:%S").to_string(),
"entry": entry_list,
});
if let Some(ref desc) = playlist.description {
pl_json["comment"] = serde_json::json!(desc);
}
response::ok(
&params.format,
serde_json::json!({
"playlist": pl_json,
}),
)
}
/// GET /rest/createPlaylist[.view]
pub async fn create_playlist(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
let (params, user) = match authenticate(&req, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
let name = match get_query_param(&req, "name") {
Some(n) => n,
None => {
return response::error(
&params.format,
response::ERROR_MISSING_PARAM,
"missing required parameter: name",
);
}
};
// Collect songId params (can be repeated)
let qs = req.query_string();
let query_params: Vec<(String, String)> = serde_urlencoded::from_str(qs).unwrap_or_default();
let track_ids: Vec<i32> = query_params
.iter()
.filter(|(k, _)| k == "songId")
.filter_map(|(_, v)| parse_subsonic_id(v).map(|(_, id)| id))
.collect();
match queries::playlists::create(state.db.conn(), &name, None, Some(user.id), &track_ids).await
{
Ok(playlist) => {
let tracks = queries::playlists::get_tracks(state.db.conn(), playlist.id)
.await
.unwrap_or_default();
let duration: i32 = tracks
.iter()
.filter_map(|t| t.duration.map(|d| d as i32))
.sum();
let entry_list: Vec<serde_json::Value> = tracks
.iter()
.map(|track| {
serde_json::to_value(SubsonicChild::from_track(track)).unwrap_or_default()
})
.collect();
let mut pl_json = serde_json::json!({
"id": format!("pl-{}", playlist.id),
"name": playlist.name,
"owner": user.username,
"public": false,
"songCount": tracks.len(),
"duration": duration,
"created": playlist.created_at.format("%Y-%m-%dT%H:%M:%S").to_string(),
"changed": playlist.updated_at.format("%Y-%m-%dT%H:%M:%S").to_string(),
"entry": entry_list,
});
if let Some(ref desc) = playlist.description {
pl_json["comment"] = serde_json::json!(desc);
}
response::ok(
&params.format,
serde_json::json!({
"playlist": pl_json,
}),
)
}
Err(e) => response::error(
&params.format,
response::ERROR_GENERIC,
&format!("failed to create playlist: {e}"),
),
}
}
/// GET /rest/deletePlaylist[.view]
pub async fn delete_playlist(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
let (params, _user) = match authenticate(&req, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
let id_str = match get_query_param(&req, "id") {
Some(id) => id,
None => {
return response::error(
&params.format,
response::ERROR_MISSING_PARAM,
"missing required parameter: id",
);
}
};
let (_prefix, playlist_id) = match parse_subsonic_id(&id_str) {
Some(v) => v,
None => {
return response::error(
&params.format,
response::ERROR_NOT_FOUND,
"invalid playlist id",
);
}
};
match queries::playlists::delete(state.db.conn(), playlist_id).await {
Ok(()) => response::ok(&params.format, serde_json::json!({})),
Err(e) => response::error(
&params.format,
response::ERROR_GENERIC,
&format!("failed to delete playlist: {e}"),
),
}
}

View File

@@ -0,0 +1,249 @@
use actix_web::HttpResponse;
use serde::Serialize;
const SUBSONIC_VERSION: &str = "1.16.1";
const XMLNS: &str = "http://subsonic.org/restapi";
/// Build a successful Subsonic response in the requested format.
pub fn ok(format: &str, body: serde_json::Value) -> HttpResponse {
format_response(format, "ok", body, None)
}
/// Build a Subsonic error response.
pub fn error(format: &str, code: u32, message: &str) -> HttpResponse {
let err = serde_json::json!({
"error": {
"code": code,
"message": message,
}
});
format_response(format, "failed", err, None)
}
/// Subsonic error codes.
pub const ERROR_GENERIC: u32 = 0;
pub const ERROR_MISSING_PARAM: u32 = 10;
pub const ERROR_NOT_AUTHENTICATED: u32 = 40;
#[allow(dead_code)]
pub const ERROR_NOT_AUTHORIZED: u32 = 50;
pub const ERROR_NOT_FOUND: u32 = 70;
fn format_response(
format: &str,
status: &str,
body: serde_json::Value,
_type_attr: Option<&str>,
) -> HttpResponse {
match format {
"json" => format_json(status, body),
_ => format_xml(status, body),
}
}
fn format_json(status: &str, body: serde_json::Value) -> HttpResponse {
let mut response = serde_json::json!({
"status": status,
"version": SUBSONIC_VERSION,
"type": "shanty",
"serverVersion": "0.1.0",
"openSubsonic": true,
});
// Merge body into response
if let serde_json::Value::Object(map) = body
&& let serde_json::Value::Object(ref mut resp_map) = response
{
for (k, v) in map {
resp_map.insert(k, v);
}
}
let wrapper = serde_json::json!({
"subsonic-response": response,
});
HttpResponse::Ok()
.content_type("application/json")
.json(wrapper)
}
fn format_xml(status: &str, body: serde_json::Value) -> HttpResponse {
let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.push_str(&format!(
"<subsonic-response xmlns=\"{XMLNS}\" status=\"{status}\" version=\"{SUBSONIC_VERSION}\" type=\"shanty\" serverVersion=\"0.1.0\" openSubsonic=\"true\">"
));
if let serde_json::Value::Object(map) = &body {
for (key, value) in map {
json_to_xml(&mut xml, key, value);
}
}
xml.push_str("</subsonic-response>");
HttpResponse::Ok()
.content_type("application/xml; charset=UTF-8")
.body(xml)
}
/// Convert a JSON value into XML elements. The Subsonic XML format uses:
/// - Object keys become element names
/// - Primitive values in objects become attributes
/// - Arrays become repeated elements
/// - Nested objects become child elements
fn json_to_xml(xml: &mut String, tag: &str, value: &serde_json::Value) {
match value {
serde_json::Value::Array(arr) => {
for item in arr {
json_to_xml(xml, tag, item);
}
}
serde_json::Value::Object(map) => {
xml.push_str(&format!("<{tag}"));
let mut children = Vec::new();
for (k, v) in map {
match v {
serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
children.push((k, v));
}
_ => {
let val_str = match v {
serde_json::Value::String(s) => xml_escape(s),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Null => String::new(),
_ => v.to_string(),
};
xml.push_str(&format!(" {k}=\"{val_str}\""));
}
}
}
if children.is_empty() {
xml.push_str("/>");
} else {
xml.push('>');
for (k, v) in children {
json_to_xml(xml, k, v);
}
xml.push_str(&format!("</{tag}>"));
}
}
serde_json::Value::String(s) => {
xml.push_str(&format!("<{tag}>{}</{tag}>", xml_escape(s)));
}
serde_json::Value::Number(n) => {
xml.push_str(&format!("<{tag}>{n}</{tag}>"));
}
serde_json::Value::Bool(b) => {
xml.push_str(&format!("<{tag}>{b}</{tag}>"));
}
serde_json::Value::Null => {}
}
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
/// Helper to build a "child" (track) JSON for Subsonic responses.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SubsonicChild {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
pub is_dir: bool,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub album: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub artist: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub track: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub year: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub genre: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cover_art: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub suffix: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bit_rate: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disc_number: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub album_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub artist_id: Option<String>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub media_type: Option<String>,
}
impl SubsonicChild {
pub fn from_track(track: &shanty_db::entities::track::Model) -> Self {
let suffix = std::path::Path::new(&track.file_path)
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_string());
let content_type = suffix.as_deref().map(|s| {
match s {
"mp3" => "audio/mpeg",
"flac" => "audio/flac",
"ogg" | "opus" => "audio/ogg",
"m4a" | "aac" => "audio/mp4",
"wav" => "audio/wav",
"wma" => "audio/x-ms-wma",
_ => "audio/mpeg",
}
.to_string()
});
let path_display = format!(
"{}/{}",
track.artist.as_deref().unwrap_or("Unknown Artist"),
std::path::Path::new(&track.file_path)
.file_name()
.and_then(|f| f.to_str())
.unwrap_or("unknown")
);
SubsonicChild {
id: format!("tr-{}", track.id),
parent: track.album_id.map(|id| format!("al-{id}")),
is_dir: false,
title: track.title.clone().unwrap_or_else(|| "Unknown".to_string()),
album: track.album.clone(),
artist: track.artist.clone(),
track: track.track_number,
year: track.year,
genre: track.genre.clone(),
cover_art: track.album_id.map(|id| format!("al-{id}")),
size: Some(track.file_size),
content_type,
suffix,
duration: track.duration.map(|d| d as i32),
bit_rate: track.bitrate,
path: Some(path_display),
disc_number: track.disc_number,
album_id: track.album_id.map(|id| format!("al-{id}")),
artist_id: track.artist_id.map(|id| format!("ar-{id}")),
media_type: Some("music".to_string()),
}
}
}

View File

@@ -0,0 +1,124 @@
use actix_web::{HttpRequest, HttpResponse, web};
use shanty_db::queries;
use crate::state::AppState;
use super::helpers::{authenticate, get_query_param};
use super::response::{self, SubsonicChild};
/// GET /rest/search3[.view]
pub async fn search3(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
let (params, _user) = match authenticate(&req, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
let query = match get_query_param(&req, "query") {
Some(q) => q,
None => {
return response::error(
&params.format,
response::ERROR_MISSING_PARAM,
"missing required parameter: query",
);
}
};
let artist_count: u64 = get_query_param(&req, "artistCount")
.and_then(|v| v.parse().ok())
.unwrap_or(20);
let album_count: u64 = get_query_param(&req, "albumCount")
.and_then(|v| v.parse().ok())
.unwrap_or(20);
let song_count: u64 = get_query_param(&req, "songCount")
.and_then(|v| v.parse().ok())
.unwrap_or(20);
// Search tracks (which gives us artists and albums too)
let tracks = queries::tracks::search(state.db.conn(), &query)
.await
.unwrap_or_default();
// Collect unique artists from tracks
let mut seen_artists = std::collections::HashSet::new();
let mut artist_results: Vec<serde_json::Value> = Vec::new();
for track in &tracks {
if let Some(artist_id) = track.artist_id
&& seen_artists.insert(artist_id)
&& artist_results.len() < artist_count as usize
&& let Ok(artist) = queries::artists::get_by_id(state.db.conn(), artist_id).await
{
let album_ct = queries::albums::get_by_artist(state.db.conn(), artist_id)
.await
.map(|a| a.len())
.unwrap_or(0);
artist_results.push(serde_json::json!({
"id": format!("ar-{}", artist.id),
"name": artist.name,
"albumCount": album_ct,
}));
}
}
// Also search artists by name directly
let all_artists = queries::artists::list(state.db.conn(), 10000, 0)
.await
.unwrap_or_default();
let query_lower = query.to_lowercase();
for artist in &all_artists {
if artist.name.to_lowercase().contains(&query_lower)
&& seen_artists.insert(artist.id)
&& artist_results.len() < artist_count as usize
{
let album_ct = queries::albums::get_by_artist(state.db.conn(), artist.id)
.await
.map(|a| a.len())
.unwrap_or(0);
artist_results.push(serde_json::json!({
"id": format!("ar-{}", artist.id),
"name": artist.name,
"albumCount": album_ct,
}));
}
}
// Collect unique albums from tracks
let mut seen_albums = std::collections::HashSet::new();
let mut album_results: Vec<serde_json::Value> = Vec::new();
for track in &tracks {
if let Some(aid) = track.album_id
&& seen_albums.insert(aid)
&& album_results.len() < album_count as usize
&& let Ok(album) = queries::albums::get_by_id(state.db.conn(), aid).await
{
album_results.push(serde_json::json!({
"id": format!("al-{}", album.id),
"name": album.name,
"artist": album.album_artist,
"artistId": album.artist_id.map(|id| format!("ar-{id}")),
"coverArt": format!("al-{}", album.id),
"year": album.year,
"genre": album.genre,
}));
}
}
// Song results
let song_results: Vec<serde_json::Value> = tracks
.iter()
.take(song_count as usize)
.map(|track| serde_json::to_value(SubsonicChild::from_track(track)).unwrap_or_default())
.collect();
response::ok(
&params.format,
serde_json::json!({
"searchResult3": {
"artist": artist_results,
"album": album_results,
"song": song_results,
}
}),
)
}

View File

@@ -0,0 +1,35 @@
use actix_web::{HttpRequest, HttpResponse, web};
use crate::state::AppState;
use super::helpers::authenticate;
use super::response;
/// GET /rest/ping[.view]
pub async fn ping(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
let (params, _user) = match authenticate(&req, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
response::ok(&params.format, serde_json::json!({}))
}
/// GET /rest/getLicense[.view]
pub async fn get_license(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
let (params, _user) = match authenticate(&req, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
response::ok(
&params.format,
serde_json::json!({
"license": {
"valid": true,
"email": "shanty@localhost",
"licenseExpires": "2099-12-31T23:59:59",
}
}),
)
}

View File

@@ -0,0 +1,42 @@
use actix_web::{HttpRequest, HttpResponse, web};
use shanty_db::entities::user::UserRole;
use crate::state::AppState;
use super::helpers::authenticate;
use super::response;
/// GET /rest/getUser[.view]
pub async fn get_user(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
let (params, user) = match authenticate(&req, &state).await {
Ok(v) => v,
Err(resp) => return resp,
};
let is_admin = user.role == UserRole::Admin;
response::ok(
&params.format,
serde_json::json!({
"user": {
"username": user.username,
"email": "",
"scrobblingEnabled": false,
"adminRole": is_admin,
"settingsRole": is_admin,
"downloadRole": true,
"uploadRole": false,
"playlistRole": true,
"coverArtRole": false,
"commentRole": false,
"podcastRole": false,
"streamRole": true,
"jukeboxRole": false,
"shareRole": false,
"videoConversionRole": false,
"folder": [1],
}
}),
)
}

View File

@@ -22,6 +22,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(web::resource("/watchlist/{id}").route(web::delete().to(remove_watchlist))) .service(web::resource("/watchlist/{id}").route(web::delete().to(remove_watchlist)))
.service(web::resource("/monitor/check").route(web::post().to(trigger_monitor_check))) .service(web::resource("/monitor/check").route(web::post().to(trigger_monitor_check)))
.service(web::resource("/monitor/status").route(web::get().to(get_monitor_status))) .service(web::resource("/monitor/status").route(web::get().to(get_monitor_status)))
.service(web::resource("/scheduler/skip-pipeline").route(web::post().to(skip_pipeline)))
.service(web::resource("/scheduler/skip-monitor").route(web::post().to(skip_monitor)))
.service(web::resource("/mb-status").route(web::get().to(get_mb_status)))
.service(web::resource("/mb-import").route(web::post().to(trigger_mb_import)))
.service( .service(
web::resource("/config") web::resource("/config")
.route(web::get().to(get_config)) .route(web::get().to(get_config))
@@ -303,3 +307,128 @@ async fn save_config(
tracing::info!("config updated via API"); tracing::info!("config updated via API");
Ok(HttpResponse::Ok().json(&new_config)) Ok(HttpResponse::Ok().json(&new_config))
} }
async fn skip_pipeline(
state: web::Data<AppState>,
session: Session,
) -> Result<HttpResponse, ApiError> {
auth::require_admin(&session)?;
let mut sched = state.scheduler.lock().await;
sched.skip_pipeline = true;
sched.next_pipeline = None;
Ok(HttpResponse::Ok().json(serde_json::json!({"status": "skipped"})))
}
async fn skip_monitor(
state: web::Data<AppState>,
session: Session,
) -> Result<HttpResponse, ApiError> {
auth::require_admin(&session)?;
let mut sched = state.scheduler.lock().await;
sched.skip_monitor = true;
sched.next_monitor = None;
Ok(HttpResponse::Ok().json(serde_json::json!({"status": "skipped"})))
}
async fn get_mb_status(
state: web::Data<AppState>,
session: Session,
) -> Result<HttpResponse, ApiError> {
auth::require_auth(&session)?;
let has_local = state.mb_client.has_local_db();
let stats = state.mb_client.local_stats();
Ok(HttpResponse::Ok().json(serde_json::json!({
"has_local_db": has_local,
"stats": stats,
})))
}
async fn trigger_mb_import(
state: web::Data<AppState>,
session: Session,
) -> Result<HttpResponse, ApiError> {
auth::require_admin(&session)?;
let task_id = state.tasks.register("mb_import");
let tid = task_id.clone();
let config = state.config.read().await.clone();
tokio::spawn(async move {
state
.tasks
.update_progress(&tid, 0, 0, "Starting MusicBrainz import...");
let data_dir = shanty_config::data_dir().join("mb-dumps");
let db_path = config
.musicbrainz
.local_db_path
.clone()
.unwrap_or_else(|| shanty_config::data_dir().join("shanty-mb.db"));
// Download dumps
state
.tasks
.update_progress(&tid, 0, 4, "Downloading dumps...");
if let Err(e) = std::fs::create_dir_all(&data_dir) {
state
.tasks
.fail(&tid, format!("Failed to create data dir: {e}"));
return;
}
let timestamp = match shanty_data::mb_import::discover_latest_dump_folder().await {
Ok(t) => t,
Err(e) => {
state
.tasks
.fail(&tid, format!("Failed to discover latest dump: {e}"));
return;
}
};
for (i, filename) in shanty_data::mb_import::DUMP_FILES.iter().enumerate() {
state.tasks.update_progress(
&tid,
i as u64,
4 + 4, // 4 downloads + 4 imports
&format!("Downloading {filename}..."),
);
if let Err(e) =
shanty_data::mb_import::download_dump(filename, &timestamp, &data_dir, |_| {}).await
{
state
.tasks
.fail(&tid, format!("Failed to download {filename}: {e}"));
return;
}
}
// Run import
state
.tasks
.update_progress(&tid, 4, 8, "Importing into database...");
let tid_clone = tid.clone();
let state_clone = state.clone();
// Run import in blocking task since rusqlite is sync
let result = tokio::task::spawn_blocking(move || {
shanty_data::mb_import::run_import_at_path(&db_path, &data_dir, |msg| {
state_clone.tasks.update_progress(&tid_clone, 4, 8, msg);
})
})
.await;
match result {
Ok(Ok(stats)) => {
state.tasks.complete(&tid, format!("{stats}"));
}
Ok(Err(e)) => {
state.tasks.fail(&tid, format!("Import failed: {e}"));
}
Err(e) => {
state.tasks.fail(&tid, format!("Import task panicked: {e}"));
}
}
});
Ok(HttpResponse::Accepted().json(serde_json::json!({ "task_id": task_id })))
}

View File

@@ -1,7 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::{Mutex, RwLock}; use tokio::sync::{Mutex, RwLock};
use shanty_data::MusicBrainzFetcher; use shanty_data::HybridMusicBrainzFetcher;
use shanty_data::WikipediaFetcher; use shanty_data::WikipediaFetcher;
use shanty_db::Database; use shanty_db::Database;
use shanty_search::MusicBrainzSearch; use shanty_search::MusicBrainzSearch;
@@ -20,11 +20,15 @@ pub struct SchedulerInfo {
pub next_pipeline: Option<chrono::NaiveDateTime>, pub next_pipeline: Option<chrono::NaiveDateTime>,
/// When the next monitor check is scheduled (None if disabled). /// When the next monitor check is scheduled (None if disabled).
pub next_monitor: Option<chrono::NaiveDateTime>, pub next_monitor: Option<chrono::NaiveDateTime>,
/// Skip the next pipeline run (one-shot, resets after skip).
pub skip_pipeline: bool,
/// Skip the next monitor run (one-shot, resets after skip).
pub skip_monitor: bool,
} }
pub struct AppState { pub struct AppState {
pub db: Database, pub db: Database,
pub mb_client: MusicBrainzFetcher, pub mb_client: HybridMusicBrainzFetcher,
pub search: MusicBrainzSearch, pub search: MusicBrainzSearch,
pub wiki_fetcher: WikipediaFetcher, pub wiki_fetcher: WikipediaFetcher,
pub config: Arc<RwLock<AppConfig>>, pub config: Arc<RwLock<AppConfig>>,