package library import ( "context" "errors" "os" "path/filepath" "testing" "github.com/jackc/pgx/v5/pgtype" "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 } 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 nil } // 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 { pool *pgxpool.Pool clean, rip dbq.Track cleanPath, ripPath string groupID pgtype.UUID } func newResolveFixture(t *testing.T) resolveFixture { t.Helper() pool := newPool(t) ctx := context.Background() q := dbq.New(pool) dir := filepath.Join(t.TempDir(), "Gorillaz", "Humanz (2017)") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } f := resolveFixture{pool: pool} f.cleanPath = filepath.Join(dir, "Gorillaz - Humanz - 03 - Saturnz Barz.mp3") f.ripPath = filepath.Join(dir, "Gorillaz - Humanz - 03 - Saturnz Barz (Official Video).mp3") for _, p := range []string{f.cleanPath, f.ripPath} { if err := os.WriteFile(p, []byte("audio"), 0o644); err != nil { t.Fatal(err) } } var album dbq.Album var artist dbq.Artist f.clean, album, artist = seedTrack(t, pool, f.cleanPath) rip, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{ Title: "Saturnz Barz (Official Video)", AlbumID: album.ID, ArtistID: artist.ID, DurationMs: 1000, FilePath: f.ripPath, FileSize: 100, FileFormat: "mp3", }) if err != nil { t.Fatal(err) } f.rip = rip exec := func(sql string, args ...any) { t.Helper() if _, err := pool.Exec(ctx, sql, args...); err != nil { t.Fatalf("exec %q: %v", sql, err) } } 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 { t.Fatal(err) } exec(`INSERT INTO duplicate_group_members (group_id, track_id) VALUES ($1, $2), ($1, $3)`, f.groupID, f.clean.ID, f.rip.ID) 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(), `SELECT status, class, resolve_note, resolved_automatically FROM duplicate_groups WHERE id = $1`, f.groupID, ).Scan(&status, &class, ¬e, &auto); err != nil { t.Fatal(err) } return status, class, note, auto } 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)}} res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, 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 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)) } status, class, _, auto := f.group(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) } } // 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) { 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) 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) } } // 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) } } // crossReleaseRipFixture is a video rip on one release with the clean copy on // another: the album cut, and the same song's video filed as a single. type crossReleaseRipFixture struct { pool *pgxpool.Pool clean, rip dbq.Track cleanPath, ripPath string groupID pgtype.UUID } func newCrossReleaseRipFixture(t *testing.T) crossReleaseRipFixture { t.Helper() pool := newPool(t) ctx := context.Background() q := dbq.New(pool) root := t.TempDir() f := crossReleaseRipFixture{pool: pool} f.cleanPath = filepath.Join(root, "Gorillaz", "Demon Days (2005)", "06 - Feel Good Inc.mp3") f.ripPath = filepath.Join(root, "Gorillaz", "Feel Good Inc (2005)", "01 - Feel Good Inc. (Official Video).mp3") artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "Gorillaz", SortName: "Gorillaz"}) if err != nil { t.Fatal(err) } track := func(albumTitle, title, path string) dbq.Track { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte("audio"), 0o644); err != nil { t.Fatal(err) } album, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: albumTitle, SortTitle: albumTitle, ArtistID: artist.ID}) if err != nil { t.Fatal(err) } tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{ Title: title, AlbumID: album.ID, ArtistID: artist.ID, DurationMs: 1000, FilePath: path, FileSize: 100, FileFormat: "mp3", }) if err != nil { t.Fatal(err) } return tr } f.clean = track("Demon Days", "Feel Good Inc.", f.cleanPath) f.rip = track("Feel Good Inc.", "Feel Good Inc. (Official Video)", f.ripPath) if err := pool.QueryRow(ctx, `INSERT INTO duplicate_groups (member_key, tier) VALUES ('cross-rip', 'acoustic') RETURNING id`, ).Scan(&f.groupID); err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `INSERT INTO duplicate_group_members (group_id, track_id) VALUES ($1, $2), ($1, $3)`, f.groupID, f.clean.ID, f.rip.ID); err != nil { t.Fatal(err) } 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) { f := newCrossReleaseRipFixture(t) lid := &fakeLidarr{unmapped: []string{lidarrPath(f.ripPath)}} res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true) if err != nil { t.Fatal(err) } if res.Classes[ClassCrossRelease] != 1 || res.Merged != 1 { t.Fatalf("classes %v, merged %d; want the cross-release group merged", res.Classes, res.Merged) } 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)) } } // 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) { f := newCrossReleaseRipFixture(t) res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", &fakeLidarr{}, 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) } } // A rip is held back from radio and the mixes; the operator's "fine" sticks // through later passes; a renamed file is released. func TestResolveDuplicates_HoldsBackRips_Integration(t *testing.T) { f := newCrossReleaseRipFixture(t) ctx := context.Background() q := dbq.New(f.pool) verdict := func(id pgtype.UUID) *string { t.Helper() var v *string if err := f.pool.QueryRow(ctx, `SELECT source_verdict FROM tracks WHERE id = $1`, id).Scan(&v); err != nil { t.Fatal(err) } return v } resolve := func() DuplicateResolveResult { t.Helper() res, err := ResolveDuplicates(ctx, f.pool, nil, "", &fakeLidarr{}, true) if err != nil { t.Fatal(err) } return res } if res := resolve(); res.HeldBack != 1 { t.Errorf("held back %d, want the rip", res.HeldBack) } if v := verdict(f.rip.ID); v == nil || *v != "suspect" { t.Errorf("rip verdict %v, want suspect", v) } if v := verdict(f.clean.ID); v != nil { t.Errorf("clean copy verdict %v, want none", *v) } if n, err := q.SetTrackSourceVerdict(ctx, dbq.SetTrackSourceVerdictParams{Verdict: "fine", ID: f.rip.ID}); err != nil || n != 1 { t.Fatalf("set fine: %d, %v", n, err) } if res := resolve(); res.HeldBack != 0 { t.Errorf("held back %d after the operator's fine, want 0", res.HeldBack) } if v := verdict(f.rip.ID); v == nil || *v != "fine" { t.Errorf("rip verdict %v, want fine kept", v) } // Held back again, then renamed: the next pass lets it go. if _, err := q.SetTrackSourceVerdict(ctx, dbq.SetTrackSourceVerdictParams{Verdict: "suspect", ID: f.rip.ID}); err != nil { t.Fatal(err) } if _, err := f.pool.Exec(ctx, `UPDATE tracks SET file_path = $2 WHERE id = $1`, f.rip.ID, filepath.Join(filepath.Dir(f.ripPath), "01 - Feel Good Inc.mp3")); err != nil { t.Fatal(err) } if res := resolve(); res.Released != 1 { t.Errorf("released %d, want the renamed track", res.Released) } if v := verdict(f.rip.ID); v != nil { t.Errorf("renamed track verdict %v, want none", *v) } // The clean copy can't be flagged by hand: there is nothing to judge. if n, err := q.SetTrackSourceVerdict(ctx, dbq.SetTrackSourceVerdictParams{Verdict: "fine", ID: f.clean.ID}); err != nil || n != 0 { 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") } }