feat: duplicates settle themselves: retitled copies across releases link, alike versions on one album are dismissed, and a file in another song's slot is removed so the album is fetched again (M498 #6159-#6162)
release / govulncheck (push) Successful in 14s
release / web (push) Failing after 1m26s
release / go (push) Successful in 1m35s
release / integration (push) Successful in 5m8s
release / android (push) Successful in 5m12s
release / Build signed APK (releases and dev) (push) Successful in 5m26s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 14s
release / web (push) Failing after 1m26s
release / go (push) Successful in 1m35s
release / integration (push) Successful in 5m8s
release / android (push) Successful in 5m12s
release / Build signed APK (releases and dev) (push) Successful in 5m26s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
The operator's library had 536 groups waiting for review, 2026-10-19. A sample
showed most were one recording under two titles on two releases.
- The headline and the Duplicates count leave out cross-release groups, which
the resolver keeps and links: the headline read 3,479 when 536 needed review.
- Any group spanning albums is cross_release and is linked as one song,
whatever its titles say. Nothing is deleted.
- One album, two versions named as such ("Lovesick (Instrumental)") and not
byte-identical: a new class, distinct, dismissed by the resolver.
- One album, one recording under different song titles (mismatch): the
album's cached MusicBrainz track list decides. When exactly one slot's song
is as long as the file and every other slot's song is clearly not, the other
copies are removed and their tracks marked missing, so the reacquisition
sweeper wants the album back. Otherwise the group stays for review.
- The duplicates_found notice moves from the sweep to the resolver: it names
only groups a pass first left for review.
- Resolved automatically lists dismissals and wrong-slot removals beside
merges.
Migration 0094 adds 'distinct' to the class CHECK (rule 36).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -293,61 +293,10 @@ func (q *Queries) ListDuplicateCandidates(ctx context.Context, arg ListDuplicate
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listDuplicateGroupsDetectedSince = `-- name: ListDuplicateGroupsDetectedSince :many
|
||||
SELECT ar.name AS artist_name, t.title, count(*) OVER ()::bigint AS total
|
||||
FROM duplicate_groups g
|
||||
JOIN LATERAL (SELECT m.track_id
|
||||
FROM duplicate_group_members m
|
||||
WHERE m.group_id = g.id
|
||||
ORDER BY m.track_id
|
||||
LIMIT 1) one ON true
|
||||
JOIN tracks t ON t.id = one.track_id
|
||||
JOIN artists ar ON ar.id = t.artist_id
|
||||
WHERE g.status = 'pending'
|
||||
AND g.detected_at >= $1
|
||||
ORDER BY g.detected_at DESC, g.id
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListDuplicateGroupsDetectedSinceParams struct {
|
||||
Since pgtype.Timestamptz
|
||||
N int32
|
||||
}
|
||||
|
||||
type ListDuplicateGroupsDetectedSinceRow struct {
|
||||
ArtistName string
|
||||
Title string
|
||||
Total int64
|
||||
}
|
||||
|
||||
// Pending proposals first made at or after `since`: what a sweep that started
|
||||
// then found for the first time, each named by one of its songs, and `total`
|
||||
// of them however many are listed. A refreshed proposal keeps its detected_at,
|
||||
// so a sweep that only re-finds known groups lists none (M489: admins are told
|
||||
// about new duplicates, not reminded of the same ones every sweep).
|
||||
func (q *Queries) ListDuplicateGroupsDetectedSince(ctx context.Context, arg ListDuplicateGroupsDetectedSinceParams) ([]ListDuplicateGroupsDetectedSinceRow, error) {
|
||||
rows, err := q.db.Query(ctx, listDuplicateGroupsDetectedSince, arg.Since, arg.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListDuplicateGroupsDetectedSinceRow
|
||||
for rows.Next() {
|
||||
var i ListDuplicateGroupsDetectedSinceRow
|
||||
if err := rows.Scan(&i.ArtistName, &i.Title, &i.Total); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listDuplicateGroupsForResolve = `-- name: ListDuplicateGroupsForResolve :many
|
||||
SELECT g.id AS group_id,
|
||||
g.tier,
|
||||
g.class AS prior_class,
|
||||
t.id AS track_id,
|
||||
t.album_id,
|
||||
t.title,
|
||||
@@ -356,6 +305,7 @@ SELECT g.id AS group_id,
|
||||
t.file_path,
|
||||
t.file_format,
|
||||
t.file_size,
|
||||
t.duration_ms,
|
||||
t.added_at,
|
||||
t.disc_number,
|
||||
t.track_number,
|
||||
@@ -364,32 +314,38 @@ SELECT g.id AS group_id,
|
||||
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
|
||||
AND o.disc_number IS NOT DISTINCT FROM t.disc_number
|
||||
AND o.track_number = t.track_number)::boolean AS position_clash
|
||||
AND o.track_number = t.track_number)::boolean AS position_clash,
|
||||
COALESCE(NULLIF(al.release_group_mbid, ''), lam.release_group_mbid, '')::text AS release_group_mbid
|
||||
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 artists ON artists.id = t.artist_id
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
LEFT JOIN library_album_matches lam ON lam.album_id = t.album_id AND lam.outcome = 'listed'
|
||||
WHERE g.status = 'pending'
|
||||
AND t.missing_since IS NULL
|
||||
ORDER BY g.id, t.id
|
||||
`
|
||||
|
||||
type ListDuplicateGroupsForResolveRow struct {
|
||||
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
|
||||
GroupID pgtype.UUID
|
||||
Tier string
|
||||
PriorClass *string
|
||||
TrackID pgtype.UUID
|
||||
AlbumID pgtype.UUID
|
||||
Title string
|
||||
SourceVerdict *string
|
||||
ArtistName string
|
||||
FilePath string
|
||||
FileFormat string
|
||||
FileSize int64
|
||||
DurationMs int32
|
||||
AddedAt pgtype.Timestamptz
|
||||
DiscNumber *int32
|
||||
TrackNumber *int32
|
||||
HasMbid bool
|
||||
PositionClash bool
|
||||
ReleaseGroupMbid string
|
||||
}
|
||||
|
||||
// Every pending group's present copies, for the resolver (M498 #5435): what it
|
||||
@@ -398,6 +354,9 @@ type ListDuplicateGroupsForResolveRow struct {
|
||||
// same album claims the same disc and track number: tags that do not fit the
|
||||
// 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').
|
||||
// prior_class is the class the last pass gave the group, NULL for a group no
|
||||
// pass has seen. release_group_mbid is the album's MusicBrainz group, from its
|
||||
// tags or the library listing's match; ” when neither names one.
|
||||
func (q *Queries) ListDuplicateGroupsForResolve(ctx context.Context) ([]ListDuplicateGroupsForResolveRow, error) {
|
||||
rows, err := q.db.Query(ctx, listDuplicateGroupsForResolve)
|
||||
if err != nil {
|
||||
@@ -410,6 +369,7 @@ func (q *Queries) ListDuplicateGroupsForResolve(ctx context.Context) ([]ListDupl
|
||||
if err := rows.Scan(
|
||||
&i.GroupID,
|
||||
&i.Tier,
|
||||
&i.PriorClass,
|
||||
&i.TrackID,
|
||||
&i.AlbumID,
|
||||
&i.Title,
|
||||
@@ -418,11 +378,13 @@ func (q *Queries) ListDuplicateGroupsForResolve(ctx context.Context) ([]ListDupl
|
||||
&i.FilePath,
|
||||
&i.FileFormat,
|
||||
&i.FileSize,
|
||||
&i.DurationMs,
|
||||
&i.AddedAt,
|
||||
&i.DiscNumber,
|
||||
&i.TrackNumber,
|
||||
&i.HasMbid,
|
||||
&i.PositionClash,
|
||||
&i.ReleaseGroupMbid,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -633,6 +595,27 @@ func (q *Queries) SetDuplicateGroupClasses(ctx context.Context, arg SetDuplicate
|
||||
return err
|
||||
}
|
||||
|
||||
const settleDuplicateGroupAutomatically = `-- name: SettleDuplicateGroupAutomatically :execrows
|
||||
UPDATE duplicate_groups
|
||||
SET status = $1, resolved_at = now(), resolved_automatically = true
|
||||
WHERE id = $2 AND status = 'pending'
|
||||
`
|
||||
|
||||
type SettleDuplicateGroupAutomaticallyParams struct {
|
||||
Status string
|
||||
ID pgtype.UUID
|
||||
}
|
||||
|
||||
// The resolver closing a group itself: 'dismissed' for versions that are not
|
||||
// duplicates, 'merged' when it removed the wrong copies. Only a pending group.
|
||||
func (q *Queries) SettleDuplicateGroupAutomatically(ctx context.Context, arg SettleDuplicateGroupAutomaticallyParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, settleDuplicateGroupAutomatically, arg.Status, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const startDuplicateSweep = `-- name: StartDuplicateSweep :one
|
||||
INSERT INTO duplicate_sweeps DEFAULT VALUES RETURNING id, started_at
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user