prefer release with no disambiguation, match track counts

This commit is contained in:
Gabe Farrell 2026-04-12 19:33:38 -04:00
parent b910e32d6c
commit 769f3ff08c
3 changed files with 111 additions and 30 deletions

View file

@ -22,7 +22,8 @@ type mbArtistCredit struct {
} }
type mbMedia struct { type mbMedia struct {
Format string `json:"format"` Format string `json:"format"`
TrackCount int `json:"track-count"`
} }
type mbRelease struct { type mbRelease struct {
@ -55,6 +56,22 @@ type mbReleaseGroup struct {
FirstReleaseDate string `json:"first-release-date"` FirstReleaseDate string `json:"first-release-date"`
} }
// releaseTrackCount returns the total number of tracks across all media in a release.
func releaseTrackCount(r mbRelease) int {
total := 0
for _, m := range r.Media {
total += m.TrackCount
}
return total
}
// getMBRelease fetches a single release by MBID (with media/track-count included).
func getMBRelease(mbid string) (*mbRelease, error) {
var r mbRelease
err := mbGet(fmt.Sprintf("/ws/2/release/%s?fmt=json&inc=media", url.QueryEscape(mbid)), &r)
return &r, err
}
func mbGet(path string, out interface{}) error { func mbGet(path string, out interface{}) error {
req, err := http.NewRequest("GET", "https://musicbrainz.org"+path, nil) req, err := http.NewRequest("GET", "https://musicbrainz.org"+path, nil)
if err != nil { if err != nil {
@ -131,8 +148,8 @@ func timeStringIsBefore(ts1, ts2 string) (bool, error) {
} }
// pickBestRelease selects the preferred release from a list. // pickBestRelease selects the preferred release from a list.
// Format (CD > Digital Media > *) is the primary sort key; // No disambiguation (canonical release) is the primary sort key;
// country (KR > JP > XW > *) breaks ties. // format (CD > Digital Media > *) is secondary; country (KR > XW > *) breaks ties.
func pickBestRelease(releases []mbRelease) *mbRelease { func pickBestRelease(releases []mbRelease) *mbRelease {
if len(releases) == 0 { if len(releases) == 0 {
return nil return nil
@ -140,6 +157,20 @@ func pickBestRelease(releases []mbRelease) *mbRelease {
best := &releases[0] best := &releases[0]
for i := 1; i < len(releases); i++ { for i := 1; i < len(releases); i++ {
r := &releases[i] r := &releases[i]
rNoDisamb := r.Disambiguation == ""
bestNoDisamb := best.Disambiguation == ""
// Prefer releases with no disambiguation — they are the canonical default.
if rNoDisamb && !bestNoDisamb {
best = r
continue
}
if !rNoDisamb && bestNoDisamb {
continue
}
// Both have the same disambiguation status; use date/format/country.
if before, err := timeStringIsBefore(r.Date, best.Date); before && err == nil { if before, err := timeStringIsBefore(r.Date, best.Date); before && err == nil {
rf, bf := releaseFormatScore(*r), releaseFormatScore(*best) rf, bf := releaseFormatScore(*r), releaseFormatScore(*best)
if rf > bf || (rf == bf && releaseCountryScore(*r) > releaseCountryScore(*best)) { if rf > bf || (rf == bf && releaseCountryScore(*r) > releaseCountryScore(*best)) {
@ -225,18 +256,20 @@ func fetchArtist(artistMBID, artistName string, logf func(string)) error {
time.Sleep(time.Second) // MusicBrainz rate limit time.Sleep(time.Second) // MusicBrainz rate limit
rel := pickBestReleaseForGroup(rg.ID) rel := pickBestReleaseForGroup(rg.ID)
releaseMBID := "" releaseMBID := ""
trackCount := 0
if rel == nil { if rel == nil {
logf(fmt.Sprintf(" ↳ warning: could not resolve release for group %s, beets will search by name", rg.ID)) logf(fmt.Sprintf(" ↳ warning: could not resolve release for group %s, beets will search by name", rg.ID))
} else { } else {
releaseMBID = rel.ID releaseMBID = rel.ID
trackCount = releaseTrackCount(*rel)
format := "" format := ""
if len(rel.Media) > 0 { if len(rel.Media) > 0 {
format = rel.Media[0].Format format = rel.Media[0].Format
} }
logf(fmt.Sprintf(" ↳ selected release: %s [%s / %s]", releaseMBID, format, rel.Country)) logf(fmt.Sprintf(" ↳ selected release: %s [%s / %s / %d tracks]", releaseMBID, format, rel.Country, trackCount))
} }
folder, err := fetchRelease(artistName, rg.Title, releaseMBID, logf) folder, err := fetchRelease(artistName, rg.Title, releaseMBID, trackCount, logf)
if err != nil { if err != nil {
log.Printf("[discover] fetch failed for %q by %s: %v", rg.Title, artistName, err) log.Printf("[discover] fetch failed for %q by %s: %v", rg.Title, artistName, err)
logf(fmt.Sprintf(" ↳ failed: %v", err)) logf(fmt.Sprintf(" ↳ failed: %v", err))
@ -244,7 +277,7 @@ func fetchArtist(artistMBID, artistName string, logf func(string)) error {
continue continue
} }
// Key the pending download by release group ID for dedup; beets uses releaseMBID. // Key the pending download by release group ID for dedup; beets uses releaseMBID.
registerDownload(rg.ID, releaseMBID, artistName, rg.Title, folder, nil) registerDownload(rg.ID, releaseMBID, artistName, rg.Title, trackCount, folder, nil)
logf(fmt.Sprintf(" ↳ registered for import (release mbid: %s)", releaseMBID)) logf(fmt.Sprintf(" ↳ registered for import (release mbid: %s)", releaseMBID))
} }
@ -383,15 +416,26 @@ func handleDiscoverFetch(w http.ResponseWriter, r *http.Request) {
log.Printf("[discover] starting fetch: %q by %s (mbid: %s)", body.Album, body.Artist, body.ID) log.Printf("[discover] starting fetch: %q by %s (mbid: %s)", body.Album, body.Artist, body.ID)
entry := newFetchEntry(body.ID, body.Artist, body.Album) entry := newFetchEntry(body.ID, body.Artist, body.Album)
// Look up the expected track count from MusicBrainz so the folder-selection
// logic can prefer results that match the release we intend to import.
trackCount := 0
if rel, err := getMBRelease(body.ID); err == nil {
trackCount = releaseTrackCount(*rel)
log.Printf("[discover] release %s has %d tracks", body.ID, trackCount)
} else {
log.Printf("[discover] could not fetch release track count for %s: %v", body.ID, err)
}
go func() { go func() {
folder, err := fetchRelease(body.Artist, body.Album, body.ID, entry.appendLog) folder, err := fetchRelease(body.Artist, body.Album, body.ID, trackCount, entry.appendLog)
if err != nil { if err != nil {
log.Printf("[discover] fetch failed for %q by %s: %v", body.Album, body.Artist, err) log.Printf("[discover] fetch failed for %q by %s: %v", body.Album, body.Artist, err)
entry.finish(err) entry.finish(err)
return return
} }
log.Printf("[discover] fetch complete for %q by %s, registering for import", body.Album, body.Artist) log.Printf("[discover] fetch complete for %q by %s, registering for import", body.Album, body.Artist)
registerDownload(body.ID, body.ID, body.Artist, body.Album, folder, entry) registerDownload(body.ID, body.ID, body.Artist, body.Album, trackCount, folder, entry)
// entry.finish is called by the monitor when import completes // entry.finish is called by the monitor when import completes
}() }()

View file

@ -13,14 +13,15 @@ import (
// pendingDownload tracks a queued slskd download that should be auto-imported // pendingDownload tracks a queued slskd download that should be auto-imported
// once all files have transferred successfully. // once all files have transferred successfully.
type pendingDownload struct { type pendingDownload struct {
ID string // dedup key (release MBID for single fetches; release group MBID for artist fetches) ID string // dedup key (release MBID for single fetches; release group MBID for artist fetches)
BeetsMBID string // release MBID passed to beets --search-id (may differ from ID) BeetsMBID string // release MBID passed to beets --search-id (may differ from ID)
Artist string Artist string
Album string Album string
Username string // slskd peer username Username string // slskd peer username
Dir string // remote directory path on the peer Dir string // remote directory path on the peer
Files []slskdFile // files that were queued for download Files []slskdFile // files that were queued for download
Entry *fetchEntry // fetch card to update with import progress Entry *fetchEntry // fetch card to update with import progress
TrackCount int // expected number of audio tracks (0 = unknown, skip check)
} }
var ( var (
@ -31,18 +32,21 @@ var (
// registerDownload records a queued slskd download for monitoring and eventual // registerDownload records a queued slskd download for monitoring and eventual
// auto-import. id is used as the dedup key; beetsMBID is the release MBID // auto-import. id is used as the dedup key; beetsMBID is the release MBID
// forwarded to beets --search-id (may be empty or differ from id). // forwarded to beets --search-id (may be empty or differ from id).
// trackCount is the expected number of audio tracks from MusicBrainz; 0 means
// unknown and the sanity check will be skipped at import time.
// If entry is nil a new fetchEntry is created so the frontend can discover it // If entry is nil a new fetchEntry is created so the frontend can discover it
// via /discover/fetch/list. // via /discover/fetch/list.
func registerDownload(id, beetsMBID, artist, album string, folder *albumFolder, entry *fetchEntry) { func registerDownload(id, beetsMBID, artist, album string, trackCount int, folder *albumFolder, entry *fetchEntry) {
pd := &pendingDownload{ pd := &pendingDownload{
ID: id, ID: id,
BeetsMBID: beetsMBID, BeetsMBID: beetsMBID,
Artist: artist, Artist: artist,
Album: album, Album: album,
Username: folder.Username, Username: folder.Username,
Dir: folder.Dir, Dir: folder.Dir,
Files: folder.Files, Files: folder.Files,
Entry: entry, Entry: entry,
TrackCount: trackCount,
} }
if entry == nil { if entry == nil {
@ -56,8 +60,8 @@ func registerDownload(id, beetsMBID, artist, album string, folder *albumFolder,
pendingDownloads[id] = pd pendingDownloads[id] = pd
pendingMu.Unlock() pendingMu.Unlock()
log.Printf("[monitor] registered: %q by %s (id: %s, beets mbid: %s, peer: %s, %d files)", log.Printf("[monitor] registered: %q by %s (id: %s, beets mbid: %s, peer: %s, %d files, expected tracks: %d)",
album, artist, id, beetsMBID, folder.Username, len(folder.Files)) album, artist, id, beetsMBID, folder.Username, len(folder.Files), trackCount)
} }
// startMonitor launches a background goroutine that periodically checks whether // startMonitor launches a background goroutine that periodically checks whether
@ -215,6 +219,14 @@ func importPendingRelease(pd *pendingDownload, localDir string) {
} }
logf(fmt.Sprintf("Found %d tracks", len(tracks))) logf(fmt.Sprintf("Found %d tracks", len(tracks)))
if pd.TrackCount > 0 && len(tracks) != pd.TrackCount {
entry.finish(fmt.Errorf(
"track count mismatch: downloaded %d tracks but release expects %d — aborting to avoid importing wrong edition",
len(tracks), pd.TrackCount,
))
return
}
if err := cleanAlbumTags(localDir); err != nil { if err := cleanAlbumTags(localDir); err != nil {
logf(fmt.Sprintf("Clean tags warning: %v", err)) logf(fmt.Sprintf("Clean tags warning: %v", err))
} }

View file

@ -384,9 +384,12 @@ func getSlskdTransfers(username string) ([]slskdTransferDir, error) {
// fetchRelease searches slskd for an album, queues the best-quality match for // fetchRelease searches slskd for an album, queues the best-quality match for
// download, and returns the chosen folder so the caller can monitor completion. // download, and returns the chosen folder so the caller can monitor completion.
// mbid, if non-empty, will be stored for use during import (beets --search-id). // mbid, if non-empty, will be stored for use during import (beets --search-id).
func fetchRelease(artist, album, mbid string, logf func(string)) (*albumFolder, error) { // trackCount, if > 0, filters candidate folders to those whose audio file count
// matches the expected number of tracks on the release, so alternate editions
// with different track counts are not accidentally selected.
func fetchRelease(artist, album, mbid string, trackCount int, logf func(string)) (*albumFolder, error) {
query := artist + " " + album query := artist + " " + album
log.Printf("[discover] fetch started: %q by %s", album, artist) log.Printf("[discover] fetch started: %q by %s (expected tracks: %d)", album, artist, trackCount)
logf("Starting fetch for: " + query) logf("Starting fetch for: " + query)
logf("Creating slskd search…") logf("Creating slskd search…")
@ -418,7 +421,29 @@ func fetchRelease(artist, album, mbid string, logf func(string)) (*albumFolder,
return nil, fmt.Errorf("no audio files found for %q by %s", album, artist) return nil, fmt.Errorf("no audio files found for %q by %s", album, artist)
} }
best := bestAlbumFolder(folders) // When we know the expected track count, prefer folders that match exactly
// so we don't accidentally grab a bonus-track edition or a different version
// that won't align with the release MBID we pass to beets.
candidates := folders
if trackCount > 0 {
var matched []albumFolder
for _, f := range folders {
if len(f.Files) == trackCount {
matched = append(matched, f)
}
}
if len(matched) > 0 {
log.Printf("[discover] %d/%d folders match expected track count (%d)", len(matched), len(folders), trackCount)
logf(fmt.Sprintf("Filtered to %d/%d folders matching expected track count (%d)",
len(matched), len(folders), trackCount))
candidates = matched
} else {
log.Printf("[discover] no folders matched expected track count (%d); using best available", trackCount)
logf(fmt.Sprintf("Warning: no folders matched expected track count (%d); using best available", trackCount))
}
}
best := bestAlbumFolder(candidates)
log.Printf("[discover] selected folder: %s from %s (%s, %d files)", log.Printf("[discover] selected folder: %s from %s (%s, %d files)",
best.Dir, best.Username, qualityLabel(best.Quality), len(best.Files)) best.Dir, best.Username, qualityLabel(best.Quality), len(best.Files))
logf(fmt.Sprintf("Selected folder: %s", best.Dir)) logf(fmt.Sprintf("Selected folder: %s", best.Dir))