From 0b3919e4a13b6c16734101f8a15bc41108c33d80 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 30 Apr 2026 14:54:44 -0400 Subject: [PATCH] docs(spec): add M5b quarantine workflow design Per-user track-level soft-hide with reason taxonomy, aggregated admin queue at /admin/quarantine with Resolve / Delete file / Delete via Lidarr actions, audit log for admin actions, soft-hide honored by /api/* read endpoints (Subsonic /rest/* unchanged per legacy rule). Lidarr client extends with LookupAlbumByMBID + DeleteAlbum (deleteFiles + addImportListExclusion). --- .../specs/2026-04-30-m5b-quarantine-design.md | 389 ++++++++++++++++++ 1 file changed, 389 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-30-m5b-quarantine-design.md diff --git a/docs/superpowers/specs/2026-04-30-m5b-quarantine-design.md b/docs/superpowers/specs/2026-04-30-m5b-quarantine-design.md new file mode 100644 index 00000000..850ccbcf --- /dev/null +++ b/docs/superpowers/specs/2026-04-30-m5b-quarantine-design.md @@ -0,0 +1,389 @@ +# M5b — Quarantine workflow + admin resolution UI + +> **Status:** Draft for review · 2026-04-30 +> +> **Sub-plan of:** M5 (Lidarr integration + quarantine workflow). Decomposition recap from the M5a spec: +> +> - **M5a** — Lidarr connection + search/add + admin shell. Shipped on `dev`. +> - **M5b (this spec)** — Quarantine workflow (per-user soft-hide of tracks, admin resolution UI). +> - **M5c** — Radio "suggested additions" (out-of-library MBIDs surfaced in `/api/radio` responses; SPA add affordance). +> +> Each ships as its own PR with its own brainstorm/spec/plan cycle. + +## 1. Goal + +Authenticated users can flag any local track as broken with a reason and optional notes. A flagged track disappears from that user's library, search, browse, and `/api/radio` responses — but appears on a dedicated `/library/hidden` page where they can review and un-hide. Admins see an aggregated queue at `/admin/quarantine` (one row per track with reason distribution + per-user details + inline playback) and resolve each row by clearing the reports, deleting the local file, or telling Lidarr to remove the parent album with import-list exclusion. + +The dominant user mental model is **data quality**: "this track is a bad rip / wrong file / wrong tags / duplicate." Personal preference framing is out of scope. + +## 2. Goals and non-goals + +### Goals + +- Authenticated user can flag any track with reason ∈ {`bad_rip`, `wrong_file`, `wrong_tags`, `duplicate`, `other`} plus optional notes. +- Trigger affordance is a `` overflow (kebab) on every track row and on the now-playing player bar — sibling to `` but one-click-removed to prevent miss-clicks. +- Quarantined tracks are hidden from that user's `/api/albums/:id`, `/api/artists/:id`, library track lists, search, `/api/radio`, and contextual radio. They remain visible only on `/library/hidden`. +- `/library/hidden` page lists the user's own quarantines with un-hide affordance. +- Admin queue at `/admin/quarantine` aggregates by track, shows reason distribution + count, expandable per-user reports, inline playback, and the three resolution actions. +- Admin resolution actions: **Resolve** (clear the row), **Delete file** (rm file from disk + tracks row + clear), **Delete via Lidarr** (call Lidarr `DELETE /api/v1/album/{id}?deleteFiles=true&addImportListExclusion=true`, cascade Minstrel rows, clear). +- Admin actions write to a `lidarr_quarantine_actions` audit log with snapshot fields so the log stays readable after the underlying tracks/albums are deleted. +- Subsonic API (`/rest/*`) stays unfiltered — quarantine is `/api/*`-only. + +### Non-goals (this slice) + +- Per-album / per-artist quarantine. Tracks only. +- Quarantine on shared playlists, queues, or Subsonic clients. +- Auto-resolve rules ("if 3 users flag, auto-quarantine globally"). Admin always decides. +- Notifying users when their report is acted on (toast / email). → polish slot. +- Bulk admin operations (multi-select + apply). → revisit if queue grows beyond what one-by-one handles. + +## 3. Architecture + +### New Go packages + +- **`internal/lidarrquarantine/`** — `Service` owning the quarantine lifecycle: + - `Flag(ctx, userID, trackID, reason, notes)` — upsert a `lidarr_quarantine` row. + - `Unflag(ctx, userID, trackID)` — remove the caller's row. + - `ListMine(ctx, userID)` — caller's own quarantines (drives `/library/hidden`). + - `ListAdminQueue(ctx)` — aggregated by track. Returns `[]AdminQueueRow{TrackID, TrackTitle, ArtistName, AlbumTitle, AlbumID, LidarrAlbumMBID, ReportCount, ReasonCounts, LatestAt, Reports []UserReport}`. + - `Resolve(ctx, trackID, adminID)` — delete all per-user rows for the track + write audit row. + - `DeleteFile(ctx, trackID, adminID)` — call `library.DeleteTrackFile` then Resolve. + - `DeleteViaLidarr(ctx, trackID, adminID)` — look up the parent album in Lidarr by MBID, call `Client.DeleteAlbum(id, deleteFiles=true, addImportListExclusion=true)`, remove Minstrel rows for all tracks of that album, clear all related quarantine rows, write audit row. + +### Lidarr client additions (`internal/lidarr`) + +- `LookupArtistByMBID(ctx, mbid) (LidarrArtist, error)` — `GET /api/v1/artist?mbId=…`. +- `LookupAlbumByMBID(ctx, mbid) (LidarrAlbum, error)` — `GET /api/v1/album?foreignAlbumId=…`. +- `DeleteAlbum(ctx, lidarrAlbumID, deleteFiles bool, addImportListExclusion bool) error` — `DELETE /api/v1/album/{id}?deleteFiles=...&addImportListExclusion=...`. M5b admin path always passes both `true`. + +Returns the same typed errors the existing client uses: `ErrUnreachable`, `ErrAuthFailed`, `ErrLookupFailed`, plus `ErrNotFound` for the lookup methods when no row matches. + +### Library deletion (`internal/library`) + +- New `DeleteTrackFile(ctx, trackID) error` — removes the file from disk and the row from `tracks`. Album/artist rows stay (other tracks may reference them). The reconciler's MBID lookup will simply find no match next pass; M5a's reconcile is unaffected. + +### Soft-hide enforcement + +Every endpoint that returns track lists in user-context joins against `lidarr_quarantine`: + +```sql +WHERE NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $userID AND q.track_id = tracks.id +) +``` + +Affected queries (modified, not new): +- `GetAlbumDetail` (track list filter) +- `GetArtistDetail` / library artist views (transitively, via track filter) +- `Search` (track facet) +- Library track-list endpoints +- Radio generator + contextual radio endpoints + similarity-driven autoplay + +`/rest/*` Subsonic queries are NOT modified — Subsonic clients see the unfiltered library. + +### Wiring + +- `cmd/minstrel/main.go` constructs `lidarrquarantine.Service` and injects into `internal/api/handlers`. No new background worker. +- New handler files: `internal/api/quarantine.go` (user-facing), `internal/api/admin_quarantine.go` (admin endpoints). +- Reuses M5a's `RequireAdmin` middleware — `/api/admin/quarantine/*` mounts on the existing admin route group. + +### SPA additions + +- `` Svelte component — kebab icon button + dropdown menu. Renders a single "Flag this track…" item for M5b; designed for future actions. +- `` Svelte component — opens from `` with the reason ``: "Bad rip", "Wrong file", "Wrong tags", "Duplicate", "Other". +- Notes `