Files
minstrel/internal/library/duplicate_settle_test.go
T
bvandeusenandClaude Opus 5.5 b9684e6d12
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
fix: a group of one recording in several slots is settled by getting the album again, not merged (#6184)
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>
2026-10-21 13:47:43 -04:00

149 lines
5.8 KiB
Go

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")
}
})
}