Operator decision during Task 2 implementation: routing track removal through lidarrquarantine.DeleteViaLidarr is wrong because Lidarr is album-granular (no per-track delete). The original spec would have silently deleted sibling tracks. New shape: - Always delete file + DB row + cascade through Minstrel (os.Remove). - Confirm dialog asks "find a replacement?": Yes (default — no Lidarr call; monitoring re-imports on next scan) or No (call new Lidarr.UnmonitorTrack(mbid) primitive). - Lidarr unmonitor failure is non-fatal — sets lidarr_unmonitor_failed on the success envelope so the UI toasts a follow-up message. Adds a new internal/lidarr.UnmonitorTrack method (LookupTrack → PUT /api/v1/track/monitor with monitored:false) to Task 2's scope. Wire codes lidarr_unreachable / lidarr_unauthorized / lidarr_server_error no longer come back from this endpoint.
19 KiB
M7 — Track-level actions menu
Status: Draft for review · 2026-05-03
Phase: M7 task #372. Web-side polish slice. Replaces the single-entry
<TrackMenu>(built as scaffolding for M5b's quarantine flag) with a real per-track action set. Lands before M7 #352 (Playlists CRUD) so the "Add to playlist…" entry has a menu to slot into. Mirrored on Flutter in a later slice once the web surface is settled.
1. Goal
Per-track kebab menu wherever <TrackRow> or the <PlayerBar> track display is used, exposing the full set of actions a user can take on a track right now: queue manipulation, library state changes (Like, Hide), navigation (Go to album/artist), Lidarr-aware track removal, and a placeholder slot for "Add to playlist…" that #352 fills.
2. Goals and non-goals
Goals
<TrackMenu>exposes nine entries, grouped four ways:- Queue: Play next · Add to queue
- Collection: Like / Unlike · Add to playlist… (slot reserved; wired by #352)
- Navigation: Go to album · Go to artist
- Lifecycle: Flag this track… · Hide / Unhide · Remove from library (admin-only)
- "Like / Unlike" and "Hide / Unhide" entries duplicate functionality already on the row (heart and eye icon buttons). Reason: keyboard discoverability — once the kebab is focused, arrow-keys reach every action; mouse users still get one-tap heart/eye on the row.
- "Add to playlist…" reserves the menu slot but the submenu of playlists is wired in #352, not here. The slot renders as disabled with a tooltip "Coming with playlists" until #352 ships.
- "Go to artist" is single-target (
track.artistId); compilation/feature artists aren't modeled in the schema today and that's a schema problem to solve before this entry needs a submenu. - "Remove from library" is admin-only — gated client-side by
currentUser.is_admin, server-side byauth.RequireAdmin()on the new/api/admin/tracks/{id}route. The endpoint always deletes the file from disk viaos.Remove(not via Lidarr, which is album-granular and has no per-track delete) plus the DB row, then cascades album → artist tidy-up. For Lidarr-managed tracks the operator chooses whether Lidarr should look for a replacement (default — no extra action; Lidarr's monitoring re-imports on next scan) or the track should be unmonitored (server callsLidarr.UnmonitorTrack(mbid)to flipmonitored: false). The?unmonitor=truequery param drives this — unset /false= replace,true= unmonitor. - Keyboard accessibility: kebab is
<button aria-haspopup="menu" aria-expanded>; opened menu hasrole="menu"with each entryrole="menuitem". Up/Down arrows cycle, Enter activates, Escape closes and returns focus to the kebab. - Error envelope: existing
copyForCode()helper resolves all surface errors (lidarr_unreachable,lidarr_unauthorized,lidarr_server_error,not_found,unauthorized,connection_refused).
Non-goals (this slice)
- Per-track metadata editing (title / artist / track_number / genre / year / etc.) — filed as M7 #373 ("Track metadata editing"). Crosses into Lidarr's territory and the scanner's reconciliation logic; deserves its own spec.
- "Add to playlist…" submenu wiring — lands in #352 (Playlists CRUD slice 1).
- "Remove from library" exposed to non-admin users — admin-only matches the existing model (file management is an operator concern).
- Multi-artist disambiguation on "Go to artist" — schema doesn't model collaborator artists today; expanding the menu to a submenu earns its keep only after the schema does.
- Submenu UI infrastructure beyond the playlists hook — no other entries need submenus in this slice.
- Flutter mirror — separate task once web settles (#356 follow-up slice).
3. Architecture
Backend
One new admin endpoint: DELETE /api/admin/tracks/{id}?unmonitor=true|false. Handler in internal/api/admin_tracks.go (new file). Calls a new internal/tracks package that owns the removal logic. Also adds one new method to internal/lidarr/client.go: UnmonitorTrack(ctx, trackMbid) — looks up the track in Lidarr by mbid via LookupTrack, then PUTs /api/v1/track/monitor with {trackIds: [lidarrTrackID], monitored: false}. (Requires a small put helper alongside the existing get / post.)
internal/tracks/service.go exposes RemoveTrack(ctx, trackID, adminID, unmonitor bool) returning (deletedAlbumID *pgtype.UUID, deletedArtistID *pgtype.UUID, err). Steps:
- Look up the track. Need:
file_path,mbid,album_id,artist_id. ReturnsErrNotFoundif missing. - Always:
os.Remove(file_path). Tolerateos.ErrNotExist(file already gone — proceed with DB cleanup; the goal is consistency). Otheros.Removeerrors log a warning and proceed; orphan files are recoverable, orphan DB rows are not. - Begin a single DB transaction:
DeleteTrack(id)— returnsalbum_id,artist_idfrom RETURNING. Existing FK CASCADE ontrack_idhandlesplay_events,general_likes_tracks,lidarr_quarantine,lidarr_quarantine_actions. (Verified live — all four already cascade. Futureplaylist_tracksfrom #352 will also needON DELETE CASCADE.)DeleteAlbumIfEmpty(album_id). Onpgx.ErrNoRows(album still has other tracks) skip. Otherwise setdeletedAlbumID.- If album was deleted:
DeleteArtistIfEmpty(artist_id). Onpgx.ErrNoRowsskip. Otherwise setdeletedArtistID. - Commit.
- If
unmonitorAND track had a non-empty mbid (Lidarr-managed): callLidarr.UnmonitorTrack(ctx, mbid). Lidarr errors here are non-fatal — log a warning and set alidarrUnmonitorFailedflag, but don't fail the request. The file is already gone and the DB is consistent; failing now would leave the operator with no recovery path. The flag flows to the wire response so the UI can toast a follow-up message. - Return
(deletedAlbumID, deletedArtistID, lidarrUnmonitorFailed bool, nil).
API handler maps the typed errors:
ErrNotFound→ 404{"error": "not_found"}- success → 200
{"deleted_track_id": "...", "deleted_album_id": "...?", "deleted_artist_id": "...?", "lidarr_unmonitor_failed": true|false}—lidarr_unmonitor_failedis only set totruewhen the operator requested unmonitor AND the Lidarr call failed; omitted in all other cases.
Frontend
<TrackMenu> is rewritten from "kebab → FlagPopover only" to "kebab → menu with grouped entries". The menu API stays compatible (same track: TrackRef and direction: 'up'|'down' props), so existing callers (<TrackRow>, <PlayerBar>) pick up the new entries with no code change.
Two new component primitives:
<TrackMenuItem>— single row of the menu (<button role="menuitem">+ Lucide icon + label + optional shortcut indicator).<TrackMenuDivider>—<hr>styled to FabledSword's pewter/slate.
<TrackMenu>'s state machine:
menuOpen: boolean— set true on kebab click; togglesaria-expanded.popoverOpen: boolean— set true when "Flag this track…" fires; menuOpen flips to false.- Outside-click handler closes both.
Audio queue mutations are pure client-state. The existing audio store gets two methods:
function playNext(track: TrackRef) {
// Splice immediately after the current index.
_queue.splice(_index + 1, 0, track);
}
function addToQueue(track: TrackRef) {
_queue.push(track);
}
Like / Unlike + Hide / Unhide reuse the existing likeTrack / unlikeTrack and quarantineTrack / unquarantineTrack API helpers + their TanStack Query invalidations.
"Remove from library" calls a new web/src/lib/api/admin/tracks.ts::removeTrack(id). On success, invalidate qk.albums(track.albumId), qk.artists(track.artistId), qk.home(), plus any list-page query keys currently rendered. The response payload's optional deleted_album_id / deleted_artist_id lets the client also evict those entity caches.
Navigation entries call goto('/albums/' + track.albumId) and goto('/artists/' + track.artistId) directly — no async, no error path.
Accessibility
- Kebab
<button>carriesaria-label="Track actions",aria-haspopup="menu",aria-expanded={menuOpen}. - Open menu has
role="menu". Each entry:<button role="menuitem">. Disabled entries (admin-only for non-admin users; "Add to playlist…" pre-#352) havearia-disabled="true"rather than being absent — keeps the menu height stable so muscle memory works for both user types. - Up/Down arrow keys cycle focus through visible enabled items. Home/End jump to first/last. Escape closes and returns focus to the kebab. Enter activates the focused item.
- The submenu plumbing #352 lands will use arrow-right to enter, arrow-left to exit, with focus following.
4. File map
Backend — create
internal/tracks/service.go—RemoveTrack(ctx, id, adminID)+ typed errors.internal/tracks/service_test.go— unit tests covering the six service paths (Lidarr happy, non-Lidarr happy, file-already-gone, Lidarr error, album-empty cascade, artist-empty cascade).internal/api/admin_tracks.go—handleRemoveTrack(w, r).internal/api/admin_tracks_test.go— HTTP tests (admin-only auth, 4 error codes, response envelope).
Backend — modify
internal/api/api.go— registeradmin.Delete("/tracks/{id}", h.handleRemoveTrack).internal/db/queries/tracks.sql— appendDeleteTrack,DeleteAlbumIfEmpty,DeleteArtistIfEmpty,CountTracksByAlbum,CountAlbumsByArtist.internal/db/dbq/*— regenerated bysqlc generate.internal/db/migrations/0014_track_cascades.up.sql— conditional, only if any ofplay_events,general_likes_tracks,lidarr_quarantinelacksON DELETE CASCADEon itstrack_idFK. Read existing migrations first; skip if all cascades are already correct.
Frontend — create
web/src/lib/components/TrackMenuItem.svelte— single-entry primitive.web/src/lib/components/TrackMenuItem.test.tsweb/src/lib/components/TrackMenuDivider.svelte— visual separator.web/src/lib/api/admin/tracks.ts—removeTrack(id).web/src/lib/api/admin/tracks.test.ts
Frontend — modify
web/src/lib/components/TrackMenu.svelte— full rewrite from FlagPopover-only to multi-entry menu.web/src/lib/components/TrackMenu.test.ts— full rewrite.web/src/lib/components/TrackRow.svelte— no code change required (uses<TrackMenu>'s same prop API).web/src/lib/components/PlayerBar.svelte— no code change required.web/src/lib/components/TrackRow.test.ts— adjust expectations (menu now has 9 entries instead of 1).web/src/lib/components/PlayerBar.test.ts— same.web/src/lib/api/queries.ts— no new query keys. The Remove flow invalidates existingqk.albums(id)/qk.artists(id)/qk.home()plus any list-page keys currently rendered. There is no admin-tracks list view in this slice.web/src/lib/stores/audio.ts— addplayNext(track)andaddToQueue(track)methods.web/src/lib/stores/audio.test.ts— extend to cover the new methods.
5. API contract
DELETE /api/admin/tracks/{id}?unmonitor=true|false
Auth: admin session.
Query params:
unmonitor(optional, defaultfalse) — whentrueAND the track is Lidarr-managed (has an mbid), the server callsLidarr.UnmonitorTrackafter the file/DB delete so Lidarr won't search for a replacement. Whenfalseor omitted, no Lidarr call — Lidarr's monitoring re-imports on next scan, which is the operator's "find a replacement" path.
Request: no body.
Response (200 OK):
{
"deleted_track_id": "uuid",
"deleted_album_id": "uuid",
"deleted_artist_id": "uuid",
"lidarr_unmonitor_failed": true
}
deleted_album_idanddeleted_artist_idare optional. Present only when removing the track left the parent empty and the row was deleted too.lidarr_unmonitor_failedis onlytruewhen the operator requestedunmonitor=trueAND the Lidarr call failed (network / auth / 5xx). The file deletion + DB cleanup still succeeded — the field is informational so the UI can toast "File removed, but Lidarr couldn't be reached to stop the replacement search." Omitted in all other cases.
Errors:
| Status | Code | Trigger |
|---|---|---|
| 401 | unauthenticated |
no session |
| 403 | unauthorized |
non-admin session |
| 404 | not_found |
track id doesn't exist |
| 500 | server_error |
unexpected error (DB, filesystem) before delete completed |
Note: Lidarr errors during the unmonitor step are NOT mapped to wire errors — the response is 200 with lidarr_unmonitor_failed: true, since the destructive part (file + DB) already succeeded and the operator needs to know to retry the unmonitor manually.
6. Error handling (frontend)
removeTrack(id) reuses apiFetch with the existing dual-envelope handling. Errors surface as a toast via copyForCode(code):
not_found→ "Track no longer exists." Trigger refetch of parent list query.unauthorized→ "Admins only." (Defensive — UI should hide the entry for non-admins.)connection_refused→ existing copy.server_error→ existing copy.
Lidarr-related error codes do NOT come back from this endpoint anymore (they're handled inline as a non-fatal lidarr_unmonitor_failed: true flag in the success envelope). When the response sets that flag, surface a follow-up toast: "File removed, but Lidarr couldn't be reached to stop the replacement search. Unmonitor manually in Admin → Integrations if you don't want it to come back."
Audio queue mutations have no error path — they are local state writes. Defensive guard: if _queue is null (audio store uninitialized), the action is a no-op.
7. Testing
Backend
internal/tracks/service_test.go— unit tests, table-driven where it helps:- Local-file happy path.
unmonitor=false. File present, deletes cleanly. DB row gone. No Lidarr call. - File-already-missing path.
os.RemovereturnsErrNotExist. Service logs warning, proceeds. DB row gone. - Album-empty cascade. Track was the album's only track. Album deleted; artist persists if it has other albums.
deletedAlbumIDreturned,deletedArtistIDnil. - Artist-empty cascade. Track was the artist's only track. Album AND artist deleted; both IDs returned.
unmonitor=trueAND track has mbid →Lidarr.UnmonitorTrackis called once with the mbid.lidarrUnmonitorFailed=falsereturned.unmonitor=trueAND Lidarr fails (unreachable / 5xx / auth) → file + DB still deleted,lidarrUnmonitorFailed=truereturned, no error from RemoveTrack.unmonitor=trueAND track has empty mbid (non-Lidarr) → no Lidarr call,lidarrUnmonitorFailed=false.unmonitor=falseAND track has mbid → no Lidarr call.
- Local-file happy path.
internal/lidarr/client_test.go— extend withUnmonitorTracktests:- Track found in lookup → PUT to
/api/v1/track/monitorwith{trackIds: [...], monitored: false}. - Track not found in lookup → typed error.
- Lidarr 5xx →
ErrServerError. - Lidarr unreachable →
ErrUnreachable.
- Track found in lookup → PUT to
internal/api/admin_tracks_test.go— HTTP tests:- Non-admin → 403
unauthorized. - No session → 401
unauthenticated. - Unknown id → 404
not_found. - Happy path no unmonitor → 200 with
deleted_track_id(and optionallydeleted_album_id/deleted_artist_id); nolidarr_unmonitor_failedin response. - Happy path with
?unmonitor=trueand Lidarr failure → 200 withlidarr_unmonitor_failed: true.
- Non-admin → 403
Frontend
TrackMenu.test.ts(rewrite) covers:- Each of the 9 entries renders for an admin user.
- "Remove from library" is hidden for non-admin (or renders disabled with
aria-disabled="true"). - "Add to playlist…" renders disabled with the "Coming with playlists" tooltip.
- Kebab
aria-haspopup="menu",aria-expandedcycles on open/close. - Up/Down arrow keys cycle focus; Escape closes; Enter activates.
- Outside click closes.
- Submenu plumbing (
role="menu"on the playlist hook) renders even though disabled.
TrackMenuItem.test.ts— primitive renders icon, label, click handler fires,aria-disabledhonored.tracks.test.ts(new admin API helper) —removeTrack(id, {unmonitor})sends?unmonitor=true|false, parses success envelope (incl. optionallidarr_unmonitor_failed), surfaces typed errors viaapiFetch.audio.test.ts(extend) —playNext(track)splices at_index+1;addToQueue(track)appends; both no-op on empty queue.TrackRow.test.tsandPlayerBar.test.ts(adjust) — existing assertions about the menu's sole "Flag" entry need updating to match the new 9-entry structure. The compatible prop API means no other test changes needed.
8. Distribution / migration notes
- No migration needed. All four
track_idFKs (play_events,general_likes_tracks,lidarr_quarantine,lidarr_quarantine_actions) already useON DELETE CASCADE; verified live before plan-write. - No client breaking changes. The
<TrackMenu>prop API is unchanged. min_client_versionbump ininternal/server/version.go: not required — the new endpoint only matters for admins, and the menu degrades gracefully on old clients (they don't show entries they don't know about).
9. Origin
M7 #372. Brainstorm 2026-05-03. Decisions locked:
- Per-track metadata editing is out of scope — filed as M7 #373.
- Menu structure: 9 entries in 4 groups. Like/Hide duplicated in menu for keyboard discoverability.
Go to artistsingle-target — multi-artist data model isn't there yet.Remove from libraryadmin-only, with cascade album → artist tidy-up.- Submenu plumbing only matters for #352's
Add to playlist…and is reserved as a slot here.
Revision 2026-05-03 (during Task 2 implementation): original spec routed Lidarr-managed track removal through lidarrquarantine.DeleteViaLidarr. Implementer surfaced that primitive deletes the entire Lidarr album (Lidarr is album-granular), not the single track — the spec's behavior would silently delete sibling tracks. Operator decision: drop the routing-via-Lidarr approach entirely. Always delete file + DB directly through Minstrel; ask the operator at confirm time whether Lidarr should look for a replacement (default — no extra action) or be told to unmonitor (new Lidarr.UnmonitorTrack(mbid) primitive). Lidarr unmonitor failure is non-fatal — the destructive part already succeeded; surface a follow-up toast so the operator can manually unmonitor.
10. Related
- M7 #352 — Playlists CRUD. Wires
Add to playlist…submenu into the menu slot reserved here. Brainstorm parked at task-log id 44. - M7 #373 — Track metadata editing. Split out from #372 in this brainstorm.
- M5b quarantine —
<FlagPopover>and thequarantineTrack/unquarantineTrackhelpers reused by the Hide/Unhide entries. - M2 likes —
likeTrack/unlikeTrackand the existing heart icon, now also surfaced as a menu entry. - M5a Lidarr — Lidarr-aware delete reuses the same primitive
lidarrquarantine.DeleteViaLidarruses today. - M7 #356 — Flutter mirror in a later slice once the web menu settles.