use std::fmt; use sea_orm::{ActiveValue::Set, DatabaseConnection, NotSet}; use shanty_db::entities::track; use shanty_db::queries; use crate::error::TagResult; use crate::file_tags; use crate::matcher::{self, ScoredMatch}; use crate::provider::MetadataProvider; /// Configuration for a tagging operation. pub struct TagConfig { /// If true, show what would change without writing to DB or files. pub dry_run: bool, /// If true, write updated tags back to the music files. pub write_tags: bool, /// Minimum match confidence (0.0 - 1.0). pub confidence: f64, } /// Statistics from a completed tagging run. #[derive(Debug, Default, Clone)] pub struct TagStats { pub tracks_processed: u64, pub tracks_matched: u64, pub tracks_updated: u64, pub tracks_skipped: u64, pub tracks_errored: u64, } impl fmt::Display for TagStats { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, "processed: {}, matched: {}, updated: {}, skipped: {}, errors: {}", self.tracks_processed, self.tracks_matched, self.tracks_updated, self.tracks_skipped, self.tracks_errored, ) } } /// Tag a single track. Returns `Ok(true)` if matched and updated. pub async fn tag_track( conn: &DatabaseConnection, provider: &impl MetadataProvider, track: &track::Model, config: &TagConfig, ) -> TagResult { // If the track already has an MBID, skip searching and go straight to lookup let (details, best_release) = if let Some(ref mbid) = track.musicbrainz_id { tracing::info!(id = track.id, mbid = %mbid, "looking up recording by MBID"); if config.dry_run { tracing::info!(id = track.id, mbid = %mbid, "DRY RUN: would enrich from MBID"); return Ok(true); } let details = provider.get_recording(mbid).await?; let best_release = details.releases.first().cloned(); (details, best_release) } else { // No MBID — search by artist + title let (artist, title) = match matcher::build_query(track) { Some(q) => q, None => { tracing::debug!(id = track.id, path = %track.file_path, "no query possible, skipping"); return Ok(false); } }; tracing::info!(id = track.id, artist = %artist, title = %title, "searching MusicBrainz"); let candidates = provider.search_recording(&artist, &title).await?; if candidates.is_empty() { tracing::debug!(id = track.id, "no results from MusicBrainz"); return Ok(false); } let best = match matcher::select_best_match(track, candidates, config.confidence) { Some(m) => m, None => { tracing::debug!( id = track.id, "no match above confidence threshold {}", config.confidence ); return Ok(false); } }; log_match(track, &best); if config.dry_run { return Ok(true); } let details = provider.get_recording(&best.recording.mbid).await?; let best_release = best.best_release; (details, best_release) }; // Upsert artist with MusicBrainz ID let artist_id = match &details.artist_mbid { Some(mbid) => { Some(queries::artists::upsert(conn, &details.artist, Some(mbid)).await?.id) } None => { Some(queries::artists::upsert(conn, &details.artist, None).await?.id) } }; // Upsert album from best release let (album_id, album_name) = if let Some(ref release) = best_release { let album = queries::albums::upsert( conn, &release.title, &details.artist, Some(&release.mbid), artist_id, ) .await?; (Some(album.id), Some(release.title.clone())) } else { (None, None) }; // Parse year from release date let year = best_release .as_ref() .and_then(|r| r.date.as_deref()) .and_then(|d| d.split('-').next()) .and_then(|y| y.parse::().ok()); let genre = details.genres.first().cloned(); // Update track metadata let active = track::ActiveModel { id: Set(track.id), file_path: Set(track.file_path.clone()), title: Set(Some(details.title.clone())), artist: Set(Some(details.artist.clone())), album: Set(album_name), album_artist: Set(Some(details.artist.clone())), musicbrainz_id: Set(Some(details.mbid.clone())), artist_id: Set(artist_id), album_id: Set(album_id), year: Set(year), genre: Set(genre.clone()), // Preserve existing values for fields we don't update track_number: NotSet, disc_number: NotSet, duration: NotSet, codec: NotSet, bitrate: NotSet, file_size: NotSet, fingerprint: NotSet, file_mtime: NotSet, added_at: NotSet, updated_at: NotSet, }; queries::tracks::update_metadata(conn, track.id, active).await?; // Optionally write tags to file if config.write_tags { if let Err(e) = file_tags::write_tags( &track.file_path, &details, best_release.as_ref(), year, genre.as_deref(), ) { tracing::warn!(id = track.id, path = %track.file_path, "failed to write file tags: {e}"); } } Ok(true) } fn log_match(track: &track::Model, best: &ScoredMatch) { tracing::info!( id = track.id, confidence = format!("{:.2}", best.confidence), matched_title = %best.recording.title, matched_artist = %best.recording.artist, release = best.best_release.as_ref().map(|r| r.title.as_str()).unwrap_or("(none)"), "match found" ); } /// Run tagging on all untagged tracks or a specific track. pub async fn run_tagging( conn: &DatabaseConnection, provider: &impl MetadataProvider, config: &TagConfig, track_id: Option, ) -> TagResult { let tracks: Vec = if let Some(id) = track_id { vec![queries::tracks::get_by_id(conn, id).await?] } else { queries::tracks::get_needing_metadata(conn).await? }; tracing::info!(count = tracks.len(), "tracks to process"); let mut stats = TagStats::default(); for track in &tracks { stats.tracks_processed += 1; match tag_track(conn, provider, track, config).await { Ok(true) => { stats.tracks_matched += 1; if !config.dry_run { stats.tracks_updated += 1; } } Ok(false) => { stats.tracks_skipped += 1; } Err(e) => { tracing::error!(id = track.id, path = %track.file_path, "tagging error: {e}"); stats.tracks_errored += 1; } } } tracing::info!(%stats, "tagging complete"); Ok(stats) }