diff --git a/frontend/src/pages/library.rs b/frontend/src/pages/library.rs index 361ec53..fc906d4 100644 --- a/frontend/src/pages/library.rs +++ b/frontend/src/pages/library.rs @@ -37,6 +37,7 @@ pub fn library_page() -> Html { // Measure DOM heights after render and set spacer flex-grow values directly. // Must be called before any early returns to maintain consistent hook order. + const SCROLL_TRACK_FONT_PX: f64 = 18.0; { let artist_count = artists.as_ref().map(|a| a.len()).unwrap_or(0); use_effect_with(artist_count, move |_| { @@ -126,7 +127,7 @@ pub fn library_page() -> Html { .style() .set_property("height", &format!("{pct:.2}%")); // Hide text if cell is too short to fit it - if px < 18.0 { + if px < SCROLL_TRACK_FONT_PX { let _ = el.style().set_property("font-size", "0"); } } diff --git a/frontend/style.css b/frontend/style.css index ee78b62..8abca19 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -462,7 +462,7 @@ tr[draggable="true"]:active { cursor: grabbing; } } .scroll-track-letter { pointer-events: auto; - font-size: 1.2rem; + font-size: 18px; font-weight: 600; color: var(--text-secondary); cursor: pointer; diff --git a/src/routes/subsonic/annotation.rs b/src/routes/subsonic/annotation.rs index a59e209..430a7c2 100644 --- a/src/routes/subsonic/annotation.rs +++ b/src/routes/subsonic/annotation.rs @@ -40,3 +40,42 @@ pub async fn scrobble(req: HttpRequest, state: web::Data) -> HttpRespo response::ok(¶ms.format, serde_json::json!({})) } + +/// GET /rest/star[.view] — no-op stub (returns OK without persisting). +pub async fn star(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/unstar[.view] — no-op stub (returns OK without persisting). +pub async fn unstar(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/getStarred2[.view] — returns empty starred lists. +pub async fn get_starred2(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!({ + "starred2": { + "artist": [], + "album": [], + "song": [], + } + }), + ) +} diff --git a/src/routes/subsonic/browsing.rs b/src/routes/subsonic/browsing.rs index 59f61d2..5094158 100644 --- a/src/routes/subsonic/browsing.rs +++ b/src/routes/subsonic/browsing.rs @@ -587,3 +587,411 @@ pub async fn get_music_directory(req: HttpRequest, state: web::Data) - _ => response::error(¶ms.format, response::ERROR_NOT_FOUND, "unknown id type"), } } + +/// GET /rest/getRandomSongs[.view] +pub async fn get_random_songs(req: HttpRequest, state: web::Data) -> HttpResponse { + let (params, _user) = match authenticate(&req, &state).await { + Ok(v) => v, + Err(resp) => return resp, + }; + + let size: u64 = get_query_param(&req, "size") + .and_then(|v| v.parse().ok()) + .unwrap_or(10) + .min(500); + let genre = get_query_param(&req, "genre"); + let from_year: Option = get_query_param(&req, "fromYear").and_then(|v| v.parse().ok()); + let to_year: Option = get_query_param(&req, "toYear").and_then(|v| v.parse().ok()); + + let tracks = queries::tracks::get_random_filtered( + state.db.conn(), + size, + genre.as_deref(), + from_year, + to_year, + ) + .await + .unwrap_or_default(); + + let songs: Vec = tracks + .iter() + .map(|t| serde_json::to_value(SubsonicChild::from_track(t)).unwrap_or_default()) + .collect(); + + response::ok( + ¶ms.format, + serde_json::json!({ + "randomSongs": { + "song": songs, + } + }), + ) +} + +/// GET /rest/getSongsByGenre[.view] +pub async fn get_songs_by_genre(req: HttpRequest, state: web::Data) -> HttpResponse { + let (params, _user) = match authenticate(&req, &state).await { + Ok(v) => v, + Err(resp) => return resp, + }; + + let genre = match get_query_param(&req, "genre") { + Some(g) => g, + None => { + return response::error( + ¶ms.format, + response::ERROR_MISSING_PARAM, + "missing required parameter: genre", + ); + } + }; + + let count: u64 = get_query_param(&req, "count") + .and_then(|v| v.parse().ok()) + .unwrap_or(10) + .min(500); + let offset: u64 = get_query_param(&req, "offset") + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + + let tracks = queries::tracks::get_by_genre_paginated(state.db.conn(), &genre, count, offset) + .await + .unwrap_or_default(); + + let songs: Vec = tracks + .iter() + .map(|t| serde_json::to_value(SubsonicChild::from_track(t)).unwrap_or_default()) + .collect(); + + response::ok( + ¶ms.format, + serde_json::json!({ + "songsByGenre": { + "song": songs, + } + }), + ) +} + +/// GET /rest/getAlbumList2[.view] +pub async fn get_album_list2(req: HttpRequest, state: web::Data) -> HttpResponse { + let (params, _user) = match authenticate(&req, &state).await { + Ok(v) => v, + Err(resp) => return resp, + }; + + let list_type = match get_query_param(&req, "type") { + Some(t) => t, + None => { + return response::error( + ¶ms.format, + response::ERROR_MISSING_PARAM, + "missing required parameter: type", + ); + } + }; + + let size: u64 = get_query_param(&req, "size") + .and_then(|v| v.parse().ok()) + .unwrap_or(10) + .min(500); + let offset: u64 = get_query_param(&req, "offset") + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + + let albums = match list_type.as_str() { + "alphabeticalByName" => queries::albums::list(state.db.conn(), size, offset) + .await + .unwrap_or_default(), + "alphabeticalByArtist" => { + queries::albums::list_alphabetical_by_artist(state.db.conn(), size, offset) + .await + .unwrap_or_default() + } + "newest" | "frequent" | "highest" => { + queries::albums::list_newest(state.db.conn(), size, offset) + .await + .unwrap_or_default() + } + "recent" => queries::albums::list_recent(state.db.conn(), size, offset) + .await + .unwrap_or_default(), + "random" => queries::albums::get_random(state.db.conn(), size) + .await + .unwrap_or_default(), + "starred" => vec![], + "byYear" => { + let from: i32 = get_query_param(&req, "fromYear") + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let to: i32 = get_query_param(&req, "toYear") + .and_then(|v| v.parse().ok()) + .unwrap_or(9999); + queries::albums::list_by_year_range(state.db.conn(), from, to, size, offset) + .await + .unwrap_or_default() + } + "byGenre" => { + let genre = get_query_param(&req, "genre").unwrap_or_default(); + if genre.is_empty() { + return response::error( + ¶ms.format, + response::ERROR_MISSING_PARAM, + "missing required parameter: genre", + ); + } + queries::albums::list_by_genre(state.db.conn(), &genre, size, offset) + .await + .unwrap_or_default() + } + _ => { + return response::error( + ¶ms.format, + response::ERROR_MISSING_PARAM, + &format!("unknown list type: {list_type}"), + ); + } + }; + + let mut album_list: Vec = Vec::new(); + for album in &albums { + let tracks = queries::tracks::get_by_album(state.db.conn(), album.id) + .await + .unwrap_or_default(); + let song_count = tracks.len(); + let duration: i32 = tracks + .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": album.album_artist, + "artistId": album.artist_id.map(|id| format!("ar-{id}")), + "coverArt": format!("al-{}", album.id), + "songCount": song_count, + "duration": duration, + "created": "2024-01-01T00:00:00", + }); + 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!({ + "albumList2": { + "album": album_list, + } + }), + ) +} + +/// GET /rest/getTopSongs[.view] +pub async fn get_top_songs(req: HttpRequest, state: web::Data) -> HttpResponse { + let (params, _user) = match authenticate(&req, &state).await { + Ok(v) => v, + Err(resp) => return resp, + }; + + let artist_name = match get_query_param(&req, "artist") { + Some(a) => a, + None => { + return response::error( + ¶ms.format, + response::ERROR_MISSING_PARAM, + "missing required parameter: artist", + ); + } + }; + + let count: usize = get_query_param(&req, "count") + .and_then(|v| v.parse().ok()) + .unwrap_or(50); + + // Find the artist in our DB + let artist = match queries::artists::find_by_name(state.db.conn(), &artist_name).await { + Ok(Some(a)) => a, + _ => { + return response::ok( + ¶ms.format, + serde_json::json!({ "topSongs": { "song": [] } }), + ); + } + }; + + // Parse the top_songs JSON stored on the artist model + let popular: Vec = + serde_json::from_str(&artist.top_songs).unwrap_or_default(); + + // Match top songs to local tracks + let artist_tracks = queries::tracks::get_by_artist(state.db.conn(), artist.id) + .await + .unwrap_or_default(); + + let mut songs: Vec = Vec::new(); + for pt in popular.iter().take(count) { + // Try MBID match first, then case-insensitive title match + let matched = pt + .mbid + .as_ref() + .and_then(|mbid| { + artist_tracks + .iter() + .find(|t| t.musicbrainz_id.as_deref() == Some(mbid)) + }) + .or_else(|| { + let name_lower = pt.name.to_lowercase(); + artist_tracks.iter().find(|t| { + t.title + .as_deref() + .is_some_and(|tt| tt.to_lowercase() == name_lower) + }) + }); + + if let Some(track) = matched { + songs.push(serde_json::to_value(SubsonicChild::from_track(track)).unwrap_or_default()); + } + } + + response::ok( + ¶ms.format, + serde_json::json!({ + "topSongs": { + "song": songs, + } + }), + ) +} + +/// GET /rest/getArtistInfo2[.view] +pub async fn get_artist_info2(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 count: usize = get_query_param(&req, "count") + .and_then(|v| v.parse().ok()) + .unwrap_or(20); + let include_not_present = + get_query_param(&req, "includeNotPresent").is_some_and(|v| v == "true"); + + let mbid = artist.musicbrainz_id.as_deref().unwrap_or(""); + + // Pull cached image and bio + let config = state.config.read().await; + let image_source = config.metadata.artist_image_source.clone(); + let bio_source = config.metadata.artist_bio_source.clone(); + drop(config); + + let image_url = if !mbid.is_empty() { + let key = format!("artist_image:{image_source}:{mbid}"); + queries::cache::get(state.db.conn(), &key) + .await + .ok() + .flatten() + .filter(|s| !s.is_empty()) + } else { + None + }; + + let biography = if !mbid.is_empty() { + let key = format!("artist_bio:{bio_source}:{mbid}"); + queries::cache::get(state.db.conn(), &key) + .await + .ok() + .flatten() + .filter(|s| !s.is_empty()) + } else { + None + }; + + // Build similar artists list from the model's JSON field + let similar: Vec = + serde_json::from_str(&artist.similar_artists).unwrap_or_default(); + + let mut similar_list: Vec = Vec::new(); + for sa in similar.iter().take(count) { + // Check if similar artist exists locally + let local = queries::artists::find_by_name(state.db.conn(), &sa.name) + .await + .ok() + .flatten(); + + if !include_not_present && local.is_none() { + continue; + } + + let mut entry = serde_json::json!({ + "name": sa.name, + }); + if let Some(ref local_artist) = local { + entry["id"] = serde_json::json!(format!("ar-{}", local_artist.id)); + let album_count = queries::albums::get_by_artist(state.db.conn(), local_artist.id) + .await + .map(|a| a.len()) + .unwrap_or(0); + entry["albumCount"] = serde_json::json!(album_count); + } + similar_list.push(entry); + } + + let mut info = serde_json::json!({ + "biography": biography.unwrap_or_default(), + "musicBrainzId": mbid, + "similarArtist": similar_list, + }); + if let Some(ref url) = image_url { + info["smallImageUrl"] = serde_json::json!(url); + info["mediumImageUrl"] = serde_json::json!(url); + info["largeImageUrl"] = serde_json::json!(url); + } + + response::ok( + ¶ms.format, + serde_json::json!({ + "artistInfo2": info, + }), + ) +} diff --git a/src/routes/subsonic/mod.rs b/src/routes/subsonic/mod.rs index 06ab0ed..5e8ac28 100644 --- a/src/routes/subsonic/mod.rs +++ b/src/routes/subsonic/mod.rs @@ -48,6 +48,31 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .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)) + .route("/getRandomSongs", web::get().to(browsing::get_random_songs)) + .route( + "/getRandomSongs.view", + web::get().to(browsing::get_random_songs), + ) + .route( + "/getSongsByGenre", + web::get().to(browsing::get_songs_by_genre), + ) + .route( + "/getSongsByGenre.view", + web::get().to(browsing::get_songs_by_genre), + ) + .route("/getAlbumList2", web::get().to(browsing::get_album_list2)) + .route( + "/getAlbumList2.view", + web::get().to(browsing::get_album_list2), + ) + .route("/getTopSongs", web::get().to(browsing::get_top_songs)) + .route("/getTopSongs.view", web::get().to(browsing::get_top_songs)) + .route("/getArtistInfo2", web::get().to(browsing::get_artist_info2)) + .route( + "/getArtistInfo2.view", + web::get().to(browsing::get_artist_info2), + ) // Search .route("/search3", web::get().to(search::search3)) .route("/search3.view", web::get().to(search::search3)) @@ -79,6 +104,12 @@ pub fn configure(cfg: &mut web::ServiceConfig) { // Annotation .route("/scrobble", web::get().to(annotation::scrobble)) .route("/scrobble.view", web::get().to(annotation::scrobble)) + .route("/star", web::get().to(annotation::star)) + .route("/star.view", web::get().to(annotation::star)) + .route("/unstar", web::get().to(annotation::unstar)) + .route("/unstar.view", web::get().to(annotation::unstar)) + .route("/getStarred2", web::get().to(annotation::get_starred2)) + .route("/getStarred2.view", web::get().to(annotation::get_starred2)) // User .route("/getUser", web::get().to(user::get_user)) .route("/getUser.view", web::get().to(user::get_user)),