Skip to content

Commit c93d8d9

Browse files
colonelpanic8claude
andcommitted
feat: improve release ranking to prioritize original singles over compilations
- Add Interview to compilation secondary types to better detect non-album releases - Implement hybrid search strategy (always perform both recording + album search) - Add date gap preference logic to prioritize singles 10+ years earlier than albums - Fix ranking issue where "20 Greatest Hits" was ranking above "Hey Jude / Revolution" (1968) - Refactor rank_releases_for_recording into smaller, more focused methods - Fix clippy warning: use .cloned() instead of .map(|r| r.clone()) - Fix clippy warnings about uninlined format arguments in tests This ensures that original singles like "Hey Jude / Revolution" (1968) now rank #1 instead of later compilation albums. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent d34f347 commit c93d8d9

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

58 files changed

+182936
-991
lines changed

app/src/api.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -484,7 +484,7 @@ pub async fn search_musicbrainz_for_track(
484484
title: String,
485485
album: Option<String>,
486486
) -> Result<Vec<MusicBrainzResult>, Box<dyn std::error::Error + Send + Sync>> {
487-
use scrobble_scrubber::musicbrainz_provider::MusicBrainzScrubActionProvider;
487+
use scrobble_scrubber::musicbrainz::MusicBrainzScrubActionProvider;
488488

489489
log::info!("Searching MusicBrainz for: '{title}' by '{artist}'");
490490

app/src/scrubber_manager.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use crate::types::{AppState, GlobalScrubber};
2-
use ::scrobble_scrubber::compilation_to_canonical_provider::CompilationToCanonicalProvider;
32
use ::scrobble_scrubber::config::{ScrobbleScrubberConfig, TrackProviderType};
4-
use ::scrobble_scrubber::musicbrainz_provider::MusicBrainzScrubActionProvider;
3+
use ::scrobble_scrubber::musicbrainz::CompilationToCanonicalProvider;
4+
use ::scrobble_scrubber::musicbrainz::MusicBrainzScrubActionProvider;
55
use ::scrobble_scrubber::persistence::FileStorage;
66
use ::scrobble_scrubber::rewrite::RewriteRule;
77
use ::scrobble_scrubber::scrub_action_provider::{

lib/src/cli/commands/musicbrainz.rs

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
use crate::musicbrainz_provider::MusicBrainzScrubActionProvider;
1+
use crate::musicbrainz::MusicBrainzScrubActionProvider;
22
use crate::scrub_action_provider::ScrubActionProvider;
33
use clap::Subcommand;
44
use lastfm_edit::Track;
55

6+
use crate::musicbrainz::CompilationToCanonicalProvider;
7+
68
#[derive(Subcommand, Debug, Clone)]
79
pub enum MusicBrainzCommands {
810
/// Search for albums/releases for an artist
@@ -80,6 +82,25 @@ pub enum MusicBrainzCommands {
8082
#[arg(long)]
8183
show_tracks: bool,
8284
},
85+
86+
/// Show ranked releases for a track (for debugging compilation provider)
87+
RankReleases {
88+
/// Artist name
89+
#[arg(short, long)]
90+
artist: String,
91+
92+
/// Track title
93+
#[arg(short, long)]
94+
title: String,
95+
96+
/// Current album (optional, helps identify compilations)
97+
#[arg(short = 'A', long)]
98+
album: Option<String>,
99+
100+
/// Output format (text or json)
101+
#[arg(short = 'f', long, default_value = "text")]
102+
format: String,
103+
},
83104
}
84105

85106
impl MusicBrainzCommands {
@@ -109,6 +130,12 @@ impl MusicBrainzCommands {
109130
show_all,
110131
show_tracks,
111132
} => Self::show_canonical_album(&artist, &album, show_all, show_tracks).await,
133+
Self::RankReleases {
134+
artist,
135+
title,
136+
album,
137+
format,
138+
} => Self::rank_releases(&artist, &title, album.as_deref(), &format).await,
112139
}
113140
}
114141

@@ -604,4 +631,149 @@ impl MusicBrainzCommands {
604631

605632
Ok(())
606633
}
634+
635+
/// Show ranked releases for a track
636+
async fn rank_releases(
637+
artist: &str,
638+
title: &str,
639+
current_album: Option<&str>,
640+
format: &str,
641+
) -> Result<(), Box<dyn std::error::Error>> {
642+
// Create the compilation provider
643+
let provider = CompilationToCanonicalProvider::new();
644+
645+
println!("🔍 Ranking releases for '{title}' by '{artist}'");
646+
if let Some(album) = current_album {
647+
println!(" Current album: '{album}'");
648+
}
649+
println!();
650+
651+
// Get ranked releases
652+
let ranked_releases = provider
653+
.rank_releases_for_recording(artist, title, current_album)
654+
.await
655+
.map_err(|e| format!("Failed to rank releases: {e}"))?;
656+
657+
if ranked_releases.is_empty() {
658+
println!("❌ No releases found");
659+
return Ok(());
660+
}
661+
662+
// Output based on format
663+
match format {
664+
"json" => {
665+
// JSON output for programmatic use
666+
let json = serde_json::to_string_pretty(&ranked_releases)?;
667+
println!("{json}");
668+
}
669+
_ => {
670+
// Text output for human reading
671+
println!("📊 RELEASE RANKINGS");
672+
println!("{}", "=".repeat(80));
673+
println!(
674+
"Found {} releases, ranked from best to worst:\n",
675+
ranked_releases.len()
676+
);
677+
678+
for release in &ranked_releases {
679+
// Rank indicator
680+
let rank_emoji = match release.rank {
681+
1 => "🥇",
682+
2 => "🥈",
683+
3 => "🥉",
684+
_ => " ",
685+
};
686+
687+
println!("{} Rank #{}: {}", rank_emoji, release.rank, release.title);
688+
println!(" Artist: {}", release.artist);
689+
println!(" Reason: {}", release.rank_reason);
690+
691+
if let Some(date) = &release.date {
692+
println!(" Date: {date}");
693+
}
694+
695+
if let Some(country) = &release.country {
696+
println!(" Country: {country}");
697+
}
698+
699+
if let Some(status) = &release.status {
700+
println!(" Status: {status}");
701+
}
702+
703+
if let Some(primary) = &release.primary_type {
704+
println!(" Primary Type: {primary}");
705+
}
706+
707+
if !release.secondary_types.is_empty() {
708+
println!(" Secondary Types: {}", release.secondary_types.join(", "));
709+
}
710+
711+
if release.is_compilation {
712+
println!(" ⚠️ COMPILATION");
713+
}
714+
715+
if release.is_various_artists {
716+
println!(" ⚠️ VARIOUS ARTISTS");
717+
}
718+
719+
if let Some(disamb) = &release.disambiguation {
720+
println!(" Note: {disamb}");
721+
}
722+
723+
println!(" MBID: {}", release.release_id);
724+
println!();
725+
}
726+
727+
// Summary
728+
println!("{}", "=".repeat(80));
729+
println!("📋 SUMMARY");
730+
731+
// What would the provider suggest?
732+
if let Some(current) = current_album {
733+
let current_is_compilation = ranked_releases
734+
.iter()
735+
.find(|r| r.title.eq_ignore_ascii_case(current))
736+
.map(|r| r.is_compilation)
737+
.unwrap_or(false);
738+
739+
if current_is_compilation {
740+
// Find the best non-compilation
741+
let best_non_compilation = ranked_releases.iter().find(|r| {
742+
!r.is_compilation
743+
&& !r.is_various_artists
744+
&& !r.title.eq_ignore_ascii_case(current)
745+
});
746+
747+
if let Some(best) = best_non_compilation {
748+
println!(
749+
"✅ Provider would suggest: '{}' → '{}'",
750+
current, best.title
751+
);
752+
println!(
753+
" Reason: Moving from compilation to {}",
754+
best.primary_type.as_deref().unwrap_or("studio release")
755+
);
756+
} else {
757+
println!("❌ Provider would NOT suggest a change");
758+
println!(" Reason: No suitable non-compilation release found");
759+
}
760+
} else {
761+
println!("✅ Provider would NOT suggest a change");
762+
println!(" Reason: '{current}' is not a compilation");
763+
}
764+
} else {
765+
// Just show the best release
766+
if let Some(best) = ranked_releases.first() {
767+
if !best.is_compilation {
768+
println!("✅ Best release: '{}'", best.title);
769+
} else {
770+
println!("⚠️ Best release is a compilation: '{}'", best.title);
771+
}
772+
}
773+
}
774+
}
775+
}
776+
777+
Ok(())
778+
}
607779
}

lib/src/cli/mod.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
pub mod auth;
22
pub mod commands;
33

4-
use crate::compilation_to_canonical_provider::CompilationToCanonicalProvider;
54
#[cfg(feature = "openai")]
65
use crate::config::OpenAIProviderConfig;
76
use crate::config::{ScrobbleScrubberConfig, StorageConfig};
87
use crate::event_logger::EventLogger;
8+
use crate::musicbrainz::CompilationToCanonicalProvider;
99
#[cfg(feature = "openai")]
1010
use crate::openai_provider::OpenAIScrubActionProvider;
1111
use crate::persistence::{FileStorage, StateStorage};
@@ -384,7 +384,7 @@ enum Commands {
384384
#[command(subcommand)]
385385
Timestamp(TimestampCommands),
386386
/// MusicBrainz operations
387-
#[command(subcommand)]
387+
#[command(subcommand, name = "musicbrainz")]
388388
MusicBrainz(MusicBrainzCommands),
389389
/// Clear saved session data (forces fresh login on next run)
390390
ClearSession,
@@ -772,13 +772,13 @@ pub async fn run() -> Result<()> {
772772
if config.providers.enable_musicbrainz {
773773
let musicbrainz_provider = if let Some(mb_config) = &config.providers.musicbrainz {
774774
// Use for_search_only to ensure no release filtering is applied to search operations
775-
crate::musicbrainz_provider::MusicBrainzScrubActionProvider::for_search_only(
775+
crate::musicbrainz::MusicBrainzScrubActionProvider::for_search_only(
776776
mb_config.confidence_threshold,
777777
mb_config.max_results,
778778
)
779779
} else {
780780
// Default provider also uses no release filtering for search operations
781-
crate::musicbrainz_provider::MusicBrainzScrubActionProvider::for_search_only(0.8, 5)
781+
crate::musicbrainz::MusicBrainzScrubActionProvider::for_search_only(0.8, 5)
782782
};
783783

784784
action_provider = action_provider.add_provider(musicbrainz_provider);

0 commit comments

Comments
 (0)