Files
minstrel/docs/superpowers/specs/2026-05-03-m7-track-actions-menu-design.md
T
bvandeusen 723eee9773 docs(m7): revise #372 spec — Remove from library no longer goes via Lidarr
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.
2026-05-02 22:28:49 -04:00

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:
    1. Queue: Play next · Add to queue
    2. Collection: Like / Unlike · Add to playlist… (slot reserved; wired by #352)
    3. Navigation: Go to album · Go to artist
    4. 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 by auth.RequireAdmin() on the new /api/admin/tracks/{id} route. The endpoint always deletes the file from disk via os.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 calls Lidarr.UnmonitorTrack(mbid) to flip monitored: false). The ?unmonitor=true query param drives this — unset / false = replace, true = unmonitor.
  • Keyboard accessibility: kebab is <button aria-haspopup="menu" aria-expanded>; opened menu has role="menu" with each entry role="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:

  1. Look up the track. Need: file_path, mbid, album_id, artist_id. Returns ErrNotFound if missing.
  2. Always: os.Remove(file_path). Tolerate os.ErrNotExist (file already gone — proceed with DB cleanup; the goal is consistency). Other os.Remove errors log a warning and proceed; orphan files are recoverable, orphan DB rows are not.
  3. Begin a single DB transaction:
    • DeleteTrack(id) — returns album_id, artist_id from RETURNING. Existing FK CASCADE on track_id handles play_events, general_likes_tracks, lidarr_quarantine, lidarr_quarantine_actions. (Verified live — all four already cascade. Future playlist_tracks from #352 will also need ON DELETE CASCADE.)
    • DeleteAlbumIfEmpty(album_id). On pgx.ErrNoRows (album still has other tracks) skip. Otherwise set deletedAlbumID.
    • If album was deleted: DeleteArtistIfEmpty(artist_id). On pgx.ErrNoRows skip. Otherwise set deletedArtistID.
    • Commit.
  4. If unmonitor AND track had a non-empty mbid (Lidarr-managed): call Lidarr.UnmonitorTrack(ctx, mbid). Lidarr errors here are non-fatal — log a warning and set a lidarrUnmonitorFailed flag, 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.
  5. 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_failed is only set to true when 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; toggles aria-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> carries aria-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) have aria-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.goRemoveTrack(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.gohandleRemoveTrack(w, r).
  • internal/api/admin_tracks_test.go — HTTP tests (admin-only auth, 4 error codes, response envelope).

Backend — modify

  • internal/api/api.go — register admin.Delete("/tracks/{id}", h.handleRemoveTrack).
  • internal/db/queries/tracks.sql — append DeleteTrack, DeleteAlbumIfEmpty, DeleteArtistIfEmpty, CountTracksByAlbum, CountAlbumsByArtist.
  • internal/db/dbq/* — regenerated by sqlc generate.
  • internal/db/migrations/0014_track_cascades.up.sqlconditional, only if any of play_events, general_likes_tracks, lidarr_quarantine lacks ON DELETE CASCADE on its track_id FK. 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.ts
  • web/src/lib/components/TrackMenuDivider.svelte — visual separator.
  • web/src/lib/api/admin/tracks.tsremoveTrack(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 existing qk.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 — add playNext(track) and addToQueue(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, default false) — when true AND the track is Lidarr-managed (has an mbid), the server calls Lidarr.UnmonitorTrack after the file/DB delete so Lidarr won't search for a replacement. When false or 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_id and deleted_artist_id are optional. Present only when removing the track left the parent empty and the row was deleted too.
  • lidarr_unmonitor_failed is only true when the operator requested unmonitor=true AND 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.Remove returns ErrNotExist. 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. deletedAlbumID returned, deletedArtistID nil.
    • Artist-empty cascade. Track was the artist's only track. Album AND artist deleted; both IDs returned.
    • unmonitor=true AND track has mbid → Lidarr.UnmonitorTrack is called once with the mbid. lidarrUnmonitorFailed=false returned.
    • unmonitor=true AND Lidarr fails (unreachable / 5xx / auth) → file + DB still deleted, lidarrUnmonitorFailed=true returned, no error from RemoveTrack.
    • unmonitor=true AND track has empty mbid (non-Lidarr) → no Lidarr call, lidarrUnmonitorFailed=false.
    • unmonitor=false AND track has mbid → no Lidarr call.
  • internal/lidarr/client_test.go — extend with UnmonitorTrack tests:
    • Track found in lookup → PUT to /api/v1/track/monitor with {trackIds: [...], monitored: false}.
    • Track not found in lookup → typed error.
    • Lidarr 5xx → ErrServerError.
    • Lidarr unreachable → ErrUnreachable.
  • 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 optionally deleted_album_id / deleted_artist_id); no lidarr_unmonitor_failed in response.
    • Happy path with ?unmonitor=true and Lidarr failure → 200 with lidarr_unmonitor_failed: true.

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-expanded cycles 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-disabled honored.
  • tracks.test.ts (new admin API helper) — removeTrack(id, {unmonitor}) sends ?unmonitor=true|false, parses success envelope (incl. optional lidarr_unmonitor_failed), surfaces typed errors via apiFetch.
  • audio.test.ts (extend) — playNext(track) splices at _index+1; addToQueue(track) appends; both no-op on empty queue.
  • TrackRow.test.ts and PlayerBar.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_id FKs (play_events, general_likes_tracks, lidarr_quarantine, lidarr_quarantine_actions) already use ON DELETE CASCADE; verified live before plan-write.
  • No client breaking changes. The <TrackMenu> prop API is unchanged.
  • min_client_version bump in internal/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:

  1. Per-track metadata editing is out of scope — filed as M7 #373.
  2. Menu structure: 9 entries in 4 groups. Like/Hide duplicated in menu for keyboard discoverability.
  3. Go to artist single-target — multi-artist data model isn't there yet.
  4. Remove from library admin-only, with cascade album → artist tidy-up.
  5. 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.

  • 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 the quarantineTrack / unquarantineTrack helpers reused by the Hide/Unhide entries.
  • M2 likes — likeTrack / unlikeTrack and the existing heart icon, now also surfaced as a menu entry.
  • M5a Lidarr — Lidarr-aware delete reuses the same primitive lidarrquarantine.DeleteViaLidarr uses today.
  • M7 #356 — Flutter mirror in a later slice once the web menu settles.