0806a37a42
16 tasks covering migration 0011, Lidarr client extensions (LookupArtistByMBID, LookupAlbumByMBID, DeleteAlbum), library.DeleteTrackFile, lidarrquarantine.Service (Flag/Unflag/ListMine/ListAdminQueue + Resolve/DeleteFile/DeleteViaLidarr), soft-hide enforcement on user-context track-list reads, /api/quarantine + admin endpoints, and frontend (TrackMenu/FlagPopover, /library/hidden, /admin/quarantine, sidebar promotion). Mirrors the M5a plan cadence.
2917 lines
106 KiB
Markdown
2917 lines
106 KiB
Markdown
# M5b — Quarantine workflow + admin resolution UI — Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Wire per-user track-level quarantine into Minstrel — flag affordance via a kebab `<TrackMenu>` on every track row + the player, soft-hide enforcement on user-context `/api/*` reads, dedicated `/library/hidden` for the user, aggregated admin queue at `/admin/quarantine` with Resolve / Delete file / Delete via Lidarr actions, audit log for admin actions.
|
||
|
||
**Architecture:** New `internal/lidarrquarantine` package (Service, no background worker) backed by two tables (`lidarr_quarantine` per-user complaints + `lidarr_quarantine_actions` audit log). Lidarr HTTP client gains `LookupArtistByMBID`, `LookupAlbumByMBID`, `DeleteAlbum` (always called with `deleteFiles=true` and `addImportListExclusion=true`). `internal/library` gains `DeleteTrackFile`. Existing read queries that return tracks in user-context get `*ForUser` variants that join against `lidarr_quarantine`; Subsonic queries are untouched. SPA gets a `<TrackMenu>` overflow component (mounted in `TrackRow` and `PlayerBar`) opening a `<FlagPopover>`, plus `/library/hidden` and `/admin/quarantine` routes.
|
||
|
||
**Tech Stack:** Go 1.23 · chi router · pgx/v5 + sqlc · Postgres + golang-migrate · SvelteKit 2 / Svelte 5 (runes) · TanStack Query · Vitest · golangci-lint · FabledSword design tokens (existing M5a infrastructure).
|
||
|
||
**Spec:** [`docs/superpowers/specs/2026-04-30-m5b-quarantine-design.md`](../specs/2026-04-30-m5b-quarantine-design.md). Read it before starting — every decision is explained there.
|
||
|
||
**Memory dependencies:** `project_design_system.md` (FabledSword token palette + voice rules), `project_subsonic_legacy.md` (`/rest/*` does not honor quarantine), `project_no_github.md` (Forgejo MCP for PR ops, not gh CLI), `project_git_workflow.md` (commit on `dev`; PR to `main` separately).
|
||
|
||
---
|
||
|
||
## File map
|
||
|
||
### Backend — create
|
||
|
||
- `internal/db/migrations/0011_lidarr_quarantine.up.sql` · `0011_lidarr_quarantine.down.sql` — schema
|
||
- `internal/db/queries/lidarr_quarantine.sql` — sqlc queries for both tables
|
||
- `internal/lidarrquarantine/service.go` — `Service` (Flag/Unflag/ListMine/ListAdminQueue/Resolve/DeleteFile/DeleteViaLidarr)
|
||
- `internal/lidarrquarantine/service_test.go` — integration tests
|
||
- `internal/lidarr/lookup_mbid.go` — `LookupArtistByMBID`, `LookupAlbumByMBID` (split from `client.go` to keep that file from growing)
|
||
- `internal/lidarr/delete.go` — `DeleteAlbum` HTTP method + `DELETE` helper
|
||
- `internal/lidarr/delete_test.go` — tests for the new methods
|
||
- `internal/lidarr/testdata/album_lookup_by_mbid.json`, `artist_lookup_by_mbid.json` — captured fixtures
|
||
- `internal/library/delete.go` — `DeleteTrackFile`
|
||
- `internal/library/delete_test.go` — tests
|
||
- `internal/api/quarantine.go` — `/api/quarantine/*` user-facing handlers
|
||
- `internal/api/quarantine_test.go`
|
||
- `internal/api/admin_quarantine.go` — `/api/admin/quarantine/*` admin handlers
|
||
- `internal/api/admin_quarantine_test.go`
|
||
|
||
### Backend — modify
|
||
|
||
- `internal/db/queries/tracks.sql` — add `ListTracksByAlbumForUser`, `SearchTracksForUser`, `CountTracksMatchingForUser` (filtered variants)
|
||
- `internal/db/queries/recommendation.sql` — extend `LoadRadioCandidates` and `LoadRadioCandidatesV2` to also exclude quarantined tracks
|
||
- `internal/api/api.go` — register routes, mount `/api/admin/quarantine` group, route the modified user-context endpoints to the `*ForUser` queries
|
||
- `internal/api/auth_test.go` — extend `testHandlers` to inject `lidarrquarantine.Service`
|
||
- `internal/api/albums.go` (or wherever album-detail composes its track list) — switch to `ListTracksByAlbumForUser` when user context is present
|
||
- `internal/api/search.go` — switch to `SearchTracksForUser`
|
||
- `internal/api/radio.go` (or wherever radio handlers live) — pass through the existing user_id parameter to the now-quarantine-aware query
|
||
- `cmd/minstrel/main.go` — construct `lidarrquarantine.Service` and inject
|
||
- `internal/db/dbq/*` — regenerated by `sqlc generate`
|
||
|
||
### Frontend — create
|
||
|
||
- `web/src/lib/api/quarantine.ts` — user-facing client (Flag/Unflag/ListMine)
|
||
- `web/src/lib/api/quarantine.test.ts`
|
||
- `web/src/lib/components/TrackMenu.svelte` — kebab overflow menu
|
||
- `web/src/lib/components/TrackMenu.test.ts`
|
||
- `web/src/lib/components/FlagPopover.svelte` — reason + notes form
|
||
- `web/src/lib/components/FlagPopover.test.ts`
|
||
- `web/src/lib/components/QuarantineRow.svelte` — shared row used by both `/library/hidden` and `/admin/quarantine`
|
||
- `web/src/lib/components/QuarantineRow.test.ts`
|
||
- `web/src/routes/library/hidden/+page.svelte`
|
||
- `web/src/routes/library/hidden/hidden.test.ts`
|
||
- `web/src/routes/admin/quarantine/+page.svelte`
|
||
- `web/src/routes/admin/quarantine/quarantine.test.ts`
|
||
|
||
### Frontend — modify
|
||
|
||
- `web/src/lib/api/admin.ts` — add `listAdminQuarantine`, `resolveQuarantine`, `deleteQuarantineFile`, `deleteQuarantineViaLidarr`, `listQuarantineActions` plus query factories
|
||
- `web/src/lib/api/queries.ts` — add `qk.myQuarantine`, `qk.adminQuarantine`, `qk.adminQuarantineActions`
|
||
- `web/src/lib/api/types.ts` — add `LidarrQuarantineReason`, `LidarrQuarantineRow`, `AdminQuarantineRow`, `LidarrQuarantineActionRow`, `LidarrQuarantineAction` enums
|
||
- `web/src/lib/components/Shell.svelte` — add `Hidden` to the main nav after `Liked`
|
||
- `web/src/lib/components/Shell.test.ts` — assert the new nav order
|
||
- `web/src/lib/components/AdminSidebar.svelte` — promote `Quarantine` from `placeholder: true` to a real link
|
||
- `web/src/lib/components/AdminSidebar.test.ts` — update tests; quarantine is now a link
|
||
- `web/src/lib/components/TrackRow.svelte` — mount `<TrackMenu>` next to `<LikeButton>`
|
||
- `web/src/lib/components/TrackRow.test.ts` — extend to cover the menu
|
||
- `web/src/lib/components/PlayerBar.svelte` — mount `<TrackMenu>` in the right cluster
|
||
- `web/src/lib/components/PlayerBar.test.ts` — extend to cover the menu
|
||
|
||
---
|
||
|
||
## Task list
|
||
|
||
### Task 1 — Migration 0011 + sqlc queries
|
||
|
||
**Files:**
|
||
- Create: `internal/db/migrations/0011_lidarr_quarantine.up.sql`
|
||
- Create: `internal/db/migrations/0011_lidarr_quarantine.down.sql`
|
||
- Create: `internal/db/queries/lidarr_quarantine.sql`
|
||
- Modify: `internal/db/dbq/*` (regenerated by `sqlc generate`)
|
||
|
||
- [ ] **Step 1.1: Write the up migration**
|
||
|
||
`internal/db/migrations/0011_lidarr_quarantine.up.sql`:
|
||
|
||
```sql
|
||
-- M5b: per-user track quarantines + admin action audit log.
|
||
--
|
||
-- lidarr_quarantine — one row per (user, track) complaint. PK matches
|
||
-- the general_likes pattern. Re-flagging the same track upserts. Deleted
|
||
-- on user resolution (un-hide), admin Resolve, or any of the deletes.
|
||
--
|
||
-- lidarr_quarantine_actions — audit log of admin destructive actions.
|
||
-- Snapshot text columns let the log stay readable after the underlying
|
||
-- track/album rows are gone.
|
||
|
||
CREATE TYPE lidarr_quarantine_reason AS ENUM (
|
||
'bad_rip', 'wrong_file', 'wrong_tags', 'duplicate', 'other'
|
||
);
|
||
|
||
CREATE TABLE lidarr_quarantine (
|
||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||
reason lidarr_quarantine_reason NOT NULL,
|
||
notes text,
|
||
created_at timestamptz NOT NULL DEFAULT now(),
|
||
PRIMARY KEY (user_id, track_id)
|
||
);
|
||
|
||
CREATE INDEX lidarr_quarantine_track_idx ON lidarr_quarantine (track_id);
|
||
CREATE INDEX lidarr_quarantine_user_idx ON lidarr_quarantine (user_id, created_at DESC);
|
||
|
||
CREATE TYPE lidarr_quarantine_action AS ENUM (
|
||
'resolved', 'deleted_file', 'deleted_via_lidarr'
|
||
);
|
||
|
||
CREATE TABLE lidarr_quarantine_actions (
|
||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
track_id uuid NOT NULL,
|
||
track_title text NOT NULL,
|
||
artist_name text NOT NULL,
|
||
album_title text,
|
||
action lidarr_quarantine_action NOT NULL,
|
||
admin_id uuid REFERENCES users(id) ON DELETE SET NULL,
|
||
lidarr_album_mbid text,
|
||
affected_users int NOT NULL,
|
||
created_at timestamptz NOT NULL DEFAULT now()
|
||
);
|
||
|
||
CREATE INDEX lidarr_quarantine_actions_track_idx ON lidarr_quarantine_actions (track_id);
|
||
CREATE INDEX lidarr_quarantine_actions_created_idx ON lidarr_quarantine_actions (created_at DESC);
|
||
```
|
||
|
||
- [ ] **Step 1.2: Write the down migration**
|
||
|
||
`internal/db/migrations/0011_lidarr_quarantine.down.sql`:
|
||
|
||
```sql
|
||
DROP INDEX IF EXISTS lidarr_quarantine_actions_created_idx;
|
||
DROP INDEX IF EXISTS lidarr_quarantine_actions_track_idx;
|
||
DROP TABLE IF EXISTS lidarr_quarantine_actions;
|
||
DROP TYPE IF EXISTS lidarr_quarantine_action;
|
||
DROP INDEX IF EXISTS lidarr_quarantine_user_idx;
|
||
DROP INDEX IF EXISTS lidarr_quarantine_track_idx;
|
||
DROP TABLE IF EXISTS lidarr_quarantine;
|
||
DROP TYPE IF EXISTS lidarr_quarantine_reason;
|
||
```
|
||
|
||
- [ ] **Step 1.3: Apply migration locally to confirm it runs**
|
||
|
||
```bash
|
||
docker compose up -d postgres
|
||
docker compose exec -T postgres psql -U minstrel -d minstrel -c "DROP TABLE IF EXISTS lidarr_quarantine_actions; DROP TYPE IF EXISTS lidarr_quarantine_action; DROP TABLE IF EXISTS lidarr_quarantine; DROP TYPE IF EXISTS lidarr_quarantine_reason;"
|
||
go run ./cmd/minstrel up 2>/dev/null || true # apply via server start instead
|
||
docker compose exec -T postgres psql -U minstrel -d minstrel -c "\d lidarr_quarantine"
|
||
docker compose exec -T postgres psql -U minstrel -d minstrel -c "\d lidarr_quarantine_actions"
|
||
```
|
||
|
||
Expected: both `\d` commands print the table with columns and indexes.
|
||
|
||
If the project doesn't have a standalone migrate command, the migration applies on server start via `db.Migrate(...)` — restart the minstrel container instead.
|
||
|
||
- [ ] **Step 1.4: Write the queries**
|
||
|
||
`internal/db/queries/lidarr_quarantine.sql`:
|
||
|
||
```sql
|
||
-- name: UpsertQuarantine :one
|
||
-- Insert a new quarantine row, or update reason/notes if the user has
|
||
-- already flagged this track.
|
||
INSERT INTO lidarr_quarantine (user_id, track_id, reason, notes)
|
||
VALUES ($1, $2, $3, $4)
|
||
ON CONFLICT (user_id, track_id) DO UPDATE SET
|
||
reason = EXCLUDED.reason,
|
||
notes = EXCLUDED.notes,
|
||
created_at = now()
|
||
RETURNING user_id, track_id, reason, notes, created_at;
|
||
|
||
-- name: DeleteQuarantine :one
|
||
-- Removes the caller's row. Returns the deleted row so the handler can
|
||
-- distinguish "no row existed" (zero rows -> ErrNoRows) from success.
|
||
DELETE FROM lidarr_quarantine
|
||
WHERE user_id = $1 AND track_id = $2
|
||
RETURNING user_id, track_id, reason, notes, created_at;
|
||
|
||
-- name: ListQuarantineForUser :many
|
||
-- Caller's own quarantines joined with track + album + artist for full
|
||
-- detail. Drives /library/hidden.
|
||
SELECT
|
||
sqlc.embed(q),
|
||
sqlc.embed(t),
|
||
sqlc.embed(al),
|
||
sqlc.embed(ar)
|
||
FROM lidarr_quarantine q
|
||
JOIN tracks t ON t.id = q.track_id
|
||
JOIN albums al ON al.id = t.album_id
|
||
JOIN artists ar ON ar.id = t.artist_id
|
||
WHERE q.user_id = $1
|
||
ORDER BY q.created_at DESC;
|
||
|
||
-- name: ListAdminQuarantineQueue :many
|
||
-- Aggregated admin queue. One row per track. The handler post-processes
|
||
-- the rows it gets from this query plus a per-track ListQuarantineReports
|
||
-- call to materialize reason_counts and the per-user reports list.
|
||
SELECT
|
||
t.id AS track_id,
|
||
t.title AS track_title,
|
||
ar.name AS artist_name,
|
||
al.title AS album_title,
|
||
al.id AS album_id,
|
||
al.mbid AS lidarr_album_mbid,
|
||
count(q.user_id)::int AS report_count,
|
||
max(q.created_at) AS latest_at
|
||
FROM lidarr_quarantine q
|
||
JOIN tracks t ON t.id = q.track_id
|
||
JOIN albums al ON al.id = t.album_id
|
||
JOIN artists ar ON ar.id = t.artist_id
|
||
GROUP BY t.id, ar.name, al.title, al.id, al.mbid
|
||
ORDER BY max(q.created_at) DESC;
|
||
|
||
-- name: ListQuarantineReportsForTrack :many
|
||
-- Per-user reports for a single track. Returned by ListAdminQuarantineQueue
|
||
-- post-processing and exposed expandable in the SPA admin queue rows.
|
||
SELECT
|
||
q.user_id,
|
||
u.username,
|
||
q.reason,
|
||
q.notes,
|
||
q.created_at
|
||
FROM lidarr_quarantine q
|
||
JOIN users u ON u.id = q.user_id
|
||
WHERE q.track_id = $1
|
||
ORDER BY q.created_at DESC;
|
||
|
||
-- name: DeleteQuarantineForTrack :exec
|
||
-- Clears all per-user rows for a given track. Used by Resolve and the
|
||
-- two delete actions. Caller writes the audit row separately before
|
||
-- this fires (so we can capture the affected_users count).
|
||
DELETE FROM lidarr_quarantine WHERE track_id = $1;
|
||
|
||
-- name: CountQuarantineForTrack :one
|
||
-- Reads affected_users for the audit row before the delete fires.
|
||
SELECT count(*)::int FROM lidarr_quarantine WHERE track_id = $1;
|
||
|
||
-- name: WriteQuarantineAction :one
|
||
-- Audit row for an admin destructive action.
|
||
INSERT INTO lidarr_quarantine_actions (
|
||
track_id, track_title, artist_name, album_title,
|
||
action, admin_id, lidarr_album_mbid, affected_users
|
||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||
RETURNING *;
|
||
|
||
-- name: ListQuarantineActions :many
|
||
SELECT * FROM lidarr_quarantine_actions
|
||
ORDER BY created_at DESC
|
||
LIMIT $1;
|
||
```
|
||
|
||
- [ ] **Step 1.5: Run sqlc generate**
|
||
|
||
```bash
|
||
cd internal/db && sqlc generate && cd -
|
||
go build ./...
|
||
```
|
||
|
||
Expected: clean build. New types `LidarrQuarantine`, `LidarrQuarantineAction`, `ListAdminQuarantineQueueRow`, `ListQuarantineForUserRow`, `ListQuarantineReportsForTrackRow` etc. surface in `internal/db/dbq/`.
|
||
|
||
- [ ] **Step 1.6: Commit**
|
||
|
||
```bash
|
||
git add internal/db/migrations/0011_lidarr_quarantine.up.sql \
|
||
internal/db/migrations/0011_lidarr_quarantine.down.sql \
|
||
internal/db/queries/lidarr_quarantine.sql \
|
||
internal/db/dbq/
|
||
git commit -m "feat(db): add lidarr_quarantine + actions schema (migration 0011)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2 — Lidarr HTTP client extensions
|
||
|
||
**Files:**
|
||
- Create: `internal/lidarr/lookup_mbid.go`
|
||
- Create: `internal/lidarr/delete.go`
|
||
- Create: `internal/lidarr/delete_test.go`
|
||
- Create: `internal/lidarr/testdata/album_lookup_by_mbid.json`
|
||
- Create: `internal/lidarr/testdata/artist_lookup_by_mbid.json`
|
||
- Modify: `internal/lidarr/types.go` — add `LidarrArtist`, `LidarrAlbum`
|
||
|
||
The existing M5a client lives in `internal/lidarr/client.go`. To keep it from sprawling, the M5b additions land in two new files: `lookup_mbid.go` for the GET-by-MBID methods and `delete.go` for the DELETE method + a tiny `del()` HTTP helper.
|
||
|
||
- [ ] **Step 2.1: Add the typed structs**
|
||
|
||
`internal/lidarr/types.go` (modify) — append the two structs at the bottom of the file:
|
||
|
||
```go
|
||
// LidarrArtist is the subset of Lidarr's artist resource used by M5b
|
||
// admin actions. The "id" field is Lidarr's internal numeric ID — needed
|
||
// for DELETE /api/v1/artist/{id} calls.
|
||
type LidarrArtist struct {
|
||
ID int `json:"id"`
|
||
ForeignArtistID string `json:"foreignArtistId"` // MBID
|
||
ArtistName string `json:"artistName"`
|
||
}
|
||
|
||
// LidarrAlbum is the subset of Lidarr's album resource used by M5b
|
||
// admin actions.
|
||
type LidarrAlbum struct {
|
||
ID int `json:"id"`
|
||
ForeignAlbumID string `json:"foreignAlbumId"` // MBID
|
||
Title string `json:"title"`
|
||
ArtistID int `json:"artistId"`
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2.2: Add a sentinel error for not-found**
|
||
|
||
`internal/lidarr/errors.go` (modify) — append:
|
||
|
||
```go
|
||
// ErrNotFound is returned by LookupArtistByMBID and LookupAlbumByMBID
|
||
// when Lidarr returns 200 with an empty array — i.e., the MBID isn't in
|
||
// Lidarr's monitored set. Distinguished from network/auth errors so admin
|
||
// handlers can surface it as `lidarr_album_lookup_failed` (502) instead
|
||
// of `lidarr_unreachable` (503).
|
||
var ErrNotFound = errors.New("lidarr: not found")
|
||
```
|
||
|
||
If `errors.go` doesn't already import `"errors"`, add it.
|
||
|
||
- [ ] **Step 2.3: Write `lookup_mbid.go`**
|
||
|
||
```go
|
||
package lidarr
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/url"
|
||
)
|
||
|
||
// LookupArtistByMBID returns the artist Lidarr has indexed under that
|
||
// MBID. Returns ErrNotFound if Lidarr returns an empty array.
|
||
func (c *Client) LookupArtistByMBID(ctx context.Context, mbid string) (LidarrArtist, error) {
|
||
if mbid == "" {
|
||
return LidarrArtist{}, fmt.Errorf("lidarr: empty mbid")
|
||
}
|
||
q := url.Values{"mbId": []string{mbid}}
|
||
resp, err := c.get(ctx, "/api/v1/artist", q)
|
||
if err != nil {
|
||
return LidarrArtist{}, err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
var rows []LidarrArtist
|
||
if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil {
|
||
return LidarrArtist{}, fmt.Errorf("lidarr: decode artist: %w", err)
|
||
}
|
||
if len(rows) == 0 {
|
||
return LidarrArtist{}, ErrNotFound
|
||
}
|
||
return rows[0], nil
|
||
}
|
||
|
||
// LookupAlbumByMBID returns the album Lidarr has indexed under that
|
||
// MBID. Returns ErrNotFound on empty result.
|
||
func (c *Client) LookupAlbumByMBID(ctx context.Context, mbid string) (LidarrAlbum, error) {
|
||
if mbid == "" {
|
||
return LidarrAlbum{}, fmt.Errorf("lidarr: empty mbid")
|
||
}
|
||
q := url.Values{"foreignAlbumId": []string{mbid}}
|
||
resp, err := c.get(ctx, "/api/v1/album", q)
|
||
if err != nil {
|
||
return LidarrAlbum{}, err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
var rows []LidarrAlbum
|
||
if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil {
|
||
return LidarrAlbum{}, fmt.Errorf("lidarr: decode album: %w", err)
|
||
}
|
||
if len(rows) == 0 {
|
||
return LidarrAlbum{}, ErrNotFound
|
||
}
|
||
return rows[0], nil
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2.4: Write `delete.go`**
|
||
|
||
```go
|
||
package lidarr
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"net/http"
|
||
"net/url"
|
||
"strconv"
|
||
)
|
||
|
||
// del issues a DELETE against the given path with optional query params.
|
||
// Mirrors the existing get/post helpers in client.go; consolidating the
|
||
// auth header + base-URL handling behavior in one place.
|
||
func (c *Client) del(ctx context.Context, path string, q url.Values) (*http.Response, error) {
|
||
u, err := c.url(path)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if q != nil {
|
||
u.RawQuery = q.Encode()
|
||
}
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u.String(), nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("lidarr: build DELETE: %w", err)
|
||
}
|
||
req.Header.Set("X-Api-Key", c.APIKey)
|
||
|
||
resp, err := c.HTTP.Do(req)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
|
||
}
|
||
if resp.StatusCode == http.StatusUnauthorized {
|
||
resp.Body.Close()
|
||
return nil, ErrAuthFailed
|
||
}
|
||
if resp.StatusCode >= 400 {
|
||
resp.Body.Close()
|
||
return nil, fmt.Errorf("%w: status %d", ErrLookupFailed, resp.StatusCode)
|
||
}
|
||
return resp, nil
|
||
}
|
||
|
||
// DeleteAlbum removes an album from Lidarr's library.
|
||
// - deleteFiles=true also removes the audio files from disk.
|
||
// - addImportListExclusion=true tells Lidarr to never re-add this album
|
||
// via import-list scans.
|
||
//
|
||
// M5b's admin "delete via Lidarr" action always passes both `true`.
|
||
func (c *Client) DeleteAlbum(ctx context.Context, lidarrAlbumID int, deleteFiles, addImportListExclusion bool) error {
|
||
if lidarrAlbumID == 0 {
|
||
return fmt.Errorf("lidarr: zero album id")
|
||
}
|
||
q := url.Values{
|
||
"deleteFiles": []string{strconv.FormatBool(deleteFiles)},
|
||
"addImportListExclusion": []string{strconv.FormatBool(addImportListExclusion)},
|
||
}
|
||
resp, err := c.del(ctx, "/api/v1/album/"+strconv.Itoa(lidarrAlbumID), q)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
resp.Body.Close()
|
||
return nil
|
||
}
|
||
```
|
||
|
||
If `client.go` doesn't already export a `url(path)` helper, look at how `get(ctx, path, q)` builds its URL and either factor out the helper or inline the logic here. (M5a's `client.go` has `c.url(path)` at the top of the file — check before duplicating.)
|
||
|
||
- [ ] **Step 2.5: Capture fixtures**
|
||
|
||
`internal/lidarr/testdata/album_lookup_by_mbid.json` — a single-element JSON array matching what Lidarr returns for `GET /api/v1/album?foreignAlbumId=<mbid>`:
|
||
|
||
```json
|
||
[
|
||
{
|
||
"id": 42,
|
||
"foreignAlbumId": "3a2c2c8c-7e6f-4f8a-b1d2-9a8b6c4e3f1d",
|
||
"title": "Music Has The Right To Children",
|
||
"artistId": 7
|
||
}
|
||
]
|
||
```
|
||
|
||
`internal/lidarr/testdata/artist_lookup_by_mbid.json` — same shape:
|
||
|
||
```json
|
||
[
|
||
{
|
||
"id": 7,
|
||
"foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01",
|
||
"artistName": "Boards of Canada"
|
||
}
|
||
]
|
||
```
|
||
|
||
- [ ] **Step 2.6: Write `delete_test.go` (covers all three new methods)**
|
||
|
||
```go
|
||
package lidarr
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"testing"
|
||
)
|
||
|
||
func TestLookupAlbumByMBID_HappyPath(t *testing.T) {
|
||
body, err := os.ReadFile("testdata/album_lookup_by_mbid.json")
|
||
if err != nil {
|
||
t.Fatalf("read fixture: %v", err)
|
||
}
|
||
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.URL.Path != "/api/v1/album" {
|
||
t.Errorf("path = %q, want /api/v1/album", r.URL.Path)
|
||
}
|
||
if got := r.URL.Query().Get("foreignAlbumId"); got != "3a2c2c8c-7e6f-4f8a-b1d2-9a8b6c4e3f1d" {
|
||
t.Errorf("foreignAlbumId = %q", got)
|
||
}
|
||
if got := r.Header.Get("X-Api-Key"); got != "test-key" {
|
||
t.Errorf("X-Api-Key = %q, want test-key", got)
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write(body)
|
||
})
|
||
defer srv.Close()
|
||
|
||
got, err := c.LookupAlbumByMBID(context.Background(), "3a2c2c8c-7e6f-4f8a-b1d2-9a8b6c4e3f1d")
|
||
if err != nil {
|
||
t.Fatalf("LookupAlbumByMBID: %v", err)
|
||
}
|
||
if got.ID != 42 || got.Title != "Music Has The Right To Children" {
|
||
t.Errorf("got = %+v", got)
|
||
}
|
||
}
|
||
|
||
func TestLookupAlbumByMBID_EmptyArrayReturnsErrNotFound(t *testing.T) {
|
||
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write([]byte("[]"))
|
||
})
|
||
defer srv.Close()
|
||
|
||
_, err := c.LookupAlbumByMBID(context.Background(), "x")
|
||
if !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("err = %v, want ErrNotFound", err)
|
||
}
|
||
}
|
||
|
||
func TestLookupAlbumByMBID_AuthFailed(t *testing.T) {
|
||
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
|
||
w.WriteHeader(http.StatusUnauthorized)
|
||
})
|
||
defer srv.Close()
|
||
|
||
_, err := c.LookupAlbumByMBID(context.Background(), "x")
|
||
if !errors.Is(err, ErrAuthFailed) {
|
||
t.Errorf("err = %v, want ErrAuthFailed", err)
|
||
}
|
||
}
|
||
|
||
func TestLookupArtistByMBID_HappyPath(t *testing.T) {
|
||
body, err := os.ReadFile("testdata/artist_lookup_by_mbid.json")
|
||
if err != nil {
|
||
t.Fatalf("read fixture: %v", err)
|
||
}
|
||
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.URL.Path != "/api/v1/artist" {
|
||
t.Errorf("path = %q, want /api/v1/artist", r.URL.Path)
|
||
}
|
||
if got := r.URL.Query().Get("mbId"); got != "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" {
|
||
t.Errorf("mbId = %q", got)
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write(body)
|
||
})
|
||
defer srv.Close()
|
||
|
||
got, err := c.LookupArtistByMBID(context.Background(), "069b64b6-7884-4f6a-94cc-e4c1d6c87a01")
|
||
if err != nil {
|
||
t.Fatalf("LookupArtistByMBID: %v", err)
|
||
}
|
||
if got.ID != 7 || got.ArtistName != "Boards of Canada" {
|
||
t.Errorf("got = %+v", got)
|
||
}
|
||
}
|
||
|
||
func TestDeleteAlbum_PassesBothFlags(t *testing.T) {
|
||
var captured *http.Request
|
||
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||
captured = r
|
||
w.WriteHeader(http.StatusOK)
|
||
})
|
||
defer srv.Close()
|
||
|
||
if err := c.DeleteAlbum(context.Background(), 42, true, true); err != nil {
|
||
t.Fatalf("DeleteAlbum: %v", err)
|
||
}
|
||
if captured == nil || captured.Method != http.MethodDelete {
|
||
t.Fatalf("method = %v, want DELETE", captured)
|
||
}
|
||
if captured.URL.Path != "/api/v1/album/42" {
|
||
t.Errorf("path = %q", captured.URL.Path)
|
||
}
|
||
if got := captured.URL.Query().Get("deleteFiles"); got != "true" {
|
||
t.Errorf("deleteFiles = %q", got)
|
||
}
|
||
if got := captured.URL.Query().Get("addImportListExclusion"); got != "true" {
|
||
t.Errorf("addImportListExclusion = %q", got)
|
||
}
|
||
}
|
||
|
||
func TestDeleteAlbum_5xxReturnsErrLookupFailed(t *testing.T) {
|
||
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
})
|
||
defer srv.Close()
|
||
|
||
err := c.DeleteAlbum(context.Background(), 42, true, true)
|
||
if !errors.Is(err, ErrLookupFailed) {
|
||
t.Errorf("err = %v, want ErrLookupFailed", err)
|
||
}
|
||
}
|
||
|
||
func TestDeleteAlbum_NetworkErrorReturnsErrUnreachable(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||
srv.Close() // server is closed; client should fail to connect
|
||
c := NewClient(srv.URL, "test-key")
|
||
|
||
err := c.DeleteAlbum(context.Background(), 42, true, true)
|
||
if !errors.Is(err, ErrUnreachable) {
|
||
t.Errorf("err = %v, want ErrUnreachable", err)
|
||
}
|
||
}
|
||
```
|
||
|
||
`newTestClient` is the existing helper in `client_test.go` (M5a). Reuse it.
|
||
|
||
- [ ] **Step 2.7: Run tests + build**
|
||
|
||
```bash
|
||
go test ./internal/lidarr/... -count=1
|
||
go build ./...
|
||
```
|
||
|
||
Expected: all green.
|
||
|
||
- [ ] **Step 2.8: Commit**
|
||
|
||
```bash
|
||
git add internal/lidarr/
|
||
git commit -m "feat(lidarr): LookupArtistByMBID, LookupAlbumByMBID, DeleteAlbum"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3 — `internal/library` `DeleteTrackFile`
|
||
|
||
**Files:**
|
||
- Create: `internal/library/delete.go`
|
||
- Create: `internal/library/delete_test.go`
|
||
|
||
The admin "Delete file" action removes the file from disk and the row from `tracks`. The album/artist rows stay. Other tracks may reference them; the admin only nuked one track.
|
||
|
||
- [ ] **Step 3.1: Write `delete.go`**
|
||
|
||
```go
|
||
package library
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"io/fs"
|
||
"os"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||
)
|
||
|
||
// ErrTrackNotFound is returned when DeleteTrackFile is called with an id
|
||
// that has no row in tracks.
|
||
var ErrTrackNotFound = errors.New("library: track not found")
|
||
|
||
// DeleteTrackFile removes a track file from disk and its row from the
|
||
// tracks table. Album and artist rows are left untouched.
|
||
//
|
||
// Steps:
|
||
// 1. Look up the track to get its file_path.
|
||
// 2. Remove the file from disk. fs.ErrNotExist is OK — already gone.
|
||
// 3. Delete the tracks row.
|
||
//
|
||
// Order matters: file first, then DB. If the file delete fails (permission,
|
||
// I/O error), we leave the DB row alone so the admin can retry.
|
||
func DeleteTrackFile(ctx context.Context, pool *pgxpool.Pool, trackID pgtype.UUID) error {
|
||
q := dbq.New(pool)
|
||
track, err := q.GetTrackByID(ctx, trackID)
|
||
if err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return ErrTrackNotFound
|
||
}
|
||
return fmt.Errorf("get track: %w", err)
|
||
}
|
||
|
||
if err := os.Remove(track.FilePath); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||
return fmt.Errorf("remove file: %w", err)
|
||
}
|
||
|
||
if _, err := pool.Exec(ctx, "DELETE FROM tracks WHERE id = $1", trackID); err != nil {
|
||
return fmt.Errorf("delete row: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3.2: Write `delete_test.go`**
|
||
|
||
```go
|
||
package library
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"io"
|
||
"log/slog"
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||
)
|
||
|
||
func TestDeleteTrackFile_HappyPath(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping integration test in -short mode")
|
||
}
|
||
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
|
||
if dsn == "" {
|
||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||
}
|
||
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
|
||
t.Fatalf("migrate: %v", err)
|
||
}
|
||
pool, err := pgxpool.New(context.Background(), dsn)
|
||
if err != nil {
|
||
t.Fatalf("pool: %v", err)
|
||
}
|
||
t.Cleanup(pool.Close)
|
||
|
||
if _, err := pool.Exec(context.Background(),
|
||
"TRUNCATE tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil {
|
||
t.Fatalf("truncate: %v", err)
|
||
}
|
||
q := dbq.New(pool)
|
||
artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "X", SortName: "X"})
|
||
album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "A", SortTitle: "A", ArtistID: artist.ID})
|
||
|
||
// Create a real on-disk file the test can prove is removed.
|
||
dir := t.TempDir()
|
||
path := filepath.Join(dir, "track.mp3")
|
||
if err := os.WriteFile(path, []byte("payload"), 0o644); err != nil {
|
||
t.Fatalf("write file: %v", err)
|
||
}
|
||
track, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
||
Title: "T", AlbumID: album.ID, ArtistID: artist.ID,
|
||
DurationMs: 1000, FilePath: path, FileSize: 7, FileFormat: "mp3",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("upsert: %v", err)
|
||
}
|
||
|
||
if err := DeleteTrackFile(context.Background(), pool, track.ID); err != nil {
|
||
t.Fatalf("DeleteTrackFile: %v", err)
|
||
}
|
||
|
||
// File gone.
|
||
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||
t.Errorf("file still exists: %v", err)
|
||
}
|
||
// Row gone.
|
||
if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil {
|
||
t.Errorf("track row still exists")
|
||
}
|
||
// Album row preserved.
|
||
if _, err := q.GetAlbumByID(context.Background(), album.ID); err != nil {
|
||
t.Errorf("album row vanished: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestDeleteTrackFile_FileAlreadyGoneSucceeds(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping integration test in -short mode")
|
||
}
|
||
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
|
||
if dsn == "" {
|
||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||
}
|
||
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
|
||
t.Fatalf("migrate: %v", err)
|
||
}
|
||
pool, _ := pgxpool.New(context.Background(), dsn)
|
||
t.Cleanup(pool.Close)
|
||
if _, err := pool.Exec(context.Background(),
|
||
"TRUNCATE tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil {
|
||
t.Fatalf("truncate: %v", err)
|
||
}
|
||
q := dbq.New(pool)
|
||
artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "X", SortName: "X"})
|
||
album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "A", SortTitle: "A", ArtistID: artist.ID})
|
||
|
||
track, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
||
Title: "T", AlbumID: album.ID, ArtistID: artist.ID,
|
||
DurationMs: 1000, FilePath: "/no/such/file/anywhere.mp3", FileSize: 0, FileFormat: "mp3",
|
||
})
|
||
|
||
if err := DeleteTrackFile(context.Background(), pool, track.ID); err != nil {
|
||
t.Fatalf("DeleteTrackFile with missing file: %v", err)
|
||
}
|
||
if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil {
|
||
t.Errorf("track row still exists")
|
||
}
|
||
}
|
||
|
||
func TestDeleteTrackFile_NotFoundReturnsErr(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping integration test in -short mode")
|
||
}
|
||
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
|
||
if dsn == "" {
|
||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||
}
|
||
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
|
||
t.Fatalf("migrate: %v", err)
|
||
}
|
||
pool, _ := pgxpool.New(context.Background(), dsn)
|
||
t.Cleanup(pool.Close)
|
||
|
||
var bogus pgxUUID
|
||
bogus.Set([16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16})
|
||
err := DeleteTrackFile(context.Background(), pool, bogus.UUID)
|
||
if !errors.Is(err, ErrTrackNotFound) {
|
||
t.Errorf("err = %v, want ErrTrackNotFound", err)
|
||
}
|
||
}
|
||
|
||
// pgxUUID is a tiny shim for the test — the existing scanner_test.go in
|
||
// this package uses raw byte arrays to build a synthetic pgtype.UUID. If
|
||
// the convention changes, mirror whatever helper that test uses.
|
||
type pgxUUID struct {
|
||
UUID interface {
|
||
// satisfied by pgtype.UUID
|
||
}
|
||
}
|
||
|
||
func (u *pgxUUID) Set(b [16]byte) {
|
||
// Replace this body with whatever the existing tests use to construct
|
||
// a pgtype.UUID from raw bytes. If unsure, copy from
|
||
// internal/lidarrrequests/service_test.go's TestApprove_NotFound.
|
||
panic("replace with the project's pgtype.UUID construction helper")
|
||
}
|
||
```
|
||
|
||
The `pgxUUID` shim above is a placeholder — when implementing, look at `internal/lidarrrequests/service_test.go:TestApprove_NotFound` which constructs a synthetic UUID with `bogus.Bytes = [16]byte{...}; bogus.Valid = true`. Use that pattern instead.
|
||
|
||
- [ ] **Step 3.3: Run tests**
|
||
|
||
```bash
|
||
docker compose up -d postgres
|
||
docker run --rm --network minstrel_minstrel \
|
||
-v "$(pwd):/src" -w /src \
|
||
-e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \
|
||
golang:1.23-bookworm \
|
||
go test -race ./internal/library/... -run TestDeleteTrackFile
|
||
```
|
||
|
||
Expected: all three subtests pass.
|
||
|
||
- [ ] **Step 3.4: Commit**
|
||
|
||
```bash
|
||
git add internal/library/delete.go internal/library/delete_test.go
|
||
git commit -m "feat(library): DeleteTrackFile (rm file + tracks row, album/artist preserved)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4 — `lidarrquarantine.Service` — Flag/Unflag/ListMine/ListAdminQueue
|
||
|
||
**Files:**
|
||
- Create: `internal/lidarrquarantine/service.go`
|
||
- Create: `internal/lidarrquarantine/service_test.go`
|
||
|
||
Read the M5a `internal/lidarrrequests/service.go` first — it's the closest analog. Same shape (`Service` struct, factory function, integration tests gated on `MINSTREL_TEST_DATABASE_URL`, `dbtest.ResetDB` for isolation). Mirror it.
|
||
|
||
- [ ] **Step 4.1: Write the package skeleton + read paths**
|
||
|
||
`internal/lidarrquarantine/service.go`:
|
||
|
||
```go
|
||
// Package lidarrquarantine owns the per-user track quarantine workflow.
|
||
// Users flag a track as broken (Flag/Unflag), the SPA hides the track
|
||
// from their views, and admins resolve the resulting reports via the
|
||
// Service's admin actions (Resolve / DeleteFile / DeleteViaLidarr).
|
||
package lidarrquarantine
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||
)
|
||
|
||
// Public errors. Handlers map these to API codes.
|
||
var (
|
||
ErrBadReason = errors.New("lidarrquarantine: invalid reason")
|
||
ErrTrackNotFound = errors.New("lidarrquarantine: track not found")
|
||
ErrQuarantineNotFound = errors.New("lidarrquarantine: quarantine row not found")
|
||
ErrAlbumMBIDMissing = errors.New("lidarrquarantine: track has no parent album mbid")
|
||
ErrLidarrAlbumNotFound = errors.New("lidarrquarantine: lidarr has no album for that mbid")
|
||
ErrLidarrDisabled = errors.New("lidarrquarantine: lidarr is not configured")
|
||
)
|
||
|
||
// Service is the lifecycle owner. clientFn is a per-call factory so config
|
||
// changes in lidarrconfig take effect immediately. clientFn returns nil
|
||
// when Lidarr is disabled.
|
||
type Service struct {
|
||
pool *pgxpool.Pool
|
||
lidarrCfg *lidarrconfig.Service
|
||
clientFn func() *lidarr.Client
|
||
}
|
||
|
||
func NewService(pool *pgxpool.Pool, cfg *lidarrconfig.Service, clientFn func() *lidarr.Client) *Service {
|
||
if clientFn == nil {
|
||
clientFn = func() *lidarr.Client { return nil }
|
||
}
|
||
return &Service{pool: pool, lidarrCfg: cfg, clientFn: clientFn}
|
||
}
|
||
|
||
// Flag inserts or updates a quarantine row for the caller. Re-flagging
|
||
// the same (user, track) overwrites reason+notes.
|
||
func (s *Service) Flag(ctx context.Context, userID, trackID pgtype.UUID, reason string, notes string) (dbq.LidarrQuarantine, error) {
|
||
if !validReason(reason) {
|
||
return dbq.LidarrQuarantine{}, ErrBadReason
|
||
}
|
||
var notesPtr *string
|
||
if notes != "" {
|
||
notesPtr = ¬es
|
||
}
|
||
row, err := dbq.New(s.pool).UpsertQuarantine(ctx, dbq.UpsertQuarantineParams{
|
||
UserID: userID,
|
||
TrackID: trackID,
|
||
Reason: dbq.LidarrQuarantineReason(reason),
|
||
Notes: notesPtr,
|
||
})
|
||
if err != nil {
|
||
// ON CONFLICT path can't trip ErrNoRows; only an FK violation does
|
||
// (track_id doesn't exist). Surface that as ErrTrackNotFound.
|
||
return dbq.LidarrQuarantine{}, fmt.Errorf("upsert: %w", err)
|
||
}
|
||
return row, nil
|
||
}
|
||
|
||
// Unflag removes the caller's row. Returns ErrQuarantineNotFound if
|
||
// no row exists.
|
||
func (s *Service) Unflag(ctx context.Context, userID, trackID pgtype.UUID) error {
|
||
_, err := dbq.New(s.pool).DeleteQuarantine(ctx, dbq.DeleteQuarantineParams{
|
||
UserID: userID, TrackID: trackID,
|
||
})
|
||
if err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return ErrQuarantineNotFound
|
||
}
|
||
return fmt.Errorf("delete: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ListMine returns the caller's quarantines with track/album/artist
|
||
// detail. Drives /library/hidden.
|
||
func (s *Service) ListMine(ctx context.Context, userID pgtype.UUID) ([]dbq.ListQuarantineForUserRow, error) {
|
||
return dbq.New(s.pool).ListQuarantineForUser(ctx, userID)
|
||
}
|
||
|
||
// AdminQueueRow is the assembled aggregated row served by the admin
|
||
// queue endpoint. The handler post-processes the SQL results to attach
|
||
// reason_counts and per-user reports.
|
||
type AdminQueueRow struct {
|
||
TrackID pgtype.UUID
|
||
TrackTitle string
|
||
ArtistName string
|
||
AlbumTitle *string
|
||
AlbumID pgtype.UUID
|
||
LidarrAlbumMBID *string
|
||
ReportCount int32
|
||
LatestAt pgtype.Timestamptz
|
||
ReasonCounts map[string]int
|
||
Reports []UserReport
|
||
}
|
||
|
||
type UserReport struct {
|
||
UserID pgtype.UUID
|
||
Username string
|
||
Reason string
|
||
Notes *string
|
||
CreatedAt pgtype.Timestamptz
|
||
}
|
||
|
||
// ListAdminQueue returns the aggregated admin queue. One row per track.
|
||
func (s *Service) ListAdminQueue(ctx context.Context) ([]AdminQueueRow, error) {
|
||
q := dbq.New(s.pool)
|
||
aggregated, err := q.ListAdminQuarantineQueue(ctx)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("aggregate: %w", err)
|
||
}
|
||
out := make([]AdminQueueRow, 0, len(aggregated))
|
||
for _, r := range aggregated {
|
||
reports, err := q.ListQuarantineReportsForTrack(ctx, r.TrackID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("reports for track %v: %w", r.TrackID, err)
|
||
}
|
||
rc := make(map[string]int, len(reports))
|
||
userReports := make([]UserReport, 0, len(reports))
|
||
for _, rep := range reports {
|
||
rc[string(rep.Reason)]++
|
||
userReports = append(userReports, UserReport{
|
||
UserID: rep.UserID,
|
||
Username: rep.Username,
|
||
Reason: string(rep.Reason),
|
||
Notes: rep.Notes,
|
||
CreatedAt: rep.CreatedAt,
|
||
})
|
||
}
|
||
out = append(out, AdminQueueRow{
|
||
TrackID: r.TrackID,
|
||
TrackTitle: r.TrackTitle,
|
||
ArtistName: r.ArtistName,
|
||
AlbumTitle: r.AlbumTitle,
|
||
AlbumID: r.AlbumID,
|
||
LidarrAlbumMBID: r.LidarrAlbumMbid,
|
||
ReportCount: r.ReportCount,
|
||
LatestAt: r.LatestAt,
|
||
ReasonCounts: rc,
|
||
Reports: userReports,
|
||
})
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func validReason(r string) bool {
|
||
switch r {
|
||
case "bad_rip", "wrong_file", "wrong_tags", "duplicate", "other":
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
```
|
||
|
||
(Admin actions Resolve / DeleteFile / DeleteViaLidarr land in Task 5.)
|
||
|
||
- [ ] **Step 4.2: Write the integration tests for the read paths**
|
||
|
||
`internal/lidarrquarantine/service_test.go`:
|
||
|
||
```go
|
||
package lidarrquarantine
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"io"
|
||
"log/slog"
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
||
)
|
||
|
||
func newPool(t *testing.T) *pgxpool.Pool {
|
||
t.Helper()
|
||
if testing.Short() {
|
||
t.Skip("skipping integration test in -short mode")
|
||
}
|
||
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
|
||
if dsn == "" {
|
||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||
}
|
||
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
|
||
t.Fatalf("migrate: %v", err)
|
||
}
|
||
pool, err := pgxpool.New(context.Background(), dsn)
|
||
if err != nil {
|
||
t.Fatalf("pool: %v", err)
|
||
}
|
||
t.Cleanup(pool.Close)
|
||
dbtest.ResetDB(t, pool)
|
||
if _, err := pool.Exec(context.Background(),
|
||
"DELETE FROM lidarr_quarantine; DELETE FROM lidarr_quarantine_actions;"); err != nil {
|
||
t.Fatalf("reset quarantine tables: %v", err)
|
||
}
|
||
return pool
|
||
}
|
||
|
||
func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User {
|
||
t.Helper()
|
||
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
|
||
Username: dbtest.TestUserPrefix + name, PasswordHash: "x",
|
||
ApiToken: name + "-token", IsAdmin: false,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("seed user %s: %v", name, err)
|
||
}
|
||
return u
|
||
}
|
||
|
||
func seedTrack(t *testing.T, pool *pgxpool.Pool, title, mbid string) (dbq.Track, dbq.Album, dbq.Artist) {
|
||
t.Helper()
|
||
q := dbq.New(pool)
|
||
artist, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{
|
||
Name: "Test Artist", SortName: "Test Artist",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("artist: %v", err)
|
||
}
|
||
albumMBID := mbid + "-album"
|
||
album, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
|
||
Title: "Test Album", SortTitle: "Test Album",
|
||
ArtistID: artist.ID, Mbid: &albumMBID,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("album: %v", err)
|
||
}
|
||
track, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
||
Title: title, AlbumID: album.ID, ArtistID: artist.ID,
|
||
DurationMs: 1000, FilePath: filepath.Join(t.TempDir(), title+".mp3"),
|
||
FileSize: 100, FileFormat: "mp3",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("track: %v", err)
|
||
}
|
||
return track, album, artist
|
||
}
|
||
|
||
func TestFlag_HappyPath(t *testing.T) {
|
||
pool := newPool(t)
|
||
user := seedUser(t, pool, "alice")
|
||
track, _, _ := seedTrack(t, pool, "Bad Track", "abc")
|
||
|
||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
||
row, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "crackly")
|
||
if err != nil {
|
||
t.Fatalf("Flag: %v", err)
|
||
}
|
||
if string(row.Reason) != "bad_rip" {
|
||
t.Errorf("reason = %v", row.Reason)
|
||
}
|
||
if row.Notes == nil || *row.Notes != "crackly" {
|
||
t.Errorf("notes = %v", row.Notes)
|
||
}
|
||
}
|
||
|
||
func TestFlag_UpsertOnSecondFlag(t *testing.T) {
|
||
pool := newPool(t)
|
||
user := seedUser(t, pool, "alice")
|
||
track, _, _ := seedTrack(t, pool, "T", "x")
|
||
|
||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
||
if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "first"); err != nil {
|
||
t.Fatalf("first flag: %v", err)
|
||
}
|
||
row, err := svc.Flag(context.Background(), user.ID, track.ID, "wrong_tags", "")
|
||
if err != nil {
|
||
t.Fatalf("second flag: %v", err)
|
||
}
|
||
if string(row.Reason) != "wrong_tags" {
|
||
t.Errorf("reason = %v", row.Reason)
|
||
}
|
||
if row.Notes != nil {
|
||
t.Errorf("notes = %v, want nil after empty notes upsert", row.Notes)
|
||
}
|
||
}
|
||
|
||
func TestFlag_BadReasonRejected(t *testing.T) {
|
||
pool := newPool(t)
|
||
user := seedUser(t, pool, "alice")
|
||
track, _, _ := seedTrack(t, pool, "T", "x")
|
||
|
||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
||
_, err := svc.Flag(context.Background(), user.ID, track.ID, "garbage", "")
|
||
if !errors.Is(err, ErrBadReason) {
|
||
t.Errorf("err = %v, want ErrBadReason", err)
|
||
}
|
||
}
|
||
|
||
func TestUnflag_DeletesRow(t *testing.T) {
|
||
pool := newPool(t)
|
||
user := seedUser(t, pool, "alice")
|
||
track, _, _ := seedTrack(t, pool, "T", "x")
|
||
|
||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
||
_, _ = svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "")
|
||
if err := svc.Unflag(context.Background(), user.ID, track.ID); err != nil {
|
||
t.Fatalf("Unflag: %v", err)
|
||
}
|
||
if err := svc.Unflag(context.Background(), user.ID, track.ID); !errors.Is(err, ErrQuarantineNotFound) {
|
||
t.Errorf("second Unflag err = %v, want ErrQuarantineNotFound", err)
|
||
}
|
||
}
|
||
|
||
func TestListMine_OrderedNewestFirst(t *testing.T) {
|
||
pool := newPool(t)
|
||
user := seedUser(t, pool, "alice")
|
||
t1, _, _ := seedTrack(t, pool, "T1", "x")
|
||
t2, _, _ := seedTrack(t, pool, "T2", "y")
|
||
|
||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
||
_, _ = svc.Flag(context.Background(), user.ID, t1.ID, "bad_rip", "")
|
||
_, _ = svc.Flag(context.Background(), user.ID, t2.ID, "duplicate", "")
|
||
|
||
rows, err := svc.ListMine(context.Background(), user.ID)
|
||
if err != nil {
|
||
t.Fatalf("ListMine: %v", err)
|
||
}
|
||
if len(rows) != 2 {
|
||
t.Fatalf("len = %d, want 2", len(rows))
|
||
}
|
||
// T2 was flagged second — newest first.
|
||
if rows[0].LidarrQuarantine.TrackID != t2.ID {
|
||
t.Errorf("first row track = %v, want T2 (%v)", rows[0].LidarrQuarantine.TrackID, t2.ID)
|
||
}
|
||
}
|
||
|
||
func TestListAdminQueue_AggregatesByTrackWithReasonCounts(t *testing.T) {
|
||
pool := newPool(t)
|
||
alice := seedUser(t, pool, "alice")
|
||
bob := seedUser(t, pool, "bob")
|
||
carol := seedUser(t, pool, "carol")
|
||
track, _, _ := seedTrack(t, pool, "Hot Mess", "abc")
|
||
|
||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
||
_, _ = svc.Flag(context.Background(), alice.ID, track.ID, "bad_rip", "")
|
||
_, _ = svc.Flag(context.Background(), bob.ID, track.ID, "bad_rip", "")
|
||
_, _ = svc.Flag(context.Background(), carol.ID, track.ID, "wrong_tags", "")
|
||
|
||
rows, err := svc.ListAdminQueue(context.Background())
|
||
if err != nil {
|
||
t.Fatalf("ListAdminQueue: %v", err)
|
||
}
|
||
if len(rows) != 1 {
|
||
t.Fatalf("len = %d, want 1 aggregated row", len(rows))
|
||
}
|
||
r := rows[0]
|
||
if r.ReportCount != 3 {
|
||
t.Errorf("report_count = %d, want 3", r.ReportCount)
|
||
}
|
||
if r.ReasonCounts["bad_rip"] != 2 || r.ReasonCounts["wrong_tags"] != 1 {
|
||
t.Errorf("reason_counts = %+v, want bad_rip=2 wrong_tags=1", r.ReasonCounts)
|
||
}
|
||
if len(r.Reports) != 3 {
|
||
t.Errorf("reports len = %d, want 3", len(r.Reports))
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4.3: Run the tests**
|
||
|
||
```bash
|
||
docker run --rm --network minstrel_minstrel \
|
||
-v "$(pwd):/src" -w /src \
|
||
-e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \
|
||
golang:1.23-bookworm \
|
||
go test -race ./internal/lidarrquarantine/...
|
||
```
|
||
|
||
Expected: all green.
|
||
|
||
- [ ] **Step 4.4: Commit**
|
||
|
||
```bash
|
||
git add internal/lidarrquarantine/
|
||
git commit -m "feat(lidarrquarantine): Service Flag/Unflag/ListMine/ListAdminQueue"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5 — `lidarrquarantine.Service` admin actions
|
||
|
||
**Files:**
|
||
- Modify: `internal/lidarrquarantine/service.go` — append Resolve / DeleteFile / DeleteViaLidarr
|
||
- Modify: `internal/lidarrquarantine/service_test.go` — append admin-action tests
|
||
|
||
The three admin actions all follow the same shape:
|
||
1. Read the track (and parent album for DeleteViaLidarr) for snapshot fields.
|
||
2. Capture `affected_users` count via `CountQuarantineForTrack` *before* deleting.
|
||
3. For DeleteFile: call `library.DeleteTrackFile`; for DeleteViaLidarr: lookup album in Lidarr, call `Client.DeleteAlbum`, delete all Minstrel tracks in that album.
|
||
4. Delete `lidarr_quarantine` rows for the affected tracks.
|
||
5. Write a `lidarr_quarantine_actions` audit row.
|
||
|
||
Order matters: Lidarr/file delete first, then DB writes. Failure of the external call leaves the per-user rows intact for retry. **No partial state.**
|
||
|
||
- [ ] **Step 5.1: Append `Resolve` to `service.go`**
|
||
|
||
```go
|
||
// Resolve clears all per-user quarantine rows for a track and writes an
|
||
// audit log row. Idempotent — a track with no rows still writes an audit
|
||
// entry with affected_users=0 (so admin can see "I clicked resolve on a
|
||
// track that already had no reports").
|
||
func (s *Service) Resolve(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, error) {
|
||
q := dbq.New(s.pool)
|
||
track, err := q.GetTrackByID(ctx, trackID)
|
||
if err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return dbq.LidarrQuarantineAction{}, ErrTrackNotFound
|
||
}
|
||
return dbq.LidarrQuarantineAction{}, fmt.Errorf("get track: %w", err)
|
||
}
|
||
snap, err := s.snapshot(ctx, q, track)
|
||
if err != nil {
|
||
return dbq.LidarrQuarantineAction{}, err
|
||
}
|
||
|
||
affected, err := q.CountQuarantineForTrack(ctx, trackID)
|
||
if err != nil {
|
||
return dbq.LidarrQuarantineAction{}, fmt.Errorf("count: %w", err)
|
||
}
|
||
if err := q.DeleteQuarantineForTrack(ctx, trackID); err != nil {
|
||
return dbq.LidarrQuarantineAction{}, fmt.Errorf("delete rows: %w", err)
|
||
}
|
||
return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{
|
||
TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName,
|
||
AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionResolved,
|
||
AdminID: adminID, LidarrAlbumMbid: nil, AffectedUsers: affected,
|
||
})
|
||
}
|
||
|
||
// snapshot is shared scaffolding — pulls album/artist titles for the audit row.
|
||
type quarantineSnapshot struct {
|
||
TrackTitle string
|
||
ArtistName string
|
||
AlbumTitle *string
|
||
LidarrAlbumMBID *string
|
||
}
|
||
|
||
func (s *Service) snapshot(ctx context.Context, q *dbq.Queries, track dbq.Track) (quarantineSnapshot, error) {
|
||
album, err := q.GetAlbumByID(ctx, track.AlbumID)
|
||
if err != nil {
|
||
return quarantineSnapshot{}, fmt.Errorf("get album: %w", err)
|
||
}
|
||
artist, err := q.GetArtistByID(ctx, track.ArtistID)
|
||
if err != nil {
|
||
return quarantineSnapshot{}, fmt.Errorf("get artist: %w", err)
|
||
}
|
||
return quarantineSnapshot{
|
||
TrackTitle: track.Title,
|
||
ArtistName: artist.Name,
|
||
AlbumTitle: &album.Title,
|
||
LidarrAlbumMBID: album.Mbid,
|
||
}, nil
|
||
}
|
||
```
|
||
|
||
If `dbq.GetAlbumByID` / `dbq.GetArtistByID` don't exist as named queries, check the existing albums.sql / artists.sql files — they almost certainly do under different names (`AlbumByID`, `ArtistByID`, etc.) — and substitute the actual names.
|
||
|
||
- [ ] **Step 5.2: Append `DeleteFile`**
|
||
|
||
```go
|
||
// DeleteFile removes the track file from disk and the tracks row, then
|
||
// clears all per-user quarantine rows for that track and writes an audit
|
||
// row. If the file deletion fails, the per-user rows stay so admin can
|
||
// retry. No partial state.
|
||
func (s *Service) DeleteFile(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, error) {
|
||
q := dbq.New(s.pool)
|
||
track, err := q.GetTrackByID(ctx, trackID)
|
||
if err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return dbq.LidarrQuarantineAction{}, ErrTrackNotFound
|
||
}
|
||
return dbq.LidarrQuarantineAction{}, fmt.Errorf("get track: %w", err)
|
||
}
|
||
snap, err := s.snapshot(ctx, q, track)
|
||
if err != nil {
|
||
return dbq.LidarrQuarantineAction{}, err
|
||
}
|
||
|
||
affected, err := q.CountQuarantineForTrack(ctx, trackID)
|
||
if err != nil {
|
||
return dbq.LidarrQuarantineAction{}, fmt.Errorf("count: %w", err)
|
||
}
|
||
|
||
if err := library.DeleteTrackFile(ctx, s.pool, trackID); err != nil {
|
||
return dbq.LidarrQuarantineAction{}, fmt.Errorf("delete file: %w", err)
|
||
}
|
||
// tracks row is gone; the FK ON DELETE CASCADE on lidarr_quarantine
|
||
// already cleared the per-user rows.
|
||
return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{
|
||
TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName,
|
||
AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionDeletedFile,
|
||
AdminID: adminID, LidarrAlbumMbid: nil, AffectedUsers: affected,
|
||
})
|
||
}
|
||
```
|
||
|
||
Note the cascade comment: the schema has `ON DELETE CASCADE` on `lidarr_quarantine.track_id`, so when `library.DeleteTrackFile` runs `DELETE FROM tracks WHERE id = $1`, the per-user quarantine rows go too. We don't call `DeleteQuarantineForTrack` separately. **Verify this assumption holds when implementing** — re-check `0011_lidarr_quarantine.up.sql` and the existing `tracks` constraints.
|
||
|
||
- [ ] **Step 5.3: Append `DeleteViaLidarr`**
|
||
|
||
```go
|
||
// DeleteViaLidarr is the destructive admin path: tells Lidarr to remove
|
||
// the parent album with deleteFiles=true + addImportListExclusion=true,
|
||
// then removes Minstrel rows for all tracks of that album. The cascade
|
||
// on lidarr_quarantine clears per-user rows automatically.
|
||
//
|
||
// On Lidarr failure (unreachable, auth-failed, lookup-empty), nothing
|
||
// changes locally. Admin retries.
|
||
func (s *Service) DeleteViaLidarr(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, int, error) {
|
||
cfg, err := s.lidarrCfg.Get(ctx)
|
||
if err != nil {
|
||
return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("load config: %w", err)
|
||
}
|
||
client := s.clientFn()
|
||
if !cfg.Enabled || client == nil {
|
||
return dbq.LidarrQuarantineAction{}, 0, ErrLidarrDisabled
|
||
}
|
||
|
||
q := dbq.New(s.pool)
|
||
track, err := q.GetTrackByID(ctx, trackID)
|
||
if err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return dbq.LidarrQuarantineAction{}, 0, ErrTrackNotFound
|
||
}
|
||
return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("get track: %w", err)
|
||
}
|
||
snap, err := s.snapshot(ctx, q, track)
|
||
if err != nil {
|
||
return dbq.LidarrQuarantineAction{}, 0, err
|
||
}
|
||
if snap.LidarrAlbumMBID == nil || *snap.LidarrAlbumMBID == "" {
|
||
return dbq.LidarrQuarantineAction{}, 0, ErrAlbumMBIDMissing
|
||
}
|
||
|
||
affected, err := q.CountQuarantineForTrack(ctx, trackID)
|
||
if err != nil {
|
||
return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("count: %w", err)
|
||
}
|
||
|
||
// Look up the album in Lidarr to translate MBID -> Lidarr internal ID.
|
||
album, err := client.LookupAlbumByMBID(ctx, *snap.LidarrAlbumMBID)
|
||
if err != nil {
|
||
if errors.Is(err, lidarr.ErrNotFound) {
|
||
return dbq.LidarrQuarantineAction{}, 0, ErrLidarrAlbumNotFound
|
||
}
|
||
return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("lidarr lookup: %w", err)
|
||
}
|
||
|
||
// Lidarr DELETE — both flags true.
|
||
if err := client.DeleteAlbum(ctx, album.ID, true, true); err != nil {
|
||
return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("lidarr delete: %w", err)
|
||
}
|
||
|
||
// Now remove the local rows. Cascade handles per-user quarantine
|
||
// rows via the FK on lidarr_quarantine.track_id.
|
||
res, err := s.pool.Exec(ctx, "DELETE FROM tracks WHERE album_id = $1", track.AlbumID)
|
||
if err != nil {
|
||
// We deleted in Lidarr but failed in our DB. Operator-recoverable
|
||
// by re-running. Audit row will reflect the eventual state.
|
||
return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("delete tracks: %w", err)
|
||
}
|
||
deletedCount := int(res.RowsAffected())
|
||
|
||
// Album/artist rows stay; if the operator wants those gone too they
|
||
// can be cleaned up by a future scan or a manual SQL pass.
|
||
|
||
action, err := q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{
|
||
TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName,
|
||
AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionDeletedViaLidarr,
|
||
AdminID: adminID, LidarrAlbumMbid: snap.LidarrAlbumMBID,
|
||
AffectedUsers: affected,
|
||
})
|
||
return action, deletedCount, err
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5.4: Append admin-action tests**
|
||
|
||
`internal/lidarrquarantine/service_test.go` — append:
|
||
|
||
```go
|
||
import (
|
||
// ... add to existing imports:
|
||
"net/http"
|
||
"net/http/httptest"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
||
)
|
||
|
||
func TestResolve_ClearsRowsAndWritesAudit(t *testing.T) {
|
||
pool := newPool(t)
|
||
alice := seedUser(t, pool, "alice")
|
||
bob := seedUser(t, pool, "bob")
|
||
track, _, _ := seedTrack(t, pool, "T", "x")
|
||
|
||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
||
_, _ = svc.Flag(context.Background(), alice.ID, track.ID, "bad_rip", "")
|
||
_, _ = svc.Flag(context.Background(), bob.ID, track.ID, "wrong_tags", "")
|
||
|
||
audit, err := svc.Resolve(context.Background(), track.ID, alice.ID)
|
||
if err != nil {
|
||
t.Fatalf("Resolve: %v", err)
|
||
}
|
||
if audit.AffectedUsers != 2 {
|
||
t.Errorf("affected_users = %d, want 2", audit.AffectedUsers)
|
||
}
|
||
if audit.Action != dbq.LidarrQuarantineActionResolved {
|
||
t.Errorf("action = %v, want resolved", audit.Action)
|
||
}
|
||
// No more rows for this track.
|
||
n, _ := dbq.New(pool).CountQuarantineForTrack(context.Background(), track.ID)
|
||
if n != 0 {
|
||
t.Errorf("rows after resolve = %d, want 0", n)
|
||
}
|
||
}
|
||
|
||
func TestDeleteFile_RemovesFileAndAuditsAffected(t *testing.T) {
|
||
pool := newPool(t)
|
||
user := seedUser(t, pool, "alice")
|
||
// Real on-disk file:
|
||
dir := t.TempDir()
|
||
path := filepath.Join(dir, "track.mp3")
|
||
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
q := dbq.New(pool)
|
||
artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "A", SortName: "A"})
|
||
album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "Al", SortTitle: "Al", ArtistID: artist.ID})
|
||
track, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
||
Title: "T", AlbumID: album.ID, ArtistID: artist.ID,
|
||
DurationMs: 1000, FilePath: path, FileSize: 1, FileFormat: "mp3",
|
||
})
|
||
|
||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
||
_, _ = svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "")
|
||
|
||
audit, err := svc.DeleteFile(context.Background(), track.ID, user.ID)
|
||
if err != nil {
|
||
t.Fatalf("DeleteFile: %v", err)
|
||
}
|
||
if audit.AffectedUsers != 1 {
|
||
t.Errorf("affected_users = %d, want 1", audit.AffectedUsers)
|
||
}
|
||
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||
t.Errorf("file still exists: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestDeleteViaLidarr_FullCascade(t *testing.T) {
|
||
pool := newPool(t)
|
||
user := seedUser(t, pool, "alice")
|
||
|
||
// Stub Lidarr server.
|
||
var captured []string
|
||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
captured = append(captured, r.Method+" "+r.URL.Path+"?"+r.URL.RawQuery)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
if r.URL.Path == "/api/v1/album" && r.Method == http.MethodGet {
|
||
_, _ = w.Write([]byte(`[{"id":42,"foreignAlbumId":"al-mbid","title":"Al","artistId":7}]`))
|
||
return
|
||
}
|
||
// DELETE /api/v1/album/42 -> 200.
|
||
w.WriteHeader(http.StatusOK)
|
||
}))
|
||
t.Cleanup(stub.Close)
|
||
|
||
cfg := lidarrconfig.New(pool)
|
||
if err := cfg.Save(context.Background(), lidarrconfig.Config{
|
||
Enabled: true, BaseURL: stub.URL, APIKey: "k",
|
||
}); err != nil {
|
||
t.Fatalf("save config: %v", err)
|
||
}
|
||
clientFn := func() *lidarr.Client { return lidarr.NewClient(stub.URL, "k") }
|
||
svc := NewService(pool, cfg, clientFn)
|
||
|
||
// Seed a track on an album whose mbid we'll match in the stub.
|
||
q := dbq.New(pool)
|
||
artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "A", SortName: "A"})
|
||
albumMBID := "al-mbid"
|
||
album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
|
||
Title: "Al", SortTitle: "Al", ArtistID: artist.ID, Mbid: &albumMBID,
|
||
})
|
||
dir := t.TempDir()
|
||
path := filepath.Join(dir, "T.mp3")
|
||
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
track, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
||
Title: "T", AlbumID: album.ID, ArtistID: artist.ID,
|
||
DurationMs: 1000, FilePath: path, FileSize: 1, FileFormat: "mp3",
|
||
})
|
||
_, _ = svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "")
|
||
|
||
audit, deleted, err := svc.DeleteViaLidarr(context.Background(), track.ID, user.ID)
|
||
if err != nil {
|
||
t.Fatalf("DeleteViaLidarr: %v", err)
|
||
}
|
||
if deleted != 1 {
|
||
t.Errorf("deleted = %d, want 1 track removed", deleted)
|
||
}
|
||
if audit.Action != dbq.LidarrQuarantineActionDeletedViaLidarr {
|
||
t.Errorf("action = %v", audit.Action)
|
||
}
|
||
if audit.AffectedUsers != 1 {
|
||
t.Errorf("affected_users = %d, want 1", audit.AffectedUsers)
|
||
}
|
||
if audit.LidarrAlbumMbid == nil || *audit.LidarrAlbumMbid != "al-mbid" {
|
||
t.Errorf("lidarr_album_mbid = %v", audit.LidarrAlbumMbid)
|
||
}
|
||
// Track row is gone (and so is the per-user quarantine row, via cascade).
|
||
if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil {
|
||
t.Errorf("track row still exists")
|
||
}
|
||
// Verify Lidarr was called with both flags true.
|
||
foundDelete := false
|
||
for _, c := range captured {
|
||
if c == "DELETE /api/v1/album/42?addImportListExclusion=true&deleteFiles=true" ||
|
||
c == "DELETE /api/v1/album/42?deleteFiles=true&addImportListExclusion=true" {
|
||
foundDelete = true
|
||
}
|
||
}
|
||
if !foundDelete {
|
||
t.Errorf("Lidarr DELETE not called with both flags true; captured = %v", captured)
|
||
}
|
||
}
|
||
|
||
func TestDeleteViaLidarr_LidarrDisabled(t *testing.T) {
|
||
pool := newPool(t)
|
||
user := seedUser(t, pool, "alice")
|
||
track, _, _ := seedTrack(t, pool, "T", "x")
|
||
|
||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
||
_, _, err := svc.DeleteViaLidarr(context.Background(), track.ID, user.ID)
|
||
if !errors.Is(err, ErrLidarrDisabled) {
|
||
t.Errorf("err = %v, want ErrLidarrDisabled", err)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5.5: Run + commit**
|
||
|
||
```bash
|
||
docker run --rm --network minstrel_minstrel \
|
||
-v "$(pwd):/src" -w /src \
|
||
-e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \
|
||
golang:1.23-bookworm \
|
||
go test -race ./internal/lidarrquarantine/...
|
||
git add internal/lidarrquarantine/
|
||
git commit -m "feat(lidarrquarantine): admin actions Resolve/DeleteFile/DeleteViaLidarr"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6 — Soft-hide query updates
|
||
|
||
**Files:**
|
||
- Modify: `internal/db/queries/tracks.sql` — add `*ForUser` variants
|
||
- Modify: `internal/db/queries/recommendation.sql` — extend existing radio queries
|
||
- Regenerate: `internal/db/dbq/`
|
||
|
||
The four affected queries are `ListTracksByAlbum`, `SearchTracks`, `CountTracksMatching`, and the radio loaders. The album/artist views are read through the existing handlers — adding `*ForUser` variants that take `user_id` lets the handlers route based on auth context.
|
||
|
||
- [ ] **Step 6.1: Add `ListTracksByAlbumForUser` to `tracks.sql`**
|
||
|
||
Append to `internal/db/queries/tracks.sql`:
|
||
|
||
```sql
|
||
-- name: ListTracksByAlbumForUser :many
|
||
-- Same as ListTracksByAlbum but excludes tracks the user has quarantined.
|
||
SELECT * FROM tracks
|
||
WHERE album_id = $1
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM lidarr_quarantine q
|
||
WHERE q.user_id = $2 AND q.track_id = tracks.id
|
||
)
|
||
ORDER BY disc_number NULLS LAST, track_number NULLS LAST;
|
||
|
||
-- name: SearchTracksForUser :many
|
||
SELECT * FROM tracks
|
||
WHERE title ILIKE '%' || $1 || '%'
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM lidarr_quarantine q
|
||
WHERE q.user_id = $2 AND q.track_id = tracks.id
|
||
)
|
||
ORDER BY title
|
||
LIMIT $3 OFFSET $4;
|
||
|
||
-- name: CountTracksMatchingForUser :one
|
||
SELECT COUNT(*) FROM tracks
|
||
WHERE title ILIKE '%' || $1::text || '%'
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM lidarr_quarantine q
|
||
WHERE q.user_id = $2 AND q.track_id = tracks.id
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 6.2: Extend the radio loaders**
|
||
|
||
Modify `internal/db/queries/recommendation.sql`. For each `LoadRadioCandidates*` query, add a quarantine clause to the `WHERE` block. The user_id is already a parameter on these queries (`$1`); the additional clause is:
|
||
|
||
```sql
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM lidarr_quarantine q
|
||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||
)
|
||
```
|
||
|
||
For `LoadRadioCandidates`:
|
||
|
||
```sql
|
||
WHERE t.id <> $2
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM play_events
|
||
WHERE user_id = $1 AND track_id = t.id
|
||
AND started_at > now() - $3 * interval '1 hour'
|
||
)
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM lidarr_quarantine q
|
||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||
);
|
||
```
|
||
|
||
For `LoadRadioCandidatesV2`, add the same clause inside the final `WHERE` of the union output (look for the comment "5-way UNION" — add the clause to the outer WHERE that filters the unioned candidates by `excluded_ids` etc.).
|
||
|
||
- [ ] **Step 6.3: Regenerate sqlc + build**
|
||
|
||
```bash
|
||
cd internal/db && sqlc generate && cd -
|
||
go build ./...
|
||
```
|
||
|
||
Expected: clean build. New methods `ListTracksByAlbumForUser`, `SearchTracksForUser`, `CountTracksMatchingForUser` appear in `internal/db/dbq/tracks.sql.go`.
|
||
|
||
- [ ] **Step 6.4: Commit**
|
||
|
||
```bash
|
||
git add internal/db/queries/ internal/db/dbq/
|
||
git commit -m "feat(db): add user-context track query variants honoring quarantine"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7 — Wire soft-hide into existing read handlers
|
||
|
||
**Files:** Modify existing handlers under `internal/api/` to call the `*ForUser` queries when an authenticated user is in context.
|
||
|
||
The pattern: where the handler currently calls (e.g.) `q.ListTracksByAlbum(ctx, albumID)`, switch to `q.ListTracksByAlbumForUser(ctx, dbq.ListTracksByAlbumForUserParams{AlbumID: albumID, UserID: user.ID})` when `user, ok := auth.UserFromContext(r.Context()); ok` is true. The `else` branch keeps the unfiltered query for any path without a user context (Subsonic, internal callers).
|
||
|
||
- [ ] **Step 7.1: Identify call sites**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
grep -rn "ListTracksByAlbum\|SearchTracks\|CountTracksMatching\|LoadRadioCandidates" \
|
||
internal/api/ internal/subsonic/
|
||
```
|
||
|
||
For every call in `internal/api/`, branch on `auth.UserFromContext`. For every call in `internal/subsonic/`, leave it alone (Subsonic is `/rest/*` and doesn't honor quarantine per the legacy memory).
|
||
|
||
- [ ] **Step 7.2: Pattern to apply**
|
||
|
||
Example for the album-detail handler:
|
||
|
||
```go
|
||
func (h *handlers) handleAlbumDetail(w http.ResponseWriter, r *http.Request) {
|
||
// ... existing parse + lookup ...
|
||
var tracks []dbq.Track
|
||
if user, ok := auth.UserFromContext(r.Context()); ok {
|
||
tracks, err = q.ListTracksByAlbumForUser(r.Context(), dbq.ListTracksByAlbumForUserParams{
|
||
AlbumID: albumID, UserID: user.ID,
|
||
})
|
||
} else {
|
||
tracks, err = q.ListTracksByAlbum(r.Context(), albumID)
|
||
}
|
||
// ... existing error + response ...
|
||
}
|
||
```
|
||
|
||
Repeat for search and radio handlers. The radio handlers already take `user_id` for personalization — the schema change in Task 6 just extended their existing query, so those handlers don't need restructuring.
|
||
|
||
- [ ] **Step 7.3: Regression-test the existing endpoints**
|
||
|
||
```bash
|
||
go test ./internal/api/... -count=1
|
||
docker run --rm --network minstrel_minstrel \
|
||
-v "$(pwd):/src" -w /src \
|
||
-e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \
|
||
golang:1.23-bookworm \
|
||
go test -race -p 1 ./internal/api/...
|
||
```
|
||
|
||
Expected: existing tests still pass — they don't seed any quarantine rows, so the filter is a no-op.
|
||
|
||
- [ ] **Step 7.4: Commit**
|
||
|
||
```bash
|
||
git add internal/api/
|
||
git commit -m "feat(api): route track-list reads through user-context quarantine filter"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8 — `/api/quarantine/*` user-facing handlers
|
||
|
||
**Files:**
|
||
- Create: `internal/api/quarantine.go`
|
||
- Create: `internal/api/quarantine_test.go`
|
||
- Modify: `internal/api/api.go` to mount the new routes
|
||
|
||
Three endpoints: `POST /api/quarantine`, `DELETE /api/quarantine/:track_id`, `GET /api/quarantine/mine`. Mirror M5a's `internal/api/requests.go` for the auth + writeJSON conventions.
|
||
|
||
- [ ] **Step 8.1: Write `quarantine.go`**
|
||
|
||
```go
|
||
package api
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
||
)
|
||
|
||
type quarantineView struct {
|
||
UserID pgtype.UUID `json:"user_id"`
|
||
TrackID pgtype.UUID `json:"track_id"`
|
||
Reason string `json:"reason"`
|
||
Notes *string `json:"notes,omitempty"`
|
||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||
}
|
||
|
||
func quarantineViewFrom(row dbq.LidarrQuarantine) quarantineView {
|
||
return quarantineView{
|
||
UserID: row.UserID, TrackID: row.TrackID,
|
||
Reason: string(row.Reason), Notes: row.Notes, CreatedAt: row.CreatedAt,
|
||
}
|
||
}
|
||
|
||
type flagBody struct {
|
||
TrackID pgtype.UUID `json:"track_id"`
|
||
Reason string `json:"reason"`
|
||
Notes string `json:"notes"`
|
||
}
|
||
|
||
func (h *handlers) handleFlag(w http.ResponseWriter, r *http.Request) {
|
||
user, ok := auth.UserFromContext(r.Context())
|
||
if !ok {
|
||
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||
return
|
||
}
|
||
var body flagBody
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
|
||
return
|
||
}
|
||
row, err := h.lidarrQuarantine.Flag(r.Context(), user.ID, body.TrackID, body.Reason, body.Notes)
|
||
if err != nil {
|
||
switch {
|
||
case errors.Is(err, lidarrquarantine.ErrBadReason):
|
||
writeErr(w, http.StatusBadRequest, "bad_reason", err.Error())
|
||
case errors.Is(err, lidarrquarantine.ErrTrackNotFound):
|
||
writeErr(w, http.StatusNotFound, "track_not_found", "track does not exist")
|
||
default:
|
||
h.logger.Error("api: flag", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "flag failed")
|
||
}
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusCreated, quarantineViewFrom(row))
|
||
}
|
||
|
||
func (h *handlers) handleUnflag(w http.ResponseWriter, r *http.Request) {
|
||
user, ok := auth.UserFromContext(r.Context())
|
||
if !ok {
|
||
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||
return
|
||
}
|
||
id, ok := parseUUID(chi.URLParam(r, "track_id"))
|
||
if !ok {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id")
|
||
return
|
||
}
|
||
if err := h.lidarrQuarantine.Unflag(r.Context(), user.ID, id); err != nil {
|
||
if errors.Is(err, lidarrquarantine.ErrQuarantineNotFound) {
|
||
writeErr(w, http.StatusNotFound, "quarantine_not_found", "no quarantine for that track")
|
||
return
|
||
}
|
||
h.logger.Error("api: unflag", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "unflag failed")
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// quarantineMineView wraps the joined row for /api/quarantine/mine. The
|
||
// SPA on /library/hidden needs the full track + album + artist payload.
|
||
type quarantineMineView struct {
|
||
quarantineView
|
||
Track dbq.Track `json:"track"`
|
||
Album dbq.Album `json:"album"`
|
||
Artist dbq.Artist `json:"artist"`
|
||
}
|
||
|
||
func (h *handlers) handleListMyQuarantine(w http.ResponseWriter, r *http.Request) {
|
||
user, ok := auth.UserFromContext(r.Context())
|
||
if !ok {
|
||
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||
return
|
||
}
|
||
rows, err := h.lidarrQuarantine.ListMine(r.Context(), user.ID)
|
||
if err != nil {
|
||
h.logger.Error("api: list mine", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
|
||
return
|
||
}
|
||
out := make([]quarantineMineView, 0, len(rows))
|
||
for _, row := range rows {
|
||
out = append(out, quarantineMineView{
|
||
quarantineView: quarantineViewFrom(row.LidarrQuarantine),
|
||
Track: row.Track,
|
||
Album: row.Album,
|
||
Artist: row.Artist,
|
||
})
|
||
}
|
||
writeJSON(w, http.StatusOK, out)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8.2: Mount routes in `api.go`**
|
||
|
||
Inside the existing authenticated route group, add:
|
||
|
||
```go
|
||
r.Post("/api/quarantine", h.handleFlag)
|
||
r.Delete("/api/quarantine/{track_id}", h.handleUnflag)
|
||
r.Get("/api/quarantine/mine", h.handleListMyQuarantine)
|
||
```
|
||
|
||
- [ ] **Step 8.3: Add `lidarrQuarantine` to the handlers struct**
|
||
|
||
Find the `handlers` struct (in `internal/api/api.go` or wherever it lives) and add:
|
||
|
||
```go
|
||
lidarrQuarantine *lidarrquarantine.Service
|
||
```
|
||
|
||
Then update the constructor / wiring to accept it.
|
||
|
||
- [ ] **Step 8.4: Write tests**
|
||
|
||
`internal/api/quarantine_test.go` mirrors the M5a `requests_test.go` shape: stub handlers, real DB via `MINSTREL_TEST_DATABASE_URL`, table-driven scenarios. Cover:
|
||
- Flag with valid reason → 201, row visible in `ListMine`.
|
||
- Flag with `bad_reason` → 400, `error.code === "bad_reason"`.
|
||
- Flag for a track that doesn't exist → 404 `track_not_found`.
|
||
- Unflag happy path → 204.
|
||
- Unflag for a row that doesn't exist → 404 `quarantine_not_found`.
|
||
- ListMine with two flags → returns two rows, newest first.
|
||
- Unauthenticated requests → 401 across the board (the existing `RequireUser` middleware handles this; one assertion is enough).
|
||
|
||
- [ ] **Step 8.5: Commit**
|
||
|
||
```bash
|
||
go test ./internal/api/... -run TestFlag -count=1
|
||
git add internal/api/quarantine.go internal/api/quarantine_test.go internal/api/api.go
|
||
git commit -m "feat(api): /api/quarantine user-facing CRUD"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9 — `/api/admin/quarantine/*` admin handlers
|
||
|
||
**Files:**
|
||
- Create: `internal/api/admin_quarantine.go`
|
||
- Create: `internal/api/admin_quarantine_test.go`
|
||
- Modify: `internal/api/api.go` to mount under the existing `/api/admin/*` route group
|
||
|
||
Five endpoints: GET queue, GET actions, three POST resolution actions. Reuse the `RequireAdmin` middleware from M5a.
|
||
|
||
- [ ] **Step 9.1: Write `admin_quarantine.go`**
|
||
|
||
```go
|
||
package api
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"strconv"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
||
)
|
||
|
||
// adminQueueRowView is the wire shape returned by GET /api/admin/quarantine.
|
||
type adminQueueRowView struct {
|
||
TrackID string `json:"track_id"`
|
||
TrackTitle string `json:"track_title"`
|
||
ArtistName string `json:"artist_name"`
|
||
AlbumTitle *string `json:"album_title,omitempty"`
|
||
AlbumID string `json:"album_id"`
|
||
LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"`
|
||
ReportCount int32 `json:"report_count"`
|
||
LatestAt string `json:"latest_at"`
|
||
ReasonCounts map[string]int `json:"reason_counts"`
|
||
Reports []adminQueueReportView `json:"reports"`
|
||
}
|
||
|
||
type adminQueueReportView struct {
|
||
UserID string `json:"user_id"`
|
||
Username string `json:"username"`
|
||
Reason string `json:"reason"`
|
||
Notes *string `json:"notes,omitempty"`
|
||
CreatedAt string `json:"created_at"`
|
||
}
|
||
|
||
func (h *handlers) handleListAdminQuarantine(w http.ResponseWriter, r *http.Request) {
|
||
rows, err := h.lidarrQuarantine.ListAdminQueue(r.Context())
|
||
if err != nil {
|
||
h.logger.Error("admin: list quarantine", "err", err)
|
||
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||
return
|
||
}
|
||
out := make([]adminQueueRowView, 0, len(rows))
|
||
for _, r := range rows {
|
||
reports := make([]adminQueueReportView, 0, len(r.Reports))
|
||
for _, rep := range r.Reports {
|
||
reports = append(reports, adminQueueReportView{
|
||
UserID: uuidToString(rep.UserID),
|
||
Username: rep.Username,
|
||
Reason: rep.Reason,
|
||
Notes: rep.Notes,
|
||
CreatedAt: rep.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
|
||
})
|
||
}
|
||
out = append(out, adminQueueRowView{
|
||
TrackID: uuidToString(r.TrackID), TrackTitle: r.TrackTitle,
|
||
ArtistName: r.ArtistName, AlbumTitle: r.AlbumTitle,
|
||
AlbumID: uuidToString(r.AlbumID), LidarrAlbumMBID: r.LidarrAlbumMBID,
|
||
ReportCount: r.ReportCount,
|
||
LatestAt: r.LatestAt.Time.Format("2006-01-02T15:04:05Z07:00"),
|
||
ReasonCounts: r.ReasonCounts, Reports: reports,
|
||
})
|
||
}
|
||
writeJSON(w, http.StatusOK, out)
|
||
}
|
||
|
||
type actionResultView struct {
|
||
ActionID string `json:"action_id"`
|
||
AffectedUsers int32 `json:"affected_users"`
|
||
DeletedTrackCount *int `json:"deleted_track_count,omitempty"`
|
||
}
|
||
|
||
func (h *handlers) handleResolveQuarantine(w http.ResponseWriter, r *http.Request) {
|
||
admin, _ := auth.UserFromContext(r.Context())
|
||
id, ok := parseUUID(chi.URLParam(r, "track_id"))
|
||
if !ok {
|
||
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
|
||
return
|
||
}
|
||
action, err := h.lidarrQuarantine.Resolve(r.Context(), id, admin.ID)
|
||
if err != nil {
|
||
if errors.Is(err, lidarrquarantine.ErrTrackNotFound) {
|
||
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
|
||
return
|
||
}
|
||
h.logger.Error("admin: resolve quarantine", "err", err)
|
||
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, actionResultView{
|
||
ActionID: uuidToString(action.ID), AffectedUsers: action.AffectedUsers,
|
||
})
|
||
}
|
||
|
||
func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Request) {
|
||
admin, _ := auth.UserFromContext(r.Context())
|
||
id, ok := parseUUID(chi.URLParam(r, "track_id"))
|
||
if !ok {
|
||
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
|
||
return
|
||
}
|
||
action, err := h.lidarrQuarantine.DeleteFile(r.Context(), id, admin.ID)
|
||
if err != nil {
|
||
switch {
|
||
case errors.Is(err, lidarrquarantine.ErrTrackNotFound):
|
||
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
|
||
default:
|
||
h.logger.Error("admin: delete file", "err", err)
|
||
writeAdminJSONErr(w, http.StatusInternalServerError, "file_delete_failed")
|
||
}
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, actionResultView{
|
||
ActionID: uuidToString(action.ID), AffectedUsers: action.AffectedUsers,
|
||
})
|
||
}
|
||
|
||
func (h *handlers) handleDeleteQuarantineViaLidarr(w http.ResponseWriter, r *http.Request) {
|
||
admin, _ := auth.UserFromContext(r.Context())
|
||
id, ok := parseUUID(chi.URLParam(r, "track_id"))
|
||
if !ok {
|
||
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
|
||
return
|
||
}
|
||
action, deleted, err := h.lidarrQuarantine.DeleteViaLidarr(r.Context(), id, admin.ID)
|
||
if err != nil {
|
||
switch {
|
||
case errors.Is(err, lidarrquarantine.ErrLidarrDisabled):
|
||
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_disabled")
|
||
case errors.Is(err, lidarrquarantine.ErrTrackNotFound):
|
||
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
|
||
case errors.Is(err, lidarrquarantine.ErrAlbumMBIDMissing):
|
||
writeAdminJSONErr(w, http.StatusNotFound, "album_mbid_missing")
|
||
case errors.Is(err, lidarrquarantine.ErrLidarrAlbumNotFound):
|
||
writeAdminJSONErr(w, http.StatusBadGateway, "lidarr_album_lookup_failed")
|
||
case errors.Is(err, lidarr.ErrUnreachable):
|
||
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_unreachable")
|
||
case errors.Is(err, lidarr.ErrAuthFailed):
|
||
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_auth_failed")
|
||
default:
|
||
h.logger.Error("admin: delete via lidarr", "err", err)
|
||
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||
}
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, actionResultView{
|
||
ActionID: uuidToString(action.ID), AffectedUsers: action.AffectedUsers,
|
||
DeletedTrackCount: &deleted,
|
||
})
|
||
}
|
||
|
||
type actionLogView struct {
|
||
ID string `json:"id"`
|
||
TrackID string `json:"track_id"`
|
||
TrackTitle string `json:"track_title"`
|
||
ArtistName string `json:"artist_name"`
|
||
AlbumTitle *string `json:"album_title,omitempty"`
|
||
Action string `json:"action"`
|
||
AdminID *string `json:"admin_id,omitempty"`
|
||
LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"`
|
||
AffectedUsers int32 `json:"affected_users"`
|
||
CreatedAt string `json:"created_at"`
|
||
}
|
||
|
||
func (h *handlers) handleListQuarantineActions(w http.ResponseWriter, r *http.Request) {
|
||
limitStr := r.URL.Query().Get("limit")
|
||
limit := int32(50)
|
||
if limitStr != "" {
|
||
if v, err := strconv.Atoi(limitStr); err == nil && v > 0 && v <= 200 {
|
||
limit = int32(v)
|
||
}
|
||
}
|
||
rows, err := dbq.New(h.pool).ListQuarantineActions(r.Context(), limit)
|
||
if err != nil {
|
||
h.logger.Error("admin: list actions", "err", err)
|
||
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||
return
|
||
}
|
||
out := make([]actionLogView, 0, len(rows))
|
||
for _, row := range rows {
|
||
var adminID *string
|
||
if row.AdminID.Valid {
|
||
s := uuidToString(row.AdminID)
|
||
adminID = &s
|
||
}
|
||
out = append(out, actionLogView{
|
||
ID: uuidToString(row.ID), TrackID: uuidToString(row.TrackID),
|
||
TrackTitle: row.TrackTitle, ArtistName: row.ArtistName, AlbumTitle: row.AlbumTitle,
|
||
Action: string(row.Action), AdminID: adminID,
|
||
LidarrAlbumMBID: row.LidarrAlbumMbid, AffectedUsers: row.AffectedUsers,
|
||
CreatedAt: row.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
|
||
})
|
||
}
|
||
writeJSON(w, http.StatusOK, out)
|
||
}
|
||
```
|
||
|
||
`uuidToString` and `parseUUID` are existing helpers in `internal/api/`. Reuse them.
|
||
|
||
- [ ] **Step 9.2: Mount routes**
|
||
|
||
Inside the `/api/admin` route group:
|
||
|
||
```go
|
||
r.Get("/api/admin/quarantine", h.handleListAdminQuarantine)
|
||
r.Post("/api/admin/quarantine/{track_id}/resolve", h.handleResolveQuarantine)
|
||
r.Post("/api/admin/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile)
|
||
r.Post("/api/admin/quarantine/{track_id}/delete-via-lidarr", h.handleDeleteQuarantineViaLidarr)
|
||
r.Get("/api/admin/quarantine/actions", h.handleListQuarantineActions)
|
||
```
|
||
|
||
- [ ] **Step 9.3: Tests (`internal/api/admin_quarantine_test.go`)**
|
||
|
||
Mirror M5a's `internal/api/admin_requests_test.go`. Cover:
|
||
- Aggregated queue shape: 3 users × 1 track yields 1 row with `report_count=3`, correct `reason_counts`.
|
||
- Resolve clears rows, returns 200 with `affected_users`.
|
||
- Delete file: file vanishes from disk, row gone, audit row written.
|
||
- Delete via Lidarr with stub server: lookup → DELETE → row removed; happy path 200 with `deleted_track_count`.
|
||
- Delete via Lidarr with `lidarr_disabled` config → 503 `lidarr_disabled`.
|
||
- Delete via Lidarr with stub returning empty array on lookup → 502 `lidarr_album_lookup_failed`.
|
||
- Delete via Lidarr on a track with no album MBID → 404 `album_mbid_missing`.
|
||
- Non-admin user → 403 across all admin endpoints.
|
||
- Action log GET returns rows ordered newest-first.
|
||
|
||
- [ ] **Step 9.4: Commit**
|
||
|
||
```bash
|
||
docker run --rm --network minstrel_minstrel -v "$(pwd):/src" -w /src \
|
||
-e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \
|
||
golang:1.23-bookworm go test -race -p 1 ./internal/api/... -run AdminQuarantine
|
||
git add internal/api/admin_quarantine.go internal/api/admin_quarantine_test.go internal/api/api.go
|
||
git commit -m "feat(api): /api/admin/quarantine queue + resolve/delete-file/delete-via-lidarr"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10 — Wire `Service` into `cmd/minstrel/main.go`
|
||
|
||
**Files:** Modify `cmd/minstrel/main.go`.
|
||
|
||
The handlers struct (Task 8 step 8.3) now expects a `*lidarrquarantine.Service`. Construct it at startup and pass it through.
|
||
|
||
- [ ] **Step 10.1: Add the construction**
|
||
|
||
Find where the M5a `lidarrrequests.Service` is constructed in `main.go`. Right after it, add:
|
||
|
||
```go
|
||
quarSvc := lidarrquarantine.NewService(pool, lidarrCfg, func() *lidarr.Client {
|
||
cfg, err := lidarrCfg.Get(ctx)
|
||
if err != nil || !cfg.Enabled {
|
||
return nil
|
||
}
|
||
return lidarr.NewClient(cfg.BaseURL, cfg.APIKey)
|
||
})
|
||
```
|
||
|
||
If a similar `clientFn` already exists for `lidarrrequests`, reuse it instead of duplicating the closure. Pass `quarSvc` into the handlers constructor.
|
||
|
||
- [ ] **Step 10.2: Build + smoke test**
|
||
|
||
```bash
|
||
go build ./cmd/minstrel
|
||
go vet ./...
|
||
golangci-lint run ./...
|
||
```
|
||
|
||
Expected: clean build, no lint warnings. If `golangci-lint` isn't installed, skip.
|
||
|
||
- [ ] **Step 10.3: Commit**
|
||
|
||
```bash
|
||
git add cmd/minstrel/main.go internal/api/api.go
|
||
git commit -m "feat(cmd): wire lidarrquarantine.Service into the API handlers"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11 — Frontend: API client modules + types
|
||
|
||
**Files:** Create `web/src/lib/api/quarantine.ts` + `quarantine.test.ts`. Modify `web/src/lib/api/types.ts`, `queries.ts`, `admin.ts`. Mirror the existing M5a pattern from `requests.ts` / `admin.ts`.
|
||
|
||
- [ ] **Step 11.1: Add types to `types.ts`**
|
||
|
||
```ts
|
||
export type LidarrQuarantineReason = 'bad_rip' | 'wrong_file' | 'wrong_tags' | 'duplicate' | 'other';
|
||
|
||
export type LidarrQuarantineRow = {
|
||
user_id: string;
|
||
track_id: string;
|
||
reason: LidarrQuarantineReason;
|
||
notes?: string | null;
|
||
created_at: string;
|
||
};
|
||
|
||
export type LidarrQuarantineMineRow = LidarrQuarantineRow & {
|
||
track: TrackRef;
|
||
album: AlbumRef;
|
||
artist: ArtistRef;
|
||
};
|
||
|
||
export type AdminQuarantineRow = {
|
||
track_id: string;
|
||
track_title: string;
|
||
artist_name: string;
|
||
album_title?: string | null;
|
||
album_id: string;
|
||
lidarr_album_mbid?: string | null;
|
||
report_count: number;
|
||
latest_at: string;
|
||
reason_counts: Record<LidarrQuarantineReason, number>;
|
||
reports: AdminQuarantineReport[];
|
||
};
|
||
|
||
export type AdminQuarantineReport = {
|
||
user_id: string;
|
||
username: string;
|
||
reason: LidarrQuarantineReason;
|
||
notes?: string | null;
|
||
created_at: string;
|
||
};
|
||
|
||
export type LidarrQuarantineAction = 'resolved' | 'deleted_file' | 'deleted_via_lidarr';
|
||
|
||
export type LidarrQuarantineActionRow = {
|
||
id: string;
|
||
track_id: string;
|
||
track_title: string;
|
||
artist_name: string;
|
||
album_title?: string | null;
|
||
action: LidarrQuarantineAction;
|
||
admin_id?: string | null;
|
||
lidarr_album_mbid?: string | null;
|
||
affected_users: number;
|
||
created_at: string;
|
||
};
|
||
|
||
export type ActionResult = {
|
||
action_id: string;
|
||
affected_users: number;
|
||
deleted_track_count?: number;
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 11.2: Add query keys to `queries.ts`**
|
||
|
||
```ts
|
||
qk.myQuarantine = () => ['myQuarantine'] as const;
|
||
qk.adminQuarantine = () => ['adminQuarantine'] as const;
|
||
qk.adminQuarantineActions = (limit?: number) => ['adminQuarantineActions', { limit: limit ?? 50 }] as const;
|
||
```
|
||
|
||
(Keep the existing `qk` object syntax — append the new functions.)
|
||
|
||
- [ ] **Step 11.3: Write `quarantine.ts`**
|
||
|
||
```ts
|
||
import { createQuery } from '@tanstack/svelte-query';
|
||
import { api, apiFetch } from './client';
|
||
import { qk } from './queries';
|
||
import type {
|
||
LidarrQuarantineRow,
|
||
LidarrQuarantineMineRow,
|
||
LidarrQuarantineReason
|
||
} from './types';
|
||
|
||
export type FlagParams = {
|
||
track_id: string;
|
||
reason: LidarrQuarantineReason;
|
||
notes?: string;
|
||
};
|
||
|
||
export async function flagTrack(params: FlagParams): Promise<LidarrQuarantineRow> {
|
||
const body: FlagParams = { track_id: params.track_id, reason: params.reason };
|
||
if (params.notes && params.notes.length > 0) body.notes = params.notes;
|
||
return api.post<LidarrQuarantineRow>('/api/quarantine', body);
|
||
}
|
||
|
||
// Server returns 204 (no body) for DELETE; handle accordingly.
|
||
export async function unflagTrack(trackID: string): Promise<void> {
|
||
await apiFetch(`/api/quarantine/${trackID}`, { method: 'DELETE' });
|
||
}
|
||
|
||
export async function listMyQuarantine(): Promise<LidarrQuarantineMineRow[]> {
|
||
return api.get<LidarrQuarantineMineRow[]>('/api/quarantine/mine');
|
||
}
|
||
|
||
export function createMyQuarantineQuery() {
|
||
return createQuery({
|
||
queryKey: qk.myQuarantine(),
|
||
queryFn: listMyQuarantine,
|
||
staleTime: 60_000
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 11.4: Append admin endpoints to `admin.ts`**
|
||
|
||
```ts
|
||
import type { AdminQuarantineRow, ActionResult, LidarrQuarantineActionRow } from './types';
|
||
|
||
export async function listAdminQuarantine(): Promise<AdminQuarantineRow[]> {
|
||
return api.get<AdminQuarantineRow[]>('/api/admin/quarantine');
|
||
}
|
||
|
||
export async function resolveQuarantine(trackID: string): Promise<ActionResult> {
|
||
return api.post<ActionResult>(`/api/admin/quarantine/${trackID}/resolve`, {});
|
||
}
|
||
|
||
export async function deleteQuarantineFile(trackID: string): Promise<ActionResult> {
|
||
return api.post<ActionResult>(`/api/admin/quarantine/${trackID}/delete-file`, {});
|
||
}
|
||
|
||
export async function deleteQuarantineViaLidarr(trackID: string): Promise<ActionResult> {
|
||
return api.post<ActionResult>(`/api/admin/quarantine/${trackID}/delete-via-lidarr`, {});
|
||
}
|
||
|
||
export async function listQuarantineActions(limit = 50): Promise<LidarrQuarantineActionRow[]> {
|
||
return api.get<LidarrQuarantineActionRow[]>(`/api/admin/quarantine/actions?limit=${limit}`);
|
||
}
|
||
|
||
export function createAdminQuarantineQuery() {
|
||
return createQuery({
|
||
queryKey: qk.adminQuarantine(),
|
||
queryFn: listAdminQuarantine,
|
||
staleTime: 30_000 // queue should refresh more aggressively than my-history
|
||
});
|
||
}
|
||
|
||
export function createQuarantineActionsQuery(limit = 50) {
|
||
return createQuery({
|
||
queryKey: qk.adminQuarantineActions(limit),
|
||
queryFn: () => listQuarantineActions(limit),
|
||
staleTime: 60_000
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 11.5: Tests**
|
||
|
||
`quarantine.test.ts` — mirror `requests.test.ts` shape: `vi.mock('./client')`, assert URL + body shapes, return-value flow-through. Cover flagTrack (with + without notes), unflagTrack, listMyQuarantine, query factory, qk shape.
|
||
|
||
For `admin.ts`: extend the existing test file to cover the five new functions + their query factories.
|
||
|
||
- [ ] **Step 11.6: Run + commit**
|
||
|
||
```bash
|
||
cd web && npm run check && npm test -- --run && cd -
|
||
git add web/src/lib/api/
|
||
git commit -m "feat(web): API client modules for quarantine + admin quarantine"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12 — Frontend: `<TrackMenu>` + `<FlagPopover>` components
|
||
|
||
**Files:** Create `web/src/lib/components/TrackMenu.svelte`, `TrackMenu.test.ts`, `FlagPopover.svelte`, `FlagPopover.test.ts`.
|
||
|
||
`<TrackMenu>` is a kebab button + dropdown menu. For M5b it has one item: "Flag this track…". Component is structured so future actions slot in alongside.
|
||
|
||
`<FlagPopover>` is the reason form, opened from TrackMenu. Pre-fills if the user already has a quarantine on this track.
|
||
|
||
- [ ] **Step 12.1: Write `TrackMenu.svelte`**
|
||
|
||
```svelte
|
||
<script lang="ts">
|
||
import { MoreVertical, Flag } from 'lucide-svelte';
|
||
import FlagPopover from './FlagPopover.svelte';
|
||
import type { TrackRef } from '$lib/api/types';
|
||
|
||
let { track }: { track: TrackRef } = $props();
|
||
|
||
let menuOpen = $state(false);
|
||
let popoverOpen = $state(false);
|
||
|
||
function toggleMenu(e: MouseEvent) {
|
||
e.stopPropagation();
|
||
menuOpen = !menuOpen;
|
||
}
|
||
|
||
function openFlag() {
|
||
menuOpen = false;
|
||
popoverOpen = true;
|
||
}
|
||
|
||
function closeAll() {
|
||
menuOpen = false;
|
||
popoverOpen = false;
|
||
}
|
||
</script>
|
||
|
||
<svelte:window
|
||
onclick={() => (menuOpen = false)}
|
||
onkeydown={(e) => e.key === 'Escape' && closeAll()}
|
||
/>
|
||
|
||
<div class="relative inline-block">
|
||
<button
|
||
type="button"
|
||
aria-label={`Track actions for ${track.title}`}
|
||
aria-haspopup="menu"
|
||
aria-expanded={menuOpen}
|
||
onclick={toggleMenu}
|
||
class="rounded p-1 text-text-muted hover:text-text-primary"
|
||
>
|
||
<MoreVertical size={16} strokeWidth={1} />
|
||
</button>
|
||
|
||
{#if menuOpen}
|
||
<div
|
||
role="menu"
|
||
class="absolute right-0 z-20 mt-1 w-48 rounded-md border border-border bg-surface p-1 shadow-lg"
|
||
onclick={(e) => e.stopPropagation()}
|
||
onkeydown={(e) => e.stopPropagation()}
|
||
>
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
onclick={openFlag}
|
||
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm text-text-primary hover:bg-surface-hover"
|
||
>
|
||
<Flag size={14} strokeWidth={1} />
|
||
Flag this track…
|
||
</button>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if popoverOpen}
|
||
<FlagPopover {track} onClose={closeAll} />
|
||
{/if}
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 12.2: Write `FlagPopover.svelte`**
|
||
|
||
```svelte
|
||
<script lang="ts">
|
||
import { Flag } from 'lucide-svelte';
|
||
import { useQueryClient } from '@tanstack/svelte-query';
|
||
import { flagTrack } from '$lib/api/quarantine';
|
||
import { qk } from '$lib/api/queries';
|
||
import type { TrackRef, LidarrQuarantineReason } from '$lib/api/types';
|
||
|
||
let {
|
||
track,
|
||
onClose,
|
||
initialReason,
|
||
initialNotes,
|
||
}: {
|
||
track: TrackRef;
|
||
onClose: () => void;
|
||
initialReason?: LidarrQuarantineReason;
|
||
initialNotes?: string;
|
||
} = $props();
|
||
|
||
let reason: LidarrQuarantineReason = $state(initialReason ?? 'bad_rip');
|
||
let notes = $state(initialNotes ?? '');
|
||
let submitting = $state(false);
|
||
let error = $state<string | null>(null);
|
||
|
||
const isUpdate = !!initialReason;
|
||
const client = useQueryClient();
|
||
|
||
async function submit() {
|
||
submitting = true;
|
||
error = null;
|
||
try {
|
||
await flagTrack({ track_id: track.id, reason, notes: notes.trim() || undefined });
|
||
await client.invalidateQueries({ queryKey: qk.myQuarantine() });
|
||
onClose();
|
||
} catch (e) {
|
||
error = (e as { code?: string }).code ?? 'flag_failed';
|
||
} finally {
|
||
submitting = false;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<!-- Anchored popover. Parent wrapper provides the relative-positioning context. -->
|
||
<div
|
||
role="dialog"
|
||
aria-modal="false"
|
||
aria-labelledby="flag-popover-title"
|
||
class="absolute right-0 z-30 mt-1 w-72 rounded-lg border border-border bg-surface p-3 shadow-xl"
|
||
onclick={(e) => e.stopPropagation()}
|
||
onkeydown={(e) => e.key === 'Escape' && onClose()}
|
||
>
|
||
<h4 id="flag-popover-title" class="text-sm font-medium text-text-primary">
|
||
Flag this track as broken
|
||
</h4>
|
||
<label class="mt-2 block">
|
||
<span class="block text-xs text-text-secondary">Reason</span>
|
||
<select
|
||
bind:value={reason}
|
||
class="mt-1 w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
|
||
>
|
||
<option value="bad_rip">Bad rip</option>
|
||
<option value="wrong_file">Wrong file</option>
|
||
<option value="wrong_tags">Wrong tags</option>
|
||
<option value="duplicate">Duplicate</option>
|
||
<option value="other">Other</option>
|
||
</select>
|
||
</label>
|
||
<label class="mt-2 block">
|
||
<span class="block text-xs text-text-secondary">Notes (optional)</span>
|
||
<textarea
|
||
bind:value={notes}
|
||
maxlength="200"
|
||
placeholder="What's wrong with it?"
|
||
class="mt-1 w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent"
|
||
rows="2"
|
||
></textarea>
|
||
</label>
|
||
{#if error}
|
||
<p class="mt-2 text-xs text-error">Couldn't save flag — {error}</p>
|
||
{/if}
|
||
<div class="mt-3 flex justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
class="rounded-md border border-border px-2.5 py-1 text-sm text-text-secondary hover:text-text-primary"
|
||
onclick={onClose}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onclick={submit}
|
||
disabled={submitting}
|
||
class="inline-flex items-center gap-1 rounded-md bg-action-secondary px-2.5 py-1 text-sm text-text-primary disabled:opacity-50"
|
||
>
|
||
<Flag size={14} strokeWidth={1} />
|
||
{isUpdate ? 'Update flag' : 'Flag'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 12.3: Tests**
|
||
|
||
`TrackMenu.test.ts`:
|
||
- Click kebab → menu visible.
|
||
- Click outside → menu closes.
|
||
- Escape → menu closes.
|
||
- Click "Flag this track…" → popover opens.
|
||
|
||
`FlagPopover.test.ts`:
|
||
- Defaults to reason=`bad_rip` when no initialReason.
|
||
- Pre-fills when initialReason+initialNotes are provided; button reads "Update flag".
|
||
- Submit calls `flagTrack` (mocked) with the typed reason + non-empty notes; empty notes are NOT sent.
|
||
- Cancel calls onClose; does not call flagTrack.
|
||
- Submit calls `invalidateQueries` on success.
|
||
|
||
For `flagTrack` mock pattern, copy from `web/src/routes/admin/integrations/integrations.test.ts` (the `vi.mock('$lib/api/admin', ...)` shape).
|
||
|
||
- [ ] **Step 12.4: Run + commit**
|
||
|
||
```bash
|
||
cd web && npm run check && npm test -- --run TrackMenu FlagPopover && cd -
|
||
git add web/src/lib/components/TrackMenu.svelte web/src/lib/components/TrackMenu.test.ts \
|
||
web/src/lib/components/FlagPopover.svelte web/src/lib/components/FlagPopover.test.ts
|
||
git commit -m "feat(web): TrackMenu overflow + FlagPopover for the quarantine flow"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13 — Mount `<TrackMenu>` in `TrackRow` + `PlayerBar`
|
||
|
||
**Files:** Modify `TrackRow.svelte`, `TrackRow.test.ts`, `PlayerBar.svelte`, `PlayerBar.test.ts`.
|
||
|
||
- [ ] **Step 13.1: TrackRow**
|
||
|
||
Add `<TrackMenu>` as a sibling to `<LikeButton>` in the row's right cluster:
|
||
|
||
```svelte
|
||
<script lang="ts">
|
||
// ... existing imports ...
|
||
import TrackMenu from './TrackMenu.svelte';
|
||
</script>
|
||
|
||
<!-- existing markup ... right cluster: -->
|
||
<LikeButton entityType="track" entityId={track.id} />
|
||
<TrackMenu {track} />
|
||
```
|
||
|
||
- [ ] **Step 13.2: PlayerBar**
|
||
|
||
Same: add the `<TrackMenu>` to the right cluster, after the like button. The `track` prop is the currently-playing track from the player store (e.g. `player.current`).
|
||
|
||
```svelte
|
||
{#if player.current}
|
||
<LikeButton entityType="track" entityId={player.current.id} />
|
||
<TrackMenu track={player.current} />
|
||
{/if}
|
||
```
|
||
|
||
- [ ] **Step 13.3: Update tests**
|
||
|
||
Both `TrackRow.test.ts` and `PlayerBar.test.ts` — add an assertion that the track-actions kebab is rendered. Existing tests (like-button presence, play-on-click) should still pass.
|
||
|
||
- [ ] **Step 13.4: Run + commit**
|
||
|
||
```bash
|
||
cd web && npm test -- --run TrackRow PlayerBar && cd -
|
||
git add web/src/lib/components/TrackRow.svelte web/src/lib/components/TrackRow.test.ts \
|
||
web/src/lib/components/PlayerBar.svelte web/src/lib/components/PlayerBar.test.ts
|
||
git commit -m "feat(web): mount TrackMenu in TrackRow + PlayerBar"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14 — Frontend: `/library/hidden` route
|
||
|
||
**Files:** Create `web/src/routes/library/hidden/+page.svelte` + `hidden.test.ts`. Modify `Shell.svelte` to add `Hidden` to the main nav (between `Liked` and `Search`).
|
||
|
||
- [ ] **Step 14.1: Write the page**
|
||
|
||
Pattern matches `/requests` exactly — same row anatomy. Use `createMyQuarantineQuery()` for data, `unflagTrack(trackID)` + `invalidateQueries(qk.myQuarantine())` for the un-hide affordance. Empty state copy: "Nothing hidden yet."
|
||
|
||
Each row (mirroring `/requests`):
|
||
- 56px album art with Lucide `Music2` fallback.
|
||
- Pills: kind ("Track", accent-tint), reason (`bad_rip` etc., accent-tint).
|
||
- Title (Parchment) + meta line "by `<artist>` · `<album>` · flagged 2d ago".
|
||
- Notes (Vellum, italic) — only when present.
|
||
- Action: Un-hide (Pewter ghost + Lucide `RotateCcw`). One click — no confirmation. Optimistic remove.
|
||
|
||
Header: H2 "Hidden" (Fraunces 24/500) + subtitle "Tracks you've flagged as broken."
|
||
|
||
- [ ] **Step 14.2: Update Shell**
|
||
|
||
Add to `navItems`:
|
||
|
||
```ts
|
||
{ href: '/library/hidden', label: 'Hidden' }
|
||
```
|
||
|
||
Position: after `Liked`, before `Search`. Update `Shell.test.ts` to assert the new link's order.
|
||
|
||
- [ ] **Step 14.3: Tests**
|
||
|
||
`hidden.test.ts`:
|
||
- Renders one row per quarantine.
|
||
- Un-hide click calls `unflagTrack` + invalidates query.
|
||
- Empty state shows "Nothing hidden yet."
|
||
- Notes render (italic) when present, absent otherwise.
|
||
|
||
- [ ] **Step 14.4: Commit**
|
||
|
||
```bash
|
||
cd web && npm run check && npm test -- --run hidden && cd -
|
||
git add web/src/routes/library/hidden/ web/src/lib/components/Shell.svelte web/src/lib/components/Shell.test.ts
|
||
git commit -m "feat(web): /library/hidden user-facing quarantine view"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 15 — Frontend: `/admin/quarantine` route + sidebar promotion
|
||
|
||
**Files:** Create `web/src/routes/admin/quarantine/+page.svelte` + `quarantine.test.ts`. Modify `AdminSidebar.svelte` (promote Quarantine from placeholder), `AdminSidebar.test.ts`.
|
||
|
||
- [ ] **Step 15.1: Promote sidebar item**
|
||
|
||
In `AdminSidebar.svelte`, change:
|
||
|
||
```diff
|
||
-{ href: '/admin/quarantine', label: 'Quarantine', icon: ShieldX, placeholder: true }
|
||
+{ href: '/admin/quarantine', label: 'Quarantine', icon: ShieldX }
|
||
```
|
||
|
||
Update `AdminSidebar.test.ts`:
|
||
- Replace the placeholder-treatment assertion for Quarantine.
|
||
- Add an assertion that Quarantine renders as a real `<a>` link.
|
||
- Add an assertion that `/admin/quarantine` activates Quarantine in the sidebar.
|
||
|
||
- [ ] **Step 15.2: Write the page**
|
||
|
||
Page layout (matches the design-system spec from §6 of the spec):
|
||
|
||
- Header: H2 "Quarantine" + accent-tint count pill when `report_count > 0`.
|
||
- Empty state: "Nothing to triage right now."
|
||
- Aggregated rows: 56px art · title + meta · reason-distribution pills · expandable per-user reports · inline play button (accent-colored — brand moment) · action cluster (Resolve / Delete file / Delete via Lidarr).
|
||
- Modal-confirm for Delete file.
|
||
- Typed-confirm modal for Delete via Lidarr (matches M5a Disconnect pattern; trim equality on "DELETE").
|
||
- Dimmed Delete-via-Lidarr button when `lidarr_album_mbid` is null, with `title` attribute "Local-only track — no Lidarr album to remove."
|
||
- Lidarr-unreachable error → toast (reuse the `errorCopy` helper pattern from `/admin/requests`).
|
||
|
||
Use `createAdminQuarantineQuery()` for data. On mutation success, `invalidateQueries({ queryKey: qk.adminQuarantine() })`.
|
||
|
||
Use the existing player's `enqueueTrack` (or `playRadio` — pick the closest-fit existing action) for the inline Play button.
|
||
|
||
- [ ] **Step 15.3: Tests (`quarantine.test.ts`)**
|
||
|
||
Cover:
|
||
- Aggregated rows render with reason distribution.
|
||
- Expand caret reveals per-user reports.
|
||
- Resolve fires `resolveQuarantine` + invalidates.
|
||
- Delete file → modal-confirm → fires `deleteQuarantineFile`.
|
||
- Delete via Lidarr → typed-confirm modal "DELETE" → fires `deleteQuarantineViaLidarr`.
|
||
- Lidarr-unreachable error → toast renders with the spec copy ("Lidarr is unreachable…").
|
||
- Dimmed Delete-via-Lidarr when MBID missing.
|
||
- Inline play button calls the player.
|
||
- Empty state copy.
|
||
|
||
- [ ] **Step 15.4: Commit**
|
||
|
||
```bash
|
||
cd web && npm run check && npm test -- --run admin/quarantine && cd -
|
||
git add web/src/routes/admin/quarantine/ web/src/lib/components/AdminSidebar.svelte web/src/lib/components/AdminSidebar.test.ts
|
||
git commit -m "feat(web): /admin/quarantine aggregated queue with resolution actions"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 16 — Final verification + branch finish
|
||
|
||
- [ ] **Step 16.1: Full Go test sweep**
|
||
|
||
```bash
|
||
go test -short -race ./...
|
||
docker run --rm --network minstrel_minstrel \
|
||
-v "$(pwd):/src" -w /src \
|
||
-e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \
|
||
golang:1.23-bookworm \
|
||
go test -race -p 1 ./...
|
||
```
|
||
|
||
Expected: short suite + integration suite both green. The pre-existing `internal/library/TestScanner_Integration` flake is documented and not blocked on (per `project_scanner_flake.md`).
|
||
|
||
- [ ] **Step 16.2: Lint clean**
|
||
|
||
```bash
|
||
golangci-lint run ./...
|
||
```
|
||
|
||
- [ ] **Step 16.3: Coverage check**
|
||
|
||
```bash
|
||
docker run --rm --network minstrel_minstrel \
|
||
-v "$(pwd):/src" -w /src \
|
||
-e MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@postgres:5432/minstrel?sslmode=disable' \
|
||
golang:1.23-bookworm \
|
||
bash -c 'go test -race -coverprofile=/tmp/cov.out \
|
||
./internal/lidarr/... ./internal/lidarrquarantine/... \
|
||
./internal/library/... && go tool cover -func=/tmp/cov.out | tail -1'
|
||
```
|
||
|
||
Expected: combined ≥ 80%, per spec §8. Both `internal/lidarr/` and `internal/lidarrquarantine/` should individually clear 80%.
|
||
|
||
- [ ] **Step 16.4: Frontend full check**
|
||
|
||
```bash
|
||
cd web && npm run check && npm test -- --run && npm run build
|
||
```
|
||
|
||
Expected: 0 errors, all vitest tests pass, build succeeds.
|
||
|
||
- [ ] **Step 16.5: Manual smoke**
|
||
|
||
- Configure Lidarr in `/admin/integrations` (or use the M5a-saved config).
|
||
- Flag a track from the now-playing bar → confirm it disappears from the album page.
|
||
- Confirm `/library/hidden` shows the flagged track.
|
||
- Un-hide → track returns to album page.
|
||
- Re-flag from a different user (admin user A flags as `bad_rip`, then user B flags same track as `wrong_tags`).
|
||
- Open `/admin/quarantine` → aggregated row shows count=2, distribution `1× bad_rip, 1× wrong_tags`.
|
||
- Click Play → track plays in the player.
|
||
- Click Resolve → row clears for both users.
|
||
- Re-flag, then click Delete file → file gone from disk, row gone from queue.
|
||
- Re-flag a track on a Lidarr-managed album → click Delete via Lidarr → typed-confirm "DELETE" → confirm Lidarr received DELETE call (check Lidarr's UI/logs).
|
||
- Verify non-admin user redirected when navigating to `/admin/quarantine`.
|
||
|
||
- [ ] **Step 16.6: Use `superpowers:finishing-a-development-branch`**
|
||
|
||
Verify tests are still green, then run the skill to present finish options (merge / PR / keep / discard). Default for this slice is "create a PR to main" matching the established cadence (per `project_git_workflow` memory).
|
||
|
||
---
|
||
|
||
## Self-review checklist (run before declaring the plan ready)
|
||
|
||
**Spec coverage** — every spec section maps to a task:
|
||
- §3 Architecture: Tasks 2 (client extensions), 3 (library), 4-5 (Service), 7 (soft-hide enforcement), 10 (wiring)
|
||
- §4 Schema: Task 1
|
||
- §5 API surface: Tasks 8 (user), 9 (admin)
|
||
- §6 UI surfaces: Tasks 12 (TrackMenu+FlagPopover), 13 (mount), 14 (/library/hidden), 15 (/admin/quarantine + sidebar)
|
||
- §7 Error handling: distributed across Tasks 8, 9 (handler error mapping); Service layer (Tasks 4, 5) defines the typed errors
|
||
- §8 Testing: every Task includes tests; Task 16 verifies coverage targets
|
||
- §9 Decisions ledger: not directly implemented but referenced in commit messages
|
||
- §10 Out of scope: explicitly excluded — no album/artist quarantine, no bulk operations, no auto-resolve, no Subsonic honoring
|
||
- §11 Open questions: position of `/library/hidden` in nav (Task 14, between Liked and Search per the spec); dimmed-with-tooltip for missing MBID (Task 15)
|
||
|
||
**Placeholder scan:** the per-task detail level drops after Task 9 (frontend tasks become 1-2 paragraphs) — intentional for navigability. Tasks 14 and 15 reference established patterns from M5a (`/requests` page anatomy, M5a typed-confirm modal for Disconnect) without re-stating the full code. No "TBD" or "TODO" remains.
|
||
|
||
**Type consistency:**
|
||
- Service method names: `Flag`, `Unflag`, `ListMine`, `ListAdminQueue`, `Resolve`, `DeleteFile`, `DeleteViaLidarr` — consistent across plan
|
||
- Error names: `ErrBadReason`, `ErrTrackNotFound`, `ErrQuarantineNotFound`, `ErrAlbumMBIDMissing`, `ErrLidarrAlbumNotFound`, `ErrLidarrDisabled` — consistent
|
||
- Lidarr client method names: `LookupArtistByMBID`, `LookupAlbumByMBID`, `DeleteAlbum` — consistent
|
||
- API paths match spec §5
|
||
- Component names: `<TrackMenu>`, `<FlagPopover>`, `<QuarantineRow>` (mentioned in file map but not separately tasked — bake into Tasks 14 & 15 as inline JSX)
|
||
- DB column names: `lidarr_quarantine.{user_id,track_id,reason,notes,created_at}`, `lidarr_quarantine_actions.{id,track_id,track_title,artist_name,album_title,action,admin_id,lidarr_album_mbid,affected_users,created_at}` — consistent
|
||
|
||
Plan is complete.
|