The operator chose option 2: an album holding a video rip that is the only
copy of its song is searched in Lidarr (AlbumSearch), at most once a week
and ten albums a pass. Lidarr grabs only when the quality profile allows an
upgrade, so nothing is removed and the rip stays held back either way.
- lidarr.Client.SearchAlbums posts the AlbumSearch command.
- ListAlbumsWithSoleCopyRips finds the albums: a held-back rip with no
present clean copy sharing its song or its pending group, and not searched
in the last week (audit action lidarr_rip_search).
- Suspect sources shows when Lidarr last searched the track's album.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The duplicate sweep proposed 4,197 groups and every one waited for the
operator. Most are safe to settle, and Lidarr defines what safe means: it
maps one file to each track of the release it monitors and downloads any
mapped file that disappears. Deleting a mapped copy opens exactly the hole
the operator saw Lidarr fill.
Classify (#5435)
- Migration 0075: duplicate_groups.class (same_release, cross_release,
mismatch, review), resolve_note, resolved_automatically;
duplicate_group_members.lidarr_state (tracked, unmapped);
fingerprint_settings.auto_resolve; notification kind
duplicates_resolved with both kind CHECKs swapped (rule 36).
- library.ClassifyDuplicateGroup, with MatchTitleKey dropping featuring
credits, remaster notes and video-rip markers, and keeping live, demo,
remix and instrumental. The rip markers move from api to library.
Choose the copy to keep (#5436)
- ProposeSurvivor ranks the copy Lidarr maps first, then tag fit (a
clash-free track number, no rip marker in the name, an MBID), then the
quality rules. File size picked the wrong Humanz copy in 6 of 21 groups.
Act (#5437)
- An hourly resolver pass reads Lidarr's unmapped files, matched by the
last three path components, and records each copy's state.
- Same album, with at most one copy mapped: merged into the mapped copy.
The merge is guarded, so a mapped copy can never be removed
(MergeDuplicateGroupGuarded, ErrCopyTrackedByLidarr).
- Same album, every copy mapped: the monitored release lists the song
twice (Humanz's 14x12" box set). The pass moves Lidarr to the release
that lists each song once and best covers what is on disk. It never
picks one covering less, and is capped at 10 albums per pass.
- Fixed point (lesson #4183): the chosen release no longer repeats.
- The album is left alone for 24h while Lidarr rescans, so "every copy
unmapped" mid-rescan is never read as licence to merge.
- Both actions are audited with no actor and summarised to admins. The
operator can switch them off in the Fingerprinting card (rule 25).
- Manual merges use the same guard: 409 copy_tracked_by_lidarr, or 503
lidarr_unavailable when Lidarr cannot say.
Web
- Duplicates gets tabs: Needs review, Across releases, Resolved
automatically. Each loads as you scroll (rule 172), replacing the
pager.
- Each copy says whether Lidarr uses it.
- The resolver's note shows on each group.
- The merge confirm blocks, before sending, a merge that would remove
the copy Lidarr uses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ArtistResource has no top-level `monitor`. Lidarr reads the choice from
AddOptions (AddArtistOptions, a MonitoringOptions), so the "all"/"future"
we sent there was dropped on deserialisation. AddOptions.Monitor stayed
Unknown, and AlbumMonitoredService.SetAlbumMonitoredStatus returns early
on Unknown. The request's monitoring was never applied.
Send monitor and monitored inside addOptions with searchForMissingAlbums,
the shape Lidarr's getNewArtist.js posts, and set monitorNewItems "all"
explicitly: both choices mean new releases are watched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lidarr's POST /api/v1/album validates `artist` as a nested resource
(AlbumController: RuleFor(s => s.Artist).NotNull()), so the flat payload
we sent was refused with "'Artist' must not be empty" every time. The
album add has never worked against a real Lidarr; approved album and
track requests sat in the reconciler retrying every 5 minutes.
AddAlbum now does what Lidarr's own add-album UI does (getNewAlbum /
getNewArtist): look the album up by MBID (album/lookup?term=lidarr:<mbid>),
then POST that resource back with monitored + searchForNewAlbum. When
Lidarr doesn't have the artist yet, the nested artist gets the request's
quality/metadata profile and root folder, monitors this album only
(monitor "none" + albumsToMonitor, which AlbumMonitoredService prefers)
and no future releases. An artist Lidarr already has is left as it is.
An MBID Lidarr's metadata doesn't know is ErrNotFound with no POST.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
player: setQueueFromTracks fast-starts a single source at player-index 0
while the full queue is broadcast, so the transient currentIndexStream→0
emission clobbered the correct mediaItem with queue.value[0] (the first
track). Mini bar / playlist marker pinned to the wrong track until a
later index event (~the "passive ~30s recovery"). Track a logical-index
base so the player→queue mapping stays correct during the fill window;
also fixes the latent forward-fill auto-advance off-by-base.
lidarr #50: approving no longer fails when Lidarr is down. Approve
records the decision durably first, then best-effort adds; the
reconciler idempotently (re)sends unconfirmed adds every tick until they
stick (new additive lidarr_add_confirmed_at; AddArtist/AddAlbum map
Lidarr's "already exists" 400 → ErrAlreadyExists). No failed-state or
expiry by design — Lidarr keeps trying, operator monitors.
lidarr #51: Create() is now idempotent — a non-terminal request for the
same MBID returns the existing row instead of inserting a duplicate.
Rewrites the obsolete LidarrUnreachable_503 test to assert the durable-
approve contract; threads a client factory into NewReconciler.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces commit 50a231f's wrong-shape Lidarr-routed delete. The previous
design called lidarrquarantine.DeleteViaLidarr for Lidarr-managed tracks,
which deletes the entire **album** in Lidarr (Lidarr is album-granular,
no per-track delete API) — silently dropping sibling tracks the operator
didn't ask to remove.
New shape per spec revision 723eee9:
- Always: os.Remove + DB delete + cascade through Minstrel.
- When unmonitor=true AND track has mbid: call new Lidarr.UnmonitorTrack
primitive so Lidarr won't replace. Failure is non-fatal — file is
already gone, DB consistent; the lidarrUnmonitorFailed bool flows to
the wire response so the UI can surface a follow-up "unmonitor
manually" toast.
Service signature changes from `(trackID, adminID) → (delAlbum, delArtist, err)`
to `(trackID, adminID, unmonitor) → (delAlbum, delArtist, lidarrFailed, err)`.
Adds Lidarr.UnmonitorTrack with full three-step API walk:
1. GET /api/v1/album?foreignAlbumId={mbid} → Lidarr's internal album id
2. GET /api/v1/track?albumId={id} → match track by foreignTrackId
3. PUT /api/v1/track/monitor with {trackIds:[id], monitored:false}
Refactors post() into a shared bodyRequest() so put() reuses the same
status-code → typed-error mapping. The album mbid is captured *before*
the cascade-delete transaction — DeleteAlbumIfEmpty may remove the
album row, after which a post-commit GetAlbumByID returns ErrNoRows
and the unmonitor walk would have nothing to walk against.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 'lidarr_rejected' the operator was hitting on Approve was Lidarr 4xx-ing
the POST with two field-validation errors:
'Metadata Profile Id' must be greater than '0'.
'Artist Name' must not be empty.
Our payload was missing both. Lidarr's metadata profile is a separate
concept from quality profile (it controls which release types are
tracked: albums, singles, EPs, etc.) and is required by /api/v1/artist
and /api/v1/album. The 'already exists' line in the toast copy was an
incorrect guess at the cause; the real issue was an invalid payload.
Changes:
- internal/lidarr/types.go: AddArtistParams + AddAlbumParams gain
ArtistName and MetadataProfileID. New MetadataProfile struct mirroring
QualityProfile.
- internal/lidarr/client.go: AddArtist + AddAlbum payloads include both
new fields. New ListMetadataProfiles fetches GET /api/v1/metadataprofile.
- internal/lidarrrequests/service.go: Approve fetches the metadata
profile list and uses the first as a default (Lidarr installs ship
'Standard' at id=1 OOB, so this works for vanilla setups). Picker
on /admin/integrations is a follow-up. ArtistName flows from the
request row.
- web/src/routes/admin/requests/+page.svelte: drop the
speculative 'usually means already in library' copy on lidarr_rejected;
point operators at server logs for the field-level reason instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "I can't approve a request, the toast just says unknown" bug was
four problems compounded:
1. Lidarr config let enabled=true save with default_quality_profile_id=0
and default_root_folder_path=''. Approve then sent invalid POST bodies
to Lidarr, which 5xx'd.
2. lidarr.client.post() discarded Lidarr's response body on error, so
we couldn't tell why Lidarr 5xx'd from server logs.
3. handleApproveRequest's error switch didn't map ErrServerError or
ErrLookupFailed — both fell through to a generic 500 server_error.
4. apiFetch only parsed {error: {code, message}} envelopes, but admin
endpoints write {error: 'code_string'}. Every admin error toast
rendered as 'unknown'.
Fixes:
- internal/lidarr/client.go: capture up to 512 bytes of Lidarr's response
body when it returns 4xx/5xx; include in the wrapped error so server
logs show what Lidarr actually said instead of just the status bucket.
- internal/lidarrrequests/service.go: new ErrDefaultsIncomplete fires
before the Lidarr call when QP=0 or root_folder=''. Stops the bad
POST entirely.
- internal/api/admin_requests.go: handleApproveRequest now maps
ErrDefaultsIncomplete -> 'lidarr_defaults_incomplete' (400),
ErrServerError -> 'lidarr_server_error' (502),
ErrLookupFailed -> 'lidarr_rejected' (502).
- internal/api/admin_lidarr.go: handlePutLidarrConfig now requires
QP + root folder to be set whenever enabled=true.
- web/src/lib/api/client.ts: apiFetch handles both error envelope shapes
so admin error codes propagate to toasts.
- web/src/routes/admin/integrations/+page.svelte: auto-default to the
first quality profile and first root folder Lidarr returns when the
operator hasn't picked one yet — saves a click for typical
one-profile/one-folder home setups.
- web/src/routes/admin/requests/+page.svelte: friendly toast copy for
the new error codes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Decode errors in LookupArtist/AlbumByMBID now wrap ErrInvalidPayload so
callers can errors.Is the same way they do against the M5a methods.
- Tighten TestLookupAlbumByMBID_BadJSON to assert on ErrInvalidPayload.
- Add empty-mbid rejection tests for both methods.
- Add TestLookupArtistByMBID_EmptyArrayReturnsErrNotFound (only the album
variant had the test before — symmetric coverage now).
- Normalize the network-error test's api key to key123 for consistency
with the rest of delete_test.go.
- Add ArtistMBID/AlbumMBID to LookupResult so handlers can build the
full lidarr_requests row from album/track lookups (parent MBID is
needed for the AddAlbum API call and for the schema's NOT NULL
lidarr_artist_mbid)
- Trim trailing slash on BaseURL before joining paths to avoid
double-slash URLs when operators enter "http://lidarr.lan/"
- Check json.Marshal errors in AddArtist/AddAlbum
- Extract lidarrImage as a named unexported type instead of repeating
the anonymous struct three times
- Add NewClient(baseURL, apiKey) constructor with 30s default timeout
- Add ErrLookupFailed test coverage for the 4xx-non-auth branch on
both get and post helpers
- Rename TestListRootFolders_BadJSON -> _NullBody (it tests null,
not malformed); add a real malformed-JSON test
- Defensive separator guard on LookupAlbum.Secondary so an empty
ArtistName doesn't produce a leading " · "
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>