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 }