use actix_session::Session; use actix_web::{HttpResponse, web}; use serde::{Deserialize, Serialize}; use shanty_data::MetadataFetcher; use shanty_db::entities::wanted_item::WantedStatus; use shanty_db::queries; use crate::auth; use crate::error::ApiError; use crate::state::AppState; #[derive(Deserialize)] pub struct PaginationParams { #[serde(default = "default_limit")] limit: u64, #[serde(default)] offset: u64, } fn default_limit() -> u64 { 50 } #[derive(Deserialize)] pub struct AddAlbumRequest { artist: Option, album: Option, mbid: Option, } #[derive(Serialize)] struct AlbumTrackInfo { recording_mbid: String, title: String, track_number: Option, disc_number: Option, duration_ms: Option, status: Option, } pub fn configure(cfg: &mut web::ServiceConfig) { cfg.service( web::resource("/albums") .route(web::get().to(list_albums)) .route(web::post().to(add_album)), ) .service(web::resource("/albums/{mbid}/watch").route(web::delete().to(unwatch_album))) .service(web::resource("/albums/{mbid}").route(web::get().to(get_album))); } async fn list_albums( state: web::Data, session: Session, query: web::Query, ) -> Result { auth::require_auth(&session)?; let albums = queries::albums::list(state.db.conn(), query.limit, query.offset).await?; 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( state: web::Data, session: Session, path: web::Path, ) -> Result { auth::require_auth(&session)?; let mbid = path.into_inner(); // Try fetching as a release first let (mb_tracks, _release_mbid) = match state.mb_client.get_release_tracks(&mbid).await { Ok(tracks) => (tracks, mbid.clone()), Err(_) => { // Probably a release-group MBID. Browse releases for this group. let resolved = resolve_release_from_group(&state, &mbid).await?; let tracks = state .mb_client .get_release_tracks(&resolved) .await .map_err(|e| ApiError::Internal(format!("MusicBrainz error: {e}")))?; (tracks, resolved) } }; // Get the album artist from the release's recording credits let album_artist = if let Some(first_track) = mb_tracks.first() { state .mb_client .get_recording(&first_track.recording_mbid) .await .ok() .map(|r| r.artist) } else { None }; // Get all wanted items to check local status let all_wanted = queries::wanted::list(state.db.conn(), None, None).await?; let tracks: Vec = 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!({ "mbid": mbid, "artist": album_artist, "tracks": tracks, }))) } /// Given a release-group MBID, find the first release MBID via the shared MB client. async fn resolve_release_from_group( state: &web::Data, release_group_mbid: &str, ) -> Result { state .mb_client .resolve_release_from_group(release_group_mbid) .await .map_err(|e| { ApiError::NotFound(format!( "no releases found for release group {release_group_mbid}: {e}" )) }) } async fn add_album( state: web::Data, session: Session, body: web::Json, ) -> Result { let (user_id, _, _) = auth::require_auth(&session)?; if body.artist.is_none() && body.album.is_none() && body.mbid.is_none() { return Err(ApiError::BadRequest("provide artist+album or mbid".into())); } // Try adding with the given MBID first. If it fails (e.g., the MBID is a release-group, // not a release), resolve it to an actual release MBID and retry. let mut mbid = body.mbid.clone(); let result = shanty_watch::add_album( state.db.conn(), body.artist.as_deref(), body.album.as_deref(), mbid.as_deref(), &state.mb_client, Some(user_id), ) .await; let summary = match result { Ok(s) => s, Err(_) if mbid.is_some() => { let rg_mbid = mbid.as_deref().unwrap(); let release_mbid = resolve_release_from_group(&state, rg_mbid).await?; mbid = Some(release_mbid); shanty_watch::add_album( state.db.conn(), body.artist.as_deref(), body.album.as_deref(), mbid.as_deref(), &state.mb_client, Some(user_id), ) .await? } Err(e) => return Err(e.into()), }; Ok(HttpResponse::Ok().json(serde_json::json!({ "tracks_added": summary.tracks_added, "tracks_already_owned": summary.tracks_already_owned, "errors": summary.errors, }))) } async fn unwatch_album( state: web::Data, session: Session, path: web::Path, ) -> Result { auth::require_auth(&session)?; let mbid = path.into_inner(); let conn = state.db.conn(); // Get the album's tracks from MB to find their recording MBIDs let tracks = match state.mb_client.get_release_tracks(&mbid).await { Ok(t) => t, Err(_) => { // Try as release-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}")))? } }; let mut removed = 0u64; for track in &tracks { removed += queries::wanted::remove_by_mbid(conn, &track.recording_mbid).await?; } Ok(HttpResponse::Ok().json(serde_json::json!({"removed": removed}))) }