Files
minstrel/internal/library/missing_pairs.go
T
bvandeusenandClaude Opus 5.5 865a3176c9
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
feat(library): fold a missing track into its on-disk replacement (M485 #5286 #5287)
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>
2026-10-07 17:23:32 -04:00

152 lines
5.3 KiB
Go

package library
import (
"context"
"errors"
"fmt"
"log/slog"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// Missing-pair folding (M485).
//
// A track marked missing whose replacement already sits on disk under another
// row is folded into that replacement: its likes, plays, playlist entries and
// tags move across and the missing row goes. On the operator's library this was
// 355 of 451 missing tracks (#5274), nearly all Lidarr quality upgrades — the
// mp3 removed, a flac imported at a new path.
//
// Move adoption (moved.go) can't catch these. It links a new file to a missing
// row when the file is first inserted, matching on the recording MBID or the
// audio hash. A re-encode never shares the hash, and the replacements arrived
// untagged, so there was nothing to match; AcoustID filled many of the MBIDs
// weeks later, by which time both rows existed. Adoption keeps the old row and
// re-points its path, which only works while the new file has no row of its own.
// Here both do, so this folds instead, with the duplicate merge's row half.
//
// The rules are in ListMissingTrackPairs. Only a pair where each side has exactly
// one candidate is folded: a wrong fold can't be undone, a skipped one is only a
// row left in the missing list.
//
// Unlike the duplicate merge this runs without review (operator, 2026-10-07):
// no file is touched, the missing row has nothing to play, and adoption, its
// closest relative, is automatic too. Each fold is logged with both paths so a
// wrong pair can be read back.
// PairResult tallies one pass.
type PairResult struct {
ByMbid int // folded on a shared recording MBID
ByTitle int // folded on the same album and title
Ambiguous int // missing rows with more than one candidate, or a candidate claimed twice
Skipped int // the pair changed between listing and folding
Failed int
}
// Folded is how many missing rows the pass folded.
func (r PairResult) Folded() int { return r.ByMbid + r.ByTitle }
// errPairChanged means a pair no longer holds: a scan restored or adopted the
// missing row, or marked the present one, after the pairs were listed.
var errPairChanged = errors.New("library: missing pair changed before it was folded")
// PairMissingTracks folds every unambiguous missing pair, one transaction each,
// so a failed pair is logged and skipped rather than stopping the rest. It is
// idempotent and cheap (one query over the missing rows), so callers run it
// wherever a pair can newly appear: after a scan, a watcher batch, or an
// AcoustID pass that set an MBID.
func PairMissingTracks(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) (PairResult, error) {
if logger == nil {
logger = slog.Default()
}
var res PairResult
pairs, err := dbq.New(pool).ListMissingTrackPairs(ctx)
if err != nil {
return res, fmt.Errorf("list missing pairs: %w", err)
}
ambiguous := map[[16]byte]struct{}{}
for _, p := range pairs {
if err := ctx.Err(); err != nil {
return res, err
}
if p.MissingCandidates != 1 || p.PresentCandidates != 1 {
ambiguous[p.MissingID.Bytes] = struct{}{}
continue
}
f, err := foldMissingPair(ctx, pool, p.MissingID, p.PresentID)
switch {
case errors.Is(err, errPairChanged):
res.Skipped++
continue
case err != nil:
if ctx.Err() != nil {
return res, ctx.Err()
}
res.Failed++
logger.Warn("missing pair: fold failed",
"missing_path", p.MissingPath, "present_path", p.PresentPath, "err", err)
continue
}
if p.Rule == "mbid" {
res.ByMbid++
} else {
res.ByTitle++
}
logger.Info("missing pair: folded into its replacement",
"rule", p.Rule,
"missing_path", p.MissingPath, "present_path", p.PresentPath,
"play_events", f.PlayEvents, "likes", len(f.Likers), "playlist_entries", f.PlaylistEntries)
}
res.Ambiguous = len(ambiguous)
if res.Folded() > 0 || res.Failed > 0 {
logger.Info("missing pair: pass complete",
"by_mbid", res.ByMbid, "by_title", res.ByTitle,
"ambiguous", res.Ambiguous, "skipped", res.Skipped, "failed", res.Failed)
}
return res, nil
}
// foldMissingPair folds one pair in its own transaction, after locking both rows
// and checking the pair still holds.
func foldMissingPair(ctx context.Context, pool *pgxpool.Pool, missingID, presentID pgtype.UUID) (foldResult, error) {
var f foldResult
err := pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error {
tq := dbq.New(tx)
rows, err := tq.LockTracksForPairing(ctx, []pgtype.UUID{missingID, presentID})
if err != nil {
return fmt.Errorf("lock pair: %w", err)
}
if !pairStillHolds(rows, missingID, presentID) {
return errPairChanged
}
changes := mergeChanges{}
f, err = foldTrackInto(ctx, tq, presentID, missingID, &changes)
if err != nil {
return err
}
return changes.log(ctx, tx)
})
return f, err
}
// pairStillHolds reports whether both rows still exist, the first still marked
// missing and the second still present.
func pairStillHolds(rows []dbq.LockTracksForPairingRow, missingID, presentID pgtype.UUID) bool {
var missingOK, presentOK bool
for _, r := range rows {
switch r.ID.Bytes {
case missingID.Bytes:
missingOK = r.MissingSince.Valid
case presentID.Bytes:
presentOK = !r.MissingSince.Valid
}
}
return missingOK && presentOK
}