fix: a group of one recording in several slots is settled by getting the album again, not merged (#6184)
release / govulncheck (push) Successful in 36s
release / web (push) Successful in 1m39s
release / go (push) Successful in 1m45s
release / android (push) Successful in 5m9s
release / integration (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m21s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m23s
release / Verify release artifacts (tag releases only) (push) Skipped

The duplicates review offered only Merge for a wrong-slot group. A merge keeps
one copy's title whether or not it is the audio's, and neither sets the release
aside nor wants the missing song back.

For class mismatch the page now asks which song the recording is. "Get it
again" (the operator can't tell) removes every copy; "Keep <title>" keeps that
copy and removes the ones under other titles. Either way it runs the resolver's
own path: the release the files came from is set aside, the removed copies'
tracks stay marked missing and emptied, and the album is wanted again.

- library.SettleWrongSlot, POST /api/admin/library/duplicates/{id}/refetch
- ListDuplicateGroupsForResolve takes an optional group_id
- removeWrongSlots takes the acting operator (manual: not automatic, actor in
  the audit row) and removes files through removeTrackFileOnDisk, so a
  read-only library answers library_not_writable

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 13:47:43 -04:00
co-authored by Claude Opus 5.5
parent 5bcbc80555
commit b9684e6d12
11 changed files with 601 additions and 50 deletions
+94 -9
View File
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"log/slog"
"os"
"slices"
"github.com/jackc/pgx/v5"
@@ -45,7 +44,8 @@ import (
// Whenever copies go, 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.
// Anything less certain stays with the operator, who settles it the same way
// with their own answer (SettleWrongSlot).
const (
// trackListFetchesPerPass is how many albums' track lists a pass may ask
@@ -239,7 +239,7 @@ func settleWrongSlots(
// still does not fetch the same files again.
blocked := blockReleasesHolding(ctx, pool, logger, g)
emptied := emptiedSlots(ctx, q, logger, g, losers, tracks)
n, err := removeWrongSlots(ctx, pool, logger, g, keep, losers, emptied, blocked)
n, err := removeWrongSlots(ctx, pool, logger, g, keep, losers, emptied, blocked, pgtype.UUID{})
removed += n
if err != nil {
logger.Warn("duplicate resolve: removing wrong-slot copies failed", "group_id", syncpkg.FormatUUID(g.id), "err", err)
@@ -253,6 +253,86 @@ func settleWrongSlots(
return removed, names
}
// ErrNotWrongSlot means a group is not one recording filling several of an
// album's track slots, so there is no slot to empty and nothing to fetch again.
var ErrNotWrongSlot = errors.New("library: duplicate group is not one recording in several slots")
// WrongSlotResult is what settling a wrong slot by hand did. Kept is nil when
// every copy went.
type WrongSlotResult struct {
Kept *MergedCopy
Removed []MergedCopy
// Blocked names the releases set aside. None when the album was imported
// before Minstrel downloaded anything: the same files may come back.
Blocked []string
}
// SettleWrongSlot settles a one-album mismatch the resolver left for the
// operator (#6184), with their answer in place of MusicBrainz's lengths and
// AcoustID's. keepID is the copy whose title they say the recording is; an
// invalid id means they cannot say, and every copy goes. Either way it is the
// resolver's own path: the release the files came from is set aside, the
// copies removed are marked missing, and the album is wanted again for them.
// A merge does none of that, and keeps one copy's title whether or not it is
// the audio's.
//
// Copies sharing the kept copy's title are one song held twice and stay, as in
// wrongSlots: the next sweep finds the pair and the resolver merges it.
func SettleWrongSlot(
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, groupID, keepID, actorID pgtype.UUID,
) (WrongSlotResult, error) {
if logger == nil {
logger = slog.Default()
}
q := dbq.New(pool)
rows, err := q.ListDuplicateGroupsForResolve(ctx, groupID)
if err != nil {
return WrongSlotResult{}, fmt.Errorf("read duplicate group: %w", err)
}
groups := foldResolveGroups(rows)
if len(groups) == 0 {
return WrongSlotResult{}, ErrDuplicateGroupNotPending
}
g := groups[0]
if g.classify(); g.class != ClassMismatch {
return WrongSlotResult{}, ErrNotWrongSlot
}
keep := -1
if keepID.Valid {
keep = slices.IndexFunc(g.members, func(m dbq.ListDuplicateGroupsForResolveRow) bool { return m.TrackID == keepID })
if keep < 0 {
return WrongSlotResult{}, ErrSurvivorNotInGroup
}
}
var losers []int
for i, m := range g.members {
if keep >= 0 && MatchTitleKey(m.Title, m.ArtistName) == MatchTitleKey(g.members[keep].Title, m.ArtistName) {
continue
}
losers = append(losers, i)
}
if len(losers) == 0 {
return WrongSlotResult{}, ErrNotWrongSlot
}
var tracks []dbq.MbReleaseTrack
if rg := g.members[0].ReleaseGroupMbid; rg != "" {
if tracks, err = q.ListMBReleaseTracksForGroup(ctx, rg); err != nil {
return WrongSlotResult{}, fmt.Errorf("read the album's track list: %w", err)
}
}
res := WrongSlotResult{Blocked: blockReleasesHolding(ctx, pool, logger, g)}
emptied := emptiedSlots(ctx, q, logger, g, losers, tracks)
n, err := removeWrongSlots(ctx, pool, logger, g, keep, losers, emptied, res.Blocked, actorID)
if keep >= 0 {
res.Kept = &MergedCopy{TrackID: g.members[keep].TrackID, FilePath: g.members[keep].FilePath}
}
for _, i := range losers[:n] {
res.Removed = append(res.Removed, MergedCopy{TrackID: g.members[i].TrackID, FilePath: g.members[i].FilePath})
}
return res, err
}
// 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 {
@@ -442,18 +522,19 @@ func neverRead(ctx context.Context, q *dbq.Queries, rg string) bool {
// are stamped to wait for a copy fetched again (emptiedSlots). 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.
// the next pass. actorID is the operator settling it by hand, invalid for the
// resolver.
func removeWrongSlots(
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, g *resolveGroup, keep int, losers []int,
emptied []pgtype.UUID, blocked []string,
emptied []pgtype.UUID, blocked []string, actorID pgtype.UUID,
) (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)
if err := removeTrackFileOnDisk(m.FilePath); err != nil {
removeErr = err
break
}
gone = append(gone, m.TrackID)
@@ -481,6 +562,10 @@ func removeWrongSlots(
if removeErr != nil {
return nil
}
if actorID.Valid {
_, err := tq.MarkDuplicateGroupMerged(ctx, g.id)
return err
}
_, err := tq.SettleDuplicateGroupAutomatically(ctx, dbq.SettleDuplicateGroupAutomaticallyParams{
ID: g.id, Status: "merged",
})
@@ -490,7 +575,7 @@ func removeWrongSlots(
return 0, err
}
details := map[string]any{
"automatic": true,
"automatic": !actorID.Valid,
"group_id": syncpkg.FormatUUID(g.id),
"artist": g.members[0].ArtistName,
"removed": removedFiles,
@@ -504,7 +589,7 @@ func removeWrongSlots(
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)
audit.WriteOrLog(ctx, pool, logger, actorID, 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