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

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:
2026-10-09 20:12:49 -04:00
co-authored by Claude Opus 5.5
parent 8bd48b4302
commit 5306a261c1
27 changed files with 301 additions and 1743 deletions
+20 -77
View File
@@ -210,32 +210,6 @@ func (q *Queries) GetLatestFingerprintComputedAt(ctx context.Context) (pgtype.Ti
return latest, err
}
const listAlbumPresentTrackTitles = `-- name: ListAlbumPresentTrackTitles :many
SELECT title FROM tracks WHERE album_id = $1 AND missing_since IS NULL
`
// The titles an album has on disk, for choosing the Lidarr release that covers
// them best (M498 #5437).
func (q *Queries) ListAlbumPresentTrackTitles(ctx context.Context, albumID pgtype.UUID) ([]string, error) {
rows, err := q.db.Query(ctx, listAlbumPresentTrackTitles, albumID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var title string
if err := rows.Scan(&title); err != nil {
return nil, err
}
items = append(items, title)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listDismissedDuplicateMemberSets = `-- name: ListDismissedDuplicateMemberSets :many
SELECT g.id, array_agg(m.track_id ORDER BY m.track_id)::uuid[] AS track_ids
FROM duplicate_groups g
@@ -343,6 +317,7 @@ SELECT g.id AS group_id,
t.id AS track_id,
t.album_id,
t.title,
t.source_verdict,
artists.name AS artist_name,
t.file_path,
t.file_format,
@@ -351,8 +326,6 @@ SELECT g.id AS group_id,
t.disc_number,
t.track_number,
(t.mbid IS NOT NULL)::boolean AS has_mbid,
albums.release_group_mbid,
albums.title AS album_title,
EXISTS (SELECT 1 FROM tracks o
WHERE o.album_id = t.album_id AND o.id <> t.id AND o.missing_since IS NULL
AND t.track_number IS NOT NULL
@@ -361,7 +334,6 @@ SELECT g.id AS group_id,
FROM duplicate_groups g
JOIN duplicate_group_members m ON m.group_id = g.id
JOIN tracks t ON t.id = m.track_id
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE g.status = 'pending'
AND t.missing_since IS NULL
@@ -369,29 +341,29 @@ SELECT g.id AS group_id,
`
type ListDuplicateGroupsForResolveRow struct {
GroupID pgtype.UUID
Tier string
TrackID pgtype.UUID
AlbumID pgtype.UUID
Title string
ArtistName string
FilePath string
FileFormat string
FileSize int64
AddedAt pgtype.Timestamptz
DiscNumber *int32
TrackNumber *int32
HasMbid bool
ReleaseGroupMbid *string
AlbumTitle string
PositionClash bool
GroupID pgtype.UUID
Tier string
TrackID pgtype.UUID
AlbumID pgtype.UUID
Title string
SourceVerdict *string
ArtistName string
FilePath string
FileFormat string
FileSize int64
AddedAt pgtype.Timestamptz
DiscNumber *int32
TrackNumber *int32
HasMbid bool
PositionClash bool
}
// Every pending group's present copies, for the resolver (M498 #5435): what it
// needs to classify the group and to choose the copy to keep. One row per copy,
// grouped by group. position_clash is true when another present track on the
// same album claims the same disc and track number: tags that do not fit the
// album, which the survivor rule weighs against a copy.
// album, which the survivor rule weighs against a copy. source_verdict says
// whether the copy is a video rip ('suspect') or one the operator kept ('fine').
func (q *Queries) ListDuplicateGroupsForResolve(ctx context.Context) ([]ListDuplicateGroupsForResolveRow, error) {
rows, err := q.db.Query(ctx, listDuplicateGroupsForResolve)
if err != nil {
@@ -407,6 +379,7 @@ func (q *Queries) ListDuplicateGroupsForResolve(ctx context.Context) ([]ListDupl
&i.TrackID,
&i.AlbumID,
&i.Title,
&i.SourceVerdict,
&i.ArtistName,
&i.FilePath,
&i.FileFormat,
@@ -415,8 +388,6 @@ func (q *Queries) ListDuplicateGroupsForResolve(ctx context.Context) ([]ListDupl
&i.DiscNumber,
&i.TrackNumber,
&i.HasMbid,
&i.ReleaseGroupMbid,
&i.AlbumTitle,
&i.PositionClash,
); err != nil {
return nil, err
@@ -487,7 +458,6 @@ SELECT p.id AS group_id,
p.detected_at,
p.class,
p.resolve_note,
m.lidarr_state,
t.disc_number,
t.track_number,
(t.mbid IS NOT NULL)::boolean AS has_mbid,
@@ -529,7 +499,6 @@ type ListPendingDuplicateGroupMembersRow struct {
DetectedAt pgtype.Timestamptz
Class *string
ResolveNote *string
LidarrState *string
DiscNumber *int32
TrackNumber *int32
HasMbid bool
@@ -552,8 +521,7 @@ type ListPendingDuplicateGroupMembersRow struct {
// newest first, flattened to one row per member so the handler folds them
// without a query per group. What each copy carries — likes and plays from
// every user — is here because it is what the operator weighs when deciding
// which copy to keep; Lidarr's view of it, because a copy Lidarr tracks cannot
// be removed without Lidarr downloading it again.
// which copy to keep.
func (q *Queries) ListPendingDuplicateGroupMembers(ctx context.Context, arg ListPendingDuplicateGroupMembersParams) ([]ListPendingDuplicateGroupMembersRow, error) {
rows, err := q.db.Query(ctx, listPendingDuplicateGroupMembers, arg.View, arg.PageOffset, arg.PageLimit)
if err != nil {
@@ -570,7 +538,6 @@ func (q *Queries) ListPendingDuplicateGroupMembers(ctx context.Context, arg List
&i.DetectedAt,
&i.Class,
&i.ResolveNote,
&i.LidarrState,
&i.DiscNumber,
&i.TrackNumber,
&i.HasMbid,
@@ -632,30 +599,6 @@ func (q *Queries) SetDuplicateGroupClasses(ctx context.Context, arg SetDuplicate
return err
}
const setDuplicateMemberLidarrStates = `-- name: SetDuplicateMemberLidarrStates :exec
UPDATE duplicate_group_members m
SET lidarr_state = NULLIF(v.state, '')
FROM (SELECT unnest($1::uuid[]) AS group_id,
unnest($2::uuid[]) AS track_id,
unnest($3::text[]) AS state) v
WHERE m.group_id = v.group_id
AND m.track_id = v.track_id
AND m.lidarr_state IS DISTINCT FROM NULLIF(v.state, '')
`
type SetDuplicateMemberLidarrStatesParams struct {
GroupIds []pgtype.UUID
TrackIds []pgtype.UUID
States []string
}
// Lidarr's view of each copy as of this pass; an empty state clears it (Lidarr
// not consulted). Unchanged rows are left alone.
func (q *Queries) SetDuplicateMemberLidarrStates(ctx context.Context, arg SetDuplicateMemberLidarrStatesParams) error {
_, err := q.db.Exec(ctx, setDuplicateMemberLidarrStates, arg.GroupIds, arg.TrackIds, arg.States)
return err
}
const startDuplicateSweep = `-- name: StartDuplicateSweep :one
INSERT INTO duplicate_sweeps DEFAULT VALUES RETURNING id, started_at
`
+2 -3
View File
@@ -337,9 +337,8 @@ type DuplicateGroup struct {
}
type DuplicateGroupMember struct {
GroupID pgtype.UUID
TrackID pgtype.UUID
LidarrState *string
GroupID pgtype.UUID
TrackID pgtype.UUID
}
type DuplicateSweep struct {
-72
View File
@@ -405,78 +405,6 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
return items, nil
}
const listAlbumsWithSoleCopyRips = `-- name: ListAlbumsWithSoleCopyRips :many
SELECT a.id AS album_id,
a.release_group_mbid::text AS release_group_mbid,
a.title AS album_title,
ar.name AS artist_name,
count(*)::bigint AS rips
FROM tracks t
JOIN albums a ON a.id = t.album_id
JOIN artists ar ON ar.id = a.artist_id
WHERE t.source_verdict = 'suspect'
AND t.missing_since IS NULL
AND a.release_group_mbid IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM tracks c
WHERE c.song_key = t.song_key AND c.id <> t.id
AND c.missing_since IS NULL AND c.source_verdict IS DISTINCT FROM 'suspect')
AND NOT EXISTS (
SELECT 1 FROM duplicate_group_members m
JOIN duplicate_groups g ON g.id = m.group_id AND g.status = 'pending'
JOIN duplicate_group_members m2 ON m2.group_id = g.id AND m2.track_id <> t.id
JOIN tracks c ON c.id = m2.track_id
WHERE m.track_id = t.id
AND c.missing_since IS NULL AND c.source_verdict IS DISTINCT FROM 'suspect')
AND NOT EXISTS (
SELECT 1 FROM audit_log l
WHERE l.action = 'lidarr_rip_search'
AND l.metadata->>'album_id' = a.id::text
AND l.created_at > now() - interval '7 days')
GROUP BY a.id, a.release_group_mbid, a.title, ar.name
ORDER BY count(*) DESC, a.id
LIMIT $1
`
type ListAlbumsWithSoleCopyRipsRow struct {
AlbumID pgtype.UUID
ReleaseGroupMbid string
AlbumTitle string
ArtistName string
Rips int64
}
// Albums holding a held-back video rip that is the only copy of its song
// (#5447): no present copy that is not a rip shares its song, and no pending
// duplicate group pairs it with one. Lidarr counts such a track fulfilled, so
// the resolver asks it to search the album for a better release. Albums
// searched within the last week are left out, most rips first.
func (q *Queries) ListAlbumsWithSoleCopyRips(ctx context.Context, pageLimit int32) ([]ListAlbumsWithSoleCopyRipsRow, error) {
rows, err := q.db.Query(ctx, listAlbumsWithSoleCopyRips, pageLimit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListAlbumsWithSoleCopyRipsRow
for rows.Next() {
var i ListAlbumsWithSoleCopyRipsRow
if err := rows.Scan(
&i.AlbumID,
&i.ReleaseGroupMbid,
&i.AlbumTitle,
&i.ArtistName,
&i.Rips,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listArtistTracksForUser = `-- name: ListArtistTracksForUser :many
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at,
albums.title AS album_title,
@@ -0,0 +1,3 @@
ALTER TABLE duplicate_group_members ADD COLUMN lidarr_state text;
ALTER TABLE duplicate_group_members ADD CONSTRAINT duplicate_group_members_lidarr_state_check
CHECK (lidarr_state IN ('tracked', 'unmapped'));
@@ -0,0 +1,9 @@
-- 0078_drop_duplicate_lidarr_state.up.sql — the duplicate resolver works
-- without Lidarr (Scribe milestone #502, #5517).
--
-- 0075 recorded Lidarr's view of each duplicate copy (tracked or unmapped)
-- because a copy Lidarr mapped could not be removed without Lidarr downloading
-- it again. Lidarr is gone: Minstrel is the only program writing the library,
-- so no copy is anyone else's to keep, and the column has nothing to say.
ALTER TABLE duplicate_group_members DROP CONSTRAINT IF EXISTS duplicate_group_members_lidarr_state_check;
ALTER TABLE duplicate_group_members DROP COLUMN IF EXISTS lidarr_state;
+4 -24
View File
@@ -131,8 +131,7 @@ SELECT count(*)::bigint
-- newest first, flattened to one row per member so the handler folds them
-- without a query per group. What each copy carries — likes and plays from
-- every user — is here because it is what the operator weighs when deciding
-- which copy to keep; Lidarr's view of it, because a copy Lidarr tracks cannot
-- be removed without Lidarr downloading it again.
-- which copy to keep.
WITH page AS (
SELECT g.id, g.tier, g.worst_bit_error_rate, g.detected_at, g.class, g.resolve_note
FROM duplicate_groups g
@@ -151,7 +150,6 @@ SELECT p.id AS group_id,
p.detected_at,
p.class,
p.resolve_note,
m.lidarr_state,
t.disc_number,
t.track_number,
(t.mbid IS NOT NULL)::boolean AS has_mbid,
@@ -201,12 +199,14 @@ SELECT count(*)::bigint
-- needs to classify the group and to choose the copy to keep. One row per copy,
-- grouped by group. position_clash is true when another present track on the
-- same album claims the same disc and track number: tags that do not fit the
-- album, which the survivor rule weighs against a copy.
-- album, which the survivor rule weighs against a copy. source_verdict says
-- whether the copy is a video rip ('suspect') or one the operator kept ('fine').
SELECT g.id AS group_id,
g.tier,
t.id AS track_id,
t.album_id,
t.title,
t.source_verdict,
artists.name AS artist_name,
t.file_path,
t.file_format,
@@ -215,8 +215,6 @@ SELECT g.id AS group_id,
t.disc_number,
t.track_number,
(t.mbid IS NOT NULL)::boolean AS has_mbid,
albums.release_group_mbid,
albums.title AS album_title,
EXISTS (SELECT 1 FROM tracks o
WHERE o.album_id = t.album_id AND o.id <> t.id AND o.missing_since IS NULL
AND t.track_number IS NOT NULL
@@ -225,7 +223,6 @@ SELECT g.id AS group_id,
FROM duplicate_groups g
JOIN duplicate_group_members m ON m.group_id = g.id
JOIN tracks t ON t.id = m.track_id
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE g.status = 'pending'
AND t.missing_since IS NULL
@@ -244,22 +241,5 @@ UPDATE duplicate_groups g
AND g.status = 'pending'
AND (g.class IS DISTINCT FROM v.class OR g.resolve_note IS DISTINCT FROM NULLIF(v.note, ''));
-- name: SetDuplicateMemberLidarrStates :exec
-- Lidarr's view of each copy as of this pass; an empty state clears it (Lidarr
-- not consulted). Unchanged rows are left alone.
UPDATE duplicate_group_members m
SET lidarr_state = NULLIF(v.state, '')
FROM (SELECT unnest(sqlc.arg(group_ids)::uuid[]) AS group_id,
unnest(sqlc.arg(track_ids)::uuid[]) AS track_id,
unnest(sqlc.arg(states)::text[]) AS state) v
WHERE m.group_id = v.group_id
AND m.track_id = v.track_id
AND m.lidarr_state IS DISTINCT FROM NULLIF(v.state, '');
-- name: MarkDuplicateGroupResolvedAutomatically :exec
UPDATE duplicate_groups SET resolved_automatically = true WHERE id = sqlc.arg(id);
-- name: ListAlbumPresentTrackTitles :many
-- The titles an album has on disk, for choosing the Lidarr release that covers
-- them best (M498 #5437).
SELECT title FROM tracks WHERE album_id = sqlc.arg(album_id) AND missing_since IS NULL;
-37
View File
@@ -334,40 +334,3 @@ UPDATE tracks
SET source_verdict = sqlc.arg(verdict)::text, source_verdict_at = now()
WHERE id = sqlc.arg(id)
AND source_verdict IS NOT NULL;
-- name: ListAlbumsWithSoleCopyRips :many
-- Albums holding a held-back video rip that is the only copy of its song
-- (#5447): no present copy that is not a rip shares its song, and no pending
-- duplicate group pairs it with one. Lidarr counts such a track fulfilled, so
-- the resolver asks it to search the album for a better release. Albums
-- searched within the last week are left out, most rips first.
SELECT a.id AS album_id,
a.release_group_mbid::text AS release_group_mbid,
a.title AS album_title,
ar.name AS artist_name,
count(*)::bigint AS rips
FROM tracks t
JOIN albums a ON a.id = t.album_id
JOIN artists ar ON ar.id = a.artist_id
WHERE t.source_verdict = 'suspect'
AND t.missing_since IS NULL
AND a.release_group_mbid IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM tracks c
WHERE c.song_key = t.song_key AND c.id <> t.id
AND c.missing_since IS NULL AND c.source_verdict IS DISTINCT FROM 'suspect')
AND NOT EXISTS (
SELECT 1 FROM duplicate_group_members m
JOIN duplicate_groups g ON g.id = m.group_id AND g.status = 'pending'
JOIN duplicate_group_members m2 ON m2.group_id = g.id AND m2.track_id <> t.id
JOIN tracks c ON c.id = m2.track_id
WHERE m.track_id = t.id
AND c.missing_since IS NULL AND c.source_verdict IS DISTINCT FROM 'suspect')
AND NOT EXISTS (
SELECT 1 FROM audit_log l
WHERE l.action = 'lidarr_rip_search'
AND l.metadata->>'album_id' = a.id::text
AND l.created_at > now() - interval '7 days')
GROUP BY a.id, a.release_group_mbid, a.title, ar.name
ORDER BY count(*) DESC, a.id
LIMIT sqlc.arg(page_limit);