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
+191 -13
View File
@@ -6,11 +6,13 @@ import (
"fmt"
"log/slog"
"os"
"slices"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/acquire"
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
@@ -32,7 +34,15 @@ import (
// in the other slots are removed and their tracks marked missing, so the
// reacquisition sweeper wants the album back and a good copy fills them.
// When every slot's song is clearly not the file, every copy goes.
// Anything less certain stays with the operator.
// - trimmed: one recording cut to each of several slots' lengths (Savant's
// Mortals held one as 16 of its 50 tracks), so every slot fits and the
// lengths cannot say. AcoustID names the recording and the track list
// names each slot's: the copy in that recording's slot is kept and the
// rest go, or every copy when the recording is in none of their slots.
// The release the files came from is set aside, so the album fetched
// again is not the same files (#6169).
//
// Anything less certain stays with the operator.
const (
// trackListFetchesPerPass is how many albums' track lists a pass may ask
@@ -44,6 +54,14 @@ const (
// title must be for the file to be another song's.
slotFit = 3000
slotOther = 10000
// identifiesPerPass is how many groups a pass may ask AcoustID about. The
// client keeps to AcoustID's rate; this keeps a pass short.
identifiesPerPass = 5
// sameAudioBER is the worst bit error rate at which an acoustic group's
// copies are one recording cut differently rather than takes that sound
// alike: the groups measured as one recording trimmed to several slots
// read 0.000, and two takes of one song read far above this (#6169).
sameAudioBER = 0.02
)
// slotCopy is what deciding a wrong slot needs to know about one copy.
@@ -149,19 +167,22 @@ func dismissDistinct(
}
// settleWrongSlots removes the copies that sit in another song's slot, for
// every one-album mismatch the track list decides. It returns how many copies
// went and the songs whose slots were emptied, and adds the groups it settled
// to settled.
// every one-album mismatch the track list, or AcoustID with it, decides. It
// returns how many copies went and the songs whose slots were emptied, and
// adds the groups it settled to settled.
func settleWrongSlots(
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, groups []*resolveGroup, settled map[pgtype.UUID]bool,
fetch TrackListFetcher,
src ResolveSources,
) (int, []string) {
q := dbq.New(pool)
tracksByGroup := map[string][]dbq.MbReleaseTrack{}
fetches := trackListFetchesPerPass
if fetch == nil {
fetches, identifies := trackListFetchesPerPass, identifiesPerPass
if src.TrackLists == nil {
fetches = 0
}
if src.Identify == nil {
identifies = 0
}
removed := 0
var names []string
for _, g := range groups {
@@ -181,7 +202,7 @@ func settleWrongSlots(
}
if len(tracks) == 0 && fetches > 0 && neverRead(ctx, q, rg) {
fetches--
if err := fetch(ctx, rg); err != nil {
if err := src.TrackLists(ctx, rg); err != nil {
logger.Info("duplicate resolve: fetching the album's track list failed", "release_group", rg, "err", err)
if errors.Is(err, musicbrainz.ErrUnreachable) {
fetches = 0
@@ -198,10 +219,20 @@ func settleWrongSlots(
copies[i] = slotCopy{Title: m.Title, Artist: m.ArtistName, DurationMs: m.DurationMs}
}
keep, losers, ok := wrongSlots(copies, tracks)
if !ok && sameAudio(g) && trimmedToSlots(copies, tracks) {
recordings, known := groupRecordings(ctx, logger, g, src.Identify, &identifies)
if !known {
continue
}
keep, losers, ok = trimmedCopies(copies, tracks, recordings)
}
if !ok {
continue
}
n, err := removeWrongSlots(ctx, pool, logger, g, keep, losers)
// Set aside before the files go: a pass that dies between the two
// still does not fetch the same files again.
blocked := blockReleasesHolding(ctx, pool, logger, g)
n, err := removeWrongSlots(ctx, pool, logger, g, keep, losers, blocked)
removed += n
if err != nil {
logger.Warn("duplicate resolve: removing wrong-slot copies failed", "group_id", syncpkg.FormatUUID(g.id), "err", err)
@@ -215,6 +246,149 @@ func settleWrongSlots(
return removed, names
}
// sameAudio reports whether a group's copies are one recording, cut however:
// identical audio, or fingerprints all but identical (sameAudioBER).
func sameAudio(g *resolveGroup) bool {
return g.tier == "exact" || (g.worstBER != nil && *g.worstBER <= sameAudioBER)
}
// trimmedToSlots reports whether every copy is as long as its own slot's song
// and they are filed as more than one song: one recording cut to several
// slots' lengths, which the lengths alone cannot place.
func trimmedToSlots(copies []slotCopy, tracks []dbq.MbReleaseTrack) bool {
keys := map[string]bool{}
for _, c := range copies {
key := MatchTitleKey(c.Title, c.Artist)
fits := false
for _, t := range tracks {
if t.LengthMs > 0 && MatchTitleKey(t.Title, c.Artist) == key && absMs(t.LengthMs-c.DurationMs) <= slotFit {
fits = true
break
}
}
if !fits {
return false
}
keys[key] = true
}
return len(keys) > 1
}
// trimmedCopies decides a group trimmedToSlots holds, from the recordings
// AcoustID says it may be. The track list names each slot's recording: the
// copy in a slot of one of them is kept and the others are losers. When none
// of the copies' slots is one of them, or AcoustID knows none (recordings
// empty), every copy is another song's and all go. ok is false when they fall
// in more than one copy's slot, or the track list names no recording for a
// copy's slot.
//
// Copies sharing the kept copy's title are one song held twice, as in
// wrongSlots: neither kept nor lost here.
func trimmedCopies(copies []slotCopy, tracks []dbq.MbReleaseTrack, recordings []string) (keep int, losers []int, ok bool) {
keys := make([]string, len(copies))
theirs := map[string]bool{}
for i, c := range copies {
keys[i] = MatchTitleKey(c.Title, c.Artist)
named := false
for _, t := range tracks {
if t.RecordingMbid == "" || MatchTitleKey(t.Title, c.Artist) != keys[i] {
continue
}
named = true
if slices.Contains(recordings, t.RecordingMbid) {
theirs[keys[i]] = true
}
}
if !named {
return -1, nil, false
}
}
if len(theirs) > 1 {
return -1, nil, false
}
keep = -1
for i, key := range keys {
switch {
case theirs[key] && keep < 0:
keep = i
case theirs[key]:
default:
losers = append(losers, i)
}
}
return keep, losers, true
}
// groupRecordings is what AcoustID says a group's recording may be: the
// recordings the copies' own lookups matched, none when every copy's lookup
// found no match, or else a lookup of one copy now, while the pass has
// lookups left. known is false when there is no answer yet, and the group
// waits for a later pass.
func groupRecordings(
ctx context.Context, logger *slog.Logger, g *resolveGroup, identify RecordingIdentifier, left *int,
) (recordings []string, known bool) {
noMatch := 0
for _, m := range g.members {
switch {
case m.AcoustidState == nil:
case *m.AcoustidState == lookupMatched && m.AcoustidRecording != nil:
if !slices.Contains(recordings, *m.AcoustidRecording) {
recordings = append(recordings, *m.AcoustidRecording)
}
case *m.AcoustidState == lookupNoMatch:
noMatch++
}
}
if len(recordings) > 0 || noMatch == len(g.members) {
return recordings, true
}
if identify == nil || *left <= 0 {
return nil, false
}
*left--
ids, err := identify(ctx, g.members[0].FilePath)
if err != nil {
if errors.Is(err, ErrIdentifyUnavailable) {
*left = 0
}
logger.Info("duplicate resolve: asking AcoustID which song a recording is failed",
"group_id", syncpkg.FormatUUID(g.id), "err", err)
return nil, false
}
return ids, true
}
// blockReleasesHolding sets aside the downloads a group's files were imported
// from, and returns their names. An album imported before Minstrel downloaded
// anything has none.
func blockReleasesHolding(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, g *resolveGroup) []string {
q := dbq.New(pool)
seen := map[string]bool{}
var names []string
for _, m := range g.members {
grabs, err := q.ListImportedGrabsHolding(ctx, m.FilePath)
if err != nil {
logger.Warn("duplicate resolve: finding the download a file came from failed", "path", m.FilePath, "err", err)
continue
}
for _, gr := range grabs {
if seen[gr.InfoHash] {
continue
}
seen[gr.InfoHash] = true
if err := acquire.NewMemory(pool).Block(ctx, dbq.BlockReleaseParams{
InfoHash: gr.InfoHash, Name: gr.Name, Reason: acquire.BlockFiles,
Detail: "one recording fills several of the album's tracks.", WantID: gr.WantID,
}); err != nil {
logger.Warn("duplicate resolve: setting the release aside failed", "release", gr.Name, "err", err)
continue
}
names = append(names, gr.Name)
}
}
return names
}
// neverRead reports whether the catalogue has never read a release group's
// releases. A group read and found empty is not asked about again: the
// catalogue's own refresh revisits it.
@@ -227,11 +401,12 @@ func neverRead(ctx context.Context, q *dbq.Queries, rg string) bool {
// the same transaction that closes the group. The row is kept, with its likes
// and plays: it is the slot, and a good copy fetched again takes it over. Its
// recording MBID is dropped, being the wrong file's, so missing-pair folding
// does not fold the slot into the copy that was kept. A
// file that cannot be removed stops the group; the ones already gone are still
// marked, and the group waits for the next pass.
// does not fold the slot into the copy that was kept. blocked names the
// releases set aside, for the record. A file that cannot be removed stops the
// group; the ones already gone are still marked, and the group waits for the
// next pass.
func removeWrongSlots(
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, g *resolveGroup, keep int, losers []int,
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, g *resolveGroup, keep int, losers []int, blocked []string,
) (int, error) {
var gone []pgtype.UUID
var removedFiles []map[string]string
@@ -278,6 +453,9 @@ func removeWrongSlots(
"artist": g.members[0].ArtistName,
"removed": removedFiles,
}
if len(blocked) > 0 {
details["blocked"] = blocked
}
keptPath := ""
if keep >= 0 {
kept := g.members[keep]