feat: one recording cut to several songs' lengths is named by AcoustID; the copy in its slot stays, the rest go, and the release is set aside (#6169)
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m31s
release / android (push) Successful in 5m1s
release / integration (push) Successful in 5m1s
release / Build signed APK (releases and dev) (push) Successful in 5m9s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped

Savant's Mortals held one recording as 16 of its 50 tracks, each trimmed to
its own slot's MusicBrainz length, so the lengths could not say which slot
was its own and every group waited for the operator. When a one-album
mismatch is the same audio (identical, or fingerprints within 0.02) and
every copy fits its own slot, the resolver asks what the recording is: the
copies' own AcoustID lookups, or a fresh lookup of one copy (five a pass).
The track list names each slot's recording; the copy in that slot is kept
and the others removed, or every copy when the recording is in none of
their slots or AcoustID knows none. AcoustID unreachable or answers in
several copies' slots: the group waits.

Whenever wrong-slot copies are removed, the download the files were
imported from is set aside, so the album fetched again is not the same
files. Albums imported before Minstrel downloaded anything have none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 10:33:55 -04:00
co-authored by Claude Opus 5.5
parent e3eddab80f
commit 18d31e9cdd
10 changed files with 520 additions and 58 deletions
+40
View File
@@ -345,6 +345,46 @@ func (w *AcoustIDLookupWorker) lookupTrack(
return w.store(ctx, row, chooseRecording(cands, cfg.MinScore, row.Title, row.DurationMs)), nil
}
// ErrIdentifyUnavailable says AcoustID cannot be asked now: the lookup is off
// or has no key, the key was refused, or AcoustID could not be reached. The
// caller waits and asks again on a later pass.
var ErrIdentifyUnavailable = errors.New("acoustid: cannot be asked now")
// IdentifyRecordings asks AcoustID which recordings a file may be, for the
// duplicate resolver (#6169): every candidate at or above the minimum score,
// best first. None is an answer: AcoustID does not know the audio. Nothing is
// stored; the file's own lookup is the worker's.
func (w *AcoustIDLookupWorker) IdentifyRecordings(ctx context.Context, path string) ([]string, error) {
if w == nil {
return nil, ErrIdentifyUnavailable
}
cfg := w.settings.Get()
if !cfg.Ready() {
return nil, ErrIdentifyUnavailable
}
fp, err := w.fingerprint(ctx, path)
if err != nil {
if isInconclusive(err) {
return nil, fmt.Errorf("%w: %w", ErrIdentifyUnavailable, err)
}
return nil, fmt.Errorf("fingerprint: %w", err)
}
cands, err := w.client.Lookup(ctx, cfg.APIKey, fp.fingerprint, fp.durationSec)
switch {
case errors.Is(err, acoustid.ErrInvalidKey), errors.Is(err, acoustid.ErrUnavailable):
return nil, fmt.Errorf("%w: %w", ErrIdentifyUnavailable, err)
case err != nil:
return nil, err
}
var ids []string
for _, c := range cands {
if float32(c.Score) >= cfg.MinScore {
ids = append(ids, c.ID)
}
}
return ids, nil
}
// store records a decision and applies it to tracks.mbid in one transaction,
// so the gauge never shows a match whose id was not written. A failed write
// stores nothing and the track is tried again.