feat: video rips and stray copies handle themselves (M498 #5439)
release / govulncheck (push) Successful in 25s
release / web (push) Failing after 26s
release / go (push) Failing after 58s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 3m25s
release / Build signed APK (releases and dev) (push) Canceled after 3m26s
release / android (push) Canceled after 3m28s
release / govulncheck (push) Successful in 25s
release / web (push) Failing after 26s
release / go (push) Failing after 58s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 3m25s
release / Build signed APK (releases and dev) (push) Canceled after 3m26s
release / android (push) Canceled after 3m28s
- The resolver also merges cross-release and mismatch groups where Lidarr
maps exactly one copy: the others fulfil nothing, so removing them opens no
hole (D-a rule 1). That covers a rip beside the clean copy on another
release and a wrong-file import Lidarr holds unmapped. With two or more
mapped copies each fulfils its own release and nothing is removed.
- A track whose file name carries a video-rip marker is held back from radio
and the system mixes (tracks.source_verdict, migration 0077). It still plays
when chosen. A renamed file is released; the operator's "fine" sticks.
- Suspect sources shows what was done to each track, with "This one is fine"
and "Hold back again" (PUT /api/admin/library/suspect-sources/{id}).
- The Liked list prefers a copy that is not held back.
Replacing a rip that has no clean copy is left for the operator to decide.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -347,3 +347,158 @@ func TestMergeDuplicateGroupGuarded_RefusesATrackedCopy_Integration(t *testing.T
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user