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

- 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:
2026-10-08 22:13:37 -04:00
co-authored by Claude Opus 5.5
parent 5b372f61d7
commit 53eb954c86
26 changed files with 638 additions and 63 deletions
+43 -3
View File
@@ -91,6 +91,9 @@ type DuplicateResolveResult struct {
ReleaseChanges []ReleaseChange
// SongsLinked counts cross-release groups newly linked as one song.
SongsLinked int
// HeldBack counts tracks newly held back from radio and the mixes as video
// rips; Released counts held-back tracks whose name no longer says so.
HeldBack, Released int64
}
// LidarrPathKey is the part of a path Minstrel and Lidarr agree on: the last
@@ -115,6 +118,35 @@ type resolveGroup struct {
states []string // per member: tracked, unmapped, or "" when Lidarr was not asked
}
// autoMergeable says whether the resolver may merge the group on its own: when
// every copy it would remove is one Lidarr does not map (D-a rule 1). On one
// album that is any group with at most one mapped copy. Across releases each
// mapped copy fulfils its own release (D-b), so only a group with exactly one
// mapped copy qualifies: the others fulfil nothing — a rip beside the clean
// copy on another release, or a wrong-file import Lidarr holds unmapped
// (#5439) — and the mapped copy is the clear one to keep.
func autoMergeable(g *resolveGroup) bool {
switch g.class {
case ClassSameRelease:
return g.tracked() <= 1
case ClassCrossRelease, ClassMismatch:
return g.tracked() == 1
default:
return false
}
}
// settling says whether any of the group's albums had its Lidarr release
// changed recently, so Lidarr may still be remapping its files.
func (g *resolveGroup) settling(albums map[string]bool) bool {
for _, m := range g.members {
if albums[syncpkg.FormatUUID(m.AlbumID)] {
return true
}
}
return false
}
func (g *resolveGroup) tracked() int {
n := 0
for _, s := range g.states {
@@ -183,7 +215,14 @@ func ResolveDuplicates(
return res, nil
}
// Linking removes nothing and needs nothing from Lidarr.
// Holding rips back and linking songs remove nothing and need nothing from
// Lidarr.
if res.HeldBack, err = q.FlagSuspectSourceTracks(ctx, SuspectSourcePattern); err != nil {
return res, fmt.Errorf("hold back video rips: %w", err)
}
if res.Released, err = q.ClearStaleSuspectSourceTracks(ctx, SuspectSourcePattern); err != nil {
return res, fmt.Errorf("release renamed tracks: %w", err)
}
linked, err := linkCrossRelease(ctx, pool, logger, groups)
res.SongsLinked = linked
if err != nil {
@@ -208,7 +247,7 @@ func ResolveDuplicates(
if ctx.Err() != nil {
return res, ctx.Err()
}
if g.class != ClassSameRelease || g.tracked() > 1 || settling[syncpkg.FormatUUID(g.members[0].AlbumID)] {
if !autoMergeable(g) || g.settling(settling) {
continue
}
if err := autoMerge(ctx, pool, logger, dataDir, g, removable); err != nil {
@@ -747,5 +786,6 @@ func (w *DuplicateResolveWorker) tickOnce(ctx context.Context) {
}
w.logger.Info("duplicate resolve complete",
"groups", res.Groups, "lidarr", res.LidarrConsulted, "merged", res.Merged,
"merge_failed", res.MergeFailed, "release_changes", len(res.ReleaseChanges), "songs_linked", res.SongsLinked)
"merge_failed", res.MergeFailed, "release_changes", len(res.ReleaseChanges), "songs_linked", res.SongsLinked,
"held_back", res.HeldBack, "released", res.Released)
}
+155
View File
@@ -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)
}
}