Merge keeps one copy of a duplicate group and removes the rest. Every
table that references tracks does so ON DELETE CASCADE, so deleting a
duplicate's row outright would silently destroy its likes, plays,
playlist entries and tags. The merge moves all of that onto the kept
copy first, then deletes the empty row.
In one transaction, holding a lock on the group:
- repoints play_events, skip_events, contextual_likes, playback_errors,
lidarr_requests.matched_track_id and playlist_tracks. The last is
keyed by position, so every entry stays where it was.
- merges general_likes one per user, dated to the earlier like
- takes the union of track_tags, keeping the kept copy's own weight on
a shared tag
- rewrites track_similarity onto the kept copy, dropping edges that
would point a track at itself and keeping the kept copy's existing
edge on a collision
- lets the kept copy take a recording MBID only the removed copy had
- deletes the removed copies' rows, tidies emptied albums and artists,
marks the group merged
- logs sync changes: track deletes, and like and playlist-track
delete/upsert pairs
The removed copies' files are deleted first, before any row changes,
through the same helper as DeleteTrackFile (now shared, along with the
album tidy-up). A merge that left the file behind would be undone by
the next scan re-importing it. An unwritable library answers 409
library_not_writable and nothing changes.
tracks.Service.MergeDuplicates wraps it with the opt-in Lidarr unmonitor
from RemoveTrack, skipped when the removed copy is a second file of the
kept copy's own album track: unmonitoring that would stop Lidarr
managing the kept file. It writes a duplicate_merge audit row after
commit, per the audit package's best-effort contract, naming both
paths.
POST /api/admin/library/duplicates/{id}/merge takes an optional
survivor_track_id (the report's proposal otherwise) and unmonitor.
On the report page:
- each copy gets a Keep choice, defaulting to the proposed one
- Merge needs a second click, on a button that says how many files it
removes, with the consequence stated beside an opt-in Lidarr checkbox
Integration tests cover:
- every piece of history landing on the kept copy exactly: likes
deduped at the earlier time, plays and skips counted, playlist
position unchanged, tags unioned, similarity rewritten with no
duplicate or self-edge, MBID inherited
- the removed file gone, and a second merge refused
- an unwritable file leaving likes, plays, row and group untouched
- a survivor outside the group refused
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
Two delete paths had opposite failure policies. tracks.RemoveTrack
logged a failed os.Remove and deleted the row anyway, which CASCADEs
likes, plays, playlist memberships and tags, while the file survived
for the next scan to re-import as a stranger. library.DeleteTrackFile
stopped correctly but reported it as a bare 500 nobody could read.
One path now: library.DeleteTrackFile removes the file first and, on
anything but ErrNotExist, returns *FileRemoveError with nothing
deleted. Only then does it delete the row and tidy an emptied album
and artist in one transaction, log the sync change and clear orphaned
artist art. RemoveTrack calls it, which also fixes RemoveTrack never
logging a sync change. Quarantine Delete file now tidies emptied
albums and artists too.
Both endpoints answer an unwritable library (EROFS, EACCES, EPERM) with
409 library_not_writable. The message names the directory (removal
writes to the parent), the uid:gid the server runs as, and that
nothing was deleted. Other remove errors are 500 file_delete_failed
with the path.
The reachable surface is quarantine Delete file, which failed
silently: no copy for the code on either client, and Android swallowed
the exception so the row just reappeared. Web and Android now have
copy for both codes and append the server message for exactly those
two. Android's quarantine screen shows it in a snackbar.
DELETE /api/admin/tracks/{id} has had no client since f7278f24, which
kept it on purpose for a safer admin surface, so its history loss was
latent. Fixed rather than removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
Every browse, discover and mix query filters missing_since, so a track
whose file vanished disappears from the places Minstrel chooses music.
A playlist is different: the entry is there because the user put it
there, and silently dropping it rewrites their list behind their back.
So playlists keep the row and mark it instead. ListPlaylistTracks now
carries missing_since (still deliberately unfiltered), the service
layer surfaces it as PlaylistTrack.Unavailable, and the wire gains
"unavailable" on each entry.
A missing entry also loses its stream_url. Refusing to hand out a URL
that cannot serve is stronger than trusting every client to honour the
flag, and "stream_url": null is a shape the clients already model --
PlaylistWire.streamUrl is documented nullable for the track-removed
case -- so an older build degrades to "present but not playable" with
no change.
Nothing is deleted here and nothing should be: the row, its play
history, its likes and its taste contribution all survive a file going
missing, because the file may come back (and #2528 will adopt it if it
comes back renamed).
Also corrects two comments that had drifted into lying. delete.go still
claimed the file-gone case was NOT auto-reconciled and told admins to
delete rows by hand -- untrue since f6d1cf24, and that exact staleness
is what produced drift #572. It now says what DeleteTrackFile really is:
the destructive admin action, which CASCADEs play_events and likes, and
is emphatically not the missing-file path. watcher.go claimed the
safety-net scan "covers anything missed"; the walk only covers
additions, and it is reconcile that covers removals.
The docstring claimed "the next library scan reconciles missing
files by removing their tracks rows" — but scanner.go only does
filepath.WalkDir + UpsertTrack; it never enumerates existing rows
to check file_path presence, and it never DELETEs orphan rows. The
audit verified this — repo-wide grep finds no orphan-sweep code.
The lie is load-bearing: lidarrquarantine/service.go:270 leans on
this guarantee, so downstream code thinks the orphan case heals
itself. Fix the comment to state reality (admin re-trigger or
manual cleanup) and reference the open follow-up for adding a real
sweep. The actual reconcile pass is a separate piece of work
(needs scanrun integration + retention semantics + tests) and
stays in the Scribe audit queue.
Wires sync.LogChange into the library mutation sites so /api/library/sync
reflects upserts and deletes.
Architectural pivot: LogChange's signature is now (ctx, dbq.DBTX, ...) so
it works with both *pgxpool.Pool and pgx.Tx. The scanner doesn't run
mutations in explicit transactions, so it pool-binds; delete.go matches.
Tx-bound callers (likes/playlists in subsequent commits) keep atomicity.
Also: sync.FormatUUID centralizes the pgtype.UUID → canonical string
conversion that both the scanner and the sync handler need; library_sync.go
now uses it instead of a local copy.
Best-effort logging on scanner failures (Warn, don't fail the scan): a
LogChange error after a successful upsert is rare and self-healing — the
next scan that touches the entity re-emits the change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- dbtest.ResetDB.dataTables now truncates lidarr_quarantine + lidarr_quarantine_actions
alongside the other M2-M4 data tables. Without this, M5b service tests would
inherit residual state across runs.
- DeleteTrackFile godoc spells out the post-file/pre-DB failure window
reconciles via the next library scan; the function is retry-safe by design,
not atomic.