Compare commits

...

1 Commits

Author SHA1 Message Date
Connor Johnstone
a268ec4e56 Lots of fixes for artist detail page and track management 2026-03-18 13:44:49 -04:00
12 changed files with 889 additions and 80 deletions

View File

@@ -20,6 +20,7 @@ actix-cors = "0.7"
actix-files = "0.6" actix-files = "0.6"
thiserror = "2" thiserror = "2"
anyhow = "1" anyhow = "1"
reqwest = { version = "0.12", features = ["json"] }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"

View File

@@ -82,7 +82,7 @@ pub async fn search_track(query: &str, artist: Option<&str>, limit: u32) -> Resu
} }
// --- Library --- // --- Library ---
pub async fn list_artists(limit: u64, offset: u64) -> Result<Vec<Artist>, ApiError> { pub async fn list_artists(limit: u64, offset: u64) -> Result<Vec<ArtistListItem>, ApiError> {
get_json(&format!("{BASE}/artists?limit={limit}&offset={offset}")).await get_json(&format!("{BASE}/artists?limit={limit}&offset={offset}")).await
} }
@@ -90,8 +90,16 @@ pub async fn get_artist(id: i32) -> Result<ArtistDetail, ApiError> {
get_json(&format!("{BASE}/artists/{id}")).await get_json(&format!("{BASE}/artists/{id}")).await
} }
pub async fn get_album(id: i32) -> Result<AlbumDetail, ApiError> { pub async fn get_artist_full(id: &str) -> Result<FullArtistDetail, ApiError> {
get_json(&format!("{BASE}/albums/{id}")).await get_json(&format!("{BASE}/artists/{id}/full")).await
}
pub async fn get_artist_full_quick(id: &str) -> Result<FullArtistDetail, ApiError> {
get_json(&format!("{BASE}/artists/{id}/full?quick=true")).await
}
pub async fn get_album(mbid: &str) -> Result<MbAlbumDetail, ApiError> {
get_json(&format!("{BASE}/albums/{mbid}")).await
} }
pub async fn list_tracks(limit: u64, offset: u64) -> Result<Vec<Track>, ApiError> { pub async fn list_tracks(limit: u64, offset: u64) -> Result<Vec<Track>, ApiError> {

View File

@@ -1,2 +1,3 @@
pub mod navbar; pub mod navbar;
pub mod status_badge; pub mod status_badge;
pub mod watch_indicator;

View File

@@ -0,0 +1,25 @@
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct Props {
pub status: String,
}
#[function_component(WatchIndicator)]
pub fn watch_indicator(props: &Props) -> Html {
let (icon, color, title) = match props.status.as_str() {
"owned" => ("", "var(--success)", "Owned"),
"partial" => ("", "var(--warning)", "Partial"),
"wanted" => ("", "var(--accent)", "Wanted"),
"downloading" => ("", "var(--accent)", "Downloading"),
"fully_watched" => ("", "var(--accent)", "Fully watched"),
"unwatched" => ("", "var(--text-muted)", "Not watched"),
_ => ("", "var(--text-muted)", "Unknown"),
};
html! {
<span style={format!("color: {color}; font-size: 1.1em; cursor: help;")} title={title}>
{ icon }
</span>
}
}

View File

@@ -1,26 +1,27 @@
use yew::prelude::*; use yew::prelude::*;
use crate::api; use crate::api;
use crate::types::AlbumDetail; use crate::components::status_badge::StatusBadge;
use crate::types::MbAlbumDetail;
#[derive(Properties, PartialEq)] #[derive(Properties, PartialEq)]
pub struct Props { pub struct Props {
pub id: i32, pub mbid: String,
} }
#[function_component(AlbumPage)] #[function_component(AlbumPage)]
pub fn album_page(props: &Props) -> Html { pub fn album_page(props: &Props) -> Html {
let detail = use_state(|| None::<AlbumDetail>); let detail = use_state(|| None::<MbAlbumDetail>);
let error = use_state(|| None::<String>); let error = use_state(|| None::<String>);
let id = props.id; let mbid = props.mbid.clone();
{ {
let detail = detail.clone(); let detail = detail.clone();
let error = error.clone(); let error = error.clone();
use_effect_with(id, move |id| { let mbid = mbid.clone();
let id = *id; use_effect_with(mbid.clone(), move |_| {
wasm_bindgen_futures::spawn_local(async move { wasm_bindgen_futures::spawn_local(async move {
match api::get_album(id).await { match api::get_album(&mbid).await {
Ok(d) => detail.set(Some(d)), Ok(d) => detail.set(Some(d)),
Err(e) => error.set(Some(e.0)), Err(e) => error.set(Some(e.0)),
} }
@@ -33,17 +34,22 @@ pub fn album_page(props: &Props) -> Html {
} }
let Some(ref d) = *detail else { let Some(ref d) = *detail else {
return html! { <p class="loading">{ "Loading..." }</p> }; return html! { <p class="loading">{ "Loading album from MusicBrainz..." }</p> };
};
// Format duration from ms
let fmt_duration = |ms: u64| -> String {
let secs = ms / 1000;
let mins = secs / 60;
let remaining = secs % 60;
format!("{mins}:{remaining:02}")
}; };
html! { html! {
<div> <div>
<div class="page-header"> <div class="page-header">
<h2>{ &d.album.name }</h2> <h2>{ format!("Album") }</h2>
<p class="text-muted">{ format!("by {}", d.album.album_artist) }</p> <p class="text-muted text-sm">{ format!("MBID: {}", d.mbid) }</p>
if let Some(year) = d.album.year {
<p class="text-muted text-sm">{ format!("Year: {year}") }</p>
}
</div> </div>
<h3 class="mb-1">{ format!("Tracks ({})", d.tracks.len()) }</h3> <h3 class="mb-1">{ format!("Tracks ({})", d.tracks.len()) }</h3>
@@ -52,16 +58,33 @@ pub fn album_page(props: &Props) -> Html {
} else { } else {
<table> <table>
<thead> <thead>
<tr><th>{ "#" }</th><th>{ "Title" }</th><th>{ "Artist" }</th><th>{ "Codec" }</th></tr> <tr>
<th>{ "#" }</th>
<th>{ "Title" }</th>
<th>{ "Duration" }</th>
<th>{ "Status" }</th>
</tr>
</thead> </thead>
<tbody> <tbody>
{ for d.tracks.iter().map(|t| html! { { for d.tracks.iter().map(|t| {
<tr> let duration = t.duration_ms
<td>{ t.track_number.map(|n| n.to_string()).unwrap_or_default() }</td> .map(|ms| fmt_duration(ms))
<td>{ t.title.as_deref().unwrap_or("Unknown") }</td> .unwrap_or_default();
<td>{ t.artist.as_deref().unwrap_or("") }</td>
<td class="text-muted text-sm">{ t.codec.as_deref().unwrap_or("") }</td> html! {
</tr> <tr>
<td>{ t.track_number.map(|n| n.to_string()).unwrap_or_default() }</td>
<td>{ &t.title }</td>
<td class="text-muted">{ duration }</td>
<td>
if let Some(ref status) = t.status {
<StatusBadge status={status.clone()} />
} else {
<span class="text-muted text-sm">{ "" }</span>
}
</td>
</tr>
}
})} })}
</tbody> </tbody>
</table> </table>

View File

@@ -2,28 +2,59 @@ use yew::prelude::*;
use yew_router::prelude::*; use yew_router::prelude::*;
use crate::api; use crate::api;
use crate::components::watch_indicator::WatchIndicator;
use crate::pages::Route; use crate::pages::Route;
use crate::types::ArtistDetail; use crate::types::FullArtistDetail;
#[derive(Properties, PartialEq)] #[derive(Properties, PartialEq)]
pub struct Props { pub struct Props {
pub id: i32, pub id: String,
} }
#[function_component(ArtistPage)] #[function_component(ArtistPage)]
pub fn artist_page(props: &Props) -> Html { pub fn artist_page(props: &Props) -> Html {
let detail = use_state(|| None::<ArtistDetail>); let detail = use_state(|| None::<FullArtistDetail>);
let error = use_state(|| None::<String>); let error = use_state(|| None::<String>);
let id = props.id; let message = use_state(|| None::<String>);
let id = props.id.clone();
// Full fetch (with track counts) — used for refresh after actions
let fetch = {
let detail = detail.clone();
let error = error.clone();
Callback::from(move |id: String| {
let detail = detail.clone();
let error = error.clone();
wasm_bindgen_futures::spawn_local(async move {
match api::get_artist_full(&id).await {
Ok(d) => detail.set(Some(d)),
Err(e) => error.set(Some(e.0)),
}
});
})
};
// Two-phase load: quick first (release groups only), then enriched (with track counts)
{ {
let detail = detail.clone(); let detail = detail.clone();
let error = error.clone(); let error = error.clone();
use_effect_with(id, move |id| { let id = id.clone();
let id = *id; use_effect_with(id.clone(), move |_| {
wasm_bindgen_futures::spawn_local(async move { wasm_bindgen_futures::spawn_local(async move {
match api::get_artist(id).await { // Phase 1: quick load (instant for browsing artists)
Ok(d) => detail.set(Some(d)), match api::get_artist_full_quick(&id).await {
Ok(d) => {
let needs_enrich = !d.enriched;
detail.set(Some(d));
// Phase 2: if not enriched, fetch full data in background
if needs_enrich {
match api::get_artist_full(&id).await {
Ok(full) => detail.set(Some(full)),
Err(_) => {} // quick data is still showing, don't overwrite with error
}
}
}
Err(e) => error.set(Some(e.0)), Err(e) => error.set(Some(e.0)),
} }
}); });
@@ -35,41 +66,146 @@ pub fn artist_page(props: &Props) -> Html {
} }
let Some(ref d) = *detail else { let Some(ref d) = *detail else {
return html! { <p class="loading">{ "Loading..." }</p> }; return html! { <p class="loading">{ "Loading discography from MusicBrainz..." }</p> };
}; };
html! { html! {
<div> <div>
<div class="page-header"> <div class="page-header">
<h2>{ &d.artist.name }</h2> <h2>{ &d.artist.name }</h2>
if let Some(ref mbid) = d.artist.musicbrainz_id { if d.enriched {
<p class="text-muted text-sm">{ format!("MBID: {mbid}") }</p> <p class="text-sm">
<span style="color: var(--accent);">
{ format!("{}/{} watched", d.total_watched_tracks, d.total_available_tracks) }
</span>
{ " · " }
<span style="color: var(--success);">
{ format!("{} owned", d.total_owned_tracks) }
</span>
</p>
} else {
<p class="text-sm text-muted loading">{ "Loading track counts..." }</p>
} }
</div> </div>
<h3 class="mb-1">{ format!("Albums ({})", d.albums.len()) }</h3> if let Some(ref msg) = *message {
if d.albums.is_empty() { <div class="card" style="border-color: var(--success);">
<p class="text-muted">{ "No albums found." }</p> <p>{ msg }</p>
} else { </div>
<table>
<thead>
<tr><th>{ "Name" }</th><th>{ "Year" }</th><th>{ "Genre" }</th></tr>
</thead>
<tbody>
{ for d.albums.iter().map(|a| html! {
<tr>
<td>
<Link<Route> to={Route::Album { id: a.id }}>
{ &a.name }
</Link<Route>>
</td>
<td>{ a.year.map(|y| y.to_string()).unwrap_or_default() }</td>
<td>{ a.genre.as_deref().unwrap_or("") }</td>
</tr>
})}
</tbody>
</table>
} }
if d.albums.is_empty() {
<p class="text-muted">{ "No releases found on MusicBrainz." }</p>
}
// Group albums by type
{ for ["Album", "EP", "Single"].iter().map(|release_type| {
let type_albums: Vec<_> = d.albums.iter()
.filter(|a| a.release_type.as_deref().unwrap_or("Album") == *release_type)
.collect();
if type_albums.is_empty() {
return html! {};
}
html! {
<div class="mb-2">
<h3 class="mb-1">{ format!("{}s ({})", release_type, type_albums.len()) }</h3>
<table>
<thead>
<tr>
<th>{ "Title" }</th>
<th>{ "Date" }</th>
<th>{ "Owned" }</th>
<th>{ "Watched" }</th>
<th></th>
</tr>
</thead>
<tbody>
{ for type_albums.iter().map(|album| {
let is_unwatched = album.status == "unwatched";
let row_style = if is_unwatched { "opacity: 0.6;" } else { "" };
let album_link = html! {
<Link<Route> to={Route::Album { mbid: album.mbid.clone() }}>
{ &album.title }
</Link<Route>>
};
let tc = album.track_count;
// Watch button for unwatched albums
let watch_btn = if is_unwatched {
let artist_name = d.artist.name.clone();
let album_title = album.title.clone();
let album_mbid = album.mbid.clone();
let message = message.clone();
let error = error.clone();
let fetch = fetch.clone();
let artist_id = id.clone();
html! {
<button class="btn btn-sm btn-primary"
onclick={Callback::from(move |_: MouseEvent| {
let artist_name = artist_name.clone();
let album_title = album_title.clone();
let album_mbid = album_mbid.clone();
let message = message.clone();
let error = error.clone();
let fetch = fetch.clone();
let artist_id = artist_id.clone();
wasm_bindgen_futures::spawn_local(async move {
match api::add_album(&artist_name, &album_title, Some(&album_mbid)).await {
Ok(s) => {
message.set(Some(format!(
"Added {} tracks from '{}'",
s.tracks_added, album_title
)));
fetch.emit(artist_id);
}
Err(e) => error.set(Some(e.0)),
}
});
})}>
{ "Watch" }
</button>
}
} else {
html! {}
};
html! {
<tr style={row_style}>
<td>{ album_link }</td>
<td class="text-muted">{ album.date.as_deref().unwrap_or("") }</td>
<td>
if tc > 0 {
<span class="text-sm" style={
if album.owned_tracks >= tc { "color: var(--success);" }
else if album.owned_tracks > 0 { "color: var(--warning);" }
else { "color: var(--text-muted);" }
}>
{ format!("{}/{}", album.owned_tracks, tc) }
</span>
}
</td>
<td>
if tc > 0 {
<span class="text-sm" style={
if album.watched_tracks >= tc { "color: var(--accent);" }
else if album.watched_tracks > 0 { "color: var(--accent);" }
else { "color: var(--text-muted);" }
}>
{ format!("{}/{}", album.watched_tracks, tc) }
</span>
}
</td>
<td>{ watch_btn }</td>
</tr>
}
})}
</tbody>
</table>
</div>
}
})}
</div> </div>
} }
} }

View File

@@ -2,12 +2,13 @@ use yew::prelude::*;
use yew_router::prelude::*; use yew_router::prelude::*;
use crate::api; use crate::api;
use crate::components::watch_indicator::WatchIndicator;
use crate::pages::Route; use crate::pages::Route;
use crate::types::Artist; use crate::types::ArtistListItem;
#[function_component(LibraryPage)] #[function_component(LibraryPage)]
pub fn library_page() -> Html { pub fn library_page() -> Html {
let artists = use_state(|| None::<Vec<Artist>>); let artists = use_state(|| None::<Vec<ArtistListItem>>);
let error = use_state(|| None::<String>); let error = use_state(|| None::<String>);
{ {
@@ -15,7 +16,7 @@ pub fn library_page() -> Html {
let error = error.clone(); let error = error.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::list_artists(100, 0).await { match api::list_artists(200, 0).await {
Ok(a) => artists.set(Some(a)), Ok(a) => artists.set(Some(a)),
Err(e) => error.set(Some(e.0)), Err(e) => error.set(Some(e.0)),
} }
@@ -43,17 +44,30 @@ pub fn library_page() -> Html {
} else { } else {
<table> <table>
<thead> <thead>
<tr><th>{ "Name" }</th><th>{ "MusicBrainz ID" }</th></tr> <tr>
<th>{ "Name" }</th>
<th>{ "Status" }</th>
</tr>
</thead> </thead>
<tbody> <tbody>
{ for artists.iter().map(|a| html! { { for artists.iter().map(|a| html! {
<tr> <tr>
<td> <td>
<Link<Route> to={Route::Artist { id: a.id }}> <Link<Route> to={Route::Artist { id: a.id.to_string() }}>
{ &a.name } { &a.name }
</Link<Route>> </Link<Route>>
</td> </td>
<td class="text-muted text-sm">{ a.musicbrainz_id.as_deref().unwrap_or("") }</td> <td>
if a.total_items > 0 {
<span class="text-sm" style={
if a.total_owned == a.total_items { "color: var(--success);" }
else if a.total_owned > 0 { "color: var(--warning);" }
else { "color: var(--accent);" }
}>
{ format!("{}/{} owned", a.total_owned, a.total_items) }
</span>
}
</td>
</tr> </tr>
})} })}
</tbody> </tbody>

View File

@@ -18,9 +18,9 @@ pub enum Route {
#[at("/library")] #[at("/library")]
Library, Library,
#[at("/artists/:id")] #[at("/artists/:id")]
Artist { id: i32 }, Artist { id: String },
#[at("/albums/:id")] #[at("/albums/:mbid")]
Album { id: i32 }, Album { mbid: String },
#[at("/downloads")] #[at("/downloads")]
Downloads, Downloads,
#[at("/settings")] #[at("/settings")]
@@ -36,7 +36,7 @@ pub fn switch(route: Route) -> Html {
Route::Search => html! { <search::SearchPage /> }, Route::Search => html! { <search::SearchPage /> },
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 { id } => html! { <album::AlbumPage {id} /> }, Route::Album { mbid } => html! { <album::AlbumPage {mbid} /> },
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

@@ -9,6 +9,46 @@ pub struct Artist {
pub musicbrainz_id: Option<String>, pub musicbrainz_id: Option<String>,
} }
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct ArtistListItem {
pub id: i32,
pub name: String,
pub musicbrainz_id: Option<String>,
pub total_watched: usize,
pub total_owned: usize,
pub total_items: usize,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct FullAlbumInfo {
pub mbid: String,
pub title: String,
pub release_type: Option<String>,
pub date: Option<String>,
pub track_count: u32,
pub local_album_id: Option<i32>,
pub watched_tracks: u32,
pub downloaded_tracks: u32,
pub owned_tracks: u32,
pub total_local_tracks: u32,
pub status: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct FullArtistDetail {
pub artist: Artist,
pub albums: Vec<FullAlbumInfo>,
pub artist_status: String,
#[serde(default)]
pub total_available_tracks: u32,
#[serde(default)]
pub total_watched_tracks: u32,
#[serde(default)]
pub total_owned_tracks: u32,
#[serde(default)]
pub enriched: bool,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct Album { pub struct Album {
pub id: i32, pub id: i32,
@@ -44,6 +84,23 @@ pub struct AlbumDetail {
pub tracks: Vec<Track>, pub tracks: Vec<Track>,
} }
/// Album detail from MusicBrainz (the primary album view).
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct MbAlbumDetail {
pub mbid: String,
pub tracks: Vec<MbAlbumTrack>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct MbAlbumTrack {
pub recording_mbid: String,
pub title: String,
pub track_number: Option<i32>,
pub disc_number: Option<i32>,
pub duration_ms: Option<u64>,
pub status: Option<String>,
}
// --- Search results --- // --- Search results ---
#[derive(Debug, Clone, PartialEq, Deserialize)] #[derive(Debug, Clone, PartialEq, Deserialize)]

View File

@@ -16,6 +16,11 @@ pub struct AppConfig {
#[serde(default = "default_organization_format")] #[serde(default = "default_organization_format")]
pub organization_format: String, pub organization_format: String,
/// Which secondary release group types to include. Empty = studio releases only.
/// Options: "Compilation", "Live", "Soundtrack", "Remix", "DJ-mix", "Demo", etc.
#[serde(default)]
pub allowed_secondary_types: Vec<String>,
#[serde(default)] #[serde(default)]
pub web: WebConfig, pub web: WebConfig,
@@ -66,6 +71,7 @@ impl Default for AppConfig {
database_url: default_database_url(), database_url: default_database_url(),
download_path: default_download_path(), download_path: default_download_path(),
organization_format: default_organization_format(), organization_format: default_organization_format(),
allowed_secondary_types: vec![], // empty = studio only
web: WebConfig::default(), web: WebConfig::default(),
tagging: TaggingConfig::default(), tagging: TaggingConfig::default(),
download: DownloadConfig::default(), download: DownloadConfig::default(),

View File

@@ -1,7 +1,9 @@
use actix_web::{web, HttpResponse}; use actix_web::{web, HttpResponse};
use serde::Deserialize; use serde::{Deserialize, Serialize};
use shanty_db::entities::wanted_item::WantedStatus;
use shanty_db::queries; use shanty_db::queries;
use shanty_tag::provider::MetadataProvider;
use crate::error::ApiError; use crate::error::ApiError;
use crate::state::AppState; use crate::state::AppState;
@@ -22,6 +24,16 @@ pub struct AddAlbumRequest {
mbid: Option<String>, mbid: Option<String>,
} }
#[derive(Serialize)]
struct AlbumTrackInfo {
recording_mbid: String,
title: String,
track_number: Option<i32>,
disc_number: Option<i32>,
duration_ms: Option<u64>,
status: Option<String>,
}
pub fn configure(cfg: &mut web::ServiceConfig) { pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service( cfg.service(
web::resource("/albums") web::resource("/albums")
@@ -29,7 +41,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.route(web::post().to(add_album)), .route(web::post().to(add_album)),
) )
.service( .service(
web::resource("/albums/{id}") web::resource("/albums/{mbid}")
.route(web::get().to(get_album)), .route(web::get().to(get_album)),
); );
} }
@@ -42,19 +54,107 @@ async fn list_albums(
Ok(HttpResponse::Ok().json(albums)) Ok(HttpResponse::Ok().json(albums))
} }
/// Get album by MBID. Accepts either a release MBID or a release-group MBID.
/// Tries as a release first; if that fails (404), treats it as a release-group
/// and browses for its first release.
async fn get_album( async fn get_album(
state: web::Data<AppState>, state: web::Data<AppState>,
path: web::Path<i32>, path: web::Path<String>,
) -> Result<HttpResponse, ApiError> { ) -> Result<HttpResponse, ApiError> {
let id = path.into_inner(); let mbid = path.into_inner();
let album = queries::albums::get_by_id(state.db.conn(), id).await?;
let tracks = queries::tracks::get_by_album(state.db.conn(), id).await?; // Try fetching as a release first
let mb_tracks = match state.mb_client.get_release_tracks(&mbid).await {
Ok(tracks) => tracks,
Err(_) => {
// Probably a release-group MBID. Browse releases for this group.
let release_mbid = resolve_release_from_group(&state, &mbid).await?;
state.mb_client
.get_release_tracks(&release_mbid)
.await
.map_err(|e| ApiError::Internal(format!("MusicBrainz error: {e}")))?
}
};
// Get all wanted items to check local status
let all_wanted = queries::wanted::list(state.db.conn(), None).await?;
let tracks: Vec<AlbumTrackInfo> = mb_tracks
.into_iter()
.map(|t| {
let status = all_wanted
.iter()
.find(|w| w.musicbrainz_id.as_deref() == Some(&t.recording_mbid))
.map(|w| match w.status {
WantedStatus::Owned => "owned",
WantedStatus::Downloaded => "downloaded",
WantedStatus::Wanted => "wanted",
WantedStatus::Available => "available",
})
.map(String::from);
AlbumTrackInfo {
recording_mbid: t.recording_mbid,
title: t.title,
track_number: t.track_number,
disc_number: t.disc_number,
duration_ms: t.duration_ms,
status,
}
})
.collect();
Ok(HttpResponse::Ok().json(serde_json::json!({ Ok(HttpResponse::Ok().json(serde_json::json!({
"album": album, "mbid": mbid,
"tracks": tracks, "tracks": tracks,
}))) })))
} }
/// Given a release-group MBID, find the first release MBID via the MB API.
async fn resolve_release_from_group(
state: &web::Data<AppState>,
release_group_mbid: &str,
) -> Result<String, ApiError> {
// Use the MB client's get_json (it's private, so we go through search)
// The approach: search for releases by this release group
// MB API: /ws/2/release?release-group={mbid}&fmt=json&limit=1
// 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 MetadataProvider 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
.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 found for release group {release_group_mbid}")))
}
async fn add_album( async fn add_album(
state: web::Data<AppState>, state: web::Data<AppState>,
body: web::Json<AddAlbumRequest>, body: web::Json<AddAlbumRequest>,

View File

@@ -1,7 +1,10 @@
use actix_web::{web, HttpResponse}; use actix_web::{web, HttpResponse};
use serde::Deserialize; use serde::{Deserialize, Serialize};
use shanty_db::entities::wanted_item::WantedStatus;
use shanty_db::queries; use shanty_db::queries;
use shanty_search::SearchProvider;
use shanty_tag::provider::MetadataProvider;
use crate::error::ApiError; use crate::error::ApiError;
use crate::state::AppState; use crate::state::AppState;
@@ -21,12 +24,53 @@ pub struct AddArtistRequest {
mbid: Option<String>, mbid: Option<String>,
} }
#[derive(Serialize)]
struct ArtistListItem {
id: i32,
name: String,
musicbrainz_id: Option<String>,
total_watched: usize,
total_owned: usize,
total_items: usize,
}
#[derive(Serialize, Deserialize, Clone)]
struct CachedAlbumTracks {
release_mbid: String,
tracks: Vec<CachedTrack>,
}
#[derive(Serialize, Deserialize, Clone)]
struct CachedTrack {
recording_mbid: String,
title: String,
}
#[derive(Serialize)]
struct FullAlbumInfo {
mbid: String,
title: String,
release_type: Option<String>,
date: Option<String>,
track_count: u32,
local_album_id: Option<i32>,
watched_tracks: u32,
owned_tracks: u32,
downloaded_tracks: u32,
total_local_tracks: u32,
status: String,
}
pub fn configure(cfg: &mut web::ServiceConfig) { pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service( cfg.service(
web::resource("/artists") web::resource("/artists")
.route(web::get().to(list_artists)) .route(web::get().to(list_artists))
.route(web::post().to(add_artist)), .route(web::post().to(add_artist)),
) )
.service(
web::resource("/artists/{id}/full")
.route(web::get().to(get_artist_full)),
)
.service( .service(
web::resource("/artists/{id}") web::resource("/artists/{id}")
.route(web::get().to(get_artist)) .route(web::get().to(get_artist))
@@ -39,19 +83,413 @@ async fn list_artists(
query: web::Query<PaginationParams>, query: web::Query<PaginationParams>,
) -> Result<HttpResponse, ApiError> { ) -> Result<HttpResponse, ApiError> {
let artists = queries::artists::list(state.db.conn(), query.limit, query.offset).await?; let artists = queries::artists::list(state.db.conn(), query.limit, query.offset).await?;
Ok(HttpResponse::Ok().json(artists)) let wanted = queries::wanted::list(state.db.conn(), None).await?;
let mut items: Vec<ArtistListItem> = Vec::new();
for a in &artists {
let artist_wanted: Vec<_> = wanted
.iter()
.filter(|w| w.artist_id == Some(a.id))
.collect();
// Check if we have cached artist-level totals from a prior detail page load
let cache_key = format!("artist_totals:{}", a.id);
let cached_totals: Option<(u32, u32, u32)> = if let Ok(Some(json)) =
queries::cache::get(state.db.conn(), &cache_key).await
{
serde_json::from_str(&json).ok()
} else {
None
};
let (total_watched, total_owned, total_items) = if let Some((avail, watched, owned)) = cached_totals {
(watched as usize, owned as usize, avail as usize)
} else {
// Fall back to wanted item counts
let total_items = artist_wanted.len();
let total_owned = artist_wanted.iter().filter(|w| w.status == WantedStatus::Owned).count();
(total_items, total_owned, total_items)
};
items.push(ArtistListItem {
id: a.id,
name: a.name.clone(),
musicbrainz_id: a.musicbrainz_id.clone(),
total_watched,
total_owned,
total_items,
});
}
Ok(HttpResponse::Ok().json(items))
} }
async fn get_artist( async fn get_artist(
state: web::Data<AppState>, state: web::Data<AppState>,
path: web::Path<i32>, path: web::Path<String>,
) -> Result<HttpResponse, ApiError> { ) -> Result<HttpResponse, ApiError> {
let id = path.into_inner(); let id_or_mbid = path.into_inner();
let artist = queries::artists::get_by_id(state.db.conn(), id).await?; if let Ok(id) = id_or_mbid.parse::<i32>() {
let albums = queries::albums::get_by_artist(state.db.conn(), id).await?; let artist = queries::artists::get_by_id(state.db.conn(), id).await?;
let albums = queries::albums::get_by_artist(state.db.conn(), id).await?;
Ok(HttpResponse::Ok().json(serde_json::json!({
"artist": artist,
"albums": albums,
})))
} else {
Err(ApiError::BadRequest("use /artists/{id}/full for MBID lookups".into()))
}
}
/// Fetch (or retrieve from cache) the tracklist for a release group.
/// Cache key: `artist_rg_tracks:{release_group_id}`
async fn get_cached_album_tracks(
state: &web::Data<AppState>,
rg_id: &str,
first_release_id: Option<&str>,
ttl_seconds: i64,
) -> Result<CachedAlbumTracks, ApiError> {
let cache_key = format!("artist_rg_tracks:{rg_id}");
// Check cache first
if let Some(json) = queries::cache::get(state.db.conn(), &cache_key).await
.map_err(|e| ApiError::Internal(e.to_string()))?
{
if let Ok(cached) = serde_json::from_str::<CachedAlbumTracks>(&json) {
return Ok(cached);
}
}
// Not cached — resolve release MBID and fetch tracks
let release_mbid = if let Some(rid) = first_release_id {
rid.to_string()
} else {
// Browse releases for this release group
resolve_release_from_group(rg_id).await?
};
let mb_tracks = state.mb_client
.get_release_tracks(&release_mbid)
.await
.map_err(|e| ApiError::Internal(format!("MB error for release {release_mbid}: {e}")))?;
let cached = CachedAlbumTracks {
release_mbid: release_mbid.clone(),
tracks: mb_tracks
.into_iter()
.map(|t| CachedTrack {
recording_mbid: t.recording_mbid,
title: t.title,
})
.collect(),
};
// Cache with caller-specified TTL
let json = serde_json::to_string(&cached)
.map_err(|e| ApiError::Internal(e.to_string()))?;
let _ = queries::cache::set(state.db.conn(), &cache_key, "musicbrainz", &json, ttl_seconds).await;
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)]
pub struct ArtistFullParams {
#[serde(default)]
quick: bool,
}
async fn get_artist_full(
state: web::Data<AppState>,
path: web::Path<String>,
query: web::Query<ArtistFullParams>,
) -> Result<HttpResponse, ApiError> {
let id_or_mbid = path.into_inner();
let quick_mode = query.quick;
// Resolve artist: local ID or MBID
let (artist, id, mbid) = if let Ok(local_id) = id_or_mbid.parse::<i32>() {
let artist = queries::artists::get_by_id(state.db.conn(), local_id).await?;
let mbid = match &artist.musicbrainz_id {
Some(m) => m.clone(),
None => {
let results = state.search.search_artist(&artist.name, 1).await
.map_err(|e| ApiError::Internal(e.to_string()))?;
results.into_iter().next().map(|a| a.id)
.ok_or_else(|| ApiError::NotFound(format!("no MBID for artist '{}'", artist.name)))?
}
};
(artist, Some(local_id), mbid)
} else {
let mbid = id_or_mbid;
// Direct MBID lookup — first check local DB, then MusicBrainz
let local = {
// Check if any local artist has this MBID
let all = queries::artists::list(state.db.conn(), 1000, 0).await?;
all.into_iter().find(|a| a.musicbrainz_id.as_deref() == Some(&mbid))
};
if let Some(a) = local {
let local_id = a.id;
(a, Some(local_id), mbid)
} else {
// Look up artist name from MusicBrainz by MBID — don't create a local record
let (name, _disambiguation) = state.mb_client
.get_artist_by_mbid(&mbid)
.await
.map_err(|e| ApiError::NotFound(format!("artist MBID {mbid} not found: {e}")))?;
// Create a synthetic artist object for display only (not saved to DB)
let synthetic = shanty_db::entities::artist::Model {
id: 0,
name,
musicbrainz_id: Some(mbid.clone()),
added_at: chrono::Utc::now().naive_utc(),
top_songs: "[]".to_string(),
similar_artists: "[]".to_string(),
};
(synthetic, None, mbid)
}
};
// Fetch release groups and filter by allowed secondary types
let all_release_groups = state.search.get_release_groups(&mbid).await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let allowed = &state.config.allowed_secondary_types;
let release_groups: Vec<_> = all_release_groups
.into_iter()
.filter(|rg| {
if rg.secondary_types.is_empty() {
true // Pure studio releases always included
} else {
// Include if ALL of the release group's secondary types are in the allowed list
rg.secondary_types.iter().all(|st| allowed.contains(st))
}
})
.collect();
// Get all wanted items for this artist
let all_wanted = queries::wanted::list(state.db.conn(), None).await?;
let artist_wanted: Vec<_> = all_wanted
.iter()
.filter(|w| id.is_some() && w.artist_id == id)
.collect();
// Build a set of wanted item recording MBIDs and their statuses for fast lookup (MBID only)
let wanted_by_mbid: std::collections::HashMap<&str, &WantedStatus> = artist_wanted
.iter()
.filter_map(|w| w.musicbrainz_id.as_deref().map(|mbid| (mbid, &w.status)))
.collect();
// Get local albums
let local_albums = if let Some(local_id) = id {
queries::albums::get_by_artist(state.db.conn(), local_id).await?
} else {
vec![]
};
// Quick mode: if no wanted items and ?quick=true, skip per-album MB fetches
let skip_track_fetch = quick_mode && artist_wanted.is_empty();
// Build full album info — fetch tracklists (from cache or MB) for each release group
// Deduplicate at the artist level:
// - available: unique recording MBIDs across all releases
// - watched/owned: unique wanted item MBIDs (so the same wanted item matching
// multiple recordings across releases only counts once)
let mut seen_available: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut seen_watched: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut seen_owned: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut albums: Vec<FullAlbumInfo> = Vec::new();
for rg in &release_groups {
if skip_track_fetch {
// Fast path: just list the release groups without track counts
albums.push(FullAlbumInfo {
mbid: rg.first_release_id.clone().unwrap_or_else(|| rg.id.clone()),
title: rg.title.clone(),
release_type: rg.primary_type.clone(),
date: rg.first_release_date.clone(),
track_count: 0,
local_album_id: None,
watched_tracks: 0,
owned_tracks: 0,
downloaded_tracks: 0,
total_local_tracks: 0,
status: "unwatched".to_string(),
});
continue;
}
// If artist has any watched items, cache permanently (10 years);
// otherwise cache for 7 days (just browsing)
let cache_ttl = if artist_wanted.is_empty() { 7 * 86400 } else { 10 * 365 * 86400 };
let cached = match get_cached_album_tracks(
&state,
&rg.id,
rg.first_release_id.as_deref(),
cache_ttl,
).await {
Ok(c) => c,
Err(e) => {
tracing::warn!(rg_id = %rg.id, title = %rg.title, error = %e, "failed to fetch tracks");
// Still show the album, just without track data
albums.push(FullAlbumInfo {
mbid: rg.first_release_id.clone().unwrap_or_else(|| rg.id.clone()),
title: rg.title.clone(),
release_type: rg.primary_type.clone(),
date: rg.first_release_date.clone(),
track_count: 0,
local_album_id: None,
watched_tracks: 0,
owned_tracks: 0,
downloaded_tracks: 0,
total_local_tracks: 0,
status: "unwatched".to_string(),
});
continue;
}
};
let track_count = cached.tracks.len() as u32;
// Match each track against wanted items by recording MBID or title
let mut watched: u32 = 0;
let mut owned: u32 = 0;
let mut downloaded: u32 = 0;
for track in &cached.tracks {
let rec_id = &track.recording_mbid;
// Add to artist-level unique available set
seen_available.insert(rec_id.clone());
// Match by recording MBID only
if let Some(s) = wanted_by_mbid.get(rec_id.as_str()) {
watched += 1;
seen_watched.insert(rec_id.clone());
match s {
WantedStatus::Owned => {
owned += 1;
seen_owned.insert(rec_id.clone());
}
WantedStatus::Downloaded => {
downloaded += 1;
}
_ => {}
}
}
}
// Local album match
let local = local_albums
.iter()
.find(|a| a.name.to_lowercase() == rg.title.to_lowercase());
let local_album_id = local.map(|a| a.id);
let local_tracks = if let Some(aid) = local_album_id {
queries::tracks::get_by_album(state.db.conn(), aid).await.unwrap_or_default().len() as u32
} else {
0
};
let status = if owned > 0 && owned >= track_count && track_count > 0 {
"owned"
} else if owned > 0 || downloaded > 0 {
"partial"
} else if watched > 0 {
"wanted"
} else {
"unwatched"
};
albums.push(FullAlbumInfo {
mbid: cached.release_mbid.clone(),
title: rg.title.clone(),
release_type: rg.primary_type.clone(),
date: rg.first_release_date.clone(),
track_count,
local_album_id,
watched_tracks: watched,
owned_tracks: owned,
downloaded_tracks: downloaded,
total_local_tracks: local_tracks,
status: status.to_string(),
});
}
// Sort: owned first, then partial, then wanted, then unwatched; within each by date
albums.sort_by(|a, b| {
let order = |s: &str| match s {
"owned" => 0, "partial" => 1, "wanted" => 2, _ => 3,
};
order(&a.status).cmp(&order(&b.status)).then_with(|| a.date.cmp(&b.date))
});
// Deduplicated artist-level totals
let total_available_tracks = seen_available.len() as u32;
let total_artist_watched = seen_watched.len() as u32;
let total_artist_owned = seen_owned.len() as u32;
let artist_status = if total_artist_owned > 0 && total_artist_owned >= total_available_tracks && total_available_tracks > 0 {
"owned"
} else if total_artist_watched > 0 {
"partial"
} else {
"unwatched"
};
// Cache artist-level totals for the library listing page
if !skip_track_fetch {
if let Some(local_id) = id {
let cache_key = format!("artist_totals:{local_id}");
let totals = serde_json::json!([total_available_tracks, total_artist_watched, total_artist_owned]);
let _ = queries::cache::set(
state.db.conn(), &cache_key, "computed",
&totals.to_string(),
if artist_wanted.is_empty() { 7 * 86400 } else { 10 * 365 * 86400 },
).await;
}
}
Ok(HttpResponse::Ok().json(serde_json::json!({ Ok(HttpResponse::Ok().json(serde_json::json!({
"artist": artist, "artist": artist,
"albums": albums, "albums": albums,
"artist_status": artist_status,
"total_available_tracks": total_available_tracks,
"total_watched_tracks": total_artist_watched,
"total_owned_tracks": total_artist_owned,
"enriched": !skip_track_fetch,
}))) })))
} }