feat(library): adopt moved files instead of forking their history — #2528
Track identity was file_path, so a file that came back renamed or in a different directory looked like a deletion plus an unrelated new track: the old row kept the like and every play_event while a fresh zero-history row appeared, and nothing connected them. A liked song read as unliked, its play count reset, and Rediscover could offer it as a discovery — silently. Renumbering an album was enough, which is what happened to the operator's copy of Minutes to Midnight. Adoption re-points the existing row's file_path at the new location and clears its missing mark. The 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 — and clients see an update rather than a delete-and-create, so no cache churn either. Matching is MBID first (identifies the recording, so it survives a re-encode), then file_size + duration_ms for untagged files. Both fingerprint components must be non-zero: duration_ms is 0 when ffprobe failed, and matching 0 against 0 would pair up unrelated broken files. 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. An ambiguous match inserts fresh rather than adopting one arbitrarily: a fork is recoverable later, a wrong merge isn't. Scan is now three phases, and the order is the point. Adoption can only claim a row that is ALREADY marked missing, but reconcile previously ran after processing — so a rename performed while the server was down surfaced the deletion and the addition in the same scan, the new path inserted first, and the fork became permanent. Enumeration is therefore separated from processing so reconcile can run between them: walk (paths only, no tag reads or probes) -> reconcile -> process in walk order. Consequence worth knowing: when reconcile refuses (an absent root, or a reorganisation exceeding the 25% mark cap) adoption cannot fire and renamed files fork as before. That's the pre-#2528 behaviour rather than a new failure, and the warning now names it. The old outer walk-error branch was unreachable — the callback always returned nil, so WalkDir never surfaced an error — and verifyRootsPresent is the real protection, so enumerate counts walk errors instead of pretending to abort on them.
This commit is contained in:
@@ -138,6 +138,50 @@ RETURNING id, album_id, artist_id, file_path, mbid;
|
||||
-- (#357). Mirror of GetArtistsByIDs.
|
||||
SELECT * FROM tracks WHERE id = ANY($1::uuid[]);
|
||||
|
||||
-- name: FindMissingTrackByMbid :many
|
||||
-- Move detection, strongest signal (#2528). A file that turned up at a new path
|
||||
-- carrying a recording MBID we already have on a MISSING row is that recording,
|
||||
-- moved — not a new track.
|
||||
--
|
||||
-- `missing_since IS NOT NULL` is the safety constraint, not an optimisation: a
|
||||
-- row whose file is present elsewhere on disk is a DUPLICATE, and re-pointing
|
||||
-- its file_path would corrupt the copy that still exists.
|
||||
--
|
||||
-- LIMIT 2 because the caller only needs to know "exactly one" vs "more than
|
||||
-- one" — an ambiguous match must not be adopted arbitrarily.
|
||||
SELECT id, file_path FROM tracks
|
||||
WHERE missing_since IS NOT NULL
|
||||
AND mbid IS NOT NULL
|
||||
AND mbid = sqlc.arg(mbid)::text
|
||||
LIMIT 2;
|
||||
|
||||
-- name: FindMissingTrackByFingerprint :many
|
||||
-- Move detection fallback for files with no MBID (#2528). Exact byte size AND
|
||||
-- exact decoded duration is a strong pair: a plain move or rename preserves
|
||||
-- both, while a re-encode changes at least one — and a re-encode genuinely is a
|
||||
-- different file, so failing to match there is correct rather than a gap.
|
||||
--
|
||||
-- Same missing-only constraint and same LIMIT 2 rationale as the MBID variant.
|
||||
SELECT id, file_path FROM tracks
|
||||
WHERE missing_since IS NOT NULL
|
||||
AND file_size = sqlc.arg(file_size)
|
||||
AND duration_ms = sqlc.arg(duration_ms)
|
||||
LIMIT 2;
|
||||
|
||||
-- name: AdoptTrackPath :execrows
|
||||
-- Re-points a missing row at the path its file turned up on, and clears the
|
||||
-- mark. The caller's normal UpsertTrack then conflicts on file_path and updates
|
||||
-- THIS row in place, so the track id survives and its likes, play history and
|
||||
-- playlist memberships come with it.
|
||||
--
|
||||
-- `missing_since IS NOT NULL` again, this time as a race guard: two files can't
|
||||
-- both adopt the same row, and :execrows reports 0 to whichever loses.
|
||||
UPDATE tracks
|
||||
SET file_path = sqlc.arg(file_path),
|
||||
missing_since = NULL
|
||||
WHERE id = sqlc.arg(id)
|
||||
AND missing_since IS NOT NULL;
|
||||
|
||||
-- name: ListTrackPathsForReconcile :many
|
||||
-- Every row's path + current missing mark, for the scanner's reconcile pass
|
||||
-- (#2523). Deliberately unfiltered and unpaged: reconcile has to compare the
|
||||
|
||||
Reference in New Issue
Block a user