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 (#2528). // // 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 looked, to the // scanner, like a deletion plus an unrelated new track: // // - the old row stayed marked missing, holding the like and every play_event // - a fresh row appeared with no history // - nothing connected them // // A liked song read as unliked after a retag, its play count reset to zero, and // Rediscover could offer it as a discovery. All silently. Renumbering an album // was enough to do it — which is exactly what happened on the operator's copy of // Minutes to Midnight. // // The fix 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 // marking added in #2523 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) FindMissingTrackByFingerprint(ctx context.Context, arg dbq.FindMissingTrackByFingerprintParams) ([]dbq.FindMissingTrackByFingerprintRow, 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, which is the // pre-#2528 behaviour. func (s *Scanner) adoptMovedTrack( ctx context.Context, q trackAdopter, newPath string, fileSize int64, durationMs int32, recordingMBID string, ) bool { // MBID first. It identifies the recording rather than the bytes, so it // survives a re-encode that the fingerprint 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") } } // Fingerprint fallback for untagged files. Both components must be real: // duration_ms is 0 when ffprobe failed, and matching 0 against 0 would pair // up unrelated broken files. if fileSize > 0 && durationMs > 0 { rows, err := q.FindMissingTrackByFingerprint(ctx, dbq.FindMissingTrackByFingerprintParams{ FileSize: fileSize, DurationMs: durationMs, }) if err != nil { s.logger.Warn("library scan: move lookup by fingerprint failed", "path", newPath, "err", err) } else if c, ok := s.uniqueMatch(rowsFromFingerprint(rows), newPath, "fingerprint"); ok { return s.adopt(ctx, q, c, newPath, "fingerprint") } } 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 rowsFromFingerprint(rows []dbq.FindMissingTrackByFingerprintRow) []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 the operator'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 }