From 05727221846beff65c4908aff0324f5a98e1fce8 Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Tue, 17 Mar 2026 21:39:08 -0400 Subject: [PATCH] Update for the "full flow" --- src/musicbrainz.rs | 50 +++++++++++++++++++++++- src/provider.rs | 15 +++++++ src/tagger.rs | 93 ++++++++++++++++++++++++-------------------- tests/integration.rs | 4 ++ 4 files changed, 118 insertions(+), 44 deletions(-) diff --git a/src/musicbrainz.rs b/src/musicbrainz.rs index c84c1a8..aa91998 100644 --- a/src/musicbrainz.rs +++ b/src/musicbrainz.rs @@ -6,7 +6,7 @@ use crate::cleaning::escape_lucene; use crate::error::{TagError, TagResult}; use crate::provider::{ ArtistSearchResult, DiscographyEntry, MetadataProvider, RecordingDetails, RecordingMatch, - ReleaseMatch, ReleaseRef, + ReleaseMatch, ReleaseRef, ReleaseTrack, }; const BASE_URL: &str = "https://musicbrainz.org/ws/2"; @@ -215,6 +215,31 @@ impl MetadataProvider for MusicBrainzClient { }) .collect()) } + + async fn get_release_tracks( + &self, + release_mbid: &str, + ) -> TagResult> { + let url = format!( + "{BASE_URL}/release/{release_mbid}?inc=recordings&fmt=json" + ); + let resp: MbReleaseDetail = self.get_json(&url).await?; + + let mut tracks = Vec::new(); + for (disc_idx, medium) in resp.media.unwrap_or_default().into_iter().enumerate() { + for track in medium.tracks.unwrap_or_default() { + tracks.push(ReleaseTrack { + recording_mbid: track.recording.map(|r| r.id).unwrap_or_default(), + title: track.title, + track_number: track.position, + disc_number: Some(disc_idx as i32 + 1), + duration_ms: track.length, + }); + } + } + + Ok(tracks) + } } fn extract_artist_credit(credits: &Option>) -> (String, Option) { @@ -321,3 +346,26 @@ struct MbArtist { struct MbGenre { name: String, } + +#[derive(Deserialize)] +struct MbReleaseDetail { + media: Option>, +} + +#[derive(Deserialize)] +struct MbMedia { + tracks: Option>, +} + +#[derive(Deserialize)] +struct MbTrackEntry { + title: String, + position: Option, + length: Option, + recording: Option, +} + +#[derive(Deserialize)] +struct MbTrackRecording { + id: String, +} diff --git a/src/provider.rs b/src/provider.rs index 4ba5b2b..9870439 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -99,4 +99,19 @@ pub trait MetadataProvider: Send + Sync { artist_mbid: &str, limit: u32, ) -> impl std::future::Future>> + Send; + + fn get_release_tracks( + &self, + release_mbid: &str, + ) -> impl std::future::Future>> + Send; +} + +/// A track within a release. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReleaseTrack { + pub recording_mbid: String, + pub title: String, + pub track_number: Option, + pub disc_number: Option, + pub duration_ms: Option, } diff --git a/src/tagger.rs b/src/tagger.rs index 0069de5..b31ee56 100644 --- a/src/tagger.rs +++ b/src/tagger.rs @@ -51,52 +51,60 @@ pub async fn tag_track( track: &track::Model, config: &TagConfig, ) -> TagResult { - // Build search query - 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"); + // 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); } - }; - tracing::info!( - id = track.id, - artist = %artist, - title = %title, - "searching MusicBrainz" - ); + 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); + } + }; - // Search for recordings - let candidates = provider.search_recording(&artist, &title).await?; + log_match(track, &best); - if candidates.is_empty() { - tracing::debug!(id = track.id, "no results from MusicBrainz"); - return Ok(false); - } - - // Score and select best match - 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); + 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) }; - log_match(track, &best); - - if config.dry_run { - return Ok(true); - } - - // Get full details for the best match - let details = provider.get_recording(&best.recording.mbid).await?; - // Upsert artist with MusicBrainz ID let artist_id = match &details.artist_mbid { Some(mbid) => { @@ -108,7 +116,7 @@ pub async fn tag_track( }; // Upsert album from best release - let (album_id, album_name) = if let Some(ref release) = best.best_release { + let (album_id, album_name) = if let Some(ref release) = best_release { let album = queries::albums::upsert( conn, &release.title, @@ -123,8 +131,7 @@ pub async fn tag_track( }; // Parse year from release date - let year = best - .best_release + let year = best_release .as_ref() .and_then(|r| r.date.as_deref()) .and_then(|d| d.split('-').next()) @@ -164,7 +171,7 @@ pub async fn tag_track( if let Err(e) = file_tags::write_tags( &track.file_path, &details, - best.best_release.as_ref(), + best_release.as_ref(), year, genre.as_deref(), ) { @@ -196,7 +203,7 @@ pub async fn run_tagging( let tracks: Vec = if let Some(id) = track_id { vec![queries::tracks::get_by_id(conn, id).await?] } else { - queries::tracks::get_untagged(conn).await? + queries::tracks::get_needing_metadata(conn).await? }; tracing::info!(count = tracks.len(), "tracks to process"); diff --git a/tests/integration.rs b/tests/integration.rs index aa64913..b03fbb3 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -66,6 +66,10 @@ impl MetadataProvider for MockProvider { async fn get_artist_releases(&self, _artist_mbid: &str, _limit: u32) -> TagResult> { Ok(vec![]) } + + async fn get_release_tracks(&self, _release_mbid: &str) -> TagResult> { + Ok(vec![]) + } } async fn test_db() -> Database {