Files
minstrel/internal/library/moved.go
T
bvandeusenandClaude Opus 5.5 7e6228c13f
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
docs: library comments say what the scanner and resolver do, not how they came to be (#6277)
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>
2026-10-23 12:27:34 -04:00

151 lines
5.7 KiB
Go

package library
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
// Move detection.
//
// Track identity is file_path: UpsertTrack conflicts on it, and the reconcile
// pass in reconcile.go clears a missing mark when the walk sees that same path
// again. So a file that comes back exactly where it was restores cleanly, but a
// file that comes back RENAMED or in a different directory looks, to the
// scanner, like a deletion plus an unrelated new track:
//
// - the old row stays marked missing, holding the like and every play_event
// - a fresh row appears with no history
// - nothing connects them
//
// A liked song would read as unliked after a retag, its play count reset to
// zero, and Rediscover could offer it as a discovery. Renumbering an album is
// enough to do it.
//
// So the scan adopts the existing row rather than inserting: re-point its
// file_path at the new location and clear the mark. The caller's normal
// UpsertTrack then conflicts on file_path and updates THAT row, so the track id
// survives and likes, plays and playlist memberships travel with it. Clients
// see an update rather than a delete-and-create, so no cache churn either.
//
// Only rows already marked missing are eligible. A row whose file is present
// elsewhere is a duplicate, not a move, and re-pointing it would corrupt the
// copy that still exists. That constraint is what makes this safe, and the
// missing mark is what makes it expressible.
// trackAdopter is the slice of dbq.Queries move detection needs, narrowed so
// the match/ambiguity logic can be tested against a fake.
type trackAdopter interface {
FindMissingTrackByMbid(ctx context.Context, mbid string) ([]dbq.FindMissingTrackByMbidRow, error)
FindMissingTrackByAudioHash(ctx context.Context, audioStreamSha256 []byte) ([]dbq.FindMissingTrackByAudioHashRow, error)
AdoptTrackPath(ctx context.Context, arg dbq.AdoptTrackPathParams) (int64, error)
}
// adoptMovedTrack looks for a missing row that is the same recording as the
// file at newPath and re-points it there. Reports whether a row was adopted.
//
// Never returns an error: failing to detect a move is a missed optimisation,
// not a broken scan. The caller carries on and inserts a fresh row.
func (s *Scanner) adoptMovedTrack(
ctx context.Context, q trackAdopter, newPath string,
audioHash []byte, recordingMBID string,
) bool {
// MBID first. It identifies the recording rather than the bytes, so it
// survives a re-encode that the audio hash cannot.
if recordingMBID != "" {
rows, err := q.FindMissingTrackByMbid(ctx, recordingMBID)
if err != nil {
s.logger.Warn("library scan: move lookup by mbid failed",
"path", newPath, "err", err)
} else if c, ok := s.uniqueMatch(rowsFromMbid(rows), newPath, "mbid"); ok {
return s.adopt(ctx, q, c, newPath, "mbid")
}
}
// Audio-hash fallback for untagged files: the encoded audio itself, which a
// rename, a move or a retag leaves unchanged. Absent when the hash could
// not be taken, and then nothing is matched — an empty hash must never be
// looked up, or every unhashable file would pair with every other.
if len(audioHash) > 0 {
rows, err := q.FindMissingTrackByAudioHash(ctx, audioHash)
if err != nil {
s.logger.Warn("library scan: move lookup by audio hash failed",
"path", newPath, "err", err)
} else if c, ok := s.uniqueMatch(rowsFromAudioHash(rows), newPath, "audio_hash"); ok {
return s.adopt(ctx, q, c, newPath, "audio_hash")
}
}
return false
}
// candidate is the shared shape of both lookups, so uniqueMatch is written
// once.
type candidate struct {
id pgtype.UUID
filePath string
}
func rowsFromMbid(rows []dbq.FindMissingTrackByMbidRow) []candidate {
out := make([]candidate, 0, len(rows))
for _, r := range rows {
out = append(out, candidate{id: r.ID, filePath: r.FilePath})
}
return out
}
func rowsFromAudioHash(rows []dbq.FindMissingTrackByAudioHashRow) []candidate {
out := make([]candidate, 0, len(rows))
for _, r := range rows {
out = append(out, candidate{id: r.ID, filePath: r.FilePath})
}
return out
}
// uniqueMatch requires exactly one candidate. Adopting an arbitrary row out of
// several would attach this file's future history to a coin flip, which is
// worse than starting a fresh row — a fork is recoverable later, a wrong merge
// isn't. Libraries with genuine duplicates hit this, so it's logged rather than
// silent.
func (s *Scanner) uniqueMatch(
cands []candidate, newPath, via string,
) (candidate, bool) {
switch len(cands) {
case 0:
return candidate{}, false
case 1:
return cands[0], true
default:
s.logger.Info("library scan: ambiguous move match, inserting a new track instead",
"path", newPath, "via", via, "candidates", len(cands))
return candidate{}, false
}
}
func (s *Scanner) adopt(
ctx context.Context, q trackAdopter, c candidate, newPath, via string,
) bool {
n, err := q.AdoptTrackPath(ctx, dbq.AdoptTrackPathParams{ID: c.id, FilePath: newPath})
if err != nil {
// A unique violation on file_path means something else claimed this
// path first. Fall through to a normal insert rather than failing the
// file.
s.logger.Warn("library scan: adopting moved track failed",
"path", newPath, "via", via, "err", err)
return false
}
if n == 0 {
// Lost the race: another file adopted this row between lookup and
// update, so its mark was already cleared.
return false
}
// Logged with both paths: this is an admin's only window onto a
// reorganisation being understood as a move rather than a new track.
s.logger.Info("library scan: track moved, history preserved",
"from", c.filePath, "to", newPath, "via", via, "track_id", syncpkg.FormatUUID(c.id))
return true
}