release / govulncheck (push) Successful in 36s
release / web (push) Successful in 1m39s
release / go (push) Successful in 1m45s
release / android (push) Successful in 5m9s
release / integration (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m21s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m23s
release / Verify release artifacts (tag releases only) (push) Skipped
The duplicates review offered only Merge for a wrong-slot group. A merge keeps
one copy's title whether or not it is the audio's, and neither sets the release
aside nor wants the missing song back.
For class mismatch the page now asks which song the recording is. "Get it
again" (the operator can't tell) removes every copy; "Keep <title>" keeps that
copy and removes the ones under other titles. Either way it runs the resolver's
own path: the release the files came from is set aside, the removed copies'
tracks stay marked missing and emptied, and the album is wanted again.
- library.SettleWrongSlot, POST /api/admin/library/duplicates/{id}/refetch
- ListDuplicateGroupsForResolve takes an optional group_id
- removeWrongSlots takes the acting operator (manual: not automatic, actor in
the audit row) and removes files through removeTrackFileOnDisk, so a
read-only library answers library_not_writable
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
261 lines
12 KiB
SQL
261 lines
12 KiB
SQL
-- name: StartDuplicateSweep :one
|
|
INSERT INTO duplicate_sweeps DEFAULT VALUES RETURNING id, started_at;
|
|
|
|
-- name: FinishDuplicateSweep :exec
|
|
UPDATE duplicate_sweeps
|
|
SET finished_at = now(),
|
|
candidates = sqlc.arg(candidates),
|
|
groups_found = sqlc.arg(groups_found),
|
|
oversize_clusters = sqlc.arg(oversize_clusters),
|
|
error_message = NULLIF(sqlc.arg(error_message)::text, '')
|
|
WHERE id = sqlc.arg(id);
|
|
|
|
-- name: GetInFlightDuplicateSweep :one
|
|
-- The guard against two sweeps at once: "in flight" is finished_at IS NULL.
|
|
SELECT id, started_at
|
|
FROM duplicate_sweeps
|
|
WHERE finished_at IS NULL
|
|
ORDER BY started_at DESC
|
|
LIMIT 1;
|
|
|
|
-- name: GetLatestDuplicateSweep :one
|
|
SELECT id, started_at, finished_at, candidates, groups_found, oversize_clusters, error_message
|
|
FROM duplicate_sweeps
|
|
ORDER BY started_at DESC
|
|
LIMIT 1;
|
|
|
|
-- name: GetLatestFingerprintComputedAt :one
|
|
-- Whether a sweep has anything new to look at: fingerprints written since the
|
|
-- last sweep started.
|
|
SELECT max(computed_at)::timestamptz AS latest FROM track_fingerprints;
|
|
|
|
-- name: ListExactDuplicateHashes :many
|
|
-- The exact tier, library-wide in one pass: identical encoded audio shared by
|
|
-- more than one present track.
|
|
SELECT f.audio_stream_sha256,
|
|
array_agg(t.id ORDER BY t.id)::uuid[] AS track_ids
|
|
FROM track_fingerprints f
|
|
JOIN tracks t ON t.id = f.track_id
|
|
WHERE t.missing_since IS NULL
|
|
AND f.fingerprint_version >= sqlc.arg(current_version)
|
|
AND f.audio_stream_sha256 IS NOT NULL
|
|
GROUP BY f.audio_stream_sha256
|
|
HAVING count(*) > 1;
|
|
|
|
-- name: ListDuplicateCandidates :many
|
|
-- The acoustic tier's input, one page at a time in (duration_ms, id) order so the
|
|
-- sweep holds only a sliding window of durations. Tracks without a chromaprint
|
|
-- cannot be compared acoustically and are left out; any exact duplicates among
|
|
-- them come from ListExactDuplicateHashes.
|
|
SELECT t.id, t.duration_ms, f.audio_stream_sha256, f.chromaprint
|
|
FROM tracks t
|
|
JOIN track_fingerprints f ON f.track_id = t.id
|
|
WHERE t.missing_since IS NULL
|
|
AND f.fingerprint_version >= sqlc.arg(current_version)
|
|
AND f.chromaprint IS NOT NULL
|
|
-- Only chromaprints taken at the current length: prints at two lengths are not
|
|
-- comparable, and after a length change the backfill is still re-deriving the
|
|
-- rest (#3913).
|
|
AND f.chromaprint_length_sec = sqlc.arg(chromaprint_length_sec)
|
|
AND (t.duration_ms, t.id) > (sqlc.arg(after_duration_ms)::integer, sqlc.arg(after_id)::uuid)
|
|
ORDER BY t.duration_ms, t.id
|
|
LIMIT sqlc.arg(page_limit);
|
|
|
|
-- name: ListDismissedDuplicateMemberSets :many
|
|
-- What the operator has already said are not duplicates. A new proposal whose
|
|
-- every member sat together in one of these is not proposed again.
|
|
SELECT g.id, array_agg(m.track_id ORDER BY m.track_id)::uuid[] AS track_ids
|
|
FROM duplicate_groups g
|
|
JOIN duplicate_group_members m ON m.group_id = g.id
|
|
WHERE g.status = 'dismissed'
|
|
GROUP BY g.id;
|
|
|
|
-- name: UpsertDuplicateGroup :one
|
|
-- Proposes a group, or refreshes one already pending. A group already dismissed
|
|
-- or merged is left exactly as it is: the WHERE on the update makes the conflict
|
|
-- a no-op, and the caller sees no row.
|
|
INSERT INTO duplicate_groups (member_key, tier, worst_bit_error_rate, last_seen_sweep_id)
|
|
VALUES (sqlc.arg(member_key), sqlc.arg(tier), sqlc.narg(worst_bit_error_rate), sqlc.arg(sweep_id))
|
|
ON CONFLICT (member_key) DO UPDATE
|
|
SET tier = EXCLUDED.tier,
|
|
worst_bit_error_rate = EXCLUDED.worst_bit_error_rate,
|
|
last_seen_sweep_id = EXCLUDED.last_seen_sweep_id
|
|
WHERE duplicate_groups.status = 'pending'
|
|
RETURNING id;
|
|
|
|
-- name: AddDuplicateGroupMember :exec
|
|
INSERT INTO duplicate_group_members (group_id, track_id)
|
|
VALUES (sqlc.arg(group_id), sqlc.arg(track_id))
|
|
ON CONFLICT DO NOTHING;
|
|
|
|
-- name: DeleteStalePendingDuplicateGroups :execrows
|
|
-- A pending proposal this sweep did not find again no longer describes the
|
|
-- library: a member was re-fingerprinted, merged away or went missing. Dismissed
|
|
-- groups are kept regardless — they are the memory of a decision.
|
|
--
|
|
-- Only proposals last confirmed by an EARLIER sweep go. Should two sweeps ever
|
|
-- overlap (a manual trigger racing the worker), neither may delete what the other
|
|
-- has just found.
|
|
DELETE FROM duplicate_groups g
|
|
WHERE g.status = 'pending'
|
|
AND g.last_seen_sweep_id IS DISTINCT FROM sqlc.arg(sweep_id)
|
|
AND (g.last_seen_sweep_id IS NULL
|
|
OR (SELECT s.started_at FROM duplicate_sweeps s WHERE s.id = g.last_seen_sweep_id)
|
|
< (SELECT s.started_at FROM duplicate_sweeps s WHERE s.id = sqlc.arg(sweep_id)));
|
|
|
|
-- name: CountPendingDuplicateGroups :one
|
|
-- Proposals awaiting review. A group left with one member — its other tracks
|
|
-- deleted since the sweep — is no proposal at all and is not counted; the next
|
|
-- sweep retires it.
|
|
SELECT count(*)::bigint
|
|
FROM duplicate_groups g
|
|
WHERE g.status = 'pending'
|
|
AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2;
|
|
|
|
-- name: CountPendingDuplicateGroupsInView :one
|
|
-- CountPendingDuplicateGroups narrowed to one view of the report (M498):
|
|
-- review what the operator still has to decide: every class but
|
|
-- cross_release, and groups not yet classified
|
|
-- cross_release the same song on different releases, which is kept, not merged
|
|
SELECT count(*)::bigint
|
|
FROM duplicate_groups g
|
|
WHERE g.status = 'pending'
|
|
AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2
|
|
AND CASE sqlc.arg(view)::text
|
|
WHEN 'cross_release' THEN g.class = 'cross_release'
|
|
ELSE g.class IS DISTINCT FROM 'cross_release'
|
|
END;
|
|
|
|
-- name: ListPendingDuplicateGroupMembers :many
|
|
-- One page of proposals in one view (see CountPendingDuplicateGroupsInView),
|
|
-- 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.
|
|
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
|
|
WHERE g.status = 'pending'
|
|
AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2
|
|
AND CASE sqlc.arg(view)::text
|
|
WHEN 'cross_release' THEN g.class = 'cross_release'
|
|
ELSE g.class IS DISTINCT FROM 'cross_release'
|
|
END
|
|
ORDER BY g.detected_at DESC, g.id
|
|
LIMIT sqlc.arg(page_limit) OFFSET sqlc.arg(page_offset)
|
|
)
|
|
SELECT p.id AS group_id,
|
|
p.tier,
|
|
p.worst_bit_error_rate,
|
|
p.detected_at,
|
|
p.class,
|
|
p.resolve_note,
|
|
t.disc_number,
|
|
t.track_number,
|
|
(t.mbid IS NOT NULL)::boolean AS has_mbid,
|
|
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
|
|
AND o.disc_number IS NOT DISTINCT FROM t.disc_number
|
|
AND o.track_number = t.track_number)::boolean AS position_clash,
|
|
t.id AS track_id,
|
|
t.title,
|
|
artists.id AS artist_id,
|
|
artists.name AS artist_name,
|
|
albums.id AS album_id,
|
|
albums.title AS album_title,
|
|
t.file_path,
|
|
t.file_format,
|
|
t.file_size,
|
|
t.duration_ms,
|
|
t.added_at,
|
|
(SELECT count(*) FROM general_likes l WHERE l.track_id = t.id)::bigint AS like_count,
|
|
(SELECT count(*) FROM play_events e WHERE e.track_id = t.id)::bigint AS play_count
|
|
FROM page p
|
|
JOIN duplicate_group_members m ON m.group_id = p.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
|
|
ORDER BY p.detected_at DESC, p.id, t.id;
|
|
|
|
-- name: DismissDuplicateGroup :execrows
|
|
-- "These are not duplicates." Only a pending group can be dismissed; zero rows
|
|
-- means it was already resolved or no longer exists.
|
|
UPDATE duplicate_groups
|
|
SET status = 'dismissed', resolved_at = now()
|
|
WHERE id = sqlc.arg(id) AND status = 'pending';
|
|
|
|
-- name: ListDuplicateGroupsForResolve :many
|
|
-- 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. 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.
|
|
-- acoustid_state and acoustid_recording are what AcoustID said of the file,
|
|
-- when it was looked up (#6169: which song one recording cut to several slots
|
|
-- is). group_id narrows it to one group, for the operator settling it (#6184);
|
|
-- NULL reads them all.
|
|
SELECT g.id AS group_id,
|
|
g.tier,
|
|
g.worst_bit_error_rate,
|
|
g.class AS prior_class,
|
|
t.id AS track_id,
|
|
t.album_id,
|
|
t.title,
|
|
t.source_verdict,
|
|
artists.name AS artist_name,
|
|
t.file_path,
|
|
t.file_format,
|
|
t.file_size,
|
|
t.duration_ms,
|
|
t.added_at,
|
|
t.disc_number,
|
|
t.track_number,
|
|
(t.mbid IS NOT NULL)::boolean AS has_mbid,
|
|
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
|
|
AND o.disc_number IS NOT DISTINCT FROM t.disc_number
|
|
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,
|
|
l.state AS acoustid_state,
|
|
l.recording_mbid AS acoustid_recording
|
|
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'
|
|
LEFT JOIN track_acoustid_lookups l ON l.track_id = t.id
|
|
WHERE g.status = 'pending'
|
|
AND t.missing_since IS NULL
|
|
AND (sqlc.narg(group_id)::uuid IS NULL OR g.id = sqlc.narg(group_id)::uuid)
|
|
ORDER BY g.id, t.id;
|
|
|
|
-- name: SetDuplicateGroupClasses :exec
|
|
-- The resolver's verdicts, written in one statement. Only rows whose class or
|
|
-- note actually changed are touched, so an hourly pass over thousands of
|
|
-- unchanged groups writes nothing. An empty note clears it.
|
|
UPDATE duplicate_groups g
|
|
SET class = v.class, resolve_note = NULLIF(v.note, '')
|
|
FROM (SELECT unnest(sqlc.arg(group_ids)::uuid[]) AS id,
|
|
unnest(sqlc.arg(classes)::text[]) AS class,
|
|
unnest(sqlc.arg(notes)::text[]) AS note) v
|
|
WHERE g.id = v.id
|
|
AND g.status = 'pending'
|
|
AND (g.class IS DISTINCT FROM v.class OR g.resolve_note IS DISTINCT FROM NULLIF(v.note, ''));
|
|
|
|
-- name: SettleDuplicateGroupAutomatically :execrows
|
|
-- The resolver closing a group itself: 'dismissed' for versions that are not
|
|
-- duplicates, 'merged' when it removed the wrong copies. Only a pending group.
|
|
UPDATE duplicate_groups
|
|
SET status = sqlc.arg(status), resolved_at = now(), resolved_automatically = true
|
|
WHERE id = sqlc.arg(id) AND status = 'pending';
|
|
|
|
-- name: MarkDuplicateGroupResolvedAutomatically :exec
|
|
UPDATE duplicate_groups SET resolved_automatically = true WHERE id = sqlc.arg(id);
|