diff --git a/Cargo.toml b/Cargo.toml index c62cef7..ea0d349 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,10 @@ actix-cors = "0.7" actix-files = "0.6" actix-session = { version = "0.10", features = ["cookie-session"] } argon2 = "0.5" +md-5 = "0.10" +hex = "0.4" +quick-xml = { version = "0.37", features = ["serialize"] } +serde_urlencoded = "0.7" rand = "0.9" thiserror = "2" anyhow = "1" @@ -32,6 +36,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-actix-web = "0.7" diff --git a/frontend/src/api.rs b/frontend/src/api.rs index 2852aac..e42def5 100644 --- a/frontend/src/api.rs +++ b/frontend/src/api.rs @@ -326,14 +326,8 @@ pub async fn add_track_to_playlist( 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 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( @@ -348,6 +342,17 @@ pub async fn search_tracks(query: &str) -> Result, ApiError> { get_json(&format!("{BASE}/tracks?q={query}&limit=50")).await } +// --- Subsonic --- + +pub async fn get_subsonic_password_status() -> Result { + get_json(&format!("{BASE}/auth/subsonic-password-status")).await +} + +pub async fn set_subsonic_password(password: &str) -> Result { + let body = serde_json::json!({"password": password}).to_string(); + put_json(&format!("{BASE}/auth/subsonic-password"), &body).await +} + // --- YouTube Auth --- pub async fn get_ytauth_status() -> Result { diff --git a/frontend/src/pages/playlists.rs b/frontend/src/pages/playlists.rs index 6e33b49..42f0eb1 100644 --- a/frontend/src/pages/playlists.rs +++ b/frontend/src/pages/playlists.rs @@ -560,16 +560,8 @@ pub fn playlists_page() -> Html { 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(); + 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(); diff --git a/frontend/src/pages/settings.rs b/frontend/src/pages/settings.rs index 1692607..043b483 100644 --- a/frontend/src/pages/settings.rs +++ b/frontend/src/pages/settings.rs @@ -3,7 +3,7 @@ use web_sys::HtmlSelectElement; use yew::prelude::*; use crate::api; -use crate::types::{AppConfig, YtAuthStatus}; +use crate::types::{AppConfig, SubsonicPasswordStatus, YtAuthStatus}; #[function_component(SettingsPage)] pub fn settings_page() -> Html { @@ -12,11 +12,15 @@ pub fn settings_page() -> Html { let message = use_state(|| None::); let ytauth = use_state(|| None::); let ytauth_loading = use_state(|| false); + let subsonic_status = use_state(|| None::); + let subsonic_password = use_state(String::new); + let subsonic_saving = use_state(|| false); { let config = config.clone(); let error = error.clone(); let ytauth = ytauth.clone(); + let subsonic_status = subsonic_status.clone(); use_effect_with((), move |_| { wasm_bindgen_futures::spawn_local(async move { match api::get_config().await { @@ -29,6 +33,11 @@ pub fn settings_page() -> Html { 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)); + } + }); }); } @@ -500,6 +509,93 @@ pub fn settings_page() -> Html { { ytauth_html } + // Subsonic API +
+

{ "Subsonic API" }

+

+ { "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 " } + { "Navidrome" } + { " pointed at the same library." } +

+
+ + +
+ { + if let Some(ref status) = *subsonic_status { + if status.set { + html! { +

+ { "Password set" } +

+ } + } else { + html! { +

{ "No Subsonic password set. Set one below to enable access." }

+ } + } + } else { + html! {

{ "Loading..." }

} + } + } +
+

+ { "Warning: " } + { "This password is stored in plaintext per the Subsonic protocol. Do " } + { "not" } + { " reuse a password from another account." } +

+
+
+ + +
+ +
+ // Metadata Providers

{ "Metadata Providers" }

diff --git a/frontend/src/types.rs b/frontend/src/types.rs index f093139..d701c60 100644 --- a/frontend/src/types.rs +++ b/frontend/src/types.rs @@ -371,6 +371,13 @@ pub struct SavedPlaylist { pub description: Option, } +// --- Subsonic --- + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct SubsonicPasswordStatus { + pub set: bool, +} + // --- Config --- #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/src/main.rs b/src/main.rs index fbedc0f..9587f50 100644 --- a/src/main.rs +++ b/src/main.rs @@ -126,15 +126,24 @@ async fn main() -> anyhow::Result<()> { .service( actix_files::Files::new("/", static_dir.clone()) .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 - // 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({ let index_path = static_dir.join("index.html"); move |req: actix_web::HttpRequest| { let index_path = index_path.clone(); 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) .await .map(|f| f.into_response(&req)) diff --git a/src/routes/auth.rs b/src/routes/auth.rs index 029b99b..bf31090 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -20,7 +20,14 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .route(web::get().to(list_users)) .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)] @@ -41,6 +48,11 @@ struct CreateUserRequest { password: String, } +#[derive(Deserialize)] +struct SubsonicPasswordRequest { + password: String, +} + /// Check if initial setup is required (no users in database). async fn setup_required(state: web::Data) -> Result { 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"); Ok(HttpResponse::NoContent().finish()) } + +/// Set the Subsonic password for the current user. +async fn set_subsonic_password( + state: web::Data, + session: Session, + body: web::Json, +) -> Result { + 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, + session: Session, +) -> Result { + 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(), + }))) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 12fed66..3fd4cad 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -5,6 +5,7 @@ pub mod downloads; pub mod lyrics; pub mod playlists; pub mod search; +pub mod subsonic; pub mod system; pub mod tracks; pub mod ytauth; @@ -25,4 +26,6 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .configure(ytauth::configure) .configure(playlists::configure), ); + // Subsonic API at /rest/* + subsonic::configure(cfg); } diff --git a/src/routes/subsonic/annotation.rs b/src/routes/subsonic/annotation.rs new file mode 100644 index 0000000..a59e209 --- /dev/null +++ b/src/routes/subsonic/annotation.rs @@ -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) -> HttpResponse { + let (params, user) = match authenticate(&req, &state).await { + Ok(v) => v, + Err(resp) => return resp, + }; + + let id_str = match get_query_param(&req, "id") { + Some(id) => id, + None => { + return response::error( + ¶ms.format, + response::ERROR_MISSING_PARAM, + "missing required parameter: id", + ); + } + }; + + let (prefix, entity_id) = match parse_subsonic_id(&id_str) { + Some(v) => v, + None => { + return response::error(¶ms.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(¶ms.format, serde_json::json!({})) +} diff --git a/src/routes/subsonic/auth.rs b/src/routes/subsonic/auth.rs new file mode 100644 index 0000000..eb75c45 --- /dev/null +++ b/src/routes/subsonic/auth.rs @@ -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 { + let qs = req.query_string(); + let params: Vec<(String, String)> = serde_urlencoded::from_str(qs).unwrap_or_default(); + + let get = |name: &str| -> Option { + 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 { + let user = queries::users::find_by_username(db, ¶ms.username) + .await + .map_err(|_| SubsonicAuthError::AuthFailed)? + .ok_or(SubsonicAuthError::AuthFailed)?; + + let subsonic_password = user + .subsonic_password + .as_deref() + .ok_or(SubsonicAuthError::AuthFailed)?; + + match ¶ms.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) +} diff --git a/src/routes/subsonic/browsing.rs b/src/routes/subsonic/browsing.rs new file mode 100644 index 0000000..59f61d2 --- /dev/null +++ b/src/routes/subsonic/browsing.rs @@ -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) -> HttpResponse { + let (params, _user) = match authenticate(&req, &state).await { + Ok(v) => v, + Err(resp) => return resp, + }; + + response::ok( + ¶ms.format, + serde_json::json!({ + "musicFolders": { + "musicFolder": [ + { "id": 1, "name": "Music" } + ] + } + }), + ) +} + +/// GET /rest/getArtists[.view] +pub async fn get_artists(req: HttpRequest, state: web::Data) -> 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( + ¶ms.format, + response::ERROR_GENERIC, + &format!("database error: {e}"), + ); + } + }; + + // Group artists by first letter for the index + let mut index_map: BTreeMap> = 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 = index_map + .into_iter() + .map(|(name, artists)| { + serde_json::json!({ + "name": name, + "artist": artists, + }) + }) + .collect(); + + response::ok( + ¶ms.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) -> HttpResponse { + let (params, _user) = match authenticate(&req, &state).await { + Ok(v) => v, + Err(resp) => return resp, + }; + + let id_str = match get_query_param(&req, "id") { + Some(id) => id, + None => { + return response::error( + ¶ms.format, + response::ERROR_MISSING_PARAM, + "missing required parameter: id", + ); + } + }; + + let (_prefix, artist_id) = match parse_subsonic_id(&id_str) { + Some(v) => v, + None => { + return response::error( + ¶ms.format, + response::ERROR_NOT_FOUND, + "invalid artist id", + ); + } + }; + + let artist = match queries::artists::get_by_id(state.db.conn(), artist_id).await { + Ok(a) => a, + Err(_) => { + return response::error( + ¶ms.format, + response::ERROR_NOT_FOUND, + "artist not found", + ); + } + }; + + let albums = queries::albums::get_by_artist(state.db.conn(), artist_id) + .await + .unwrap_or_default(); + + let mut album_list: Vec = 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( + ¶ms.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) -> HttpResponse { + let (params, _user) = match authenticate(&req, &state).await { + Ok(v) => v, + Err(resp) => return resp, + }; + + let id_str = match get_query_param(&req, "id") { + Some(id) => id, + None => { + return response::error( + ¶ms.format, + response::ERROR_MISSING_PARAM, + "missing required parameter: id", + ); + } + }; + + let (_prefix, album_id) = match parse_subsonic_id(&id_str) { + Some(v) => v, + None => { + return response::error( + ¶ms.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(¶ms.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 = 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( + ¶ms.format, + serde_json::json!({ + "album": album_json, + }), + ) +} + +/// GET /rest/getSong[.view] +pub async fn get_song(req: HttpRequest, state: web::Data) -> HttpResponse { + let (params, _user) = match authenticate(&req, &state).await { + Ok(v) => v, + Err(resp) => return resp, + }; + + let id_str = match get_query_param(&req, "id") { + Some(id) => id, + None => { + return response::error( + ¶ms.format, + response::ERROR_MISSING_PARAM, + "missing required parameter: id", + ); + } + }; + + let (_prefix, track_id) = match parse_subsonic_id(&id_str) { + Some(v) => v, + None => { + return response::error(¶ms.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(¶ms.format, response::ERROR_NOT_FOUND, "song not found"); + } + }; + + let child = SubsonicChild::from_track(&track); + + response::ok( + ¶ms.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) -> 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 = 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> = 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 = 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( + ¶ms.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) -> 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( + ¶ms.format, + response::ERROR_GENERIC, + &format!("database error: {e}"), + ); + } + }; + + let mut index_map: BTreeMap> = 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 = index_map + .into_iter() + .map(|(name, artists)| { + serde_json::json!({ + "name": name, + "artist": artists, + }) + }) + .collect(); + + response::ok( + ¶ms.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) -> HttpResponse { + let (params, _user) = match authenticate(&req, &state).await { + Ok(v) => v, + Err(resp) => return resp, + }; + + let id_str = match get_query_param(&req, "id") { + Some(id) => id, + None => { + return response::error( + ¶ms.format, + response::ERROR_MISSING_PARAM, + "missing required parameter: id", + ); + } + }; + + let (prefix, db_id) = match parse_subsonic_id(&id_str) { + Some(v) => v, + None => { + return response::error(¶ms.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( + ¶ms.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 = 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( + ¶ms.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( + ¶ms.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 = tracks + .iter() + .map(|t| serde_json::to_value(SubsonicChild::from_track(t)).unwrap_or_default()) + .collect(); + + response::ok( + ¶ms.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 = 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( + ¶ms.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 = tracks + .iter() + .map(|t| serde_json::to_value(SubsonicChild::from_track(t)).unwrap_or_default()) + .collect(); + + response::ok( + ¶ms.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(¶ms.format, response::ERROR_NOT_FOUND, "not found") + } + } + _ => response::error(¶ms.format, response::ERROR_NOT_FOUND, "unknown id type"), + } +} diff --git a/src/routes/subsonic/helpers.rs b/src/routes/subsonic/helpers.rs new file mode 100644 index 0000000..32f48b2 --- /dev/null +++ b/src/routes/subsonic/helpers.rs @@ -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, +) -> 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(), ¶ms) + .await + .map_err(|e| match e { + SubsonicAuthError::AuthFailed => response::error( + ¶ms.format, + response::ERROR_NOT_AUTHENTICATED, + "wrong username or password", + ), + SubsonicAuthError::MissingParam(name) => response::error( + ¶ms.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 { + 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) +} diff --git a/src/routes/subsonic/media.rs b/src/routes/subsonic/media.rs new file mode 100644 index 0000000..17a7cac --- /dev/null +++ b/src/routes/subsonic/media.rs @@ -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) -> HttpResponse { + let (params, _user) = match authenticate(&req, &state).await { + Ok(v) => v, + Err(resp) => return resp, + }; + + let id_str = match get_query_param(&req, "id") { + Some(id) => id, + None => { + return response::error( + ¶ms.format, + response::ERROR_MISSING_PARAM, + "missing required parameter: id", + ); + } + }; + + let (_prefix, track_id) = match parse_subsonic_id(&id_str) { + Some(v) => v, + None => { + return response::error(¶ms.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(¶ms.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::().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( + ¶ms.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( + ¶ms.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(¶ms.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( + ¶ms.format, + response::ERROR_NOT_FOUND, + "file not found on disk", + ) + } + } + } +} + +/// GET /rest/download[.view] +pub async fn download(req: HttpRequest, state: web::Data) -> HttpResponse { + let (params, _user) = match authenticate(&req, &state).await { + Ok(v) => v, + Err(resp) => return resp, + }; + + let id_str = match get_query_param(&req, "id") { + Some(id) => id, + None => { + return response::error( + ¶ms.format, + response::ERROR_MISSING_PARAM, + "missing required parameter: id", + ); + } + }; + + let (_prefix, track_id) = match parse_subsonic_id(&id_str) { + Some(v) => v, + None => { + return response::error(¶ms.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(¶ms.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( + ¶ms.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) -> HttpResponse { + let (params, _user) = match authenticate(&req, &state).await { + Ok(v) => v, + Err(resp) => return resp, + }; + + let id_str = match get_query_param(&req, "id") { + Some(id) => id, + None => { + return response::error( + ¶ms.format, + response::ERROR_MISSING_PARAM, + "missing required parameter: id", + ); + } + }; + + // 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( + ¶ms.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( + ¶ms.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() + } + } +} diff --git a/src/routes/subsonic/mod.rs b/src/routes/subsonic/mod.rs new file mode 100644 index 0000000..06ab0ed --- /dev/null +++ b/src/routes/subsonic/mod.rs @@ -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)), + ); +} diff --git a/src/routes/subsonic/playlists.rs b/src/routes/subsonic/playlists.rs new file mode 100644 index 0000000..c728c4d --- /dev/null +++ b/src/routes/subsonic/playlists.rs @@ -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) -> 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 = 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( + ¶ms.format, + serde_json::json!({ + "playlists": { + "playlist": playlist_list, + } + }), + ) +} + +/// GET /rest/getPlaylist[.view] +pub async fn get_playlist(req: HttpRequest, state: web::Data) -> HttpResponse { + let (params, user) = match authenticate(&req, &state).await { + Ok(v) => v, + Err(resp) => return resp, + }; + + let id_str = match get_query_param(&req, "id") { + Some(id) => id, + None => { + return response::error( + ¶ms.format, + response::ERROR_MISSING_PARAM, + "missing required parameter: id", + ); + } + }; + + let (_prefix, playlist_id) = match parse_subsonic_id(&id_str) { + Some(v) => v, + None => { + return response::error( + ¶ms.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( + ¶ms.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 = 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( + ¶ms.format, + serde_json::json!({ + "playlist": pl_json, + }), + ) +} + +/// GET /rest/createPlaylist[.view] +pub async fn create_playlist(req: HttpRequest, state: web::Data) -> 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( + ¶ms.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 = 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 = 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( + ¶ms.format, + serde_json::json!({ + "playlist": pl_json, + }), + ) + } + Err(e) => response::error( + ¶ms.format, + response::ERROR_GENERIC, + &format!("failed to create playlist: {e}"), + ), + } +} + +/// GET /rest/deletePlaylist[.view] +pub async fn delete_playlist(req: HttpRequest, state: web::Data) -> HttpResponse { + let (params, _user) = match authenticate(&req, &state).await { + Ok(v) => v, + Err(resp) => return resp, + }; + + let id_str = match get_query_param(&req, "id") { + Some(id) => id, + None => { + return response::error( + ¶ms.format, + response::ERROR_MISSING_PARAM, + "missing required parameter: id", + ); + } + }; + + let (_prefix, playlist_id) = match parse_subsonic_id(&id_str) { + Some(v) => v, + None => { + return response::error( + ¶ms.format, + response::ERROR_NOT_FOUND, + "invalid playlist id", + ); + } + }; + + match queries::playlists::delete(state.db.conn(), playlist_id).await { + Ok(()) => response::ok(¶ms.format, serde_json::json!({})), + Err(e) => response::error( + ¶ms.format, + response::ERROR_GENERIC, + &format!("failed to delete playlist: {e}"), + ), + } +} diff --git a/src/routes/subsonic/response.rs b/src/routes/subsonic/response.rs new file mode 100644 index 0000000..bdd83f2 --- /dev/null +++ b/src/routes/subsonic/response.rs @@ -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("\n"); + xml.push_str(&format!( + "" + )); + + if let serde_json::Value::Object(map) = &body { + for (key, value) in map { + json_to_xml(&mut xml, key, value); + } + } + + xml.push_str(""); + + 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!("")); + } + } + serde_json::Value::String(s) => { + xml.push_str(&format!("<{tag}>{}", xml_escape(s))); + } + serde_json::Value::Number(n) => { + xml.push_str(&format!("<{tag}>{n}")); + } + serde_json::Value::Bool(b) => { + xml.push_str(&format!("<{tag}>{b}")); + } + serde_json::Value::Null => {} + } +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +/// 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, + pub is_dir: bool, + pub title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub album: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub artist: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub track: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub year: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub genre: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cover_art: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub suffix: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bit_rate: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub disc_number: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub album_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub artist_id: Option, + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub media_type: Option, +} + +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()), + } + } +} diff --git a/src/routes/subsonic/search.rs b/src/routes/subsonic/search.rs new file mode 100644 index 0000000..fe7b819 --- /dev/null +++ b/src/routes/subsonic/search.rs @@ -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) -> 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( + ¶ms.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 = 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 = 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 = tracks + .iter() + .take(song_count as usize) + .map(|track| serde_json::to_value(SubsonicChild::from_track(track)).unwrap_or_default()) + .collect(); + + response::ok( + ¶ms.format, + serde_json::json!({ + "searchResult3": { + "artist": artist_results, + "album": album_results, + "song": song_results, + } + }), + ) +} diff --git a/src/routes/subsonic/system.rs b/src/routes/subsonic/system.rs new file mode 100644 index 0000000..985f763 --- /dev/null +++ b/src/routes/subsonic/system.rs @@ -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) -> HttpResponse { + let (params, _user) = match authenticate(&req, &state).await { + Ok(v) => v, + Err(resp) => return resp, + }; + + response::ok(¶ms.format, serde_json::json!({})) +} + +/// GET /rest/getLicense[.view] +pub async fn get_license(req: HttpRequest, state: web::Data) -> HttpResponse { + let (params, _user) = match authenticate(&req, &state).await { + Ok(v) => v, + Err(resp) => return resp, + }; + + response::ok( + ¶ms.format, + serde_json::json!({ + "license": { + "valid": true, + "email": "shanty@localhost", + "licenseExpires": "2099-12-31T23:59:59", + } + }), + ) +} diff --git a/src/routes/subsonic/user.rs b/src/routes/subsonic/user.rs new file mode 100644 index 0000000..4893f96 --- /dev/null +++ b/src/routes/subsonic/user.rs @@ -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) -> 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( + ¶ms.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], + } + }), + ) +}