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.7 KiB
Go
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
|
|
}
|