use std::fmt; use sea_orm::DatabaseConnection; use serde::{Deserialize, Serialize}; use shanty_db::entities::artist; use shanty_db::entities::wanted_item::{ItemType, WantedStatus}; use shanty_db::queries; use shanty_tag::provider::MetadataProvider; use crate::error::{WatchError, WatchResult}; use crate::matching; /// A recording from an artist's discography, with release group context. /// Used in the `artist_known_recordings` cache to resolve top song titles /// to the correct recording MBID (preferring album over EP/single, older over newer). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DiscRecording { pub mbid: String, pub title: String, pub rg_type: String, pub rg_date: Option, } /// Normalize a title for comparison: lowercase + replace smart apostrophes/quotes. fn normalize_title(s: &str) -> String { s.to_lowercase() .replace(['\u{2019}', '\u{2018}'], "'") .replace(['\u{201C}', '\u{201D}'], "\"") } /// Resolve a song title to the best matching recording from an artist's discography. /// /// Uses fuzzy title matching (Jaro-Winkler > 0.85). When multiple recordings match, /// prefers: Album > EP > Single, then older release date. /// Returns `None` if no match exceeds the threshold. pub fn resolve_from_discography<'a>( title: &str, recordings: &'a [DiscRecording], ) -> Option<&'a DiscRecording> { let title_norm = normalize_title(title); let mut matches: Vec<(f64, &DiscRecording)> = recordings .iter() .map(|r| { ( strsim::jaro_winkler(&title_norm, &normalize_title(&r.title)), r, ) }) .filter(|(score, _)| *score > 0.85) .collect(); if matches.is_empty() { return None; } matches.sort_by(|(score_a, a), (score_b, b)| { score_b .partial_cmp(score_a) .unwrap_or(std::cmp::Ordering::Equal) .then_with(|| rg_type_priority(&a.rg_type).cmp(&rg_type_priority(&b.rg_type))) .then_with(|| a.rg_date.cmp(&b.rg_date)) }); matches.first().map(|(_, r)| *r) } fn rg_type_priority(rg_type: &str) -> u8 { match rg_type { "Album" => 0, "EP" => 1, _ => 2, } } /// A display-friendly watchlist entry with resolved names. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WatchListEntry { pub id: i32, pub item_type: ItemType, pub name: String, pub artist_name: Option, pub status: WantedStatus, pub added_at: chrono::NaiveDateTime, } /// Summary of how many tracks were added when watching an artist or album. #[derive(Debug, Default)] pub struct AddSummary { pub tracks_added: u64, pub tracks_already_owned: u64, pub errors: u64, } impl fmt::Display for AddSummary { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, "added: {}, already owned: {}, errors: {}", self.tracks_added, self.tracks_already_owned, self.errors, ) } } /// Summary statistics for the library. #[derive(Debug, Default, Serialize, Deserialize)] pub struct LibrarySummary { pub total_items: u64, pub wanted: u64, pub available: u64, pub downloaded: u64, pub owned: u64, } impl fmt::Display for LibrarySummary { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { writeln!(f, "Total tracked: {} items", self.total_items)?; writeln!(f, " Wanted: {}", self.wanted)?; writeln!(f, " Available: {}", self.available)?; writeln!(f, " Downloaded: {}", self.downloaded)?; write!(f, " Owned: {}", self.owned) } } /// Add an artist to the watchlist by expanding into individual track wanted items. /// /// `allowed_secondary_types` filters release groups by secondary type (e.g., Compilation, Live). /// An empty slice means studio releases only. pub async fn add_artist( conn: &DatabaseConnection, name: Option<&str>, musicbrainz_id: Option<&str>, provider: &impl MetadataProvider, allowed_secondary_types: &[String], user_id: Option, ) -> WatchResult { let (resolved_name, resolved_mbid) = resolve_artist_info(name, musicbrainz_id, provider).await?; let artist = queries::artists::upsert(conn, &resolved_name, resolved_mbid.as_deref()).await?; let artist_mbid = resolved_mbid.or_else(|| artist.musicbrainz_id.clone()); let artist_mbid = match artist_mbid { Some(mbid) => mbid, None => { let results = provider .search_artist(&resolved_name, 1) .await .map_err(|e| WatchError::Other(format!("artist search failed: {e}")))?; results.into_iter().next().map(|a| a.mbid).ok_or_else(|| { WatchError::Other(format!( "artist '{}' not found on MusicBrainz", resolved_name )) })? } }; tracing::info!(name = %resolved_name, mbid = %artist_mbid, "loading discography"); // Use the unified discography source — same MBIDs the detail page displays. // This reads from artist_rg_tracks caches (populated by enrich_artist), // ensuring wanted_items always have MBIDs that match the detail page. let disc = load_or_build_discography(conn, &artist_mbid, provider).await; if disc.is_empty() { tracing::warn!( name = %resolved_name, mbid = %artist_mbid, "no discography data available — visit the artist page first to populate caches" ); return Ok(AddSummary::default()); } // Deduplicate by MBID and expand into wanted items let mut summary = AddSummary::default(); let mut seen_mbids: std::collections::HashSet = std::collections::HashSet::new(); // Filter by allowed secondary types let filtered: Vec<_> = disc .iter() .filter(|r| { if r.rg_type.is_empty() || r.rg_type == "Album" { true } else { allowed_secondary_types.iter().any(|st| st == &r.rg_type) } }) .collect(); tracing::info!(total = disc.len(), filtered = filtered.len(), "discography loaded"); for rec in &filtered { if !seen_mbids.insert(rec.mbid.clone()) { continue; // Already processed this recording } match add_track_inner( conn, &resolved_name, &rec.title, Some(&rec.mbid), Some(&artist_mbid), user_id, ) .await { Ok(true) => summary.tracks_added += 1, Ok(false) => summary.tracks_already_owned += 1, Err(e) => { tracing::warn!(track = %rec.title, error = %e, "failed to add track"); summary.errors += 1; } } } tracing::info!(%summary, "artist watch complete"); Ok(summary) } /// Add an album to the watchlist by expanding into individual track wanted items. pub async fn add_album( conn: &DatabaseConnection, artist_name: Option<&str>, album_name: Option<&str>, musicbrainz_id: Option<&str>, provider: &impl MetadataProvider, user_id: Option, ) -> WatchResult { let (resolved_album, resolved_artist, resolved_mbid) = resolve_album_info(artist_name, album_name, musicbrainz_id, provider).await?; let release_mbid = match resolved_mbid { Some(mbid) => mbid, None => { let results = provider .search_release(&resolved_artist, &resolved_album) .await .map_err(|e| WatchError::Other(format!("album search failed: {e}")))?; results.into_iter().next().map(|r| r.mbid).ok_or_else(|| { WatchError::Other(format!( "album '{}' not found on MusicBrainz", resolved_album )) })? } }; tracing::info!(album = %resolved_album, artist = %resolved_artist, mbid = %release_mbid, "fetching tracks"); let tracks = provider .get_release_tracks(&release_mbid) .await .map_err(|e| WatchError::Other(format!("failed to fetch tracks: {e}")))?; let mut summary = AddSummary::default(); // Resolve the artist MBID for proper upsert matching let album_artist_mbid = queries::artists::find_by_name(conn, &resolved_artist) .await .ok() .flatten() .and_then(|a| a.musicbrainz_id); for track in &tracks { match add_track_inner( conn, &resolved_artist, &track.title, Some(&track.recording_mbid), album_artist_mbid.as_deref(), user_id, ) .await { Ok(true) => summary.tracks_added += 1, Ok(false) => summary.tracks_already_owned += 1, Err(e) => { tracing::warn!(track = %track.title, error = %e, "failed to add track"); summary.errors += 1; } } } tracing::info!(%summary, "album watch complete"); Ok(summary) } /// Add a single track to the watchlist. Auto-detects if already owned. pub async fn add_track( conn: &DatabaseConnection, artist_name: Option<&str>, title: Option<&str>, musicbrainz_id: Option<&str>, provider: &impl MetadataProvider, user_id: Option, ) -> WatchResult { // Fast path: if we have artist name + title, try to resolve the MBID directly from // the cached discography (no MB API calls needed). This makes top song watches instant. if let (Some(a), Some(t)) = ( artist_name.filter(|s| !s.is_empty()), title.filter(|s| !s.is_empty()), ) && let Ok(Some(existing_artist)) = queries::artists::find_by_name(conn, a).await && let Some(ref artist_mbid) = existing_artist.musicbrainz_id && let Some(disc) = load_and_resolve_discography(conn, artist_mbid, t).await { tracing::info!( title = t, mbid = %disc.mbid, rg_type = %disc.rg_type, "resolved MBID from cached discography (fast path)" ); let artist = queries::artists::upsert(conn, a, Some(artist_mbid)).await?; return finish_add_track(conn, t, a, Some(disc.mbid.clone()), artist, user_id).await; } // Slow path: resolve via MB API let (resolved_title, resolved_artist, _resolved_mbid, resolved_artist_mbid) = resolve_track_info(artist_name, title, musicbrainz_id, provider).await?; let artist = queries::artists::upsert(conn, &resolved_artist, resolved_artist_mbid.as_deref()).await?; // Try to resolve an MBID from the artist's discography using fuzzy title matching. // This ensures the wanted item's MBID matches a recording on the displayed release group. let disc_mbid = if let Some(ref artist_mbid) = artist.musicbrainz_id { let recordings = load_or_build_discography(conn, artist_mbid, provider).await; resolve_from_discography(&resolved_title, &recordings).map(|d| d.mbid.clone()) } else { None }; if disc_mbid.is_none() { tracing::warn!( title = %resolved_title, artist = %resolved_artist, "no discography match found — track will not appear on artist page" ); } finish_add_track( conn, &resolved_title, &resolved_artist, disc_mbid, artist, user_id, ) .await } /// Shared tail of `add_track`: dedup check, create wanted_item, detect ownership. async fn finish_add_track( conn: &DatabaseConnection, title: &str, artist_name: &str, recording_mbid: Option, artist: artist::Model, user_id: Option, ) -> WatchResult { // Dedup: skip if a wanted_item with this MBID already exists if let Some(ref mbid) = recording_mbid && let Ok(Some(existing)) = queries::wanted::find_by_mbid(conn, mbid).await { let artist_name_resolved = if let Some(aid) = existing.artist_id { queries::artists::get_by_id(conn, aid) .await .map(|a| a.name) .ok() } else { Some(artist.name.clone()) }; return Ok(WatchListEntry { id: existing.id, item_type: existing.item_type, name: existing.name, artist_name: artist_name_resolved, status: existing.status, added_at: existing.added_at, }); } // Dedup: skip if a wanted_item with same name + artist already exists let all_wanted = queries::wanted::list(conn, None, None).await?; let title_lower = title.to_lowercase(); if let Some(existing) = all_wanted .iter() .find(|w| w.artist_id == Some(artist.id) && w.name.to_lowercase() == title_lower) { // Update stale MBID if the new one differs (e.g., resolved from discography) if let Some(ref new_mbid) = recording_mbid && existing.musicbrainz_id.as_deref() != Some(new_mbid) { let _ = queries::wanted::update_mbid(conn, existing.id, new_mbid).await; tracing::info!( id = existing.id, old_mbid = ?existing.musicbrainz_id, new_mbid = %new_mbid, title = title, "updated stale MBID on existing wanted item" ); } return Ok(WatchListEntry { id: existing.id, item_type: existing.item_type, name: existing.name.clone(), artist_name: Some(artist.name), status: existing.status, added_at: existing.added_at, }); } let is_owned = matching::track_is_owned(conn, recording_mbid.as_deref()).await?; let item = queries::wanted::add( conn, queries::wanted::AddWantedItem { item_type: ItemType::Track, name: title, musicbrainz_id: recording_mbid.as_deref(), artist_id: Some(artist.id), album_id: None, track_id: None, user_id, }, ) .await?; let status = if is_owned { queries::wanted::update_status(conn, item.id, WantedStatus::Owned).await?; WantedStatus::Owned } else { WantedStatus::Wanted }; Ok(WatchListEntry { id: item.id, item_type: ItemType::Track, name: title.to_string(), artist_name: Some(artist.name), status, added_at: item.added_at, }) } /// Load the discography cache and resolve a title. Returns None if cache doesn't exist /// or title doesn't match. async fn load_and_resolve_discography( conn: &DatabaseConnection, artist_mbid: &str, title: &str, ) -> Option { let cache_key = format!("artist_known_recordings:{artist_mbid}"); let json = queries::cache::get(conn, &cache_key).await.ok()??; let recordings: Vec = serde_json::from_str(&json).ok()?; resolve_from_discography(title, &recordings).cloned() } /// Load the discography cache, or build it from the detail page's cached release group /// tracks (`artist_rg_tracks:*`). Only uses caches populated by enrich_artist() — never /// fetches from MB API independently, to ensure MBIDs always match the detail page. async fn load_or_build_discography( conn: &DatabaseConnection, artist_mbid: &str, provider: &impl MetadataProvider, ) -> Vec { let cache_key = format!("artist_known_recordings:{artist_mbid}"); // Try the pre-built known_recordings cache first if let Ok(Some(json)) = queries::cache::get(conn, &cache_key).await { if let Ok(recordings) = serde_json::from_str::>(&json) { return recordings; } tracing::debug!(artist_mbid, "rebuilding discography cache (old format)"); } // Build from the detail page's cached release group tracks (artist_rg_tracks:*). // These are populated by enrich_artist() and are the source of truth. // We only use cached data — never fetch from MB API here to avoid MBID divergence. let mut recordings = Vec::new(); if let Ok(release_groups) = provider.get_artist_release_groups(artist_mbid).await { for rg in &release_groups { if rg.featured || !rg.secondary_types.is_empty() { continue; } let rg_type = rg.primary_type.clone().unwrap_or_default(); let rg_date = rg.first_release_date.clone(); let rg_cache_key = format!("artist_rg_tracks:{}", rg.mbid); if let Ok(Some(json)) = queries::cache::get(conn, &rg_cache_key).await && let Ok(cached) = serde_json::from_str::(&json) && let Some(tracks) = cached.get("tracks").and_then(|t| t.as_array()) { for t in tracks { if let (Some(mbid), Some(title)) = ( t.get("recording_mbid").and_then(|v| v.as_str()), t.get("title").and_then(|v| v.as_str()), ) { recordings.push(DiscRecording { mbid: mbid.to_string(), title: title.to_string(), rg_type: rg_type.clone(), rg_date: rg_date.clone(), }); } } } // No cache for this RG = skip it (enrich_artist hasn't fetched it yet) } } // Cache the result if we found anything if !recordings.is_empty() { if let Ok(json) = serde_json::to_string(&recordings) { let _ = queries::cache::set(conn, &cache_key, "computed", &json, 7 * 86400).await; } } recordings } /// Internal: add a single track wanted item. Returns Ok(true) if added as Wanted, /// Ok(false) if already owned. async fn add_track_inner( conn: &DatabaseConnection, artist_name: &str, title: &str, recording_mbid: Option<&str>, artist_mbid: Option<&str>, user_id: Option, ) -> WatchResult { // Skip if a wanted_item with this recording MBID already exists if let Some(mbid) = recording_mbid && queries::wanted::find_by_mbid(conn, mbid).await?.is_some() { tracing::debug!(title = title, mbid = mbid, "already in watchlist, skipping"); return Ok(false); } let artist = queries::artists::upsert(conn, artist_name, artist_mbid).await?; // Also check by name + artist_id to catch race conditions (rapid double-clicks) let all_wanted = queries::wanted::list(conn, None, None).await?; let title_lower = title.to_lowercase(); if all_wanted .iter() .any(|w| w.artist_id == Some(artist.id) && w.name.to_lowercase() == title_lower) { tracing::debug!(title = title, "already in watchlist by name, skipping"); return Ok(false); } let is_owned = matching::track_is_owned(conn, recording_mbid).await?; let item = queries::wanted::add( conn, queries::wanted::AddWantedItem { item_type: ItemType::Track, name: title, musicbrainz_id: recording_mbid, artist_id: Some(artist.id), album_id: None, track_id: None, user_id, }, ) .await?; if is_owned { queries::wanted::update_status(conn, item.id, WantedStatus::Owned).await?; tracing::debug!(title = title, artist = artist_name, "already owned"); Ok(false) } else { tracing::debug!(title = title, artist = artist_name, "added to watchlist"); Ok(true) } } /// Resolve artist name from MBID if needed. async fn resolve_artist_info( name: Option<&str>, mbid: Option<&str>, provider: &impl MetadataProvider, ) -> WatchResult<(String, Option)> { if let Some(n) = name.filter(|s| !s.is_empty()) { return Ok((n.to_string(), mbid.map(String::from))); } let mbid = mbid.ok_or_else(|| WatchError::Other("either a name or --mbid is required".into()))?; let results = provider .search_artist(mbid, 1) .await .map_err(|e| WatchError::Other(format!("artist lookup failed: {e}")))?; if let Some(artist) = results.into_iter().next() { Ok((artist.name, Some(mbid.to_string()))) } else { Ok(( format!("Artist [{}]", &mbid[..8.min(mbid.len())]), Some(mbid.to_string()), )) } } /// Resolve album info from MBID if needed. async fn resolve_album_info( artist_name: Option<&str>, album_name: Option<&str>, mbid: Option<&str>, provider: &impl MetadataProvider, ) -> WatchResult<(String, String, Option)> { if let (Some(album), Some(artist)) = ( album_name.filter(|s| !s.is_empty()), artist_name.filter(|s| !s.is_empty()), ) { return Ok(( album.to_string(), artist.to_string(), mbid.map(String::from), )); } let mbid = mbid.ok_or_else(|| { WatchError::Other("either artist+album names or --mbid is required".into()) })?; let results = provider .search_release("", mbid) .await .map_err(|e| WatchError::Other(format!("MusicBrainz lookup failed: {e}")))?; if let Some(release) = results.first() { let album = release.title.clone(); let artist = artist_name .filter(|s| !s.is_empty()) .map(String::from) .unwrap_or_else(|| release.artist.clone()); Ok((album, artist, Some(mbid.to_string()))) } else { Err(WatchError::Other(format!( "no release found for MBID {mbid}" ))) } } /// Resolve track info from MBID if needed. /// Returns (title, artist_name, recording_mbid, artist_mbid). /// When an MBID is provided, validates it via MB lookup. If the MBID is stale/invalid, /// falls back to searching by artist+title to find the correct recording MBID. async fn resolve_track_info( artist_name: Option<&str>, title: Option<&str>, mbid: Option<&str>, provider: &impl MetadataProvider, ) -> WatchResult<(String, String, Option, Option)> { let has_name = title.filter(|s| !s.is_empty()).is_some() && artist_name.filter(|s| !s.is_empty()).is_some(); // If we have an MBID, validate it via lookup if let Some(mbid) = mbid.filter(|s| !s.is_empty()) { match provider.get_recording(mbid).await { Ok(details) => { return Ok(( title .filter(|s| !s.is_empty()) .map(String::from) .unwrap_or(details.title), artist_name .filter(|s| !s.is_empty()) .map(String::from) .unwrap_or(details.artist), Some(mbid.to_string()), details.artist_mbid, )); } Err(e) => { // MBID is stale/invalid — fall back to search if we have name info if has_name { tracing::warn!(mbid = mbid, error = %e, "MBID validation failed, falling back to search"); } else { return Err(WatchError::Other(format!( "MusicBrainz lookup failed for {mbid}: {e}" ))); } } } } // Search by artist + title if let (Some(t), Some(a)) = ( title.filter(|s| !s.is_empty()), artist_name.filter(|s| !s.is_empty()), ) { // Try to find the correct recording MBID via search let results = provider .search_recording(a, t) .await .map_err(|e| WatchError::Other(format!("MusicBrainz search failed: {e}")))?; // Prefer a result credited to this artist (by artist name match) // to avoid picking up compilation/VA versions of the same song let a_lower = a.to_lowercase(); let best = results .iter() .find(|r| r.artist.to_lowercase() == a_lower) .or(results.first()); if let Some(best) = best { return Ok(( t.to_string(), a.to_string(), Some(best.mbid.clone()), best.artist_mbid.clone(), )); } // No search results — return without MBID return Ok((t.to_string(), a.to_string(), None, None)); } Err(WatchError::Other( "either artist+title or a valid MBID is required".into(), )) } /// List watchlist items with optional filters. pub async fn list_items( conn: &DatabaseConnection, status_filter: Option, artist_filter: Option<&str>, user_id: Option, ) -> WatchResult> { let items = queries::wanted::list(conn, status_filter, user_id).await?; let mut entries = Vec::new(); for item in items { let artist_name = if let Some(id) = item.artist_id { queries::artists::get_by_id(conn, id) .await .map(|a| a.name) .ok() } else { None }; if let Some(filter) = artist_filter { let filter_norm = matching::normalize(filter); let matches = artist_name .as_deref() .map(|a| strsim::jaro_winkler(&filter_norm, &matching::normalize(a)) > 0.85) .unwrap_or(false); if !matches { continue; } } entries.push(WatchListEntry { id: item.id, item_type: item.item_type, name: item.name.clone(), artist_name, status: item.status, added_at: item.added_at, }); } Ok(entries) } /// Remove an item from the watchlist. pub async fn remove_item(conn: &DatabaseConnection, id: i32) -> WatchResult<()> { queries::wanted::remove(conn, id).await?; tracing::info!(id = id, "removed from watchlist"); Ok(()) } /// Get a summary of the library state. pub async fn library_summary(conn: &DatabaseConnection) -> WatchResult { let all = queries::wanted::list(conn, None, None).await?; let mut summary = LibrarySummary::default(); for item in &all { summary.total_items += 1; match item.status { WantedStatus::Wanted => summary.wanted += 1, WantedStatus::Available => summary.available += 1, WantedStatus::Downloaded => summary.downloaded += 1, WantedStatus::Owned => summary.owned += 1, } } Ok(summary) }