Files
watch/src/library.rs
T
Connor Johnstone 86b6901638 format
2026-03-26 17:47:57 -04:00

799 lines
26 KiB
Rust

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<String>,
}
/// 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<String>,
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<i32>,
) -> WatchResult<AddSummary> {
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<String> = 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<i32>,
) -> WatchResult<AddSummary> {
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<i32>,
) -> WatchResult<WatchListEntry> {
// 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<String>,
artist: artist::Model,
user_id: Option<i32>,
) -> WatchResult<WatchListEntry> {
// 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<DiscRecording> {
let cache_key = format!("artist_known_recordings:{artist_mbid}");
let json = queries::cache::get(conn, &cache_key).await.ok()??;
let recordings: Vec<DiscRecording> = 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<DiscRecording> {
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::<Vec<DiscRecording>>(&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::<serde_json::Value>(&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<i32>,
) -> WatchResult<bool> {
// 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<String>)> {
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<String>)> {
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<String>, Option<String>)> {
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<WantedStatus>,
artist_filter: Option<&str>,
user_id: Option<i32>,
) -> WatchResult<Vec<WatchListEntry>> {
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<LibrarySummary> {
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)
}