fleshed out subsonic more

This commit is contained in:
Connor Johnstone
2026-04-01 19:36:24 -04:00
parent 61225158f0
commit bd6656ff31
5 changed files with 481 additions and 2 deletions
+2 -1
View File
@@ -37,6 +37,7 @@ pub fn library_page() -> Html {
// Measure DOM heights after render and set spacer flex-grow values directly. // Measure DOM heights after render and set spacer flex-grow values directly.
// Must be called before any early returns to maintain consistent hook order. // 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); let artist_count = artists.as_ref().map(|a| a.len()).unwrap_or(0);
use_effect_with(artist_count, move |_| { use_effect_with(artist_count, move |_| {
@@ -126,7 +127,7 @@ pub fn library_page() -> Html {
.style() .style()
.set_property("height", &format!("{pct:.2}%")); .set_property("height", &format!("{pct:.2}%"));
// Hide text if cell is too short to fit it // 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"); let _ = el.style().set_property("font-size", "0");
} }
} }
+1 -1
View File
@@ -462,7 +462,7 @@ tr[draggable="true"]:active { cursor: grabbing; }
} }
.scroll-track-letter { .scroll-track-letter {
pointer-events: auto; pointer-events: auto;
font-size: 1.2rem; font-size: 18px;
font-weight: 600; font-weight: 600;
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
+39
View File
@@ -40,3 +40,42 @@ pub async fn scrobble(req: HttpRequest, state: web::Data<AppState>) -> HttpRespo
response::ok(&params.format, serde_json::json!({})) response::ok(&params.format, serde_json::json!({}))
} }
/// GET /rest/star[.view] — no-op stub (returns OK without persisting).
pub async fn star(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/unstar[.view] — no-op stub (returns OK without persisting).
pub async fn unstar(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/getStarred2[.view] — returns empty starred lists.
pub async fn get_starred2(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!({
"starred2": {
"artist": [],
"album": [],
"song": [],
}
}),
)
}
+408
View File
@@ -587,3 +587,411 @@ pub async fn get_music_directory(req: HttpRequest, state: web::Data<AppState>) -
_ => response::error(&params.format, response::ERROR_NOT_FOUND, "unknown id type"), _ => response::error(&params.format, response::ERROR_NOT_FOUND, "unknown id type"),
} }
} }
/// GET /rest/getRandomSongs[.view]
pub async fn get_random_songs(req: HttpRequest, state: web::Data<AppState>) -> 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<i32> = get_query_param(&req, "fromYear").and_then(|v| v.parse().ok());
let to_year: Option<i32> = 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<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!({
"randomSongs": {
"song": songs,
}
}),
)
}
/// GET /rest/getSongsByGenre[.view]
pub async fn get_songs_by_genre(req: HttpRequest, state: web::Data<AppState>) -> 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(
&params.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<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!({
"songsByGenre": {
"song": songs,
}
}),
)
}
/// GET /rest/getAlbumList2[.view]
pub async fn get_album_list2(req: HttpRequest, state: web::Data<AppState>) -> 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(
&params.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(
&params.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(
&params.format,
response::ERROR_MISSING_PARAM,
&format!("unknown list type: {list_type}"),
);
}
};
let mut album_list: Vec<serde_json::Value> = 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(
&params.format,
serde_json::json!({
"albumList2": {
"album": album_list,
}
}),
)
}
/// GET /rest/getTopSongs[.view]
pub async fn get_top_songs(req: HttpRequest, state: web::Data<AppState>) -> 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(
&params.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(
&params.format,
serde_json::json!({ "topSongs": { "song": [] } }),
);
}
};
// Parse the top_songs JSON stored on the artist model
let popular: Vec<shanty_data::PopularTrack> =
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<serde_json::Value> = 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(
&params.format,
serde_json::json!({
"topSongs": {
"song": songs,
}
}),
)
}
/// GET /rest/getArtistInfo2[.view]
pub async fn get_artist_info2(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 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<shanty_data::SimilarArtist> =
serde_json::from_str(&artist.similar_artists).unwrap_or_default();
let mut similar_list: Vec<serde_json::Value> = 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(
&params.format,
serde_json::json!({
"artistInfo2": info,
}),
)
}
+31
View File
@@ -48,6 +48,31 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.route("/getSong.view", 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", web::get().to(browsing::get_genres))
.route("/getGenres.view", 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 // Search
.route("/search3", web::get().to(search::search3)) .route("/search3", web::get().to(search::search3))
.route("/search3.view", 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 // Annotation
.route("/scrobble", web::get().to(annotation::scrobble)) .route("/scrobble", web::get().to(annotation::scrobble))
.route("/scrobble.view", 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 // User
.route("/getUser", web::get().to(user::get_user)) .route("/getUser", web::get().to(user::get_user))
.route("/getUser.view", web::get().to(user::get_user)), .route("/getUser.view", web::get().to(user::get_user)),