test-web / test (push) Successful in 52s
test-go / test (push) Successful in 1m9s
test-go / integration (push) Successful in 3m31s
release / Build signed APK (releases and dev) (push) Successful in 4m32s
release / Build + push container image (push) Successful in 24s
release / Verify release artifacts (tag releases only) (push) Skipped
A new admin tab, Duplicates, beside Missing files: the proposals from the duplicate sweep, with a Sweep now trigger and a Not duplicates dismissal. Nothing on it merges or deletes; the merge is #3911. Each group shows: - whether it is identical audio or the same recording, with a match percentage from the weakest link between members - every copy's format, size, duration, path, and the likes and plays it carries (every user's; this is admin-only, and it is what decides which copy to keep) - the copy proposed to keep, and the rule that chose it The survivor rule is library.ProposeSurvivor, a pure function the merge will reuse: lossless over lossy, then the larger file, then the copy in the library longest, then lowest id. Bitrate is not in it because the scanner never fills tracks.bitrate, and for one recording at one duration a larger file is the higher bitrate. m4a is not counted as lossless: it may be AAC. The reason names the rule that separated first place from second, not every rule the winner passed. An empty report has three causes, and the page says which: still fingerprinting, the sweep has never run, or it ran and found nothing. The sweep's state and the backfill's progress come back with the groups for that reason. Groups left with fewer than two members since the sweep are not shown. GET /api/admin/library/duplicates, POST .../sweep (202, or 409 sweep_in_progress), POST .../{id}/dismiss (404 duplicate_group_not_pending when already resolved). Migration 0060 indexes play_events by track_id. Its only indexes led with user_id, so each copy's play count, and the merge's repointing of play history, would scan the whole table. Web only, like Missing files: Android has no library-health admin screens. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
153 lines
6.4 KiB
SQL
153 lines
6.4 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
|
|
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: ListPendingDuplicateGroupMembers :many
|
|
-- One page of proposals, 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
|
|
FROM duplicate_groups g
|
|
WHERE g.status = 'pending'
|
|
AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2
|
|
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,
|
|
t.id AS track_id,
|
|
t.title,
|
|
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';
|