release / web (push) Successful in 2m21s
release / go (push) Successful in 2m34s
release / govulncheck (push) Successful in 40s
release / integration (push) Successful in 6m17s
release / android (push) Successful in 6m36s
release / Build signed APK (releases and dev) (push) Successful in 6m6s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m19s
release / Verify release artifacts (tag releases only) (push) Skipped
A track marked missing whose replacement is already on disk under another row — on the operator's library 355 of 451 missing tracks, nearly all Lidarr mp3 -> flac upgrades — is folded into the replacement: likes, plays, playlist entries and tags move across and the missing row goes. Move adoption could not catch these: the replacements were re-encodes (no shared audio hash) with no recording MBID at import. Pairs (ListMissingTrackPairs): the same recording MBID within the album group, or the same album row and title ignoring case. Each side must have exactly one candidate; conflicting MBIDs refuse a pair. No duration or track position gate: on the 142 pairs known to be one recording, 18% differed by over 2s and the poorly tagged set is where numbering is broken (spike #5274). Each pair folds in its own transaction after locking both rows and checking the pair still holds. Runs automatically (operator, 2026-10-07) after a full scan, after a watcher batch that added or updated tracks, and after an AcoustID pass that matched any track. The first scan after deploy repairs the existing rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
154 lines
5.1 KiB
Go
154 lines
5.1 KiB
Go
// Code generated by sqlc. DO NOT EDIT.
|
|
// versions:
|
|
// sqlc v1.31.1
|
|
// source: missing_pairs.sql
|
|
|
|
package dbq
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
const listMissingTrackPairs = `-- name: ListMissingTrackPairs :many
|
|
|
|
WITH grp AS (
|
|
SELECT id AS album_id, COALESCE(release_group_mbid, id::text) AS g
|
|
FROM albums
|
|
),
|
|
cand AS (
|
|
SELECT m.id AS missing_id, p.id AS present_id, 'mbid'::text AS rule
|
|
FROM tracks m
|
|
JOIN grp gm ON gm.album_id = m.album_id
|
|
JOIN grp gp ON gp.g = gm.g
|
|
JOIN tracks p ON p.album_id = gp.album_id
|
|
AND p.missing_since IS NULL
|
|
AND p.mbid = m.mbid
|
|
WHERE m.missing_since IS NOT NULL
|
|
AND m.mbid IS NOT NULL
|
|
UNION ALL
|
|
SELECT m.id, p.id, 'title'::text
|
|
FROM tracks m
|
|
JOIN tracks p ON p.album_id = m.album_id
|
|
AND p.missing_since IS NULL
|
|
AND lower(p.title) = lower(m.title)
|
|
WHERE m.missing_since IS NOT NULL
|
|
AND (m.mbid IS NULL OR p.mbid IS NULL OR m.mbid = p.mbid)
|
|
),
|
|
pairs AS (
|
|
-- A pair both rules found is one candidate, credited to the stronger rule.
|
|
SELECT missing_id, present_id, min(rule)::text AS rule
|
|
FROM cand
|
|
GROUP BY missing_id, present_id
|
|
)
|
|
SELECT pr.missing_id, pr.present_id, pr.rule,
|
|
m.file_path AS missing_path, p.file_path AS present_path,
|
|
(count(*) OVER (PARTITION BY pr.missing_id))::int AS missing_candidates,
|
|
(count(*) OVER (PARTITION BY pr.present_id))::int AS present_candidates
|
|
FROM pairs pr
|
|
JOIN tracks m ON m.id = pr.missing_id
|
|
JOIN tracks p ON p.id = pr.present_id
|
|
ORDER BY m.file_path, p.file_path
|
|
`
|
|
|
|
type ListMissingTrackPairsRow struct {
|
|
MissingID pgtype.UUID
|
|
PresentID pgtype.UUID
|
|
Rule string
|
|
MissingPath string
|
|
PresentPath string
|
|
MissingCandidates int32
|
|
PresentCandidates int32
|
|
}
|
|
|
|
// Missing-pair pass (M485). A track marked missing whose replacement is already
|
|
// on disk under another row — most often a Lidarr quality upgrade, mp3 → flac at
|
|
// a new path — is folded into that replacement, so its history moves across and
|
|
// re-acquisition stops chasing music the library already has.
|
|
//
|
|
// Move adoption (#2528) cannot catch these: it links a new file to a missing row
|
|
// at the moment the file is first inserted, and the replacement arrives as a
|
|
// re-encode with no recording MBID, so it has nothing to match on. AcoustID may
|
|
// give it an MBID later; by then both rows exist and only a fold can join them.
|
|
// Every candidate (missing, present) pair, with how many candidates each side
|
|
// has. Two rules, measured on the operator's library on 2026-10-07 (#5274):
|
|
//
|
|
// mbid the same recording MBID, within the album group (the album row, or
|
|
// albums sharing a release group). 142 of 359 pairs.
|
|
// title the same album row and the same title, ignoring case. 213 more. The
|
|
// replacements are often not the original file — another edition,
|
|
// re-encoded, untagged — so the metadata is what is left to go on.
|
|
//
|
|
// Deliberately NOT gated on duration or track position. On the 142 pairs known
|
|
// to be one recording, 18% differed by over 2s (old mp3 durations are poor
|
|
// estimates), and the poorly tagged set is exactly where numbering is broken.
|
|
//
|
|
// A pair whose MBIDs are both set and differ is not a title match: the tags say
|
|
// they are different recordings. The caller folds a pair only when each side has
|
|
// exactly one candidate; anything else is ambiguous and left alone.
|
|
func (q *Queries) ListMissingTrackPairs(ctx context.Context) ([]ListMissingTrackPairsRow, error) {
|
|
rows, err := q.db.Query(ctx, listMissingTrackPairs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListMissingTrackPairsRow
|
|
for rows.Next() {
|
|
var i ListMissingTrackPairsRow
|
|
if err := rows.Scan(
|
|
&i.MissingID,
|
|
&i.PresentID,
|
|
&i.Rule,
|
|
&i.MissingPath,
|
|
&i.PresentPath,
|
|
&i.MissingCandidates,
|
|
&i.PresentCandidates,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const lockTracksForPairing = `-- name: LockTracksForPairing :many
|
|
SELECT id, missing_since
|
|
FROM tracks
|
|
WHERE id = ANY($1::uuid[])
|
|
ORDER BY id
|
|
FOR UPDATE
|
|
`
|
|
|
|
type LockTracksForPairingRow struct {
|
|
ID pgtype.UUID
|
|
MissingSince pgtype.Timestamptz
|
|
}
|
|
|
|
// Locks both rows of a pair for the fold's transaction, in id order so two
|
|
// passes cannot deadlock, and returns what the fold re-checks: the missing row
|
|
// must still be missing and the present one still present. A scan may have
|
|
// restored or adopted the first, or marked the second, since the pairs were listed.
|
|
func (q *Queries) LockTracksForPairing(ctx context.Context, ids []pgtype.UUID) ([]LockTracksForPairingRow, error) {
|
|
rows, err := q.db.Query(ctx, lockTracksForPairing, ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []LockTracksForPairingRow
|
|
for rows.Next() {
|
|
var i LockTracksForPairingRow
|
|
if err := rows.Scan(&i.ID, &i.MissingSince); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|