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 }