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>
152 lines
5.3 KiB
Go
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
|
|
}
|