use actix_session::Session; use actix_web::{HttpResponse, web}; use serde::{Deserialize, Serialize}; use shanty_db::entities::wanted_item::WantedStatus; use shanty_db::queries; use shanty_tag::provider::MetadataProvider; 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}").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 = 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, 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, "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, release_group_mbid: &str, ) -> Result { // 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( 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, }))) }