release / govulncheck (push) Successful in 14s
release / web (push) Failing after 1m26s
release / go (push) Successful in 1m35s
release / integration (push) Successful in 5m8s
release / android (push) Successful in 5m12s
release / Build signed APK (releases and dev) (push) Successful in 5m26s
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
The operator's library had 536 groups waiting for review, 2026-10-19. A sample
showed most were one recording under two titles on two releases.
- The headline and the Duplicates count leave out cross-release groups, which
the resolver keeps and links: the headline read 3,479 when 536 needed review.
- Any group spanning albums is cross_release and is linked as one song,
whatever its titles say. Nothing is deleted.
- One album, two versions named as such ("Lovesick (Instrumental)") and not
byte-identical: a new class, distinct, dismissed by the resolver.
- One album, one recording under different song titles (mismatch): the
album's cached MusicBrainz track list decides. When exactly one slot's song
is as long as the file and every other slot's song is clearly not, the other
copies are removed and their tracks marked missing, so the reacquisition
sweeper wants the album back. Otherwise the group stays for review.
- The duplicates_found notice moves from the sweep to the resolver: it names
only groups a pass first left for review.
- Resolved automatically lists dismissals and wrong-slot removals beside
merges.
Migration 0094 adds 'distinct' to the class CHECK (rule 36).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
263 lines
8.3 KiB
Go
263 lines
8.3 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/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.
|
|
// Anything less certain stays with the operator.
|
|
|
|
const (
|
|
// 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; ok is false
|
|
// when the track list cannot say, and nothing should be removed.
|
|
func wrongSlots(copies []slotCopy, tracks []dbq.MbReleaseTrack) (keep int, losers []int, ok bool) {
|
|
keep = -1
|
|
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:
|
|
if keep >= 0 {
|
|
return -1, nil, false
|
|
}
|
|
keep = i
|
|
case known && other:
|
|
losers = append(losers, i)
|
|
default:
|
|
return -1, nil, false
|
|
}
|
|
}
|
|
if keep < 0 || 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,
|
|
) (int, []string) {
|
|
q := dbq.New(pool)
|
|
tracksByGroup := map[string][]dbq.MbReleaseTrack{}
|
|
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
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
kept := g.members[keep]
|
|
audit.WriteOrLog(ctx, pool, logger, pgtype.UUID{}, pgtype.UUID{}, audit.ActionDuplicateWrongFile, map[string]any{
|
|
"automatic": true,
|
|
"group_id": syncpkg.FormatUUID(g.id),
|
|
"artist": kept.ArtistName,
|
|
"kept_title": kept.Title,
|
|
"kept_path": kept.FilePath,
|
|
"removed": removedFiles,
|
|
})
|
|
logger.Info("duplicate resolve: removed copies from slots that were not theirs",
|
|
"group_id", syncpkg.FormatUUID(g.id), "kept", kept.FilePath, "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})
|
|
}
|
|
}
|