release / govulncheck (push) Successful in 45s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 4m25s
release / android (push) Successful in 6m17s
release / Build signed APK (releases and dev) (push) Successful in 5m57s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m54s
release / Verify release artifacts (tag releases only) (push) Skipped
The duplicate sweep proposed 4,197 groups and every one waited for the operator. Most are safe to settle, and Lidarr defines what safe means: it maps one file to each track of the release it monitors and downloads any mapped file that disappears. Deleting a mapped copy opens exactly the hole the operator saw Lidarr fill. Classify (#5435) - Migration 0075: duplicate_groups.class (same_release, cross_release, mismatch, review), resolve_note, resolved_automatically; duplicate_group_members.lidarr_state (tracked, unmapped); fingerprint_settings.auto_resolve; notification kind duplicates_resolved with both kind CHECKs swapped (rule 36). - library.ClassifyDuplicateGroup, with MatchTitleKey dropping featuring credits, remaster notes and video-rip markers, and keeping live, demo, remix and instrumental. The rip markers move from api to library. Choose the copy to keep (#5436) - ProposeSurvivor ranks the copy Lidarr maps first, then tag fit (a clash-free track number, no rip marker in the name, an MBID), then the quality rules. File size picked the wrong Humanz copy in 6 of 21 groups. Act (#5437) - An hourly resolver pass reads Lidarr's unmapped files, matched by the last three path components, and records each copy's state. - Same album, with at most one copy mapped: merged into the mapped copy. The merge is guarded, so a mapped copy can never be removed (MergeDuplicateGroupGuarded, ErrCopyTrackedByLidarr). - Same album, every copy mapped: the monitored release lists the song twice (Humanz's 14x12" box set). The pass moves Lidarr to the release that lists each song once and best covers what is on disk. It never picks one covering less, and is capped at 10 albums per pass. - Fixed point (lesson #4183): the chosen release no longer repeats. - The album is left alone for 24h while Lidarr rescans, so "every copy unmapped" mid-rescan is never read as licence to merge. - Both actions are audited with no actor and summarised to admins. The operator can switch them off in the Fingerprinting card (rule 25). - Manual merges use the same guard: 409 copy_tracked_by_lidarr, or 503 lidarr_unavailable when Lidarr cannot say. Web - Duplicates gets tabs: Needs review, Across releases, Resolved automatically. Each loads as you scroll (rule 172), replacing the pager. - Each copy says whether Lidarr uses it. - The resolver's note shows on each group. - The merge confirm blocks, before sending, a merge that would remove the copy Lidarr uses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
707 lines
22 KiB
Go
707 lines
22 KiB
Go
// Code generated by sqlc. DO NOT EDIT.
|
|
// versions:
|
|
// sqlc v1.31.1
|
|
// source: duplicates.sql
|
|
|
|
package dbq
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
const addDuplicateGroupMember = `-- name: AddDuplicateGroupMember :exec
|
|
INSERT INTO duplicate_group_members (group_id, track_id)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT DO NOTHING
|
|
`
|
|
|
|
type AddDuplicateGroupMemberParams struct {
|
|
GroupID pgtype.UUID
|
|
TrackID pgtype.UUID
|
|
}
|
|
|
|
func (q *Queries) AddDuplicateGroupMember(ctx context.Context, arg AddDuplicateGroupMemberParams) error {
|
|
_, err := q.db.Exec(ctx, addDuplicateGroupMember, arg.GroupID, arg.TrackID)
|
|
return err
|
|
}
|
|
|
|
const countDuplicateGroupsDetectedSince = `-- name: CountDuplicateGroupsDetectedSince :one
|
|
SELECT count(*)::bigint
|
|
FROM duplicate_groups
|
|
WHERE status = 'pending'
|
|
AND detected_at >= $1
|
|
`
|
|
|
|
// Pending proposals first made at or after `since`: what a sweep that started
|
|
// then found for the first time. A refreshed proposal keeps its detected_at,
|
|
// so a sweep that only re-finds known groups counts none (M489: admins are
|
|
// told about new duplicates, not reminded of the same ones every sweep).
|
|
func (q *Queries) CountDuplicateGroupsDetectedSince(ctx context.Context, since pgtype.Timestamptz) (int64, error) {
|
|
row := q.db.QueryRow(ctx, countDuplicateGroupsDetectedSince, since)
|
|
var column_1 int64
|
|
err := row.Scan(&column_1)
|
|
return column_1, err
|
|
}
|
|
|
|
const countPendingDuplicateGroups = `-- name: CountPendingDuplicateGroups :one
|
|
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
|
|
`
|
|
|
|
// 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.
|
|
func (q *Queries) CountPendingDuplicateGroups(ctx context.Context) (int64, error) {
|
|
row := q.db.QueryRow(ctx, countPendingDuplicateGroups)
|
|
var column_1 int64
|
|
err := row.Scan(&column_1)
|
|
return column_1, err
|
|
}
|
|
|
|
const countPendingDuplicateGroupsInView = `-- name: CountPendingDuplicateGroupsInView :one
|
|
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 $1::text
|
|
WHEN 'cross_release' THEN g.class = 'cross_release'
|
|
ELSE g.class IS DISTINCT FROM 'cross_release'
|
|
END
|
|
`
|
|
|
|
// 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
|
|
func (q *Queries) CountPendingDuplicateGroupsInView(ctx context.Context, view string) (int64, error) {
|
|
row := q.db.QueryRow(ctx, countPendingDuplicateGroupsInView, view)
|
|
var column_1 int64
|
|
err := row.Scan(&column_1)
|
|
return column_1, err
|
|
}
|
|
|
|
const deleteStalePendingDuplicateGroups = `-- name: DeleteStalePendingDuplicateGroups :execrows
|
|
DELETE FROM duplicate_groups g
|
|
WHERE g.status = 'pending'
|
|
AND g.last_seen_sweep_id IS DISTINCT FROM $1
|
|
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 = $1))
|
|
`
|
|
|
|
// 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.
|
|
func (q *Queries) DeleteStalePendingDuplicateGroups(ctx context.Context, sweepID pgtype.UUID) (int64, error) {
|
|
result, err := q.db.Exec(ctx, deleteStalePendingDuplicateGroups, sweepID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const dismissDuplicateGroup = `-- name: DismissDuplicateGroup :execrows
|
|
UPDATE duplicate_groups
|
|
SET status = 'dismissed', resolved_at = now()
|
|
WHERE id = $1 AND status = 'pending'
|
|
`
|
|
|
|
// "These are not duplicates." Only a pending group can be dismissed; zero rows
|
|
// means it was already resolved or no longer exists.
|
|
func (q *Queries) DismissDuplicateGroup(ctx context.Context, id pgtype.UUID) (int64, error) {
|
|
result, err := q.db.Exec(ctx, dismissDuplicateGroup, id)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const finishDuplicateSweep = `-- name: FinishDuplicateSweep :exec
|
|
UPDATE duplicate_sweeps
|
|
SET finished_at = now(),
|
|
candidates = $1,
|
|
groups_found = $2,
|
|
oversize_clusters = $3,
|
|
error_message = NULLIF($4::text, '')
|
|
WHERE id = $5
|
|
`
|
|
|
|
type FinishDuplicateSweepParams struct {
|
|
Candidates *int32
|
|
GroupsFound *int32
|
|
OversizeClusters *int32
|
|
ErrorMessage string
|
|
ID pgtype.UUID
|
|
}
|
|
|
|
func (q *Queries) FinishDuplicateSweep(ctx context.Context, arg FinishDuplicateSweepParams) error {
|
|
_, err := q.db.Exec(ctx, finishDuplicateSweep,
|
|
arg.Candidates,
|
|
arg.GroupsFound,
|
|
arg.OversizeClusters,
|
|
arg.ErrorMessage,
|
|
arg.ID,
|
|
)
|
|
return err
|
|
}
|
|
|
|
const getInFlightDuplicateSweep = `-- name: GetInFlightDuplicateSweep :one
|
|
SELECT id, started_at
|
|
FROM duplicate_sweeps
|
|
WHERE finished_at IS NULL
|
|
ORDER BY started_at DESC
|
|
LIMIT 1
|
|
`
|
|
|
|
type GetInFlightDuplicateSweepRow struct {
|
|
ID pgtype.UUID
|
|
StartedAt pgtype.Timestamptz
|
|
}
|
|
|
|
// The guard against two sweeps at once: "in flight" is finished_at IS NULL.
|
|
func (q *Queries) GetInFlightDuplicateSweep(ctx context.Context) (GetInFlightDuplicateSweepRow, error) {
|
|
row := q.db.QueryRow(ctx, getInFlightDuplicateSweep)
|
|
var i GetInFlightDuplicateSweepRow
|
|
err := row.Scan(&i.ID, &i.StartedAt)
|
|
return i, err
|
|
}
|
|
|
|
const getLatestDuplicateSweep = `-- 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
|
|
`
|
|
|
|
func (q *Queries) GetLatestDuplicateSweep(ctx context.Context) (DuplicateSweep, error) {
|
|
row := q.db.QueryRow(ctx, getLatestDuplicateSweep)
|
|
var i DuplicateSweep
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.StartedAt,
|
|
&i.FinishedAt,
|
|
&i.Candidates,
|
|
&i.GroupsFound,
|
|
&i.OversizeClusters,
|
|
&i.ErrorMessage,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const getLatestFingerprintComputedAt = `-- name: GetLatestFingerprintComputedAt :one
|
|
SELECT max(computed_at)::timestamptz AS latest FROM track_fingerprints
|
|
`
|
|
|
|
// Whether a sweep has anything new to look at: fingerprints written since the
|
|
// last sweep started.
|
|
func (q *Queries) GetLatestFingerprintComputedAt(ctx context.Context) (pgtype.Timestamptz, error) {
|
|
row := q.db.QueryRow(ctx, getLatestFingerprintComputedAt)
|
|
var latest pgtype.Timestamptz
|
|
err := row.Scan(&latest)
|
|
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
|
|
JOIN duplicate_group_members m ON m.group_id = g.id
|
|
WHERE g.status = 'dismissed'
|
|
GROUP BY g.id
|
|
`
|
|
|
|
type ListDismissedDuplicateMemberSetsRow struct {
|
|
ID pgtype.UUID
|
|
TrackIds []pgtype.UUID
|
|
}
|
|
|
|
// 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.
|
|
func (q *Queries) ListDismissedDuplicateMemberSets(ctx context.Context) ([]ListDismissedDuplicateMemberSetsRow, error) {
|
|
rows, err := q.db.Query(ctx, listDismissedDuplicateMemberSets)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListDismissedDuplicateMemberSetsRow
|
|
for rows.Next() {
|
|
var i ListDismissedDuplicateMemberSetsRow
|
|
if err := rows.Scan(&i.ID, &i.TrackIds); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listDuplicateCandidates = `-- name: ListDuplicateCandidates :many
|
|
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 >= $1
|
|
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 = $2
|
|
AND (t.duration_ms, t.id) > ($3::integer, $4::uuid)
|
|
ORDER BY t.duration_ms, t.id
|
|
LIMIT $5
|
|
`
|
|
|
|
type ListDuplicateCandidatesParams struct {
|
|
CurrentVersion int16
|
|
ChromaprintLengthSec int32
|
|
AfterDurationMs int32
|
|
AfterID pgtype.UUID
|
|
PageLimit int32
|
|
}
|
|
|
|
type ListDuplicateCandidatesRow struct {
|
|
ID pgtype.UUID
|
|
DurationMs int32
|
|
AudioStreamSha256 []byte
|
|
Chromaprint []int32
|
|
}
|
|
|
|
// 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.
|
|
func (q *Queries) ListDuplicateCandidates(ctx context.Context, arg ListDuplicateCandidatesParams) ([]ListDuplicateCandidatesRow, error) {
|
|
rows, err := q.db.Query(ctx, listDuplicateCandidates,
|
|
arg.CurrentVersion,
|
|
arg.ChromaprintLengthSec,
|
|
arg.AfterDurationMs,
|
|
arg.AfterID,
|
|
arg.PageLimit,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListDuplicateCandidatesRow
|
|
for rows.Next() {
|
|
var i ListDuplicateCandidatesRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.DurationMs,
|
|
&i.AudioStreamSha256,
|
|
&i.Chromaprint,
|
|
); 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,
|
|
t.id AS track_id,
|
|
t.album_id,
|
|
t.title,
|
|
artists.name AS artist_name,
|
|
t.file_path,
|
|
t.file_format,
|
|
t.file_size,
|
|
t.added_at,
|
|
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
|
|
AND o.disc_number IS NOT DISTINCT FROM t.disc_number
|
|
AND o.track_number = t.track_number)::boolean AS position_clash
|
|
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
|
|
ORDER BY g.id, t.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
|
|
}
|
|
|
|
// 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.
|
|
func (q *Queries) ListDuplicateGroupsForResolve(ctx context.Context) ([]ListDuplicateGroupsForResolveRow, error) {
|
|
rows, err := q.db.Query(ctx, listDuplicateGroupsForResolve)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListDuplicateGroupsForResolveRow
|
|
for rows.Next() {
|
|
var i ListDuplicateGroupsForResolveRow
|
|
if err := rows.Scan(
|
|
&i.GroupID,
|
|
&i.Tier,
|
|
&i.TrackID,
|
|
&i.AlbumID,
|
|
&i.Title,
|
|
&i.ArtistName,
|
|
&i.FilePath,
|
|
&i.FileFormat,
|
|
&i.FileSize,
|
|
&i.AddedAt,
|
|
&i.DiscNumber,
|
|
&i.TrackNumber,
|
|
&i.HasMbid,
|
|
&i.ReleaseGroupMbid,
|
|
&i.AlbumTitle,
|
|
&i.PositionClash,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listExactDuplicateHashes = `-- name: ListExactDuplicateHashes :many
|
|
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 >= $1
|
|
AND f.audio_stream_sha256 IS NOT NULL
|
|
GROUP BY f.audio_stream_sha256
|
|
HAVING count(*) > 1
|
|
`
|
|
|
|
type ListExactDuplicateHashesRow struct {
|
|
AudioStreamSha256 []byte
|
|
TrackIds []pgtype.UUID
|
|
}
|
|
|
|
// The exact tier, library-wide in one pass: identical encoded audio shared by
|
|
// more than one present track.
|
|
func (q *Queries) ListExactDuplicateHashes(ctx context.Context, currentVersion int16) ([]ListExactDuplicateHashesRow, error) {
|
|
rows, err := q.db.Query(ctx, listExactDuplicateHashes, currentVersion)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListExactDuplicateHashesRow
|
|
for rows.Next() {
|
|
var i ListExactDuplicateHashesRow
|
|
if err := rows.Scan(&i.AudioStreamSha256, &i.TrackIds); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listPendingDuplicateGroupMembers = `-- name: ListPendingDuplicateGroupMembers :many
|
|
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 $1::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 $3 OFFSET $2
|
|
)
|
|
SELECT p.id AS group_id,
|
|
p.tier,
|
|
p.worst_bit_error_rate,
|
|
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,
|
|
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.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
|
|
`
|
|
|
|
type ListPendingDuplicateGroupMembersParams struct {
|
|
View string
|
|
PageOffset int32
|
|
PageLimit int32
|
|
}
|
|
|
|
type ListPendingDuplicateGroupMembersRow struct {
|
|
GroupID pgtype.UUID
|
|
Tier string
|
|
WorstBitErrorRate *float32
|
|
DetectedAt pgtype.Timestamptz
|
|
Class *string
|
|
ResolveNote *string
|
|
LidarrState *string
|
|
DiscNumber *int32
|
|
TrackNumber *int32
|
|
HasMbid bool
|
|
PositionClash bool
|
|
TrackID pgtype.UUID
|
|
Title string
|
|
ArtistName string
|
|
AlbumID pgtype.UUID
|
|
AlbumTitle string
|
|
FilePath string
|
|
FileFormat string
|
|
FileSize int64
|
|
DurationMs int32
|
|
AddedAt pgtype.Timestamptz
|
|
LikeCount int64
|
|
PlayCount int64
|
|
}
|
|
|
|
// 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; Lidarr's view of it, because a copy Lidarr tracks cannot
|
|
// be removed without Lidarr downloading it again.
|
|
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 {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListPendingDuplicateGroupMembersRow
|
|
for rows.Next() {
|
|
var i ListPendingDuplicateGroupMembersRow
|
|
if err := rows.Scan(
|
|
&i.GroupID,
|
|
&i.Tier,
|
|
&i.WorstBitErrorRate,
|
|
&i.DetectedAt,
|
|
&i.Class,
|
|
&i.ResolveNote,
|
|
&i.LidarrState,
|
|
&i.DiscNumber,
|
|
&i.TrackNumber,
|
|
&i.HasMbid,
|
|
&i.PositionClash,
|
|
&i.TrackID,
|
|
&i.Title,
|
|
&i.ArtistName,
|
|
&i.AlbumID,
|
|
&i.AlbumTitle,
|
|
&i.FilePath,
|
|
&i.FileFormat,
|
|
&i.FileSize,
|
|
&i.DurationMs,
|
|
&i.AddedAt,
|
|
&i.LikeCount,
|
|
&i.PlayCount,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const markDuplicateGroupResolvedAutomatically = `-- name: MarkDuplicateGroupResolvedAutomatically :exec
|
|
UPDATE duplicate_groups SET resolved_automatically = true WHERE id = $1
|
|
`
|
|
|
|
func (q *Queries) MarkDuplicateGroupResolvedAutomatically(ctx context.Context, id pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, markDuplicateGroupResolvedAutomatically, id)
|
|
return err
|
|
}
|
|
|
|
const setDuplicateGroupClasses = `-- name: SetDuplicateGroupClasses :exec
|
|
UPDATE duplicate_groups g
|
|
SET class = v.class, resolve_note = NULLIF(v.note, '')
|
|
FROM (SELECT unnest($1::uuid[]) AS id,
|
|
unnest($2::text[]) AS class,
|
|
unnest($3::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, ''))
|
|
`
|
|
|
|
type SetDuplicateGroupClassesParams struct {
|
|
GroupIds []pgtype.UUID
|
|
Classes []string
|
|
Notes []string
|
|
}
|
|
|
|
// 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.
|
|
func (q *Queries) SetDuplicateGroupClasses(ctx context.Context, arg SetDuplicateGroupClassesParams) error {
|
|
_, err := q.db.Exec(ctx, setDuplicateGroupClasses, arg.GroupIds, arg.Classes, arg.Notes)
|
|
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
|
|
`
|
|
|
|
type StartDuplicateSweepRow struct {
|
|
ID pgtype.UUID
|
|
StartedAt pgtype.Timestamptz
|
|
}
|
|
|
|
func (q *Queries) StartDuplicateSweep(ctx context.Context) (StartDuplicateSweepRow, error) {
|
|
row := q.db.QueryRow(ctx, startDuplicateSweep)
|
|
var i StartDuplicateSweepRow
|
|
err := row.Scan(&i.ID, &i.StartedAt)
|
|
return i, err
|
|
}
|
|
|
|
const upsertDuplicateGroup = `-- name: UpsertDuplicateGroup :one
|
|
INSERT INTO duplicate_groups (member_key, tier, worst_bit_error_rate, last_seen_sweep_id)
|
|
VALUES ($1, $2, $3, $4)
|
|
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
|
|
`
|
|
|
|
type UpsertDuplicateGroupParams struct {
|
|
MemberKey string
|
|
Tier string
|
|
WorstBitErrorRate *float32
|
|
SweepID pgtype.UUID
|
|
}
|
|
|
|
// 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.
|
|
func (q *Queries) UpsertDuplicateGroup(ctx context.Context, arg UpsertDuplicateGroupParams) (pgtype.UUID, error) {
|
|
row := q.db.QueryRow(ctx, upsertDuplicateGroup,
|
|
arg.MemberKey,
|
|
arg.Tier,
|
|
arg.WorstBitErrorRate,
|
|
arg.SweepID,
|
|
)
|
|
var id pgtype.UUID
|
|
err := row.Scan(&id)
|
|
return id, err
|
|
}
|