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:
@@ -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
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user