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
+87
View File
@@ -449,3 +449,90 @@ func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Requ
}
writeJSON(w, http.StatusOK, resp)
}
// refetchDuplicateRequest is the operator's answer for one recording filling
// several of an album's slots. keep_track_id is the copy whose title the
// recording is; empty means they cannot say, and every copy goes.
type refetchDuplicateRequest struct {
KeepTrackID string `json:"keep_track_id"`
}
// refetchDuplicateResponse reports what went. Blocked names the releases set
// aside; empty when the album came from before Minstrel downloaded anything.
type refetchDuplicateResponse struct {
KeptTrackID *string `json:"kept_track_id"`
RemovedPaths []string `json:"removed_paths"`
Blocked []string `json:"blocked"`
}
// handleRefetchDuplicateGroup implements POST
// /api/admin/library/duplicates/{id}/refetch (#6184): settle a group of one
// recording in several slots of an album the way the resolver would, with the
// operator's answer. The copies in slots that are not the recording's are
// removed and marked missing, the release they came from is set aside, and the
// album is wanted again. Merge is the wrong action there: it keeps one title
// whether or not it is the audio's, and fetches nothing.
//
// Errors:
// - 409 library_not_writable / 500 file_delete_failed when a file could not be
// removed (fileRemoveAPIError)
// - 404 duplicate_group_not_pending when the group was already resolved
// - 400 not_wrong_slot when the group is not one recording in several slots
// - 400 survivor_not_in_group, invalid_id, invalid_body
func (h *handlers) handleRefetchDuplicateGroup(w http.ResponseWriter, r *http.Request) {
admin, ok := requireUser(w, r)
if !ok {
return
}
groupID, ok := parseUUID(chi.URLParam(r, "id"))
if !ok {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
return
}
var body refetchDuplicateRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, mergeRequestBodyLimit)).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_body")
return
}
var keepID pgtype.UUID // invalid: every copy goes
if body.KeepTrackID != "" {
if keepID, ok = parseUUID(body.KeepTrackID); !ok {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
return
}
}
res, err := library.SettleWrongSlot(r.Context(), h.pool, h.logger, groupID, keepID, admin.ID)
if err != nil {
if apiErr, ok := fileRemoveAPIError(err); ok {
logFileRemoveFailure(h.logger, apiErr, "group_id", uuidToString(groupID))
writeErr(w, apiErr)
return
}
switch {
case errors.Is(err, library.ErrDuplicateGroupNotPending):
writeAdminJSONErr(w, http.StatusNotFound, "duplicate_group_not_pending")
case errors.Is(err, library.ErrNotWrongSlot):
writeAdminJSONErr(w, http.StatusBadRequest, "not_wrong_slot")
case errors.Is(err, library.ErrSurvivorNotInGroup):
writeAdminJSONErr(w, http.StatusBadRequest, "survivor_not_in_group")
default:
h.logger.Error("admin: refetch duplicate group", "group_id", uuidToString(groupID), "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
}
return
}
resp := refetchDuplicateResponse{RemovedPaths: make([]string, 0, len(res.Removed)), Blocked: res.Blocked}
if resp.Blocked == nil {
resp.Blocked = []string{}
}
if res.Kept != nil {
id := uuidToString(res.Kept.TrackID)
resp.KeptTrackID = &id
}
for _, c := range res.Removed {
resp.RemovedPaths = append(resp.RemovedPaths, c.FilePath)
}
writeJSON(w, http.StatusOK, resp)
}
+1
View File
@@ -333,6 +333,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Post("/library/duplicates/sweep", h.handleRunDuplicateSweep)
admin.Post("/library/duplicates/{id}/dismiss", h.handleDismissDuplicateGroup)
admin.Post("/library/duplicates/{id}/merge", h.handleMergeDuplicateGroup)
admin.Post("/library/duplicates/{id}/refetch", h.handleRefetchDuplicateGroup)
admin.Get("/invites", h.handleListInvites)
admin.Post("/invites", h.handleCreateInvite)
+5 -3
View File
@@ -328,6 +328,7 @@ SELECT g.id AS group_id,
LEFT JOIN track_acoustid_lookups l ON l.track_id = t.id
WHERE g.status = 'pending'
AND t.missing_since IS NULL
AND ($1::uuid IS NULL OR g.id = $1::uuid)
ORDER BY g.id, t.id
`
@@ -366,9 +367,10 @@ type ListDuplicateGroupsForResolveRow struct {
// tags or the library listing's match; ” when neither names one.
// acoustid_state and acoustid_recording are what AcoustID said of the file,
// when it was looked up (#6169: which song one recording cut to several slots
// is).
func (q *Queries) ListDuplicateGroupsForResolve(ctx context.Context) ([]ListDuplicateGroupsForResolveRow, error) {
rows, err := q.db.Query(ctx, listDuplicateGroupsForResolve)
// is). group_id narrows it to one group, for the operator settling it (#6184);
// NULL reads them all.
func (q *Queries) ListDuplicateGroupsForResolve(ctx context.Context, groupID pgtype.UUID) ([]ListDuplicateGroupsForResolveRow, error) {
rows, err := q.db.Query(ctx, listDuplicateGroupsForResolve, groupID)
if err != nil {
return nil, err
}
+3 -1
View File
@@ -197,7 +197,8 @@ UPDATE duplicate_groups
-- tags or the library listing's match; '' when neither names one.
-- acoustid_state and acoustid_recording are what AcoustID said of the file,
-- when it was looked up (#6169: which song one recording cut to several slots
-- is).
-- is). group_id narrows it to one group, for the operator settling it (#6184);
-- NULL reads them all.
SELECT g.id AS group_id,
g.tier,
g.worst_bit_error_rate,
@@ -232,6 +233,7 @@ SELECT g.id AS group_id,
LEFT JOIN track_acoustid_lookups l ON l.track_id = t.id
WHERE g.status = 'pending'
AND t.missing_since IS NULL
AND (sqlc.narg(group_id)::uuid IS NULL OR g.id = sqlc.narg(group_id)::uuid)
ORDER BY g.id, t.id;
-- name: SetDuplicateGroupClasses :exec
+12 -7
View File
@@ -160,7 +160,7 @@ func ResolveDuplicatesWith(
q := dbq.New(pool)
res := DuplicateResolveResult{Classes: map[DuplicateClass]int{}}
rows, err := q.ListDuplicateGroupsForResolve(ctx)
rows, err := q.ListDuplicateGroupsForResolve(ctx, pgtype.UUID{})
if err != nil {
return res, fmt.Errorf("list duplicate groups: %w", err)
}
@@ -168,12 +168,7 @@ func ResolveDuplicatesWith(
res.Groups = len(groups)
for _, g := range groups {
cm := make([]ClassifyMember, len(g.members))
for i, m := range g.members {
cm[i] = ClassifyMember{AlbumID: syncpkg.FormatUUID(m.AlbumID), Title: m.Title, ArtistName: m.ArtistName}
}
g.class = ClassifyDuplicateGroup(g.tier, cm)
g.note = resolveNote(g)
g.classify()
res.Classes[g.class]++
}
if err := writeResolveVerdicts(ctx, q, groups); err != nil {
@@ -444,6 +439,16 @@ func foldResolveGroups(rows []dbq.ListDuplicateGroupsForResolveRow) []*resolveGr
return out
}
// classify sets the group's class and the note the report shows beside it.
func (g *resolveGroup) classify() {
cm := make([]ClassifyMember, len(g.members))
for i, m := range g.members {
cm[i] = ClassifyMember{AlbumID: syncpkg.FormatUUID(m.AlbumID), Title: m.Title, ArtistName: m.ArtistName}
}
g.class = ClassifyDuplicateGroup(g.tier, cm)
g.note = resolveNote(g)
}
// resolveNote is what the report says beside a group, when there is something
// to say.
func resolveNote(g *resolveGroup) string {
+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
+148
View File
@@ -0,0 +1,148 @@
package library
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
// undecidedSlot is one recording filed as Void and as Reach, two songs
// MusicBrainz lists at lengths that cannot tell them apart, from a download
// Minstrel imported: the resolver leaves it pending for the operator (#6184).
func undecidedSlot(t *testing.T) (oneAlbumFixture, dbq.User) {
t.Helper()
f := newOneAlbumFixture(t, "acoustic", "Void", "Reach")
ctx := context.Background()
seedTrackList(t, f.pool, f.album, map[string]int{"Void": 220500, "Reach": 221000})
if _, err := f.pool.Exec(ctx,
`INSERT INTO grabs (info_hash, name, key, client, save_path, origin, import_state, destination)
VALUES ('abc123', 'Savant - Void [WEB]', 'k', 'qbit', '/dl', 'schedule', 'imported', $1)`,
filepath.Dir(f.aPath)); err != nil {
t.Fatal(err)
}
if res, err := ResolveDuplicates(ctx, f.pool, nil, "", true); err != nil || res.WrongFiles != 0 {
t.Fatalf("resolve = %+v, %v; want the group left for the operator", res, err)
}
name := strings.NewReplacer("/", "-", " ", "-").Replace(t.Name())
u, err := dbq.New(f.pool).CreateUser(ctx, dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiTokenHash: name + "-token",
})
if err != nil {
t.Fatal(err)
}
return f, u
}
// emptiedTrack says whether a copy's track is marked missing and waiting for
// the album to be fetched again.
func emptiedTrack(t *testing.T, f oneAlbumFixture, id pgtype.UUID) bool {
t.Helper()
var emptied bool
if err := f.pool.QueryRow(context.Background(),
`SELECT missing_since IS NOT NULL AND emptied_at IS NOT NULL FROM tracks WHERE id = $1`, id).Scan(&emptied); err != nil {
t.Fatalf("track %s is gone: %v", syncpkg.FormatUUID(id), err)
}
return emptied
}
// settledByHand checks the group closed as the operator's and the audit row
// names them.
func settledByHand(t *testing.T, f oneAlbumFixture, actor dbq.User) {
t.Helper()
if status, _, _, auto := (resolveFixture{pool: f.pool, groupID: f.groupID}).group(t); status != "merged" || auto {
t.Errorf("group = (%s, auto=%v), want merged by the operator", status, auto)
}
var n int
if err := f.pool.QueryRow(context.Background(),
`SELECT count(*) FROM audit_log WHERE action = 'duplicate_wrong_file' AND actor_id = $1
AND metadata->>'group_id' = $2 AND NOT (metadata->>'automatic')::boolean`,
actor.ID, syncpkg.FormatUUID(f.groupID)).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("operator's wrong-file audit rows = %d, want 1", n)
}
}
// "Get it again": the operator cannot say which song the recording is, so
// every copy goes, the download they came from is set aside, and both slots
// wait for the album fetched again. A merge would have kept one title.
func TestSettleWrongSlot_GetItAgainRemovesEveryCopy_Integration(t *testing.T) {
f, op := undecidedSlot(t)
ctx := context.Background()
res, err := SettleWrongSlot(ctx, f.pool, nil, f.groupID, pgtype.UUID{}, op.ID)
if err != nil {
t.Fatal(err)
}
if res.Kept != nil || len(res.Removed) != 2 || exists(f.aPath) || exists(f.bPath) {
t.Fatalf("result %+v, void exists %v, reach exists %v; want both copies removed",
res, exists(f.aPath), exists(f.bPath))
}
for _, id := range []pgtype.UUID{f.a.ID, f.b.ID} {
if !emptiedTrack(t, f, id) {
t.Errorf("track %s is not waiting to be fetched again", syncpkg.FormatUUID(id))
}
}
if !releaseBlocked(t, f.pool, "abc123") || len(res.Blocked) != 1 {
t.Errorf("blocked %v; want the download the files came from set aside", res.Blocked)
}
settledByHand(t, f, op)
if _, err := SettleWrongSlot(ctx, f.pool, nil, f.groupID, pgtype.UUID{}, op.ID); !errors.Is(err, ErrDuplicateGroupNotPending) {
t.Errorf("settling it twice: err = %v, want ErrDuplicateGroupNotPending", err)
}
}
// "This one is Reach": the copy the operator names stays, and only Void's slot
// is emptied and wanted again.
func TestSettleWrongSlot_KeepsTheCopyTheOperatorNames_Integration(t *testing.T) {
f, op := undecidedSlot(t)
res, err := SettleWrongSlot(context.Background(), f.pool, nil, f.groupID, f.b.ID, op.ID)
if err != nil {
t.Fatal(err)
}
if res.Kept == nil || res.Kept.TrackID != f.b.ID || len(res.Removed) != 1 || exists(f.aPath) || !exists(f.bPath) {
t.Fatalf("result %+v, void exists %v, reach exists %v; want only Void's copy removed",
res, exists(f.aPath), exists(f.bPath))
}
if !emptiedTrack(t, f, f.a.ID) {
t.Error("Void's track is not waiting to be fetched again")
}
if !releaseBlocked(t, f.pool, "abc123") {
t.Error("the download the files came from is not set aside")
}
settledByHand(t, f, op)
}
// Only a group of one recording in several slots is settled this way, and only
// with a copy from it.
func TestSettleWrongSlot_RefusesWhatItCannotSettle_Integration(t *testing.T) {
ctx := context.Background()
t.Run("two versions of a song", func(t *testing.T) {
f := newOneAlbumFixture(t, "acoustic", "Lovesick", "Lovesick (Instrumental)")
if _, err := SettleWrongSlot(ctx, f.pool, nil, f.groupID, pgtype.UUID{}, pgtype.UUID{}); !errors.Is(err, ErrNotWrongSlot) {
t.Errorf("err = %v, want ErrNotWrongSlot", err)
}
if !exists(f.aPath) || !exists(f.bPath) {
t.Error("a file was removed")
}
})
t.Run("a copy from another group", func(t *testing.T) {
f, op := undecidedSlot(t)
other := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
if _, err := SettleWrongSlot(ctx, f.pool, nil, f.groupID, other, op.ID); !errors.Is(err, ErrSurvivorNotInGroup) {
t.Errorf("err = %v, want ErrSurvivorNotInGroup", err)
}
if !exists(f.aPath) || !exists(f.bPath) || releaseBlocked(t, f.pool, "abc123") {
t.Error("something changed")
}
})
}