release / govulncheck (push) Successful in 20s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m15s
release / android (push) Successful in 5m26s
release / Build signed APK (releases and dev) (push) Successful in 5m45s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Rewrites the internal/library comment blocks to rule 32. Task, milestone, decision and rule ids are gone; the incidents behind them are restated as the constraint they left (why a file is deleted before its row, why the missing mark is a mark, why duplicates block on duration). Counts and album names from one library are dropped from production comments; test comments keep the names their fixtures use. The tagReadVersion log keeps each version's before and after, without the measurements. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
151 lines
5.1 KiB
Go
151 lines
5.1 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.
|
|
//
|
|
// 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. The common case is a quality
|
|
// upgrade — 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 an untagged replacement
|
|
// has nothing to match until AcoustID fills its MBID, by which time both rows
|
|
// exist. 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: 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
|
|
}
|