feat: duplicates resolve themselves without Lidarr (M502 #5517)
release / govulncheck (push) Successful in 37s
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m46s
release / android (push) Successful in 5m36s
release / Build signed APK (releases and dev) (push) Successful in 5m54s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 6m48s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 37s
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m46s
release / android (push) Successful in 5m36s
release / Build signed APK (releases and dev) (push) Successful in 5m54s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 6m48s
release / Verify release artifacts (tag releases only) (push) Skipped
Lidarr is off, and with it the resolver had stopped merging: it only merged when Lidarr had said which copies it mapped. Minstrel is now the only program writing the library, so the rule is about the copies: - A song held twice on one album: the extra copies merge into the one whose tags fit best (then lossless, larger, older). - Across releases or under another title: when every copy but one is a video rip (held back, or named like one and not marked fine), the rips merge into the one that is not. - Clean copies on different releases are linked as one song, as before. - Anything else waits for the operator. Gone with Lidarr: release switches and their settle window, the unmapped-file read, the rip search, the merge guard and its 409/503, lidarr.Client's release methods, and duplicate_group_members.lidarr_state (migration 0078). The duplicates page drops the per-copy Lidarr badge and the blocked-merge notice; past release changes stay in its history. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,6 @@ package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -12,128 +10,9 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
||||
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||
)
|
||||
|
||||
func TestLidarrPathKey(t *testing.T) {
|
||||
// Minstrel and Lidarr see the library under different mounts; the key is
|
||||
// what they agree on.
|
||||
a := LidarrPathKey("/music/Gorillaz/Humanz (2017)/Gorillaz - Humanz - 01 - Ascension.mp3")
|
||||
b := LidarrPathKey("/data/media/music/Gorillaz/Humanz (2017)/Gorillaz - Humanz - 01 - Ascension.mp3")
|
||||
if a != b || a != "Gorillaz/Humanz (2017)/Gorillaz - Humanz - 01 - Ascension.mp3" {
|
||||
t.Errorf("keys differ or are wrong: %q, %q", a, b)
|
||||
}
|
||||
if LidarrPathKey("/music/Gorillaz/Humanz (2017)/a.mp3") == LidarrPathKey("/music/Gorillaz/Demon Days (2005)/a.mp3") {
|
||||
t.Error("two albums' files share a key")
|
||||
}
|
||||
}
|
||||
|
||||
func rt(titles ...string) []lidarr.ReleaseTrack {
|
||||
out := make([]lidarr.ReleaseTrack, len(titles))
|
||||
for i, title := range titles {
|
||||
out[i] = lidarr.ReleaseTrack{ID: i + 1, Title: title}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestReleaseRepeats(t *testing.T) {
|
||||
if !releaseRepeats(rt("Ascension", "Strobelite", "Ascension")) {
|
||||
t.Error("the box set lists Ascension twice, but no repeat was found")
|
||||
}
|
||||
if releaseRepeats(rt("Charger", "Charger (demo)")) {
|
||||
t.Error("a demo was read as a repeat of the song")
|
||||
}
|
||||
}
|
||||
|
||||
// The Humanz releases as Lidarr lists them (2026-10-09): the box set is
|
||||
// monitored, and the 40-track digital release is the one that lists each song
|
||||
// once and covers what is on disk.
|
||||
func TestPickRelease(t *testing.T) {
|
||||
digital40 := lidarr.AlbumRelease{ID: 10149, Format: "Digital Media", TrackCount: 40}
|
||||
deluxe26 := lidarr.AlbumRelease{ID: 10150, Format: "CD", TrackCount: 26}
|
||||
standard20 := lidarr.AlbumRelease{ID: 10151, Format: "Digital Media", TrackCount: 20}
|
||||
|
||||
got, ok := pickRelease([]releaseCandidate{
|
||||
{release: standard20, coverage: 20}, {release: deluxe26, coverage: 26}, {release: digital40, coverage: 34},
|
||||
}, 34)
|
||||
if !ok || got.ID != digital40.ID {
|
||||
t.Errorf("picked %+v (ok=%v), want the 40-track digital release", got, ok)
|
||||
}
|
||||
|
||||
// Equal coverage: the plainer edition, then digital.
|
||||
cd := lidarr.AlbumRelease{ID: 1, Format: "CD", TrackCount: 26}
|
||||
web := lidarr.AlbumRelease{ID: 2, Format: "Digital Media", TrackCount: 26}
|
||||
if got, _ := pickRelease([]releaseCandidate{{release: cd, coverage: 26}, {release: web, coverage: 26}}, 26); got.ID != web.ID {
|
||||
t.Errorf("picked %+v, want the digital one of two equal releases", got)
|
||||
}
|
||||
|
||||
// Never a release that keeps fewer of the songs on disk than the current one.
|
||||
if got, ok := pickRelease([]releaseCandidate{{release: deluxe26, coverage: 26}}, 34); ok {
|
||||
t.Errorf("picked %+v, which covers fewer songs on disk than the current release", got)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeLidarr is one Lidarr album with several releases. Switching the
|
||||
// monitored release changes what ListAlbumTracks answers, as in Lidarr.
|
||||
type fakeLidarr struct {
|
||||
unmapped []string
|
||||
unmappedErr error
|
||||
album lidarr.LidarrAlbum
|
||||
releases []lidarr.AlbumRelease
|
||||
releaseTracks map[int][]lidarr.ReleaseTrack
|
||||
setCalls [][2]int
|
||||
searchCalls [][]int
|
||||
// setErr is what SetMonitoredRelease answers after applying the change:
|
||||
// Lidarr timing out on a change that landed.
|
||||
setErr error
|
||||
}
|
||||
|
||||
func (f *fakeLidarr) ListUnmappedTrackFiles(context.Context) ([]lidarr.TrackFile, error) {
|
||||
out := make([]lidarr.TrackFile, len(f.unmapped))
|
||||
for i, p := range f.unmapped {
|
||||
out[i] = lidarr.TrackFile{ID: i + 1, Path: p}
|
||||
}
|
||||
return out, f.unmappedErr
|
||||
}
|
||||
|
||||
func (f *fakeLidarr) LookupAlbumByMBID(_ context.Context, mbid string) (lidarr.LidarrAlbum, error) {
|
||||
if f.album.ForeignAlbumID != mbid {
|
||||
return lidarr.LidarrAlbum{}, lidarr.ErrNotFound
|
||||
}
|
||||
return f.album, nil
|
||||
}
|
||||
|
||||
func (f *fakeLidarr) ListAlbumTracks(context.Context, int) ([]lidarr.ReleaseTrack, error) {
|
||||
for _, r := range f.releases {
|
||||
if r.Monitored {
|
||||
return f.releaseTracks[r.ID], nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeLidarr) GetAlbumReleases(context.Context, int) ([]lidarr.AlbumRelease, error) {
|
||||
return append([]lidarr.AlbumRelease(nil), f.releases...), nil
|
||||
}
|
||||
|
||||
func (f *fakeLidarr) ListReleaseTracks(_ context.Context, releaseID int) ([]lidarr.ReleaseTrack, error) {
|
||||
return f.releaseTracks[releaseID], nil
|
||||
}
|
||||
|
||||
func (f *fakeLidarr) SearchAlbums(_ context.Context, albumIDs []int) error {
|
||||
f.searchCalls = append(f.searchCalls, albumIDs)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeLidarr) SetMonitoredRelease(_ context.Context, albumID, releaseID int) error {
|
||||
f.setCalls = append(f.setCalls, [2]int{albumID, releaseID})
|
||||
for i := range f.releases {
|
||||
f.releases[i].Monitored = f.releases[i].ID == releaseID
|
||||
}
|
||||
return f.setErr
|
||||
}
|
||||
|
||||
// resolveFixture is the Humanz shape: one album, one song twice, both copies on
|
||||
// disk under an artist/album folder, in one pending exact-tier group.
|
||||
type resolveFixture struct {
|
||||
@@ -178,7 +57,6 @@ func newResolveFixture(t *testing.T) resolveFixture {
|
||||
}
|
||||
}
|
||||
exec(`UPDATE tracks SET title = 'Saturnz Barz' WHERE id = $1`, f.clean.ID)
|
||||
exec(`UPDATE albums SET release_group_mbid = 'rg-humanz' WHERE id = $1`, album.ID)
|
||||
if err := pool.QueryRow(ctx,
|
||||
`INSERT INTO duplicate_groups (member_key, tier) VALUES ('resolve-fixture', 'exact') RETURNING id`,
|
||||
).Scan(&f.groupID); err != nil {
|
||||
@@ -188,9 +66,6 @@ func newResolveFixture(t *testing.T) resolveFixture {
|
||||
return f
|
||||
}
|
||||
|
||||
// lidarrPath is where Lidarr, mounted elsewhere, sees a fixture file.
|
||||
func lidarrPath(p string) string { return "/music/" + LidarrPathKey(p) }
|
||||
|
||||
func (f resolveFixture) group(t *testing.T) (status string, class *string, note *string, auto bool) {
|
||||
t.Helper()
|
||||
if err := f.pool.QueryRow(context.Background(),
|
||||
@@ -203,18 +78,32 @@ func (f resolveFixture) group(t *testing.T) (status string, class *string, note
|
||||
|
||||
func exists(p string) bool { _, err := os.Stat(p); return err == nil }
|
||||
|
||||
// Lidarr maps the clean copy and holds the rip unmapped: the rip goes, the
|
||||
// clean copy stays, and the merge is recorded as the resolver's.
|
||||
func TestResolveDuplicates_MergesTheUnmappedCopy_Integration(t *testing.T) {
|
||||
f := newResolveFixture(t)
|
||||
lid := &fakeLidarr{unmapped: []string{lidarrPath(f.ripPath)}}
|
||||
// autoMergeAudit reads the resolver's audit row for a group: how many there
|
||||
// are, and the reason the last one gave for its survivor.
|
||||
func autoMergeAudit(t *testing.T, pool *pgxpool.Pool, groupID pgtype.UUID) (n int, reason string) {
|
||||
t.Helper()
|
||||
if err := pool.QueryRow(context.Background(),
|
||||
`SELECT count(*), coalesce(max(metadata->>'survivor_reason'), '') FROM audit_log
|
||||
WHERE action = 'duplicate_merge' AND actor_id IS NULL
|
||||
AND metadata->>'group_id' = $1 AND (metadata->>'automatic')::boolean`,
|
||||
syncpkg.FormatUUID(groupID)).Scan(&n, &reason); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n, reason
|
||||
}
|
||||
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true)
|
||||
// One album holding a song twice: the extra copy goes, the copy whose name and
|
||||
// tags fit the album stays, and the merge is recorded as the resolver's. No
|
||||
// Lidarr is asked (M502 #5517): Minstrel is the only program writing the
|
||||
// library.
|
||||
func TestResolveDuplicates_MergesASameReleaseCopy_Integration(t *testing.T) {
|
||||
f := newResolveFixture(t)
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if res.Merged != 1 || !res.LidarrConsulted {
|
||||
t.Fatalf("result = %+v, want one merge with Lidarr consulted", res)
|
||||
if res.Merged != 1 || res.Classes[ClassSameRelease] != 1 {
|
||||
t.Fatalf("result = %+v, want the same-release group merged", res)
|
||||
}
|
||||
if exists(f.ripPath) || !exists(f.cleanPath) {
|
||||
t.Errorf("rip exists=%v clean exists=%v; want only the clean copy left", exists(f.ripPath), exists(f.cleanPath))
|
||||
@@ -223,138 +112,24 @@ func TestResolveDuplicates_MergesTheUnmappedCopy_Integration(t *testing.T) {
|
||||
if status != "merged" || class == nil || *class != string(ClassSameRelease) || !auto {
|
||||
t.Errorf("group = (%s, %v, auto=%v), want merged same_release by the resolver", status, class, auto)
|
||||
}
|
||||
var audits int
|
||||
if err := f.pool.QueryRow(context.Background(),
|
||||
`SELECT count(*) FROM audit_log WHERE action = 'duplicate_merge' AND actor_id IS NULL
|
||||
AND metadata->>'group_id' = $1 AND (metadata->>'automatic')::boolean`,
|
||||
syncpkg.FormatUUID(f.groupID)).Scan(&audits); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if audits != 1 {
|
||||
t.Errorf("automatic merge audit rows = %d, want 1", audits)
|
||||
if n, _ := autoMergeAudit(t, f.pool, f.groupID); n != 1 {
|
||||
t.Errorf("automatic merge audit rows = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Lidarr maps both copies, and its release lists the song twice: nothing is
|
||||
// deleted, the release moves to the one listing each song once, and the next
|
||||
// pass leaves it there (lesson #4183's fixed point).
|
||||
func TestResolveDuplicates_ChangesARepeatingRelease_Integration(t *testing.T) {
|
||||
// With auto-resolve off, the pass classifies and removes nothing.
|
||||
func TestResolveDuplicates_ClassifiesOnlyWithoutPermission_Integration(t *testing.T) {
|
||||
f := newResolveFixture(t)
|
||||
lid := &fakeLidarr{
|
||||
album: lidarr.LidarrAlbum{ID: 4723, ForeignAlbumID: "rg-humanz", Title: "Humanz"},
|
||||
releases: []lidarr.AlbumRelease{
|
||||
{ID: 10148, Format: "14x12\" Vinyl, Digital Media", TrackCount: 4, Monitored: true},
|
||||
{ID: 10149, Format: "Digital Media", TrackCount: 2},
|
||||
},
|
||||
releaseTracks: map[int][]lidarr.ReleaseTrack{
|
||||
10148: rt("Saturnz Barz", "Ascension", "Saturnz Barz", "Ascension"),
|
||||
10149: rt("Saturnz Barz", "Ascension"),
|
||||
},
|
||||
}
|
||||
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if res.Merged != 0 || !exists(f.ripPath) || !exists(f.cleanPath) {
|
||||
t.Fatalf("merged %d; a copy Lidarr maps was touched", res.Merged)
|
||||
}
|
||||
if len(lid.setCalls) != 1 || lid.setCalls[0] != [2]int{4723, 10149} {
|
||||
t.Fatalf("release changes = %v, want album 4723 to release 10149", lid.setCalls)
|
||||
}
|
||||
status, _, note, _ := f.group(t)
|
||||
if status != "pending" || note == nil || *note == "" {
|
||||
t.Errorf("group = (%s, note %v), want pending with a note saying what changed", status, note)
|
||||
}
|
||||
|
||||
// The next pass: Lidarr has not rescanned yet, so both copies still read
|
||||
// as tracked. The album is settling, so nothing changes.
|
||||
if _, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true); err != nil {
|
||||
t.Fatalf("second resolve: %v", err)
|
||||
}
|
||||
if len(lid.setCalls) != 1 {
|
||||
t.Errorf("release changed again: %v", lid.setCalls)
|
||||
}
|
||||
// And once it has settled, the check itself finds nothing to do: the
|
||||
// release it chose lists each song once.
|
||||
change, _, err := changeRelease(context.Background(), dbq.New(f.pool), lid,
|
||||
&repeatingAlbum{albumID: f.clean.AlbumID, releaseGroupMbid: "rg-humanz"})
|
||||
if err != nil || change != nil || len(lid.setCalls) != 1 {
|
||||
t.Errorf("after the change, changeRelease = (%+v, %v) with %d calls; want no change", change, err, len(lid.setCalls))
|
||||
}
|
||||
}
|
||||
|
||||
// After a release change Lidarr unmaps every file of the album until it has
|
||||
// rescanned. A pass in that window must not read "every copy unmapped" as
|
||||
// licence to merge: the copy it would remove may be the one Lidarr maps next.
|
||||
func TestResolveDuplicates_LeavesAnAlbumAloneWhileLidarrRescans_Integration(t *testing.T) {
|
||||
f := newResolveFixture(t)
|
||||
lid := &fakeLidarr{
|
||||
album: lidarr.LidarrAlbum{ID: 4723, ForeignAlbumID: "rg-humanz"},
|
||||
releases: []lidarr.AlbumRelease{
|
||||
{ID: 10148, TrackCount: 2, Monitored: true}, {ID: 10149, TrackCount: 1},
|
||||
},
|
||||
releaseTracks: map[int][]lidarr.ReleaseTrack{10148: rt("Saturnz Barz", "Saturnz Barz"), 10149: rt("Saturnz Barz")},
|
||||
}
|
||||
if _, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lid.unmapped = []string{lidarrPath(f.cleanPath), lidarrPath(f.ripPath)}
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true)
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Merged != 0 || !exists(f.ripPath) || !exists(f.cleanPath) {
|
||||
t.Errorf("merged %d during the rescan window", res.Merged)
|
||||
if res.Merged != 0 || !exists(f.ripPath) {
|
||||
t.Errorf("merged %d", res.Merged)
|
||||
}
|
||||
}
|
||||
|
||||
// Without Lidarr, or with auto-resolve off, the pass classifies and removes
|
||||
// nothing.
|
||||
func TestResolveDuplicates_ClassifiesOnlyWithoutLidarrOrPermission_Integration(t *testing.T) {
|
||||
for name, run := range map[string]func(resolveFixture) (DuplicateResolveResult, error){
|
||||
"lidarr disabled": func(f resolveFixture) (DuplicateResolveResult, error) {
|
||||
return ResolveDuplicates(context.Background(), f.pool, nil, "", nil, true)
|
||||
},
|
||||
"lidarr unreachable": func(f resolveFixture) (DuplicateResolveResult, error) {
|
||||
return ResolveDuplicates(context.Background(), f.pool, nil, "", &fakeLidarr{unmappedErr: errors.New("timeout")}, true)
|
||||
},
|
||||
"auto-resolve off": func(f resolveFixture) (DuplicateResolveResult, error) {
|
||||
lid := &fakeLidarr{unmapped: []string{lidarrPath(f.ripPath)}}
|
||||
return ResolveDuplicates(context.Background(), f.pool, nil, "", lid, false)
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
f := newResolveFixture(t)
|
||||
res, err := run(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Merged != 0 || !exists(f.ripPath) {
|
||||
t.Errorf("merged %d", res.Merged)
|
||||
}
|
||||
status, class, _, _ := f.group(t)
|
||||
if status != "pending" || class == nil || *class != string(ClassSameRelease) {
|
||||
t.Errorf("group = (%s, %v), want pending and classified", status, class)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The guard itself: a merge asked to remove a copy the check refuses changes
|
||||
// nothing, file or row.
|
||||
func TestMergeDuplicateGroupGuarded_RefusesATrackedCopy_Integration(t *testing.T) {
|
||||
f := newResolveFixture(t)
|
||||
refuse := func(p string) bool { return p != f.ripPath }
|
||||
_, err := MergeDuplicateGroupGuarded(context.Background(), f.pool, nil, "", f.groupID, f.clean.ID, refuse)
|
||||
if !errors.Is(err, ErrCopyTrackedByLidarr) {
|
||||
t.Fatalf("err = %v, want ErrCopyTrackedByLidarr", err)
|
||||
}
|
||||
if !exists(f.ripPath) {
|
||||
t.Error("the refused copy's file was removed")
|
||||
}
|
||||
if status, _, _, _ := f.group(t); status != "pending" {
|
||||
t.Errorf("group status = %s, want pending", status)
|
||||
status, class, _, _ := f.group(t)
|
||||
if status != "pending" || class == nil || *class != string(ClassSameRelease) {
|
||||
t.Errorf("group = (%s, %v), want pending and classified", status, class)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,12 +191,12 @@ func newCrossReleaseRipFixture(t *testing.T) crossReleaseRipFixture {
|
||||
return f
|
||||
}
|
||||
|
||||
// The rip fulfils nothing in Lidarr while the clean copy on the album does:
|
||||
// the rip goes and the album copy stays (#5439, D-a rule 1).
|
||||
func TestResolveDuplicates_MergesAnUnmappedRipAcrossReleases_Integration(t *testing.T) {
|
||||
// A video rip on one release with the clean copy on another: the rip is no
|
||||
// release's copy, so it goes and the album cut stays (#5439, now without
|
||||
// Lidarr).
|
||||
func TestResolveDuplicates_MergesARipAcrossReleases_Integration(t *testing.T) {
|
||||
f := newCrossReleaseRipFixture(t)
|
||||
lid := &fakeLidarr{unmapped: []string{lidarrPath(f.ripPath)}}
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true)
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -431,21 +206,65 @@ func TestResolveDuplicates_MergesAnUnmappedRipAcrossReleases_Integration(t *test
|
||||
if exists(f.ripPath) || !exists(f.cleanPath) {
|
||||
t.Errorf("rip exists %v, clean exists %v; want only the clean copy", exists(f.ripPath), exists(f.cleanPath))
|
||||
}
|
||||
if _, reason := autoMergeAudit(t, f.pool, f.groupID); reason != "the copy that is not a video rip" {
|
||||
t.Errorf("survivor reason %q, want the rip rule", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// When Lidarr maps both copies, each fulfils its own release: nothing is
|
||||
// removed, and the copies are linked as one song instead.
|
||||
func TestResolveDuplicates_KeepsCopiesLidarrMapsOnTwoReleases_Integration(t *testing.T) {
|
||||
// Copies on two releases that are both clean each belong to their release:
|
||||
// nothing is removed, and they are linked as one song. A rip the operator
|
||||
// marked fine counts as clean.
|
||||
func TestResolveDuplicates_KeepsCleanCopiesOnTwoReleases_Integration(t *testing.T) {
|
||||
for name, prepare := range map[string]func(f crossReleaseRipFixture) (string, []any){
|
||||
"renamed": func(f crossReleaseRipFixture) (string, []any) {
|
||||
return `UPDATE tracks SET title = 'Feel Good Inc.', file_path = $2 WHERE id = $1`,
|
||||
[]any{f.rip.ID, filepath.Join(filepath.Dir(f.ripPath), "01 - Feel Good Inc.mp3")}
|
||||
},
|
||||
"kept as fine": func(f crossReleaseRipFixture) (string, []any) {
|
||||
return `UPDATE tracks SET source_verdict = 'fine' WHERE id = $1`, []any{f.rip.ID}
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
f := newCrossReleaseRipFixture(t)
|
||||
sql, args := prepare(f)
|
||||
if _, err := f.pool.Exec(context.Background(), sql, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Merged != 0 || !exists(f.ripPath) {
|
||||
t.Errorf("merged %d; want both copies kept", res.Merged)
|
||||
}
|
||||
if res.SongsLinked != 1 {
|
||||
t.Errorf("linked %d, want the pair linked as one song", res.SongsLinked)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Identical audio under two titles on two albums, neither a rip: which tags
|
||||
// are wrong is the operator's call, so nothing is merged or linked.
|
||||
func TestResolveDuplicates_LeavesAMismatchForTheOperator_Integration(t *testing.T) {
|
||||
f := newCrossReleaseRipFixture(t)
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", &fakeLidarr{}, true)
|
||||
ctx := context.Background()
|
||||
if _, err := f.pool.Exec(ctx, `UPDATE duplicate_groups SET tier = 'exact' WHERE id = $1`, f.groupID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.pool.Exec(ctx, `UPDATE tracks SET title = 'DARE', file_path = $2 WHERE id = $1`,
|
||||
f.rip.ID, filepath.Join(filepath.Dir(f.ripPath), "01 - DARE.mp3")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := ResolveDuplicates(ctx, f.pool, nil, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Merged != 0 || !exists(f.ripPath) {
|
||||
t.Errorf("merged %d; want both copies kept", res.Merged)
|
||||
if res.Classes[ClassMismatch] != 1 || res.Merged != 0 || res.SongsLinked != 0 {
|
||||
t.Fatalf("classes %v, merged %d, linked %d; want a mismatch left alone", res.Classes, res.Merged, res.SongsLinked)
|
||||
}
|
||||
if res.SongsLinked != 1 {
|
||||
t.Errorf("linked %d, want the pair linked as one song", res.SongsLinked)
|
||||
if status, _, note, _ := (resolveFixture{pool: f.pool, groupID: f.groupID}).group(t); status != "pending" || note == nil || *note == "" {
|
||||
t.Errorf("group = (%s, note %v), want pending with a note", status, note)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,6 +274,11 @@ func TestResolveDuplicates_HoldsBackRips_Integration(t *testing.T) {
|
||||
f := newCrossReleaseRipFixture(t)
|
||||
ctx := context.Background()
|
||||
q := dbq.New(f.pool)
|
||||
// Without its group the rip has no clean copy to merge into, so it stays
|
||||
// on disk and only its verdict changes.
|
||||
if _, err := f.pool.Exec(ctx, `DELETE FROM duplicate_groups WHERE id = $1`, f.groupID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verdict := func(id pgtype.UUID) *string {
|
||||
t.Helper()
|
||||
var v *string
|
||||
@@ -465,7 +289,7 @@ func TestResolveDuplicates_HoldsBackRips_Integration(t *testing.T) {
|
||||
}
|
||||
resolve := func() DuplicateResolveResult {
|
||||
t.Helper()
|
||||
res, err := ResolveDuplicates(ctx, f.pool, nil, "", &fakeLidarr{}, true)
|
||||
res, err := ResolveDuplicates(ctx, f.pool, nil, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -512,123 +336,3 @@ func TestResolveDuplicates_HoldsBackRips_Integration(t *testing.T) {
|
||||
t.Errorf("set on an unflagged track: %d, %v; want 0 rows", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A rip that is the only copy of its song: Lidarr is asked to search its album
|
||||
// for a better release (#5447), once a week at most, and the rip is not touched.
|
||||
// A rip with a clean copy elsewhere is not searched for.
|
||||
func TestResolveDuplicates_SearchesForASoleCopyRip_Integration(t *testing.T) {
|
||||
f := newCrossReleaseRipFixture(t)
|
||||
ctx := context.Background()
|
||||
exec := func(sql string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := f.pool.Exec(ctx, sql, args...); err != nil {
|
||||
t.Fatalf("exec %q: %v", sql, err)
|
||||
}
|
||||
}
|
||||
// The single's rip has a clean copy on the album: never searched for.
|
||||
exec(`UPDATE albums SET release_group_mbid = 'rg-single' WHERE id = $1`, f.rip.AlbumID)
|
||||
lid := &fakeLidarr{album: lidarr.LidarrAlbum{ID: 77, ForeignAlbumID: "rg-single"}}
|
||||
if _, err := ResolveDuplicates(ctx, f.pool, nil, "", lid, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(lid.searchCalls) != 0 {
|
||||
t.Fatalf("searched %v while a clean copy exists", lid.searchCalls)
|
||||
}
|
||||
|
||||
// Without the group and the song link, the rip is the only copy.
|
||||
exec(`DELETE FROM duplicate_groups WHERE id = $1`, f.groupID)
|
||||
exec(`UPDATE tracks SET song_id = NULL WHERE id IN ($1, $2)`, f.rip.ID, f.clean.ID)
|
||||
res, err := ResolveDuplicates(ctx, f.pool, nil, "", lid, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.RipSearches != 1 || len(lid.searchCalls) != 1 || len(lid.searchCalls[0]) != 1 || lid.searchCalls[0][0] != 77 {
|
||||
t.Fatalf("rip searches %d, calls %v; want album 77 searched once", res.RipSearches, lid.searchCalls)
|
||||
}
|
||||
if !exists(f.ripPath) {
|
||||
t.Error("the rip was removed; a search must not touch it")
|
||||
}
|
||||
|
||||
// The next pass, inside the week, leaves the album alone.
|
||||
if res, err := ResolveDuplicates(ctx, f.pool, nil, "", lid, true); err != nil || res.RipSearches != 0 {
|
||||
t.Errorf("second pass searched %d (%v), want 0", res.RipSearches, err)
|
||||
}
|
||||
var searchedAt pgtype.Timestamptz
|
||||
rows, err := dbq.New(f.pool).ListSuspectSourceTracks(ctx, dbq.ListSuspectSourceTracksParams{
|
||||
Pattern: SuspectSourcePattern, PageLimit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.ID == f.rip.ID {
|
||||
searchedAt = r.SearchedAt
|
||||
}
|
||||
}
|
||||
if !searchedAt.Valid {
|
||||
t.Error("the suspect-sources report doesn't show the search")
|
||||
}
|
||||
}
|
||||
|
||||
// Lidarr applies the release change but the PUT times out (Cracker Island on
|
||||
// the first deploy pass). The change is read back and recorded, so the album
|
||||
// settles; when it did not land, the error stands and nothing is recorded.
|
||||
func TestResolveDuplicates_RecordsAReleaseChangeLidarrDidNotAnswer_Integration(t *testing.T) {
|
||||
for name, landed := range map[string]bool{"landed": true, "did not land": false} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
f := newResolveFixture(t)
|
||||
lid := &fakeLidarr{
|
||||
album: lidarr.LidarrAlbum{ID: 4706, ForeignAlbumID: "rg-humanz", Title: "Cracker Island"},
|
||||
releases: []lidarr.AlbumRelease{
|
||||
{ID: 1, Format: "Digital Media", TrackCount: 4, Monitored: true},
|
||||
{ID: 2, Format: "Digital Media", TrackCount: 2},
|
||||
},
|
||||
releaseTracks: map[int][]lidarr.ReleaseTrack{
|
||||
1: rt("Saturnz Barz", "Ascension", "Saturnz Barz", "Ascension"),
|
||||
2: rt("Saturnz Barz", "Ascension"),
|
||||
},
|
||||
setErr: fmt.Errorf("%w: context deadline exceeded", lidarr.ErrUnreachable),
|
||||
}
|
||||
if !landed {
|
||||
lid.setErr = fmt.Errorf("%w: connection refused", lidarr.ErrUnreachable)
|
||||
}
|
||||
// ResetDB keeps audit_log, so other tests' release changes are
|
||||
// still in it: count what this pass adds.
|
||||
audited := func() (n int) {
|
||||
t.Helper()
|
||||
if err := f.pool.QueryRow(context.Background(),
|
||||
`SELECT count(*) FROM audit_log WHERE action = 'lidarr_release_change'`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
before := audited()
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", &undoingLidarr{fakeLidarr: lid, undo: !landed}, true)
|
||||
if landed {
|
||||
if err != nil || len(res.ReleaseChanges) != 1 {
|
||||
t.Fatalf("changes %d (%v), want the landed change recorded", len(res.ReleaseChanges), err)
|
||||
}
|
||||
} else if len(res.ReleaseChanges) != 0 {
|
||||
t.Fatalf("recorded %d changes that did not land", len(res.ReleaseChanges))
|
||||
}
|
||||
if got, want := audited()-before, map[bool]int{true: 1, false: 0}[landed]; got != want {
|
||||
t.Errorf("audited %d release changes, want %d", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// undoingLidarr is a Lidarr whose release change fails without landing when
|
||||
// undo is set: the PUT never reached it.
|
||||
type undoingLidarr struct {
|
||||
*fakeLidarr
|
||||
undo bool
|
||||
}
|
||||
|
||||
func (u *undoingLidarr) SetMonitoredRelease(ctx context.Context, albumID, releaseID int) error {
|
||||
if !u.undo {
|
||||
return u.fakeLidarr.SetMonitoredRelease(ctx, albumID, releaseID)
|
||||
}
|
||||
u.setCalls = append(u.setCalls, [2]int{albumID, releaseID})
|
||||
return u.setErr
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user