Files
minstrel/internal/library/duplicate_settle.go
T
bvandeusenandClaude Opus 5.5 18d31e9cdd
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
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)
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>
2026-10-20 10:33:55 -04:00

494 lines
17 KiB
Go

package library
import (
"context"
"errors"
"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"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
// Duplicates that settle without a merge (M498, operator 2026-10-19: "this
// needs to be automated away"). The measured library had 536 groups waiting
// on the operator; most were retitles across releases, now linked like any
// cross-release group. What is left on one album is two shapes:
//
// - distinct: two versions of a song that sound alike ("Lovesick" and
// "Lovesick (Instrumental)"). Not duplicates, so the group is dismissed.
// - mismatch: one file filling several track slots (Savant's Void held one
// recording as tracks 15, 17 and 20). The album's MusicBrainz track list
// says how long each slot's song is. When exactly one slot's song is as
// long as the file and every other slot's song is clearly not, the copies
// 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.
// - 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
// MusicBrainz for, when the catalogue has never read them. The resolver
// runs hourly, so a backlog drains over a few hours without a burst.
trackListFetchesPerPass = 5
// slotFit is how close a file must be to a slot's MusicBrainz length to be
// that slot's song, and slotOther how far away every length of a slot's
// 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.
type slotCopy struct {
Title, Artist string
DurationMs int32
}
// wrongSlots decides which copies of a one-album mismatch sit in a slot that
// is not theirs. keep is the copy whose slot fits the recording, or -1 when
// every copy is clearly another song's (Shnabubula's whole album, 45 minutes
// in one file, filed as both "Fading Light" and "Looking for Answers"): then
// all of them go. ok is false when the track list cannot say, and nothing
// should be removed.
//
// Several copies may fit when they carry the same title ("Killjoy" twice
// beside "Dreamland"): they are one song held twice, not a wrong slot, so they
// are neither kept nor lost here. Once the wrong slot is emptied, the next
// sweep finds the pair on its own, and the resolver merges it as any song held
// twice on one album.
func wrongSlots(copies []slotCopy, tracks []dbq.MbReleaseTrack) (keep int, losers []int, ok bool) {
keep = -1
keepKey := ""
for i, c := range copies {
key := MatchTitleKey(c.Title, c.Artist)
fits, other, known := false, true, false
for _, t := range tracks {
if t.LengthMs <= 0 || MatchTitleKey(t.Title, c.Artist) != key {
continue
}
known = true
d := absMs(t.LengthMs - c.DurationMs)
if d <= slotFit {
fits = true
}
if d <= slotOther {
other = false
}
}
switch {
case fits && keep >= 0:
if key != keepKey {
return -1, nil, false
}
case fits:
keep, keepKey = i, key
case known && other:
losers = append(losers, i)
default:
return -1, nil, false
}
}
// A copy that neither fits nor is clearly another song's has returned
// above, so with no keep every copy is a loser.
if len(losers) == 0 {
return -1, nil, false
}
return keep, losers, true
}
func absMs(d int32) int32 {
if d < 0 {
return -d
}
return d
}
// dismissDistinct dismisses every group of distinct versions and records it
// for the Resolved automatically tab. It adds what it settled to settled.
func dismissDistinct(
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, groups []*resolveGroup, settled map[pgtype.UUID]bool,
) int {
q := dbq.New(pool)
n := 0
for _, g := range groups {
if g.class != ClassDistinct || settled[g.id] || ctx.Err() != nil {
continue
}
rows, err := q.SettleDuplicateGroupAutomatically(ctx, dbq.SettleDuplicateGroupAutomaticallyParams{
ID: g.id, Status: "dismissed",
})
if err != nil {
logger.Warn("duplicate resolve: dismiss failed", "group_id", syncpkg.FormatUUID(g.id), "err", err)
continue
}
if rows == 0 {
continue
}
settled[g.id] = true
n++
titles := make([]string, len(g.members))
for i, m := range g.members {
titles[i] = m.Title
}
audit.WriteOrLog(ctx, pool, logger, pgtype.UUID{}, pgtype.UUID{}, audit.ActionDuplicateDismiss, map[string]any{
"automatic": true,
"group_id": syncpkg.FormatUUID(g.id),
"artist": g.members[0].ArtistName,
"titles": titles,
})
}
return n
}
// settleWrongSlots removes the copies that sit in another song's slot, for
// 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,
src ResolveSources,
) (int, []string) {
q := dbq.New(pool)
tracksByGroup := map[string][]dbq.MbReleaseTrack{}
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 {
if g.class != ClassMismatch || settled[g.id] || ctx.Err() != nil {
continue
}
rg := g.members[0].ReleaseGroupMbid
if rg == "" {
continue
}
tracks, seen := tracksByGroup[rg]
if !seen {
var err error
if tracks, err = q.ListMBReleaseTracksForGroup(ctx, rg); err != nil {
logger.Warn("duplicate resolve: reading the album's track list failed", "release_group", rg, "err", err)
continue
}
if len(tracks) == 0 && fetches > 0 && neverRead(ctx, q, rg) {
fetches--
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
}
} else if tracks, err = q.ListMBReleaseTracksForGroup(ctx, rg); err != nil {
logger.Warn("duplicate resolve: reading the album's track list failed", "release_group", rg, "err", err)
continue
}
}
tracksByGroup[rg] = tracks
}
copies := make([]slotCopy, len(g.members))
for i, m := range g.members {
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
}
// 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)
continue
}
settled[g.id] = true
for _, i := range losers {
names = append(names, TrackLabel(g.members[i].ArtistName, g.members[i].Title))
}
}
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.
func neverRead(ctx context.Context, q *dbq.Queries, rg string) bool {
g, err := q.GetMBReleaseGroup(ctx, rg)
return errors.Is(err, pgx.ErrNoRows) || (err == nil && !g.ReleasesRefreshedAt.Valid)
}
// removeWrongSlots deletes each loser's file, then marks its track missing in
// 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. 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, blocked []string,
) (int, error) {
var gone []pgtype.UUID
var removedFiles []map[string]string
var removeErr error
for _, i := range losers {
m := g.members[i]
if err := removeFile(m.FilePath); err != nil && !errors.Is(err, os.ErrNotExist) {
removeErr = fmt.Errorf("remove %s: %w", m.FilePath, err)
break
}
gone = append(gone, m.TrackID)
removedFiles = append(removedFiles, map[string]string{
"track_id": syncpkg.FormatUUID(m.TrackID), "file_path": m.FilePath, "title": m.Title,
})
}
if len(gone) == 0 {
return 0, removeErr
}
err := pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error {
tq := dbq.New(tx)
if _, err := tq.MarkTracksMissing(ctx, gone); err != nil {
return fmt.Errorf("mark missing: %w", err)
}
if err := tq.ClearTrackMbids(ctx, gone); err != nil {
return fmt.Errorf("clear mbids: %w", err)
}
if err := logTrackChanges(ctx, tq, gone); err != nil {
return fmt.Errorf("log changes: %w", err)
}
if removeErr != nil {
return nil
}
_, err := tq.SettleDuplicateGroupAutomatically(ctx, dbq.SettleDuplicateGroupAutomaticallyParams{
ID: g.id, Status: "merged",
})
return err
})
if err != nil {
return 0, err
}
details := map[string]any{
"automatic": true,
"group_id": syncpkg.FormatUUID(g.id),
"artist": g.members[0].ArtistName,
"removed": removedFiles,
}
if len(blocked) > 0 {
details["blocked"] = blocked
}
keptPath := ""
if keep >= 0 {
kept := g.members[keep]
details["kept_title"], details["kept_path"] = kept.Title, kept.FilePath
keptPath = kept.FilePath
}
audit.WriteOrLog(ctx, pool, logger, pgtype.UUID{}, pgtype.UUID{}, audit.ActionDuplicateWrongFile, details)
logger.Info("duplicate resolve: removed copies from slots that were not theirs",
"group_id", syncpkg.FormatUUID(g.id), "kept", keptPath, "removed", len(gone))
return len(gone), removeErr
}
// notifyNewForReview tells admins about groups this pass classified for the
// first time and left for them. A group the resolver settled, linked or will
// link is not news: telling admins about those was most of what the notice
// used to say.
func notifyNewForReview(ctx context.Context, groups []*resolveGroup, settled map[pgtype.UUID]bool) {
var count int64
var items []string
for _, g := range groups {
if g.members[0].PriorClass != nil || settled[g.id] {
continue
}
if g.class != ClassReview && g.class != ClassMismatch {
continue
}
count++
if len(items) < notifyItems {
items = append(items, TrackLabel(g.members[0].ArtistName, g.members[0].Title))
}
}
if count > 0 {
notifyAdmins(ctx, notifications.KindDuplicatesFound, notifications.Payload{Count: count, Items: items})
}
}