Files
minstrel/internal/library/duplicate_settle.go
T
bvandeusenandClaude Opus 5.5 a3d9effca3
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m28s
release / go (push) Failing after 1m31s
release / integration (push) Successful in 5m15s
release / android (push) Successful in 5m23s
release / Build signed APK (releases and dev) (push) Successful in 5m37s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
feat: when no slot an album gives one recording is its own, every copy is removed and the album fetched again (M498)
Shnabubula's whole album, one 45-minute file, was filed as both Fading Light
and Looking for Answers. Neither slot fits, so wrongSlots had nothing to keep
and held the group. Every copy now goes when each is clearly another song's;
a copy near its slot still holds the group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:21:43 -04:00

311 lines
10 KiB
Go

package library
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"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.
// 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
)
// 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 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,
) (int, []string) {
q := dbq.New(pool)
tracksByGroup := map[string][]dbq.MbReleaseTrack{}
fetches := trackListFetchesPerPass
if fetch == nil {
fetches = 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 := fetch(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 {
continue
}
n, err := removeWrongSlots(ctx, pool, logger, g, keep, losers)
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
}
// 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. 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,
) (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 := 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,
}
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})
}
}