feat(library): fold a missing track into its on-disk replacement (M485 #5286 #5287)
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
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>
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user