diff --git a/README.md b/README.md
index 5e00f972..db06f14b 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Minstrel
-A self-hosted music server that thinks for you. Smart shuffle, contextual likes, ListenBrainz-aware radio, and Lidarr automation — server-side, so every client (web, mobile, Subsonic third-party) gets the same intelligence.
+A self-hosted music server that thinks for you. Smart shuffle, contextual likes, ListenBrainz-aware radio, and a wanted list it fetches for you — server-side, so every client (web, mobile, Subsonic third-party) gets the same intelligence.
> State and intelligence belong on the server, not the client.
@@ -11,21 +11,21 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
- **OpenSubsonic-compatible.** Existing Subsonic clients (DSub, Symfonium, play:Sub, etc.) connect with no special configuration.
- **Server-side smart shuffle.** Track-similarity vectors, dual-like model (general + contextual), and session memory keep mixes coherent across devices.
- **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag.
-- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit — against a Lidarr instance *you* run and configure. Optional, and off until you supply a URL and API key.
+- **Finds, downloads and files music itself.** A wanted list — requests, followed artists, albums gone missing — searched through indexers *you* configure and downloaded by a client *you* run. Each download is checked against MusicBrainz, tagged, and filed into your library, and a better copy replaces a poor one. Optional, and off until you add a search service and a download client.
- **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy.
- **Native Android client, shipped with the server.** The signed APK is bundled into every image and attached to each [release](https://git.fabledsword.com/bvandeusen/minstrel/releases) — sideload it once, then the app self-updates straight from your own server (no app store, no separate download to track).
## Scope and responsible use
-**Minstrel serves music you already have.** It is a library server: it indexes files on disk you point it at, and streams them to your own clients. It does not source, search for, or acquire content, and it has no opinion about where your files came from.
+**Minstrel is a library server.** It indexes files on disk you point it at, and streams them to your own clients.
-Concretely, Minstrel ships **no** indexers, **no** trackers, **no** torrent / Usenet / NZB client, and **no** DRM circumvention of any kind. There is nothing to point at a content source because Minstrel has no such subsystem.
+Minstrel ships **no** indexers, **no** trackers, **no** torrent / Usenet / NZB engine of its own, and **no** DRM circumvention of any kind.
-The **Lidarr integration is optional and inert until you configure it.** You supply the URL and API key of a Lidarr instance you are already running; Minstrel then calls that instance's API to trigger scans, submit album requests, and reconcile imports. Minstrel neither bundles nor installs Lidarr, and configures no indexers on your behalf — Lidarr ships with none either, and any it uses are ones you added yourself.
+**Acquisition is optional and inert until you configure it.** You add the search service (a Prowlarr instance or a Torznab indexer) and the download client (qBittorrent) you already run; Minstrel then searches those for the albums on its wanted list, hands the release it picks to your client, and checks and files what arrives. It configures no sources on your behalf: any it uses are ones you added yourself.
-**What you put in your library, and what sources you configure in your own Lidarr, are your responsibility.** Copyright law applies to your collection the same way it applies to any other software that plays a file. Please respect it, and respect the terms of any service you connect.
+**What you put in your library, and what sources you configure, are your responsibility.** Copyright law applies to your collection the same way it applies to any other software that plays a file. Please respect it, and respect the terms of any service you connect.
-Minstrel is not affiliated with or endorsed by Lidarr, ListenBrainz, MusicBrainz, or Subsonic.
+Minstrel is not affiliated with or endorsed by ListenBrainz, MusicBrainz, Prowlarr, qBittorrent, or Subsonic.
## Quickstart
@@ -91,9 +91,9 @@ With the stack up, a handful of in-app steps get you to a working library. Use y
Couldn't read Lidarr's {LIST_NAMES[key]}. {said(testResult.error)} Not saved: {saveError}
- This clears the saved connection. Type
- DISCONNECT to go ahead.
- Not disconnected: {disconnectError}
- Kept {basename(item.details.survivor_path ?? '')}
- {#if item.details.survivor_reason}
- ({item.details.survivor_reason})
- {/if}
- and removed {fileCountLabel(removed.length)}.
- {r.file_path}
- Lidarr now monitors {item.details.to_release ?? 'another release'} of
- {item.details.album_title ?? 'an album'}{item.details.artist_name
- ? ` by ${item.details.artist_name}`
- : ''}, which lists each song once.
- Was {item.details.from_release}.
+ Kept {basename(item.details.survivor_path ?? '')}
+ {#if item.details.survivor_reason}
+ ({item.details.survivor_reason})
{/if}
- {/if}
+ and removed {fileCountLabel(removed.length)}.
+ {r.file_path} {relativeTime(item.created_at)}
-**3. (Optional) Name the instance and wire up integrations.** In admin **Settings → Integrations** (`/admin/integrations`), add a ListenBrainz token (scrobbling + similarity radio) and/or a Lidarr URL + API key (the request flow). These live in the UI and apply without a restart; the display name can also be set via `MINSTREL_BRANDING_APP_NAME`.
+**3. (Optional) Set up acquisition and connections.** In admin **Acquisition** (`/admin/acquisition`), add a search service and a download client so Minstrel can fetch what's wanted; each person adds their own ListenBrainz token (scrobbling + similarity radio) under **Settings → Connections** (`/settings/connections`). These live in the UI and apply without a restart; the display name can also be set via `MINSTREL_BRANDING_APP_NAME`.
-
+
**4. Install the Android app.** Open **Settings** (`/settings`) and use the *Install the Android app* card to download the APK that ships inside this server image, then sign in with the same account. From then on the app self-updates straight from your server.
@@ -115,7 +115,7 @@ Most operators only need the env vars in the quickstart above. A few extras wort
- `MINSTREL_STORAGE_DATA_DIR` — where generated artefacts (playlist cover collages, artist art, caches) are written. The container image sets this to `/app/data`, which is why the quickstart mounts the `minstrel-data` volume there.
- `MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`).
-ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
+ListenBrainz (per-user scrobble + similarity tokens) and acquisition (search services and download clients) are configured in the UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
Most operational keys have a `MINSTREL_"} JSON response. This
-// mirrors the shape the RequireAdmin middleware uses and that the spec
-// defines for /api/admin/* errors.
-func writeAdminJSONErr(w http.ResponseWriter, status int, code string) {
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(status)
- _ = json.NewEncoder(w).Encode(map[string]string{"error": code})
-}
diff --git a/internal/api/admin_lidarr_test.go b/internal/api/admin_lidarr_test.go
deleted file mode 100644
index 5db71172..00000000
--- a/internal/api/admin_lidarr_test.go
+++ /dev/null
@@ -1,455 +0,0 @@
-package api
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/go-chi/chi/v5"
-
- "git.fabledsword.com/bvandeusen/minstrel/internal/auth"
- "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
- "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
-)
-
-// newAdminLidarrRouter builds a test chi router with the five admin/lidarr
-// endpoints. RequireAdmin middleware is applied. Tests inject a user into
-// context directly (skipping RequireUser).
-func newAdminLidarrRouter(h *handlers) chi.Router {
- r := chi.NewRouter()
- r.Route("/api/admin", func(admin chi.Router) {
- admin.Use(auth.RequireAdmin())
- admin.Get("/lidarr/config", h.handleGetLidarrConfig)
- admin.Put("/lidarr/config", h.handlePutLidarrConfig)
- admin.Post("/lidarr/test", h.handleTestLidarrConnection)
- admin.Get("/lidarr/quality-profiles", h.handleListQualityProfiles)
- admin.Get("/lidarr/root-folders", h.handleListRootFolders)
- })
- return r
-}
-
-// seedAdminUser creates a test user with is_admin=true.
-func seedAdminUser(t *testing.T, h *handlers) dbq.User {
- t.Helper()
- return seedUser(t, h.pool, "admin", "pw", true)
-}
-
-// doAdminReq fires an HTTP request at the admin router with the given user
-// injected into context (bypassing RequireUser).
-func doAdminReq(t *testing.T, h *handlers, method, path string, body []byte, user dbq.User) *httptest.ResponseRecorder {
- t.Helper()
- var reqBody *bytes.Buffer
- if body != nil {
- reqBody = bytes.NewBuffer(body)
- } else {
- reqBody = bytes.NewBuffer(nil)
- }
- req := httptest.NewRequest(method, path, reqBody)
- if body != nil {
- req.Header.Set("Content-Type", "application/json")
- }
- req = withUser(req, user)
- w := httptest.NewRecorder()
- newAdminLidarrRouter(h).ServeHTTP(w, req)
- return w
-}
-
-// saveLidarrConfigFull writes a lidarr config with the given key directly
-// (for admin tests that need precise api_key control).
-func saveLidarrConfigFull(t *testing.T, h *handlers, cfg lidarrconfig.Config) {
- t.Helper()
- svc := lidarrconfig.New(h.pool)
- if err := svc.Save(context.Background(), cfg); err != nil {
- t.Fatalf("saveLidarrConfigFull: %v", err)
- }
-}
-
-// TestHandleGetLidarrConfig_MasksAPIKey verifies that a set api_key is
-// returned as "***" and never in plaintext.
-func TestHandleGetLidarrConfig_MasksAPIKey(t *testing.T) {
- h, _ := testHandlers(t)
- resetLidarrState(t, h)
- admin := seedAdminUser(t, h)
-
- saveLidarrConfigFull(t, h, lidarrconfig.Config{
- Enabled: true,
- BaseURL: "http://lidarr.lan:8686",
- APIKey: "secret123",
- })
-
- w := doAdminReq(t, h, http.MethodGet, "/api/admin/lidarr/config", nil, admin)
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
- }
- var resp lidarrConfigView
- if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("decode: %v; body = %s", err, w.Body.String())
- }
- if resp.APIKey == "secret123" {
- t.Error("api_key leaked in response; want ***")
- }
- if resp.APIKey != "***" {
- t.Errorf("api_key = %q, want ***", resp.APIKey)
- }
-}
-
-// TestHandleGetLidarrConfig_EmptyKeyReturnsEmpty verifies that when api_key is
-// unset in the DB, GET returns api_key="" (not "***").
-func TestHandleGetLidarrConfig_EmptyKeyReturnsEmpty(t *testing.T) {
- h, _ := testHandlers(t)
- resetLidarrState(t, h)
- admin := seedAdminUser(t, h)
-
- // Save config with empty api_key (null in DB via lidarrconfig.Service).
- saveLidarrConfigFull(t, h, lidarrconfig.Config{
- Enabled: false,
- BaseURL: "",
- APIKey: "",
- })
-
- w := doAdminReq(t, h, http.MethodGet, "/api/admin/lidarr/config", nil, admin)
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
- }
- var resp lidarrConfigView
- if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("decode: %v; body = %s", err, w.Body.String())
- }
- if resp.APIKey != "" {
- t.Errorf("api_key = %q, want empty string", resp.APIKey)
- }
-}
-
-// TestHandlePutLidarrConfig_EmptyKeyPreservesSaved verifies that a PUT with
-// api_key="" does not overwrite the saved api_key.
-func TestHandlePutLidarrConfig_EmptyKeyPreservesSaved(t *testing.T) {
- h, _ := testHandlers(t)
- resetLidarrState(t, h)
- admin := seedAdminUser(t, h)
-
- // Seed a config with a known api_key.
- saveLidarrConfigFull(t, h, lidarrconfig.Config{
- Enabled: true,
- BaseURL: "http://lidarr.lan:8686",
- APIKey: "originalkey",
- })
-
- // PUT with empty api_key — should preserve "originalkey". enabled=true
- // requires the defaults gate (missing_defaults) to be satisfied.
- body := []byte(`{"enabled":true,"base_url":"http://lidarr.lan:8686","api_key":"","default_quality_profile_id":1,"default_metadata_profile_id":1,"default_root_folder_path":"/music"}`)
- w := doAdminReq(t, h, http.MethodPut, "/api/admin/lidarr/config", body, admin)
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
- }
-
- // Confirm the saved api_key is still "originalkey" by reading raw config.
- svc := lidarrconfig.New(h.pool)
- saved, err := svc.Get(context.Background())
- if err != nil {
- t.Fatalf("get saved config: %v", err)
- }
- if saved.APIKey != "originalkey" {
- t.Errorf("saved api_key = %q, want originalkey", saved.APIKey)
- }
-}
-
-// TestHandlePutLidarrConfig_EnabledRequiresBaseURL_400 verifies that enabling
-// Lidarr without a base_url returns 400 with missing_required_field.
-func TestHandlePutLidarrConfig_EnabledRequiresBaseURL_400(t *testing.T) {
- h, _ := testHandlers(t)
- resetLidarrState(t, h)
- admin := seedAdminUser(t, h)
-
- body := []byte(`{"enabled":true,"base_url":"","api_key":"somekey"}`)
- w := doAdminReq(t, h, http.MethodPut, "/api/admin/lidarr/config", body, admin)
- if w.Code != http.StatusBadRequest {
- t.Fatalf("status = %d, want 400; body = %s", w.Code, w.Body.String())
- }
- var resp map[string]string
- if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("decode: %v; body = %s", err, w.Body.String())
- }
- if resp["error"] != "missing_required_field" {
- t.Errorf("error = %q, want missing_required_field", resp["error"])
- }
-}
-
-// TestHandlePutLidarrConfig_HappyPath verifies a valid PUT returns 200 with
-// the updated config and the api_key masked.
-func TestHandlePutLidarrConfig_HappyPath(t *testing.T) {
- h, _ := testHandlers(t)
- resetLidarrState(t, h)
- admin := seedAdminUser(t, h)
-
- body := []byte(`{"enabled":true,"base_url":"http://lidarr.lan:8686","api_key":"newkey","default_quality_profile_id":2,"default_metadata_profile_id":1,"default_root_folder_path":"/music"}`)
- w := doAdminReq(t, h, http.MethodPut, "/api/admin/lidarr/config", body, admin)
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
- }
- var resp lidarrConfigView
- if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("decode: %v; body = %s", err, w.Body.String())
- }
- if !resp.Enabled {
- t.Error("enabled = false, want true")
- }
- if resp.BaseURL != "http://lidarr.lan:8686" {
- t.Errorf("base_url = %q, want http://lidarr.lan:8686", resp.BaseURL)
- }
- if resp.APIKey != "***" {
- t.Errorf("api_key = %q, want ***", resp.APIKey)
- }
- if resp.DefaultQualityProfileID != 2 {
- t.Errorf("default_quality_profile_id = %d, want 2", resp.DefaultQualityProfileID)
- }
- if resp.DefaultRootFolderPath != "/music" {
- t.Errorf("default_root_folder_path = %q, want /music", resp.DefaultRootFolderPath)
- }
-}
-
-// TestHandleTestLidarrConnection_HappyPath verifies that POST /test against a
-// stub returning valid status JSON responds 200 with ok=true and the version.
-func TestHandleTestLidarrConnection_HappyPath(t *testing.T) {
- h, _ := testHandlers(t)
- resetLidarrState(t, h)
- admin := seedAdminUser(t, h)
-
- stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- switch r.URL.Path {
- case "/api/v1/system/status":
- _, _ = w.Write([]byte(`{"version":"2.0.5"}`))
- case "/api/v1/qualityprofile":
- _, _ = w.Write([]byte(`[{"id":1,"name":"FLAC"},{"id":2,"name":"MP3-320"}]`))
- case "/api/v1/metadataprofile":
- _, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`))
- case "/api/v1/rootfolder":
- _, _ = w.Write([]byte(`[{"path":"/music","accessible":true,"freeSpace":12345}]`))
- default:
- http.NotFound(w, r)
- }
- }))
- t.Cleanup(stub.Close)
-
- saveLidarrConfigFull(t, h, lidarrconfig.Config{
- Enabled: true,
- BaseURL: stub.URL,
- APIKey: "test-key",
- })
-
- // POST with empty body — falls back to saved config.
- w := doAdminReq(t, h, http.MethodPost, "/api/admin/lidarr/test", []byte(`{}`), admin)
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
- }
- var resp map[string]any
- if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("decode: %v; body = %s", err, w.Body.String())
- }
- if resp["ok"] != true {
- t.Errorf("ok = %v, want true", resp["ok"])
- }
- if resp["version"] != "2.0.5" {
- t.Errorf("version = %v, want 2.0.5", resp["version"])
- }
- qp, ok := resp["quality_profiles"].([]any)
- if !ok || len(qp) != 2 {
- t.Errorf("quality_profiles = %v, want 2 entries", resp["quality_profiles"])
- }
- mp, ok := resp["metadata_profiles"].([]any)
- if !ok || len(mp) != 1 {
- t.Errorf("metadata_profiles = %v, want 1 entry", resp["metadata_profiles"])
- }
- rf, ok := resp["root_folders"].([]any)
- if !ok || len(rf) != 1 {
- t.Errorf("root_folders = %v, want 1 entry", resp["root_folders"])
- }
- if _, present := resp["list_errors"]; present {
- t.Errorf("list_errors should be omitted when all lists succeed; got %v", resp["list_errors"])
- }
-}
-
-// TestHandleTestLidarrConnection_PartialListFailure verifies that POST /test
-// against a Lidarr that responds OK to the ping + two of three lists still
-// reports ok=true with the failing list omitted and named in list_errors.
-func TestHandleTestLidarrConnection_PartialListFailure(t *testing.T) {
- h, _ := testHandlers(t)
- resetLidarrState(t, h)
- admin := seedAdminUser(t, h)
-
- stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- switch r.URL.Path {
- case "/api/v1/system/status":
- _, _ = w.Write([]byte(`{"version":"2.0.5"}`))
- case "/api/v1/qualityprofile":
- _, _ = w.Write([]byte(`[{"id":1,"name":"FLAC"}]`))
- case "/api/v1/metadataprofile":
- http.Error(w, "boom", http.StatusInternalServerError)
- case "/api/v1/rootfolder":
- _, _ = w.Write([]byte(`[{"path":"/music","accessible":true,"freeSpace":12345}]`))
- default:
- http.NotFound(w, r)
- }
- }))
- t.Cleanup(stub.Close)
-
- body := []byte(`{"base_url":"` + stub.URL + `","api_key":"k"}`)
- w := doAdminReq(t, h, http.MethodPost, "/api/admin/lidarr/test", body, admin)
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
- }
- var resp map[string]any
- if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("decode: %v; body = %s", err, w.Body.String())
- }
- if resp["ok"] != true {
- t.Errorf("ok = %v, want true", resp["ok"])
- }
- if _, present := resp["metadata_profiles"]; present {
- t.Errorf("metadata_profiles should be omitted on failure; got %v", resp["metadata_profiles"])
- }
- listErrs, ok := resp["list_errors"].(map[string]any)
- if !ok {
- t.Fatalf("list_errors missing or wrong shape: %v", resp["list_errors"])
- }
- if _, present := listErrs["metadata_profiles"]; !present {
- t.Errorf("list_errors.metadata_profiles should be present, got %v", listErrs)
- }
-}
-
-// TestHandleTestLidarrConnection_Unreachable verifies that POST /test against
-// an unreachable URL returns 200 with ok=false and lidarr_unreachable.
-func TestHandleTestLidarrConnection_Unreachable(t *testing.T) {
- h, _ := testHandlers(t)
- resetLidarrState(t, h)
- admin := seedAdminUser(t, h)
-
- // Point at a URL that will immediately refuse connections.
- body := []byte(`{"base_url":"http://127.0.0.1:1","api_key":"k"}`)
- w := doAdminReq(t, h, http.MethodPost, "/api/admin/lidarr/test", body, admin)
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
- }
- var resp map[string]any
- if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("decode: %v; body = %s", err, w.Body.String())
- }
- if resp["ok"] != false {
- t.Errorf("ok = %v, want false", resp["ok"])
- }
- if resp["error"] != "lidarr_unreachable" {
- t.Errorf("error = %v, want lidarr_unreachable", resp["error"])
- }
-}
-
-// TestHandleListQualityProfiles_HappyPath verifies that GET /quality-profiles
-// proxies and normalizes the Lidarr response.
-func TestHandleListQualityProfiles_HappyPath(t *testing.T) {
- h, _ := testHandlers(t)
- resetLidarrState(t, h)
- admin := seedAdminUser(t, h)
-
- stub := newLidarrStub(t, `[{"id":1,"name":"Lossless"},{"id":2,"name":"Standard"}]`)
- saveLidarrConfigFull(t, h, lidarrconfig.Config{
- Enabled: true,
- BaseURL: stub.URL,
- APIKey: "test-key",
- })
-
- w := doAdminReq(t, h, http.MethodGet, "/api/admin/lidarr/quality-profiles", nil, admin)
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
- }
- var profiles []qualityProfileView
- if err := json.Unmarshal(w.Body.Bytes(), &profiles); err != nil {
- t.Fatalf("decode: %v; body = %s", err, w.Body.String())
- }
- if len(profiles) != 2 {
- t.Fatalf("len(profiles) = %d, want 2", len(profiles))
- }
- if profiles[0].ID != 1 || profiles[0].Name != "Lossless" {
- t.Errorf("profiles[0] = %+v, want {1 Lossless}", profiles[0])
- }
- if profiles[1].ID != 2 || profiles[1].Name != "Standard" {
- t.Errorf("profiles[1] = %+v, want {2 Standard}", profiles[1])
- }
-}
-
-// TestHandleListRootFolders_HappyPath verifies that GET /root-folders proxies
-// and normalizes the Lidarr response.
-func TestHandleListRootFolders_HappyPath(t *testing.T) {
- h, _ := testHandlers(t)
- resetLidarrState(t, h)
- admin := seedAdminUser(t, h)
-
- stub := newLidarrStub(t, `[{"path":"/music","accessible":true,"freeSpace":1234567890}]`)
- saveLidarrConfigFull(t, h, lidarrconfig.Config{
- Enabled: true,
- BaseURL: stub.URL,
- APIKey: "test-key",
- })
-
- w := doAdminReq(t, h, http.MethodGet, "/api/admin/lidarr/root-folders", nil, admin)
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
- }
- var folders []rootFolderView
- if err := json.Unmarshal(w.Body.Bytes(), &folders); err != nil {
- t.Fatalf("decode: %v; body = %s", err, w.Body.String())
- }
- if len(folders) != 1 {
- t.Fatalf("len(folders) = %d, want 1", len(folders))
- }
- f := folders[0]
- if f.Path != "/music" {
- t.Errorf("path = %q, want /music", f.Path)
- }
- if !f.Accessible {
- t.Error("accessible = false, want true")
- }
- if f.FreeSpace != 1234567890 {
- t.Errorf("free_space = %d, want 1234567890", f.FreeSpace)
- }
-}
-
-// TestAdminEndpoints_NonAdmin403 is a table-driven test verifying that every
-// admin endpoint returns 403 with not_authorized for a non-admin user.
-func TestAdminEndpoints_NonAdmin403(t *testing.T) {
- h, pool := testHandlers(t)
- resetLidarrState(t, h)
-
- nonAdmin := seedUser(t, pool, "regular", "pw", false)
-
- cases := []struct {
- method string
- path string
- body []byte
- }{
- {http.MethodGet, "/api/admin/lidarr/config", nil},
- {http.MethodPut, "/api/admin/lidarr/config", []byte(`{}`)},
- {http.MethodPost, "/api/admin/lidarr/test", []byte(`{}`)},
- {http.MethodGet, "/api/admin/lidarr/quality-profiles", nil},
- {http.MethodGet, "/api/admin/lidarr/root-folders", nil},
- }
-
- for _, tc := range cases {
- tc := tc
- t.Run(tc.method+" "+tc.path, func(t *testing.T) {
- w := doAdminReq(t, h, tc.method, tc.path, tc.body, nonAdmin)
- if w.Code != http.StatusForbidden {
- t.Fatalf("status = %d, want 403; body = %s", w.Code, w.Body.String())
- }
- var resp map[string]string
- if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("decode: %v; body = %s", err, w.Body.String())
- }
- if resp["error"] != "not_authorized" {
- t.Errorf("error = %q, want not_authorized", resp["error"])
- }
- })
- }
-}
diff --git a/internal/api/admin_quarantine.go b/internal/api/admin_quarantine.go
index bd372d3f..6949d234 100644
--- a/internal/api/admin_quarantine.go
+++ b/internal/api/admin_quarantine.go
@@ -10,22 +10,20 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
- "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
- "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/quarantine"
)
// adminQueueRowView is a single aggregated row in the admin queue response.
type adminQueueRowView struct {
- TrackID string `json:"track_id"`
- TrackTitle string `json:"track_title"`
- ArtistName string `json:"artist_name"`
- AlbumTitle string `json:"album_title"`
- 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"`
+ TrackID string `json:"track_id"`
+ TrackTitle string `json:"track_title"`
+ ArtistName string `json:"artist_name"`
+ AlbumTitle string `json:"album_title"`
+ AlbumID string `json:"album_id"`
+ ReportCount int32 `json:"report_count"`
+ LatestAt string `json:"latest_at"`
+ ReasonCounts map[string]int `json:"reason_counts"`
+ Reports []adminQueueReportView `json:"reports"`
}
// adminQueueReportView is one user's report under an aggregated admin row.
@@ -47,7 +45,7 @@ func formatTimestamp(ts pgtype.Timestamptz) string {
// handleListAdminQuarantine implements GET /api/admin/quarantine.
func (h *handlers) handleListAdminQuarantine(w http.ResponseWriter, r *http.Request) {
- rows, err := h.lidarrQuarantine.ListAdminQueue(r.Context())
+ rows, err := h.quarantine.ListAdminQueue(r.Context())
if err != nil {
h.logger.Error("admin: list quarantine", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
@@ -66,27 +64,25 @@ func (h *handlers) handleListAdminQuarantine(w http.ResponseWriter, r *http.Requ
})
}
out = append(out, adminQueueRowView{
- TrackID: uuidToString(row.TrackID),
- TrackTitle: row.TrackTitle,
- ArtistName: row.ArtistName,
- AlbumTitle: row.AlbumTitle,
- AlbumID: uuidToString(row.AlbumID),
- LidarrAlbumMBID: row.LidarrAlbumMBID,
- ReportCount: row.ReportCount,
- LatestAt: formatTimestamp(row.LatestAt),
- ReasonCounts: row.ReasonCounts,
- Reports: reports,
+ TrackID: uuidToString(row.TrackID),
+ TrackTitle: row.TrackTitle,
+ ArtistName: row.ArtistName,
+ AlbumTitle: row.AlbumTitle,
+ AlbumID: uuidToString(row.AlbumID),
+ ReportCount: row.ReportCount,
+ LatestAt: formatTimestamp(row.LatestAt),
+ ReasonCounts: row.ReasonCounts,
+ Reports: reports,
})
}
writeJSON(w, http.StatusOK, out)
}
-// actionResultView is the response shape for the three admin destructive
-// actions (resolve, delete-file, delete-via-lidarr).
+// actionResultView is the response shape for the admin actions (resolve,
+// delete-file).
type actionResultView struct {
- ActionID string `json:"action_id"`
- AffectedUsers int32 `json:"affected_users"`
- DeletedTrackCount *int `json:"deleted_track_count,omitempty"`
+ ActionID string `json:"action_id"`
+ AffectedUsers int32 `json:"affected_users"`
}
// handleResolveQuarantine implements POST /api/admin/quarantine/{track_id}/resolve.
@@ -101,9 +97,9 @@ func (h *handlers) handleResolveQuarantine(w http.ResponseWriter, r *http.Reques
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
return
}
- action, err := h.lidarrQuarantine.Resolve(r.Context(), id, admin.ID)
+ action, err := h.quarantine.Resolve(r.Context(), id, admin.ID)
if err != nil {
- if errors.Is(err, lidarrquarantine.ErrTrackNotFound) {
+ if errors.Is(err, quarantine.ErrTrackNotFound) {
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
return
}
@@ -131,7 +127,7 @@ func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Req
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
return
}
- action, err := h.lidarrQuarantine.DeleteFile(r.Context(), id, admin.ID)
+ action, err := h.quarantine.DeleteFile(r.Context(), id, admin.ID)
if err != nil {
// Written in the enveloped shape, not writeAdminJSONErr's bare code: the
// message is the part that tells the operator which directory and uid.
@@ -141,7 +137,7 @@ func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Req
return
}
switch {
- case errors.Is(err, lidarrquarantine.ErrTrackNotFound):
+ case errors.Is(err, quarantine.ErrTrackNotFound):
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
default:
h.logger.Error("admin: delete file", "err", err)
@@ -158,60 +154,19 @@ func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Req
})
}
-// handleDeleteQuarantineViaLidarr implements POST /api/admin/quarantine/{track_id}/delete-via-lidarr.
-func (h *handlers) handleDeleteQuarantineViaLidarr(w http.ResponseWriter, r *http.Request) {
- admin, ok := auth.UserFromContext(r.Context())
- if !ok {
- writeAdminJSONErr(w, http.StatusUnauthorized, "unauthorized")
- return
- }
- 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
- }
- // Broadcast: same rationale as delete-file — track row gone everywhere.
- h.publishQuarantineEvent("quarantine.deleted_via_lidarr", pgtype.UUID{}, id, true)
- writeJSON(w, http.StatusOK, actionResultView{
- ActionID: uuidToString(action.ID),
- AffectedUsers: action.AffectedUsers,
- DeletedTrackCount: &deleted,
- })
-}
-
// actionLogView is the row shape returned by GET /api/admin/quarantine/actions.
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"`
+ 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"`
+ // AlbumMBID is set on the older deleted_album rows: the album removed.
+ AlbumMBID *string `json:"album_mbid,omitempty"`
+ AffectedUsers int32 `json:"affected_users"`
+ CreatedAt string `json:"created_at"`
}
// handleListQuarantineActions implements GET /api/admin/quarantine/actions.
@@ -241,16 +196,16 @@ func (h *handlers) handleListQuarantineActions(w http.ResponseWriter, r *http.Re
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: formatTimestamp(row.CreatedAt),
+ ID: uuidToString(row.ID),
+ TrackID: uuidToString(row.TrackID),
+ TrackTitle: row.TrackTitle,
+ ArtistName: row.ArtistName,
+ AlbumTitle: row.AlbumTitle,
+ Action: string(row.Action),
+ AdminID: adminID,
+ AlbumMBID: row.AlbumMbid,
+ AffectedUsers: row.AffectedUsers,
+ CreatedAt: formatTimestamp(row.CreatedAt),
})
}
writeJSON(w, http.StatusOK, out)
diff --git a/internal/api/admin_quarantine_test.go b/internal/api/admin_quarantine_test.go
index 5318d5b3..e412adfd 100644
--- a/internal/api/admin_quarantine_test.go
+++ b/internal/api/admin_quarantine_test.go
@@ -15,9 +15,6 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"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/lidarrquarantine"
)
// newAdminQuarantineRouter builds a test chi router with the five admin
@@ -30,7 +27,6 @@ func newAdminQuarantineRouter(h *handlers) chi.Router {
admin.Get("/quarantine", h.handleListAdminQuarantine)
admin.Post("/quarantine/{track_id}/resolve", h.handleResolveQuarantine)
admin.Post("/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile)
- admin.Post("/quarantine/{track_id}/delete-via-lidarr", h.handleDeleteQuarantineViaLidarr)
admin.Get("/quarantine/actions", h.handleListQuarantineActions)
})
return r
@@ -56,27 +52,11 @@ func doAdminQuarantineReq(t *testing.T, h *handlers, method, path string, body [
return w
}
-// installQuarantineClientFn rewires h.lidarrQuarantine to a fresh Service
-// with a real clientFn pointing at the (current) saved lidarr_config. This
-// mirrors testHandlersWithClientFn for the requests test file.
-func installQuarantineClientFn(t *testing.T, h *handlers) {
- t.Helper()
- cfg := lidarrconfig.New(h.pool)
- clientFn := func() *lidarr.Client {
- c, err := cfg.Get(context.Background())
- if err != nil || !c.Enabled || c.BaseURL == "" {
- return nil
- }
- return lidarr.NewClient(c.BaseURL, c.APIKey)
- }
- h.lidarrQuarantine = lidarrquarantine.NewService(h.pool, cfg, clientFn, h.dataDir)
-}
-
// flagDirect bypasses the HTTP handler to seed a quarantine row via the
// Service for setup. Used to construct admin queue scenarios.
func flagDirect(t *testing.T, h *handlers, userID, trackID pgtype.UUID, reason string) {
t.Helper()
- if _, err := h.lidarrQuarantine.Flag(context.Background(), userID, trackID, reason, ""); err != nil {
+ if _, err := h.quarantine.Flag(context.Background(), userID, trackID, reason, ""); err != nil {
t.Fatalf("flagDirect: %v", err)
}
}
@@ -110,49 +90,13 @@ func seedTrackOnDisk(t *testing.T, h *handlers, unique string) (dbq.Track, strin
return tr, path
}
-// seedTrackWithAlbumMBID seeds an artist+album-with-mbid+track. The mbid is
-// required for delete-via-lidarr scenarios.
-func seedTrackWithAlbumMBID(t *testing.T, h *handlers, unique, albumMBID string) (dbq.Track, string) {
- t.Helper()
- artist := seedArtist(t, h.pool, "DvL Artist "+unique)
- dir := t.TempDir()
- path := filepath.Join(dir, "track-"+unique+".mp3")
- if err := os.WriteFile(path, []byte("audio"), 0o644); err != nil {
- t.Fatalf("write file: %v", err)
- }
- mbid := albumMBID
- album, err := dbq.New(h.pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
- Title: "DvL Album " + unique, SortTitle: "DvL Album " + unique,
- ArtistID: artist.ID, Mbid: &mbid,
- })
- if err != nil {
- t.Fatalf("UpsertAlbum: %v", err)
- }
- one := int32(1)
- tr, err := dbq.New(h.pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{
- Title: "DvL Track " + unique,
- AlbumID: album.ID,
- ArtistID: artist.ID,
- TrackNumber: &one,
- DurationMs: 30000,
- FilePath: path,
- FileSize: 5,
- FileFormat: "mp3",
- })
- if err != nil {
- t.Fatalf("UpsertTrack: %v", err)
- }
- return tr, path
-}
-
// TestListAdminQuarantine_AggregatedShape seeds 3 users flagging the same
// track with mixed reasons. Verifies one aggregated row with report_count=3,
// reason_counts populated, and a Reports list of length 3.
func TestListAdminQuarantine_AggregatedShape(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
- resetLidarrState(t, h)
- installQuarantineClientFn(t, h)
+ resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
bob := seedUser(t, pool, "bob", "pw", false)
@@ -192,8 +136,7 @@ func TestListAdminQuarantine_AggregatedShape(t *testing.T) {
func TestResolveQuarantine_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
- resetLidarrState(t, h)
- installQuarantineClientFn(t, h)
+ resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
bob := seedUser(t, pool, "bob", "pw", false)
@@ -227,7 +170,7 @@ func TestResolveQuarantine_HappyPath(t *testing.T) {
if len(actions) != 1 {
t.Fatalf("audit rows = %d, want 1", len(actions))
}
- if actions[0].Action != dbq.LidarrQuarantineActionKindResolved {
+ if actions[0].Action != dbq.QuarantineActionKindResolved {
t.Errorf("audit action = %v, want resolved", actions[0].Action)
}
}
@@ -237,8 +180,7 @@ func TestResolveQuarantine_HappyPath(t *testing.T) {
func TestDeleteQuarantineFile_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
- resetLidarrState(t, h)
- installQuarantineClientFn(t, h)
+ resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
admin := seedUser(t, pool, "admin", "pw", true)
@@ -268,141 +210,12 @@ func TestDeleteQuarantineFile_HappyPath(t *testing.T) {
}
}
-// TestDeleteQuarantineViaLidarr_HappyPath uses a stub Lidarr that returns
-// a single album for the lookup and 200 for the delete. Verifies the
-// audit row is written and the local track row is deleted.
-func TestDeleteQuarantineViaLidarr_HappyPath(t *testing.T) {
- h, pool := testHandlers(t)
- truncateLibrary(t, pool)
- resetLidarrState(t, h)
- installQuarantineClientFn(t, h)
-
- stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- 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-dvl","title":"Al","artistId":7}]`))
- return
- }
- w.WriteHeader(http.StatusOK)
- }))
- t.Cleanup(stub.Close)
- saveLidarrConfig(t, h, stub.URL, true)
- installQuarantineClientFn(t, h) // re-install after config save so clientFn picks it up
-
- alice := seedUser(t, pool, "alice", "pw", false)
- admin := seedUser(t, pool, "admin", "pw", true)
-
- track, _ := seedTrackWithAlbumMBID(t, h, "dvl-happy", "al-mbid-dvl")
- flagDirect(t, h, alice.ID, track.ID, "bad_rip")
-
- w := doAdminQuarantineReq(t, h, http.MethodPost,
- "/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-via-lidarr", nil, admin)
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
- }
- var got actionResultView
- if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
- t.Fatalf("decode: %v; body = %s", err, w.Body.String())
- }
- if got.AffectedUsers != 1 {
- t.Errorf("affected_users = %d, want 1", got.AffectedUsers)
- }
- if got.DeletedTrackCount == nil || *got.DeletedTrackCount != 1 {
- t.Errorf("deleted_track_count = %v, want 1", got.DeletedTrackCount)
- }
-
- // Track row should be gone.
- if _, err := dbq.New(pool).GetTrackByID(context.Background(), track.ID); err == nil {
- t.Error("track row still exists after delete-via-lidarr")
- }
- // Audit row written with the lidarr_album_mbid.
- actions, err := dbq.New(pool).ListQuarantineActions(context.Background(), 50)
- if err != nil {
- t.Fatalf("list actions: %v", err)
- }
- if len(actions) != 1 {
- t.Fatalf("audit rows = %d, want 1", len(actions))
- }
- if actions[0].Action != dbq.LidarrQuarantineActionKindDeletedViaLidarr {
- t.Errorf("audit action = %v, want deleted_via_lidarr", actions[0].Action)
- }
- if actions[0].LidarrAlbumMbid == nil || *actions[0].LidarrAlbumMbid != "al-mbid-dvl" {
- t.Errorf("audit lidarr_album_mbid = %v", actions[0].LidarrAlbumMbid)
- }
-}
-
-// TestDeleteQuarantineViaLidarr_LidarrDisabled verifies 503 when Lidarr is
-// not configured.
-func TestDeleteQuarantineViaLidarr_LidarrDisabled(t *testing.T) {
- h, pool := testHandlers(t)
- truncateLibrary(t, pool)
- resetLidarrState(t, h)
- installQuarantineClientFn(t, h)
-
- alice := seedUser(t, pool, "alice", "pw", false)
- admin := seedUser(t, pool, "admin", "pw", true)
-
- track, _ := seedTrackWithAlbumMBID(t, h, "dvl-disabled", "al-mbid-disabled")
- flagDirect(t, h, alice.ID, track.ID, "bad_rip")
-
- // Lidarr config is reset (disabled).
- w := doAdminQuarantineReq(t, h, http.MethodPost,
- "/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-via-lidarr", nil, admin)
- if w.Code != http.StatusServiceUnavailable {
- t.Fatalf("status = %d, want 503; body = %s", w.Code, w.Body.String())
- }
- var resp map[string]string
- if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("decode: %v; body = %s", err, w.Body.String())
- }
- if resp["error"] != "lidarr_disabled" {
- t.Errorf("error = %q, want lidarr_disabled", resp["error"])
- }
-}
-
-// TestDeleteQuarantineViaLidarr_AlbumMBIDMissing verifies 404 album_mbid_missing
-// when the parent album has no mbid.
-func TestDeleteQuarantineViaLidarr_AlbumMBIDMissing(t *testing.T) {
- h, pool := testHandlers(t)
- truncateLibrary(t, pool)
- resetLidarrState(t, h)
- installQuarantineClientFn(t, h)
-
- stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusOK)
- }))
- t.Cleanup(stub.Close)
- saveLidarrConfig(t, h, stub.URL, true)
- installQuarantineClientFn(t, h)
-
- alice := seedUser(t, pool, "alice", "pw", false)
- admin := seedUser(t, pool, "admin", "pw", true)
-
- // Track whose album has NO mbid.
- track, _ := seedTrackOnDisk(t, h, "no-mbid")
- flagDirect(t, h, alice.ID, track.ID, "bad_rip")
-
- w := doAdminQuarantineReq(t, h, http.MethodPost,
- "/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-via-lidarr", nil, admin)
- if w.Code != http.StatusNotFound {
- t.Fatalf("status = %d, want 404; body = %s", w.Code, w.Body.String())
- }
- var resp map[string]string
- if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("decode: %v; body = %s", err, w.Body.String())
- }
- if resp["error"] != "album_mbid_missing" {
- t.Errorf("error = %q, want album_mbid_missing", resp["error"])
- }
-}
-
// TestQuarantineAdminEndpointsRequire403ForNonAdmin verifies all five admin
// endpoints reject non-admin callers with 403.
func TestQuarantineAdminEndpointsRequire403ForNonAdmin(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
- resetLidarrState(t, h)
- installQuarantineClientFn(t, h)
+ resetRequests(t, h)
nonAdmin := seedUser(t, pool, "regular", "pw", false)
fakeID := "00000000-0000-0000-0000-000000000001"
@@ -414,7 +227,6 @@ func TestQuarantineAdminEndpointsRequire403ForNonAdmin(t *testing.T) {
{http.MethodGet, "/api/admin/quarantine"},
{http.MethodPost, "/api/admin/quarantine/" + fakeID + "/resolve"},
{http.MethodPost, "/api/admin/quarantine/" + fakeID + "/delete-file"},
- {http.MethodPost, "/api/admin/quarantine/" + fakeID + "/delete-via-lidarr"},
{http.MethodGet, "/api/admin/quarantine/actions"},
}
for _, tc := range cases {
diff --git a/internal/api/admin_requests_test.go b/internal/api/admin_requests_test.go
index 1fb001d9..2d6ff88a 100644
--- a/internal/api/admin_requests_test.go
+++ b/internal/api/admin_requests_test.go
@@ -9,7 +9,6 @@ import (
"testing"
"github.com/go-chi/chi/v5"
- "github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
@@ -49,13 +48,7 @@ func doAdminRequestReq(t *testing.T, h *handlers, method, path string, body []by
return w
}
-// testHandlersWithClientFn is the request handlers' test fixture.
-func testHandlersWithClientFn(t *testing.T) (*handlers, *pgxpool.Pool) {
- t.Helper()
- return testHandlers(t)
-}
-
-// seedPendingArtistRequest creates a pending artist-kind lidarr request row
+// seedPendingArtistRequest creates a pending artist-kind request row
// for the given user and returns its parsed requestView.
func seedPendingArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMBID, artistName string) requestView {
t.Helper()
@@ -65,8 +58,8 @@ func seedPendingArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMB
// TestHandleListAdminRequests_DefaultStatus_Pending seeds 2 pending + 1 rejected
// and verifies that GET without ?status returns only the 2 pending rows.
func TestHandleListAdminRequests_DefaultStatus_Pending(t *testing.T) {
- h, _ := testHandlersWithClientFn(t)
- resetLidarrState(t, h)
+ h, _ := testHandlers(t)
+ resetRequests(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
@@ -103,8 +96,8 @@ func TestHandleListAdminRequests_DefaultStatus_Pending(t *testing.T) {
// TestHandleListAdminRequests_StatusFilter verifies GET ?status=rejected returns
// only rejected rows.
func TestHandleListAdminRequests_StatusFilter(t *testing.T) {
- h, _ := testHandlersWithClientFn(t)
- resetLidarrState(t, h)
+ h, _ := testHandlers(t)
+ resetRequests(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
@@ -137,8 +130,8 @@ func TestHandleListAdminRequests_StatusFilter(t *testing.T) {
// TestHandleListAdminRequests_BadStatus_400 verifies GET with an invalid
// ?status returns 400.
func TestHandleListAdminRequests_BadStatus_400(t *testing.T) {
- h, _ := testHandlersWithClientFn(t)
- resetLidarrState(t, h)
+ h, _ := testHandlers(t)
+ resetRequests(t, h)
admin := seedUser(t, h.pool, "admin", "pw", true)
@@ -158,8 +151,8 @@ func TestHandleListAdminRequests_BadStatus_400(t *testing.T) {
// TestHandleApproveRequest_NotPending_409 pre-rejects a row and verifies
// POST /approve → 409 request_not_pending.
func TestHandleApproveRequest_NotPending_409(t *testing.T) {
- h, _ := testHandlersWithClientFn(t)
- resetLidarrState(t, h)
+ h, _ := testHandlers(t)
+ resetRequests(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
@@ -188,8 +181,8 @@ func TestHandleApproveRequest_NotPending_409(t *testing.T) {
// TestHandleRejectRequest_HappyPath verifies POST /reject with notes returns
// 200 and the row transitions to rejected with notes saved.
func TestHandleRejectRequest_HappyPath(t *testing.T) {
- h, _ := testHandlersWithClientFn(t)
- resetLidarrState(t, h)
+ h, _ := testHandlers(t)
+ resetRequests(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
@@ -217,8 +210,8 @@ func TestHandleRejectRequest_HappyPath(t *testing.T) {
// TestHandleRejectRequest_NoNotes_OK verifies POST /reject with empty body
// returns 200 (notes are optional).
func TestHandleRejectRequest_NoNotes_OK(t *testing.T) {
- h, _ := testHandlersWithClientFn(t)
- resetLidarrState(t, h)
+ h, _ := testHandlers(t)
+ resetRequests(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
@@ -242,8 +235,8 @@ func TestHandleRejectRequest_NoNotes_OK(t *testing.T) {
// TestAdminRequestsEndpoints_NonAdmin403 is a table-driven test verifying that
// all 3 admin request endpoints reject non-admin users with 403.
func TestAdminRequestsEndpoints_NonAdmin403(t *testing.T) {
- h, _ := testHandlersWithClientFn(t)
- resetLidarrState(t, h)
+ h, _ := testHandlers(t)
+ resetRequests(t, h)
nonAdmin := seedUser(t, h.pool, "regular", "pw", false)
@@ -281,8 +274,8 @@ func TestAdminRequestsEndpoints_NonAdmin403(t *testing.T) {
// TestHandleApproveRequest_AlbumBecomesAWant: approving an album request puts
// its release group on the wanted list, as the requester's.
func TestHandleApproveRequest_AlbumBecomesAWant(t *testing.T) {
- h, _ := testHandlersWithClientFn(t)
- resetLidarrState(t, h)
+ h, _ := testHandlers(t)
+ resetRequests(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
@@ -309,8 +302,8 @@ func TestHandleApproveRequest_AlbumBecomesAWant(t *testing.T) {
// TestHandleApproveRequest_ArtistOnTheChosenProfile: an artist request is
// followed on the profile the admin picked; an unknown profile is a 400.
func TestHandleApproveRequest_ArtistOnTheChosenProfile(t *testing.T) {
- h, _ := testHandlersWithClientFn(t)
- resetLidarrState(t, h)
+ h, _ := testHandlers(t)
+ resetRequests(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
ctx := context.Background()
diff --git a/internal/api/admin_summary_test.go b/internal/api/admin_summary_test.go
index 6e92ed28..263dc999 100644
--- a/internal/api/admin_summary_test.go
+++ b/internal/api/admin_summary_test.go
@@ -34,7 +34,7 @@ func getAdminSummary(t *testing.T, h *handlers, user dbq.User) (int, adminSummar
// The summary counts each queue an admin acts on and says since when the
// oldest item has waited; an empty queue has no since (#5605).
func TestAdminSummary_CountsTheQueuesAndSinceWhen(t *testing.T) {
- h, _ := testHandlersWithClientFn(t)
+ h, _ := testHandlers(t)
admin := seedUser(t, h.pool, "admin", "pw", true)
listener := seedUser(t, h.pool, "listener", "pw", false)
ctx := context.Background()
diff --git a/internal/api/admin_tracks.go b/internal/api/admin_tracks.go
index 7630fa27..4d312ab7 100644
--- a/internal/api/admin_tracks.go
+++ b/internal/api/admin_tracks.go
@@ -3,7 +3,6 @@ package api
import (
"errors"
"net/http"
- "strconv"
"github.com/go-chi/chi/v5"
@@ -11,27 +10,22 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
)
-// removeTrackResponse is the wire shape from spec §5 (M7 #372). The three
+// removeTrackResponse is the wire shape from spec §5 (M7 #372). The two
// optional fields are omitted unless the corresponding cleanup actually
// happened, so the response stays minimal in the common case.
type removeTrackResponse struct {
- DeletedTrackID string `json:"deleted_track_id"`
- DeletedAlbumID *string `json:"deleted_album_id,omitempty"`
- DeletedArtistID *string `json:"deleted_artist_id,omitempty"`
- LidarrUnmonitorFailed *bool `json:"lidarr_unmonitor_failed,omitempty"`
+ DeletedTrackID string `json:"deleted_track_id"`
+ DeletedAlbumID *string `json:"deleted_album_id,omitempty"`
+ DeletedArtistID *string `json:"deleted_artist_id,omitempty"`
}
-// handleRemoveTrack implements DELETE /api/admin/tracks/{id}?unmonitor=true|false.
+// handleRemoveTrack implements DELETE /api/admin/tracks/{id}.
//
// Admin-only (gated by auth.RequireAdmin on the /admin route group). Deletes the
// file, then the DB row, and runs the album/artist cascade tidy-up — and deletes
-// nothing at all when the file cannot be removed (#3918). When
-// unmonitor=true and the track has an mbid, also calls Lidarr.UnmonitorTrack
-// — failure there is non-fatal (the destructive part already completed) and
-// surfaces as `lidarr_unmonitor_failed: true` in the success envelope.
+// nothing at all when the file cannot be removed (#3918).
//
-// Per spec §5, Lidarr-side errors during the unmonitor step do NOT map to
-// wire error codes. The codes this handler emits are not_found,
+// The codes this handler emits are not_found,
// library_not_writable (409) and file_delete_failed when the file could not be
// removed, server_error, plus the auth codes the middleware emits upstream.
func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) {
@@ -46,13 +40,6 @@ func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) {
return
}
- unmonitor := false
- if v := r.URL.Query().Get("unmonitor"); v != "" {
- if parsed, err := strconv.ParseBool(v); err == nil {
- unmonitor = parsed
- }
- }
-
// Defensive: RequireUser+RequireAdmin should have run upstream. If
// we got here without a user in context the routing is broken.
admin, ok := requireUser(w, r)
@@ -60,9 +47,7 @@ func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) {
return
}
- deletedAlbum, deletedArtist, lidarrUnmonitorFailed, err := h.tracks.RemoveTrack(
- r.Context(), trackID, admin.ID, unmonitor,
- )
+ deletedAlbum, deletedArtist, err := h.tracks.RemoveTrack(r.Context(), trackID, admin.ID)
if err != nil {
if errors.Is(err, tracks.ErrNotFound) {
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "track not found"})
@@ -87,13 +72,5 @@ func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) {
s := uuidToString(*deletedArtist)
resp.DeletedArtistID = &s
}
- // Only emit the flag when unmonitor was requested AND it failed.
- // Avoids "false" cluttering the wire when the operator didn't ask
- // for an unmonitor in the first place.
- if unmonitor && lidarrUnmonitorFailed {
- t := true
- resp.LidarrUnmonitorFailed = &t
- }
-
writeJSON(w, http.StatusOK, resp)
}
diff --git a/internal/api/admin_tracks_test.go b/internal/api/admin_tracks_test.go
index 36637850..7ee49da0 100644
--- a/internal/api/admin_tracks_test.go
+++ b/internal/api/admin_tracks_test.go
@@ -4,7 +4,6 @@ import (
"bytes"
"context"
"encoding/json"
- "errors"
"net/http"
"net/http/httptest"
"os"
@@ -15,41 +14,12 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
- "git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
)
-// fakeLidarrUnmonitorer captures UnmonitorTrack calls for assertion and lets
-// tests dictate the return error. Used to drive both the Lidarr-success and
-// Lidarr-failure branches without spinning up a stub HTTP server.
-type fakeLidarrUnmonitorer struct {
- calls []fakeUnmonitorCall
- err error
-}
-
-type fakeUnmonitorCall struct {
- trackMbid string
- albumMbid string
-}
-
-func (f *fakeLidarrUnmonitorer) UnmonitorTrack(_ context.Context, trackMbid, albumMbid string) error {
- f.calls = append(f.calls, fakeUnmonitorCall{trackMbid: trackMbid, albumMbid: albumMbid})
- return f.err
-}
-
-// installTracksLidarrStub replaces h.tracks with a fresh tracks.Service whose
-// LidarrUnmonitorer is the supplied fake. Returns the fake so tests can
-// assert call counts and inject error returns.
-func installTracksLidarrStub(t *testing.T, h *handlers) *fakeLidarrUnmonitorer {
- t.Helper()
- fake := &fakeLidarrUnmonitorer{}
- h.tracks = tracks.NewService(h.pool, h.logger, fake, h.dataDir)
- return fake
-}
-
// newAdminTracksRouter builds a test chi router with the admin-tracks
// endpoint. RequireAdmin middleware is applied; tests inject the user into
// context manually (RequireUser is bypassed). Mirrors the pattern from
-// admin_lidarr_test.go and admin_quarantine_test.go.
+// admin_quarantine_test.go.
func newAdminTracksRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Route("/api/admin", func(admin chi.Router) {
@@ -90,8 +60,8 @@ func doAdminTracksReqNoUser(t *testing.T, h *handlers, method, path, idParam str
}
// seedTrackForRemoveTest creates a fresh artist+album+track on disk and
-// returns the track row. The mbid string is set on both track and album
-// when non-empty so the unmonitor branch has the data it needs.
+// returns the track row. The mbids are set on the track and album when
+// non-empty.
func seedTrackForRemoveTest(t *testing.T, h *handlers, unique, trackMbid, albumMbid string) (dbq.Track, string) {
t.Helper()
artist := seedArtist(t, h.pool, "RemoveTest Artist "+unique)
@@ -206,11 +176,9 @@ func TestHandleRemoveTrack_UnknownID404(t *testing.T) {
}
}
-// TestHandleRemoveTrack_HappyPath_NoUnmonitor verifies the success envelope
-// when the operator does NOT request unmonitor: 200 with deleted_track_id
-// (and the optional cascade fields when they fired) but NO
-// lidarr_unmonitor_failed key in the body.
-func TestHandleRemoveTrack_HappyPath_NoUnmonitor(t *testing.T) {
+// TestHandleRemoveTrack_HappyPath verifies the success envelope: 200 with
+// deleted_track_id and the optional cascade fields when they fired.
+func TestHandleRemoveTrack_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
admin := seedUser(t, pool, "admin", "pw", true)
@@ -237,21 +205,6 @@ func TestHandleRemoveTrack_HappyPath_NoUnmonitor(t *testing.T) {
if got.DeletedArtistID == nil {
t.Error("deleted_artist_id missing; expected sole-track cascade")
}
- if got.LidarrUnmonitorFailed != nil {
- t.Errorf("lidarr_unmonitor_failed should be omitted when unmonitor not requested; got %v",
- *got.LidarrUnmonitorFailed)
- }
-
- // Verify the raw JSON has no `lidarr_unmonitor_failed` key — omitempty
- // on a *bool drops it, but we double-check the wire to catch
- // regressions.
- var raw map[string]any
- if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil {
- t.Fatalf("decode raw: %v", err)
- }
- if _, ok := raw["lidarr_unmonitor_failed"]; ok {
- t.Error("wire body contained lidarr_unmonitor_failed when unmonitor was not requested")
- }
// File is gone, DB row is gone.
if _, err := os.Stat(path); !os.IsNotExist(err) {
@@ -261,50 +214,3 @@ func TestHandleRemoveTrack_HappyPath_NoUnmonitor(t *testing.T) {
t.Error("track row still exists")
}
}
-
-// TestHandleRemoveTrack_UnmonitorFailureFlag verifies that when the
-// operator sends ?unmonitor=true AND the Lidarr call fails, the response
-// is still 200 (file + DB delete completed) but carries
-// lidarr_unmonitor_failed: true.
-func TestHandleRemoveTrack_UnmonitorFailureFlag(t *testing.T) {
- h, pool := testHandlers(t)
- truncateLibrary(t, pool)
- admin := seedUser(t, pool, "admin", "pw", true)
-
- fake := installTracksLidarrStub(t, h)
- fake.err = errors.New("lidarr exploded")
-
- tr, path := seedTrackForRemoveTest(t, h, "umon-fail", "track-mbid-x", "album-mbid-x")
- w := doAdminTracksReq(t, h, http.MethodDelete,
- "/api/admin/tracks/"+uuidToString(tr.ID)+"?unmonitor=true", admin)
- if w.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
- }
-
- var got removeTrackResponse
- if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
- t.Fatalf("decode: %v; body = %s", err, w.Body.String())
- }
- if got.DeletedTrackID != uuidToString(tr.ID) {
- t.Errorf("deleted_track_id = %q, want %q", got.DeletedTrackID, uuidToString(tr.ID))
- }
- if got.LidarrUnmonitorFailed == nil || !*got.LidarrUnmonitorFailed {
- t.Errorf("lidarr_unmonitor_failed = %v, want true", got.LidarrUnmonitorFailed)
- }
-
- // Lidarr was actually called with the right mbids.
- if len(fake.calls) != 1 {
- t.Fatalf("UnmonitorTrack call count = %d, want 1", len(fake.calls))
- }
- if fake.calls[0].trackMbid != "track-mbid-x" || fake.calls[0].albumMbid != "album-mbid-x" {
- t.Errorf("unmonitor call = %+v, want {track-mbid-x album-mbid-x}", fake.calls[0])
- }
-
- // Destructive part still happened.
- if _, err := os.Stat(path); !os.IsNotExist(err) {
- t.Errorf("file still exists: %v", err)
- }
- if _, err := dbq.New(pool).GetTrackByID(context.Background(), tr.ID); err == nil {
- t.Error("track row still exists")
- }
-}
diff --git a/internal/api/admin_wanted.go b/internal/api/admin_wanted.go
index 7dab1834..d8642615 100644
--- a/internal/api/admin_wanted.go
+++ b/internal/api/admin_wanted.go
@@ -107,8 +107,7 @@ type wantsPage struct {
}
// wantArtistView is one artist on the Wanted view's main page (#5572): how
-// many of their albums the list holds, of how many, like Lidarr's artist
-// index.
+// many of their albums the list holds, of how many.
type wantArtistView struct {
ArtistMBID string `json:"artist_mbid"`
ArtistName string `json:"artist_name"`
diff --git a/internal/api/admin_wanted_test.go b/internal/api/admin_wanted_test.go
index b883120d..ad6adf3c 100644
--- a/internal/api/admin_wanted_test.go
+++ b/internal/api/admin_wanted_test.go
@@ -71,7 +71,7 @@ func listWanted(t *testing.T, h *handlers, user dbq.User, q string) wantsPage {
// name where the library holds the artist, each artist's albums oldest
// first; q narrows it to an artist or an album.
func TestListWants_ArtistsAToZThenTheirAlbumsByDate(t *testing.T) {
- h, _ := testHandlersWithClientFn(t)
+ h, _ := testHandlers(t)
admin := seedUser(t, h.pool, "admin", "pw", true)
ctx := context.Background()
beatles := "a0000000-0000-4000-8000-000000000002"
@@ -113,7 +113,7 @@ func TestListWants_ArtistsAToZThenTheirAlbumsByDate(t *testing.T) {
// with how many of their albums are held; an artist's page lists every album
// of theirs, held or not, under that artist.
func TestListWantArtists_CatalogueAndAnArtistsPage(t *testing.T) {
- h, _ := testHandlersWithClientFn(t)
+ h, _ := testHandlers(t)
admin := seedUser(t, h.pool, "admin", "pw", true)
ctx := context.Background()
q := dbq.New(h.pool)
@@ -226,7 +226,7 @@ func wantTracks(t *testing.T, h *handlers, user dbq.User, id string) (int, wantT
func TestWantTracks_StandardEditionFromMusicBrainz(t *testing.T) {
const group = "g0000000-0000-4000-8000-000000000010"
for _, down := range []bool{false, true} {
- h, pool := testHandlersWithClientFn(t)
+ h, pool := testHandlers(t)
admin := seedUser(t, h.pool, "admin", "pw", true)
h.catalogue = catalogue.New(pool, &musicbrainz.Client{BaseURL: fakeEditions(t, group, down).URL,
Pace: &musicbrainz.Pacer{}, Retries: -1}, h.logger)
@@ -253,7 +253,7 @@ func TestWantTracks_StandardEditionFromMusicBrainz(t *testing.T) {
// A held album shows the library's own tracks, a lost file marked.
func TestWantTracks_HeldAlbumShowsTheLibrarysTracks(t *testing.T) {
- h, _ := testHandlersWithClientFn(t)
+ h, _ := testHandlers(t)
admin := seedUser(t, h.pool, "admin", "pw", true)
ctx := context.Background()
q := dbq.New(h.pool)
@@ -296,7 +296,7 @@ func TestWantTracks_HeldAlbumShowsTheLibrarysTracks(t *testing.T) {
// local band's album left off and put back, and a link that names no
// MusicBrainz album refused before MusicBrainz is asked.
func TestUnmatched_ListLeaveOffPutBack(t *testing.T) {
- h, _ := testHandlersWithClientFn(t)
+ h, _ := testHandlers(t)
admin := seedUser(t, h.pool, "admin", "pw", true)
ctx := context.Background()
q := dbq.New(h.pool)
diff --git a/internal/api/api.go b/internal/api/api.go
index 13b2f0de..1ffdaeef 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -21,14 +21,13 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
"git.fabledsword.com/bvandeusen/minstrel/internal/importer"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
- "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
- "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/lifecycle"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/quarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
@@ -41,7 +40,7 @@ import (
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
// RequireUser; everything else is gated by the middleware. The events writer
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
-func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, requestSvc *requests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService, loudSettings *library.LoudnessSettingsService, acoustIDLookup *library.AcoustIDLookupWorker) {
+func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, requestSvc *requests.Service, quarantineSvc *quarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService, loudSettings *library.LoudnessSettingsService, acoustIDLookup *library.AcoustIDLookupWorker) {
rng := rand.New(rand.NewSource(rand.Int63()))
setupToken, err := auth.NewSetupToken()
if err != nil {
@@ -55,9 +54,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
pool: pool, logger: logger, events: events, recCfg: recCfg,
recSettings: recSettings,
rng: rng.Float64,
- lidarrCfg: lidarrCfg,
requests: requestSvc,
- lidarrQuarantine: lidarrQuar,
+ quarantine: quarantineSvc,
tracks: tracksSvc,
playlists: playlistsSvc,
coverart: coverEnricher,
@@ -229,9 +227,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Route("/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin())
- admin.Get("/lidarr/config", h.handleGetLidarrConfig)
- admin.Put("/lidarr/config", h.handlePutLidarrConfig)
- admin.Post("/lidarr/test", h.handleTestLidarrConnection)
admin.Get("/acquisition/services", h.handleListAcquisitionServices)
admin.Post("/acquisition/services", h.handleCreateAcquisitionService)
admin.Put("/acquisition/services/{code}", h.handleUpdateAcquisitionService)
@@ -241,9 +236,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Delete("/acquisition/services/{code}/api-key", h.handleSetAcquisitionSecret("api_key"))
admin.Put("/acquisition/services/{code}/password", h.handleSetAcquisitionSecret("password"))
admin.Delete("/acquisition/services/{code}/password", h.handleSetAcquisitionSecret("password"))
- admin.Get("/lidarr/quality-profiles", h.handleListQualityProfiles)
- admin.Get("/lidarr/metadata-profiles", h.handleListMetadataProfiles)
- admin.Get("/lidarr/root-folders", h.handleListRootFolders)
// The queues that wait on an admin, for the rail and the Overview (#5605).
admin.Get("/summary", h.handleGetAdminSummary)
admin.Get("/requests", h.handleListAdminRequests)
@@ -290,7 +282,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Get("/playback-errors", h.handleListAdminPlaybackErrors)
admin.Post("/playback-errors/{id}/resolve", h.handleResolvePlaybackError)
admin.Post("/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile)
- admin.Post("/quarantine/{track_id}/delete-via-lidarr", h.handleDeleteQuarantineViaLidarr)
admin.Get("/quarantine/actions", h.handleListQuarantineActions)
admin.Delete("/tracks/{id}", h.handleRemoveTrack)
@@ -402,20 +393,19 @@ type handlers struct {
// librarySize memoises the track count that sizes the candidate pool
// (#3880). Held here rather than counted per request: the count is a
// full table scan, and library size only moves when a scan runs.
- librarySize *recommendation.LibrarySize
- lidarrCfg *lidarrconfig.Service
- requests *requests.Service
- lidarrQuarantine *lidarrquarantine.Service
- tracks *tracks.Service
- playlists *playlists.Service
- coverart *coverart.Enricher
- coverSettings *coverart.SettingsService
- tagSettings *tags.SettingsService
- scanner *library.Scanner
- scanCfg library.RunScanConfig
- dataDir string
- mailer mailer.Sender
- eventbus *eventbus.Bus
+ librarySize *recommendation.LibrarySize
+ requests *requests.Service
+ quarantine *quarantine.Service
+ tracks *tracks.Service
+ playlists *playlists.Service
+ coverart *coverart.Enricher
+ coverSettings *coverart.SettingsService
+ tagSettings *tags.SettingsService
+ scanner *library.Scanner
+ scanCfg library.RunScanConfig
+ dataDir string
+ mailer mailer.Sender
+ eventbus *eventbus.Bus
// notifier writes the notifications inbox (M489). Nil-safe: a nil
// notifier records nothing, which is what most handler tests want.
notifier *notifications.Notifier
diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go
index eaef533c..f7386567 100644
--- a/internal/api/auth_test.go
+++ b/internal/api/auth_test.go
@@ -24,12 +24,11 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
- "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
- "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/quarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
@@ -66,17 +65,13 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
if err != nil {
t.Fatalf("recsettings: %v", err)
}
- lidarrCfg := lidarrconfig.New(pool)
requestSvc := requests.NewService(pool)
cat := catalogue.New(pool, nil, logger)
- lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil, "")
- // tracks.Service has no Lidarr unmonitorer in tests by default; the
- // admin-tracks tests below override h.tracks via installTracksLidarrStub
- // when they need a stubbed Lidarr.
+ quarantineSvc := quarantine.NewService(pool, "")
dataDir := t.TempDir()
- tracksSvc := tracks.NewService(pool, logger, nil, dataDir)
+ tracksSvc := tracks.NewService(pool, logger, dataDir)
playlistsSvc := playlists.NewService(pool, logger, dataDir)
- h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, recSettings: recSettings, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, requests: requestSvc, catalogue: cat, wanted: wanted.New(pool, cat, logger), lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}, notifier: notifications.New(pool, nil, nil)}
+ h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, recSettings: recSettings, rng: func() float64 { return 0.5 }, requests: requestSvc, catalogue: cat, wanted: wanted.New(pool, cat, logger), quarantine: quarantineSvc, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}, notifier: notifications.New(pool, nil, nil)}
return h, pool
}
diff --git a/internal/api/catalogue.go b/internal/api/catalogue.go
index 2a712613..cf4c0658 100644
--- a/internal/api/catalogue.go
+++ b/internal/api/catalogue.go
@@ -21,11 +21,10 @@ import (
// catalogueSearchLimit is how many results one search shows.
const catalogueSearchLimit = 25
-// catalogueSearchResult is one row of GET /api/catalogue/search. The shape is
-// the one the Lidarr search answered with, so the Discover page and request
-// rows read it unchanged. artist_mbid and album_mbid are the parents a
+// catalogueSearchResult is one row of GET /api/catalogue/search, read by the
+// Discover page and request rows. artist_mbid and album_mbid are the parents a
// request needs: empty on an artist, the artist on an album, both on a track.
-// An album is a MusicBrainz release group, as Lidarr's albums were.
+// An album is a MusicBrainz release group.
type catalogueSearchResult struct {
MBID string `json:"mbid"`
Name string `json:"name"`
diff --git a/internal/api/catalogue_test.go b/internal/api/catalogue_test.go
index c019b29d..6495ebec 100644
--- a/internal/api/catalogue_test.go
+++ b/internal/api/catalogue_test.go
@@ -56,7 +56,7 @@ func fakeMusicBrainz(t *testing.T, down bool) *httptest.Server {
func catalogueHandlers(t *testing.T, down bool) *handlers {
t.Helper()
h, pool := testHandlers(t)
- resetLidarrState(t, h)
+ resetRequests(t, h)
mb := &musicbrainz.Client{BaseURL: fakeMusicBrainz(t, down).URL, Pace: &musicbrainz.Pacer{}, Retries: -1}
h.catalogue = catalogue.New(pool, mb, h.logger)
return h
diff --git a/internal/api/errors.go b/internal/api/errors.go
index be7c0427..50bcc297 100644
--- a/internal/api/errors.go
+++ b/internal/api/errors.go
@@ -67,3 +67,12 @@ func writeJSON(w http.ResponseWriter, status int, body any) {
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
+
+// writeAdminJSONErr writes a flat {"error":""} JSON response. This
+// mirrors the shape the RequireAdmin middleware uses and that the spec
+// defines for /api/admin/* errors.
+func writeAdminJSONErr(w http.ResponseWriter, status int, code string) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(map[string]string{"error": code})
+}
diff --git a/internal/api/events_publish.go b/internal/api/events_publish.go
index 2178606d..7c6eb69f 100644
--- a/internal/api/events_publish.go
+++ b/internal/api/events_publish.go
@@ -48,8 +48,8 @@ func (h *handlers) publishLikeEvent(userID, entityID pgtype.UUID, entityType str
}
// publishQuarantineEvent broadcasts a quarantine action. User-side
-// flag/unflag are scoped to the user; admin-side resolve / delete-file /
-// delete-via-lidarr are broadcast (empty UserID) because a single admin
+// flag/unflag are scoped to the user; admin-side resolve / delete-file
+// are broadcast (empty UserID) because a single admin
// action can affect every user who'd flagged that track — every client
// invalidates and the non-affected ones no-op when the query result
// matches their cache.
@@ -103,7 +103,7 @@ func (h *handlers) publishSystemRebuilt(userID pgtype.UUID) {
})
}
-// publishRequestStatusChanged broadcasts a Lidarr request status flip to
+// publishRequestStatusChanged broadcasts a request status flip to
// the request's original requester so their /requests page reflects the
// new state without manual refresh. Admin actors (approve / reject) still
// route to the requester's user_id, not the admin's — the original user
diff --git a/internal/api/file_remove_error_test.go b/internal/api/file_remove_error_test.go
index 1da062da..b4317f92 100644
--- a/internal/api/file_remove_error_test.go
+++ b/internal/api/file_remove_error_test.go
@@ -15,7 +15,7 @@ import (
const removeTestPath = "/music/Moe Shop/WWW (2020)/01 - WWW.mp3"
// removeFailure builds the error a delete service returns when the file would
-// not go, wrapped the way lidarrquarantine.DeleteFile and tracks.RemoveTrack
+// not go, wrapped the way quarantine.DeleteFile and tracks.RemoveTrack
// wrap it — the mapping has to see through that.
func removeFailure(errno syscall.Errno) error {
return fmt.Errorf("delete file: %w", &library.FileRemoveError{
diff --git a/internal/api/library_test.go b/internal/api/library_test.go
index e3bd8f6c..7333664d 100644
--- a/internal/api/library_test.go
+++ b/internal/api/library_test.go
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
r := chi.NewRouter()
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
30*time.Minute, 0.5, 30000)
- Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.requests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil, h.netSettings, nil, nil, nil, nil)
+ Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.requests, h.quarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil, h.netSettings, nil, nil, nil, nil)
paths := []string{
"/api/artists",
@@ -561,7 +561,7 @@ func TestGetArtistTracks_HonorsQuarantine(t *testing.T) {
FileSize: 1, FileFormat: "mp3",
})
if _, err := pool.Exec(context.Background(),
- `INSERT INTO lidarr_quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`,
+ `INSERT INTO quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`,
user.ID, track.ID,
); err != nil {
t.Fatalf("quarantine insert: %v", err)
diff --git a/internal/api/lidarr_test.go b/internal/api/lidarr_test.go
deleted file mode 100644
index 5f0a71dc..00000000
--- a/internal/api/lidarr_test.go
+++ /dev/null
@@ -1,55 +0,0 @@
-package api
-
-import (
- "context"
- "net/http"
- "net/http/httptest"
- "testing"
-
- "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
-)
-
-// Helpers shared by the request, quarantine and notification tests. The
-// Lidarr search they once sat beside is now the catalogue search
-// (catalogue_test.go, M502 #5519).
-
-// resetLidarrState clears requests rows and resets lidarr_config
-// to the factory-default state (enabled=false, nulls) between tests.
-func resetLidarrState(t *testing.T, h *handlers) {
- t.Helper()
- ctx := context.Background()
- if _, err := h.pool.Exec(ctx,
- "DELETE FROM requests; UPDATE lidarr_config SET enabled=false, base_url=NULL, api_key=NULL WHERE id=1",
- ); err != nil {
- t.Fatalf("resetLidarrState: %v", err)
- }
-}
-
-// saveLidarrConfig writes a test lidarr_config row pointing at the given stub URL.
-func saveLidarrConfig(t *testing.T, h *handlers, baseURL string, enabled bool) {
- t.Helper()
- svc := lidarrconfig.New(h.pool)
- err := svc.Save(context.Background(), lidarrconfig.Config{
- Enabled: enabled,
- BaseURL: baseURL,
- APIKey: "test-key",
- DefaultQualityProfileID: 1,
- DefaultRootFolderPath: "/music",
- })
- if err != nil {
- t.Fatalf("saveLidarrConfig: %v", err)
- }
-}
-
-// newLidarrStub creates an httptest.Server that always returns the given body
-// with 200 OK for any /api/v1/* path.
-func newLidarrStub(t *testing.T, body string) *httptest.Server {
- t.Helper()
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte(body))
- }))
- t.Cleanup(srv.Close)
- return srv
-}
diff --git a/internal/api/me_history_test.go b/internal/api/me_history_test.go
index 96e224c0..57bcfeb7 100644
--- a/internal/api/me_history_test.go
+++ b/internal/api/me_history_test.go
@@ -65,7 +65,7 @@ func seedPlaySession(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, start
func quarantineTrack(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID) {
t.Helper()
_, err := pool.Exec(context.Background(),
- `INSERT INTO lidarr_quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`,
+ `INSERT INTO quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`,
userID, trackID,
)
if err != nil {
diff --git a/internal/api/notify_producers_test.go b/internal/api/notify_producers_test.go
index d5f7d0e7..5994444d 100644
--- a/internal/api/notify_producers_test.go
+++ b/internal/api/notify_producers_test.go
@@ -53,27 +53,9 @@ func wantInbox(t *testing.T, who string, got []inboxItem, kinds ...notifications
}
}
-// newApprovingLidarrStub answers every Lidarr call an approval makes.
-func newApprovingLidarrStub(t *testing.T) *httptest.Server {
- t.Helper()
- stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- switch {
- case strings.Contains(r.URL.Path, "/metadataprofile"):
- _, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`))
- case strings.Contains(r.URL.Path, "/qualityprofile"):
- _, _ = w.Write([]byte(`[{"id":1,"name":"Lossless"}]`))
- default:
- _, _ = w.Write([]byte(`{"id":1}`))
- }
- }))
- t.Cleanup(stub.Close)
- return stub
-}
-
func TestNotify_NewRequestReachesOtherAdminsOnce(t *testing.T) {
h, pool := testHandlers(t)
- resetLidarrState(t, h)
+ resetRequests(t, h)
alice := seedUser(t, pool, "np-alice", "pw", false)
admin := seedUser(t, pool, "np-admin", "pw", true)
@@ -99,9 +81,8 @@ func TestNotify_NewRequestReachesOtherAdminsOnce(t *testing.T) {
}
func TestNotify_AutoApprovedRequestWaitsOnNobody(t *testing.T) {
- h, _ := testHandlersWithClientFn(t)
- resetLidarrState(t, h)
- saveLidarrConfig(t, h, newApprovingLidarrStub(t).URL, true)
+ h, _ := testHandlers(t)
+ resetRequests(t, h)
alice := seedUser(t, h.pool, "np-auto", "pw", false)
admin := seedUser(t, h.pool, "np-auto-admin", "pw", true)
@@ -123,9 +104,8 @@ func TestNotify_AutoApprovedRequestWaitsOnNobody(t *testing.T) {
}
func TestNotify_DecisionsReachTheRequester(t *testing.T) {
- h, _ := testHandlersWithClientFn(t)
- resetLidarrState(t, h)
- saveLidarrConfig(t, h, newApprovingLidarrStub(t).URL, true)
+ h, _ := testHandlers(t)
+ resetRequests(t, h)
alice := seedUser(t, h.pool, "np-dec-alice", "pw", false)
admin := seedUser(t, h.pool, "np-dec-admin", "pw", true)
diff --git a/internal/api/quarantine.go b/internal/api/quarantine.go
index c5df1b22..de1332db 100644
--- a/internal/api/quarantine.go
+++ b/internal/api/quarantine.go
@@ -8,8 +8,8 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
- "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/quarantine"
)
// quarantineView is the JSON shape returned by the user-facing endpoints
@@ -22,7 +22,7 @@ type quarantineView struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
-func quarantineViewFrom(row dbq.LidarrQuarantine) quarantineView {
+func quarantineViewFrom(row dbq.Quarantine) quarantineView {
return quarantineView{
UserID: uuidToString(row.UserID),
TrackID: uuidToString(row.TrackID),
@@ -55,12 +55,12 @@ func (h *handlers) handleFlag(w http.ResponseWriter, r *http.Request) {
writeErr(w, apierror.BadRequest("bad_request", "invalid track id"))
return
}
- row, err := h.lidarrQuarantine.Flag(r.Context(), user.ID, trackID, body.Reason, body.Notes)
+ row, err := h.quarantine.Flag(r.Context(), user.ID, trackID, body.Reason, body.Notes)
if err != nil {
switch {
- case errors.Is(err, lidarrquarantine.ErrBadReason):
+ case errors.Is(err, quarantine.ErrBadReason):
writeErr(w, apierror.BadRequest("bad_reason", "invalid reason"))
- case errors.Is(err, lidarrquarantine.ErrTrackNotFound):
+ case errors.Is(err, quarantine.ErrTrackNotFound):
writeErr(w, &apierror.Error{Status: 404, Code: "track_not_found", Message: "track does not exist"})
default:
h.logger.Error("api: flag", "err", err)
@@ -88,8 +88,8 @@ func (h *handlers) handleUnflag(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- if err := h.lidarrQuarantine.Unflag(r.Context(), user.ID, id); err != nil {
- if errors.Is(err, lidarrquarantine.ErrQuarantineNotFound) {
+ if err := h.quarantine.Unflag(r.Context(), user.ID, id); err != nil {
+ if errors.Is(err, quarantine.ErrQuarantineNotFound) {
writeErr(w, &apierror.Error{Status: 404, Code: "quarantine_not_found", Message: "no quarantine for that track"})
return
}
@@ -125,7 +125,7 @@ func (h *handlers) handleListMyQuarantine(w http.ResponseWriter, r *http.Request
if !ok {
return
}
- rows, err := h.lidarrQuarantine.ListMine(r.Context(), user.ID)
+ rows, err := h.quarantine.ListMine(r.Context(), user.ID)
if err != nil {
h.logger.Error("api: list mine", "err", err)
writeErr(w, apierror.InternalMsg("list failed", err))
@@ -134,10 +134,10 @@ func (h *handlers) handleListMyQuarantine(w http.ResponseWriter, r *http.Request
out := make([]quarantineMineView, 0, len(rows))
for _, row := range rows {
out = append(out, quarantineMineView{
- TrackID: uuidToString(row.LidarrQuarantine.TrackID),
- Reason: string(row.LidarrQuarantine.Reason),
- Notes: row.LidarrQuarantine.Notes,
- CreatedAt: row.LidarrQuarantine.CreatedAt,
+ TrackID: uuidToString(row.Quarantine.TrackID),
+ Reason: string(row.Quarantine.Reason),
+ Notes: row.Quarantine.Notes,
+ CreatedAt: row.Quarantine.CreatedAt,
TrackTitle: row.TrackTitle,
TrackDurationMs: row.TrackDurationMs,
AlbumID: uuidToString(row.AlbumID),
diff --git a/internal/api/requests_test.go b/internal/api/requests_test.go
index 112f5d9c..4e72cf96 100644
--- a/internal/api/requests_test.go
+++ b/internal/api/requests_test.go
@@ -90,7 +90,7 @@ func createArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMBID, a
// creation (#355 / #376 U2.5): an artist request follows the artist at once.
func TestHandleCreateRequest_AutoApprove_GoesStraightOnTheWantedList(t *testing.T) {
h, pool := testHandlers(t)
- resetLidarrState(t, h)
+ resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
if _, err := pool.Exec(context.Background(),
@@ -124,7 +124,7 @@ func TestHandleCreateRequest_AutoApprove_GoesStraightOnTheWantedList(t *testing.
// returns 201 with kind=artist and status=pending.
func TestHandleCreateRequest_Artist_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
- resetLidarrState(t, h)
+ resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
body := `{"kind":"artist","artist_mbid":"artist-mbid-1","artist_name":"The Beatles"}`
@@ -158,7 +158,7 @@ func TestHandleCreateRequest_Artist_HappyPath(t *testing.T) {
// kind=album without album_mbid returns 400 with mbid_required.
func TestHandleCreateRequest_AlbumWithoutAlbumMBID_400(t *testing.T) {
h, pool := testHandlers(t)
- resetLidarrState(t, h)
+ resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
// Missing album_mbid and album_title.
@@ -185,7 +185,7 @@ func TestHandleCreateRequest_AlbumWithoutAlbumMBID_400(t *testing.T) {
// with missing track_mbid/track_title returns 400 with mbid_required.
func TestHandleCreateRequest_TrackWithoutTrackFields_400(t *testing.T) {
h, pool := testHandlers(t)
- resetLidarrState(t, h)
+ resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
// Provides album fields but misses track_mbid and track_title.
@@ -212,7 +212,7 @@ func TestHandleCreateRequest_TrackWithoutTrackFields_400(t *testing.T) {
// requests, not bob's.
func TestHandleListRequests_OnlyOwnRequests(t *testing.T) {
h, pool := testHandlers(t)
- resetLidarrState(t, h)
+ resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
bob := seedUser(t, pool, "bob", "pw", false)
@@ -242,7 +242,7 @@ func TestHandleListRequests_OnlyOwnRequests(t *testing.T) {
// TestHandleGetRequest_OwnReturns200 verifies a caller can fetch their own request.
func TestHandleGetRequest_OwnReturns200(t *testing.T) {
h, pool := testHandlers(t)
- resetLidarrState(t, h)
+ resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
rv := createArtistRequest(t, h, alice, "artist-mbid-get", "Get Artist")
@@ -264,7 +264,7 @@ func TestHandleGetRequest_OwnReturns200(t *testing.T) {
// cannot see another user's request.
func TestHandleGetRequest_OtherUserNotAdmin_404(t *testing.T) {
h, pool := testHandlers(t)
- resetLidarrState(t, h)
+ resetRequests(t, h)
bob := seedUser(t, pool, "bob", "pw", false)
charlie := seedUser(t, pool, "charlie", "pw", false)
@@ -293,7 +293,7 @@ func TestHandleGetRequest_OtherUserNotAdmin_404(t *testing.T) {
// user's request.
func TestHandleGetRequest_OtherUserAsAdmin_200(t *testing.T) {
h, pool := testHandlers(t)
- resetLidarrState(t, h)
+ resetRequests(t, h)
bob := seedUser(t, pool, "bob", "pw", false)
admin := seedUser(t, pool, "admin", "pw", true)
@@ -318,7 +318,7 @@ func TestHandleGetRequest_OtherUserAsAdmin_200(t *testing.T) {
// request returns 200 with status=rejected.
func TestHandleCancelRequest_PendingHappyPath(t *testing.T) {
h, pool := testHandlers(t)
- resetLidarrState(t, h)
+ resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
rv := createArtistRequest(t, h, alice, "cancel-mbid-1", "Cancel Me")
@@ -340,7 +340,7 @@ func TestHandleCancelRequest_PendingHappyPath(t *testing.T) {
// rejected request returns 409 with request_not_pending.
func TestHandleCancelRequest_NonPending_409(t *testing.T) {
h, pool := testHandlers(t)
- resetLidarrState(t, h)
+ resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
rv := createArtistRequest(t, h, alice, "cancel-mbid-2", "Cancel Twice")
@@ -376,7 +376,7 @@ func TestHandleCancelRequest_NonPending_409(t *testing.T) {
// another user's request returns 409 (not_pending from the ownership miss).
func TestHandleCancelRequest_WrongUser_409(t *testing.T) {
h, pool := testHandlers(t)
- resetLidarrState(t, h)
+ resetRequests(t, h)
alice := seedUser(t, pool, "alice-cancel-owner", "pw", false)
bob := seedUser(t, pool, "bob-cancel-other", "pw", false)
diff --git a/internal/api/reset_test.go b/internal/api/reset_test.go
new file mode 100644
index 00000000..fd4e4c82
--- /dev/null
+++ b/internal/api/reset_test.go
@@ -0,0 +1,14 @@
+package api
+
+import (
+ "context"
+ "testing"
+)
+
+// resetRequests clears requests rows between tests.
+func resetRequests(t *testing.T, h *handlers) {
+ t.Helper()
+ if _, err := h.pool.Exec(context.Background(), "DELETE FROM requests"); err != nil {
+ t.Fatalf("resetRequests: %v", err)
+ }
+}
diff --git a/internal/audit/audit.go b/internal/audit/audit.go
index f574daf5..b9e92c5d 100644
--- a/internal/audit/audit.go
+++ b/internal/audit/audit.go
@@ -61,16 +61,6 @@ const (
// log can answer "where did that file go" long after the report is gone.
ActionDuplicateMerge Action = "duplicate_merge"
- // Lidarr release change (M498 #5437): the duplicate resolver moved an
- // album's monitored release in Lidarr to one that lists each song once.
- // Written with no actor; the metadata names the album and both releases.
- ActionLidarrReleaseChange Action = "lidarr_release_change"
-
- // Lidarr rip search (M498 #5447): the resolver asked Lidarr to search an
- // album whose only copy of a song is a video rip, for a better release.
- // Written with no actor; the metadata names the album and how many rips.
- ActionLidarrRipSearch Action = "lidarr_rip_search"
-
// Subsonic password (#5026): generated in Settings for t/s-only clients.
ActionSubsonicPasswordSet Action = "subsonic_password_set"
ActionSubsonicPasswordClear Action = "subsonic_password_clear"
diff --git a/internal/config/config.go b/internal/config/config.go
index dd2e4ac8..aec1f643 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -106,7 +106,7 @@ func Default() Config {
Log: LogConfig{Level: "info", Format: "text"},
Branding: BrandingConfig{
AppName: "Minstrel",
- Description: "Self-hosted music server with Subsonic compatibility, smart shuffle, and Lidarr integration.",
+ Description: "Self-hosted music server with Subsonic compatibility, smart shuffle, and music acquisition.",
},
Events: EventsConfig{
SessionTimeoutMinutes: 30,
diff --git a/internal/coverart/embedded.go b/internal/coverart/embedded.go
index ce00e731..f49ef1a9 100644
--- a/internal/coverart/embedded.go
+++ b/internal/coverart/embedded.go
@@ -23,7 +23,7 @@ var ErrNoEmbeddedArt = errors.New("coverart: no embedded album art")
// usable picture; returns a wrapped error for I/O or tag-parse
// failures so callers can distinguish "no art" from "couldn't read".
//
-// Picard- and Lidarr-tagged libraries typically carry the canonical
+// Picard-tagged libraries typically carry the canonical
// art in this block already — extracting it beats every external API
// guess for accuracy and avoids a network round-trip.
func ExtractEmbeddedAlbumArt(path string) ([]byte, error) {
diff --git a/internal/coverart/enricher.go b/internal/coverart/enricher.go
index cb7dda3e..09e4e3d2 100644
--- a/internal/coverart/enricher.go
+++ b/internal/coverart/enricher.go
@@ -91,7 +91,7 @@ func (e *Enricher) EnrichAlbum(ctx context.Context, albumID pgtype.UUID) error {
// Embedded layer: read the picture stored inside the audio file's
// tag block (ID3v2 APIC, FLAC METADATA_BLOCK_PICTURE, MP4 covr).
- // Picard/Lidarr-tagged libraries typically carry the canonical art
+ // Picard-tagged libraries typically carry the canonical art
// here, so this beats every external provider for accuracy and
// avoids the network round-trip.
if row.TrackFilePath != nil && *row.TrackFilePath != "" {
diff --git a/internal/db/dbq/admin_summary.sql.go b/internal/db/dbq/admin_summary.sql.go
index c8c0ed27..6f682bdc 100644
--- a/internal/db/dbq/admin_summary.sql.go
+++ b/internal/db/dbq/admin_summary.sql.go
@@ -19,11 +19,11 @@ SELECT
-- it, or it failed to play (#5606). Counted by track, as the Reports
-- page lists them.
(SELECT count(*) FROM (
- SELECT track_id FROM lidarr_quarantine
+ SELECT track_id FROM quarantine
UNION
SELECT track_id FROM playback_errors WHERE resolved_at IS NULL) r)::bigint AS reports,
(SELECT min(at) FROM (
- SELECT created_at AS at FROM lidarr_quarantine
+ SELECT created_at AS at FROM quarantine
UNION ALL
SELECT occurred_at FROM playback_errors WHERE resolved_at IS NULL) r)::timestamptz AS reports_since,
(SELECT count(*) FROM duplicate_groups g
diff --git a/internal/db/dbq/audit_log.sql.go b/internal/db/dbq/audit_log.sql.go
index 1a69e07b..6c0bf6f4 100644
--- a/internal/db/dbq/audit_log.sql.go
+++ b/internal/db/dbq/audit_log.sql.go
@@ -84,8 +84,8 @@ type ListAuditLogByActionsParams struct {
}
// One page of the given actions, newest first: the duplicates report's
-// "resolved automatically" activity (M498) reads the resolver's merges and
-// Lidarr release changes from here.
+// "resolved automatically" activity (M498) reads the resolver's merges from
+// here.
func (q *Queries) ListAuditLogByActions(ctx context.Context, arg ListAuditLogByActionsParams) ([]AuditLog, error) {
rows, err := q.db.Query(ctx, listAuditLogByActions,
arg.Actions,
diff --git a/internal/db/dbq/discover.sql.go b/internal/db/dbq/discover.sql.go
index 3689aeba..3c87b264 100644
--- a/internal/db/dbq/discover.sql.go
+++ b/internal/db/dbq/discover.sql.go
@@ -28,7 +28,7 @@ SELECT t.id, t.album_id, t.artist_id
WHERE ml.user_id = $1 AND ml.track_id = t.id
)
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
GROUP BY t.id, t.album_id, t.artist_id
@@ -108,7 +108,7 @@ SELECT t.id, t.album_id, t.artist_id
WHERE gl.user_id = $1 AND gl.track_id = t.id
)
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY md5(t.id::text || $2::text)
@@ -173,7 +173,7 @@ SELECT t.id, t.album_id, t.artist_id
WHERE gl.user_id = $1 AND gl.track_id = t.id
)
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY md5(t.id::text || $2::text)
@@ -237,7 +237,7 @@ WITH scored AS (
WHERE gl.user_id = $3 AND gl.track_id = t.id
)
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $3 AND q.track_id = t.id
)
GROUP BY t.id, t.album_id, t.artist_id
diff --git a/internal/db/dbq/history.sql.go b/internal/db/dbq/history.sql.go
index 32943d28..8c9cd61e 100644
--- a/internal/db/dbq/history.sql.go
+++ b/internal/db/dbq/history.sql.go
@@ -24,7 +24,7 @@ SELECT pe.id AS event_id,
WHERE pe.user_id = $1
AND pe.was_skipped = false
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY pe.started_at DESC
diff --git a/internal/db/dbq/lidarr_config.sql.go b/internal/db/dbq/lidarr_config.sql.go
deleted file mode 100644
index 0f849d4a..00000000
--- a/internal/db/dbq/lidarr_config.sql.go
+++ /dev/null
@@ -1,109 +0,0 @@
-// Code generated by sqlc. DO NOT EDIT.
-// versions:
-// sqlc v1.31.1
-// source: lidarr_config.sql
-
-package dbq
-
-import (
- "context"
-
- "github.com/jackc/pgx/v5/pgtype"
-)
-
-const getLidarrConfig = `-- name: GetLidarrConfig :one
-SELECT id, enabled, base_url, api_key, default_quality_profile_id,
- default_metadata_profile_id, default_root_folder_path,
- created_at, updated_at
-FROM lidarr_config
-WHERE id = 1
-`
-
-type GetLidarrConfigRow struct {
- ID int16
- Enabled bool
- BaseUrl *string
- ApiKey *string
- DefaultQualityProfileID *int32
- DefaultMetadataProfileID *int32
- DefaultRootFolderPath *string
- CreatedAt pgtype.Timestamptz
- UpdatedAt pgtype.Timestamptz
-}
-
-func (q *Queries) GetLidarrConfig(ctx context.Context) (GetLidarrConfigRow, error) {
- row := q.db.QueryRow(ctx, getLidarrConfig)
- var i GetLidarrConfigRow
- err := row.Scan(
- &i.ID,
- &i.Enabled,
- &i.BaseUrl,
- &i.ApiKey,
- &i.DefaultQualityProfileID,
- &i.DefaultMetadataProfileID,
- &i.DefaultRootFolderPath,
- &i.CreatedAt,
- &i.UpdatedAt,
- )
- return i, err
-}
-
-const updateLidarrConfig = `-- name: UpdateLidarrConfig :one
-UPDATE lidarr_config
- SET enabled = $1,
- base_url = $2,
- api_key = $3,
- default_quality_profile_id = $4,
- default_metadata_profile_id = $5,
- default_root_folder_path = $6,
- updated_at = now()
- WHERE id = 1
- RETURNING id, enabled, base_url, api_key, default_quality_profile_id,
- default_metadata_profile_id, default_root_folder_path,
- created_at, updated_at
-`
-
-type UpdateLidarrConfigParams struct {
- Enabled bool
- BaseUrl *string
- ApiKey *string
- DefaultQualityProfileID *int32
- DefaultMetadataProfileID *int32
- DefaultRootFolderPath *string
-}
-
-type UpdateLidarrConfigRow struct {
- ID int16
- Enabled bool
- BaseUrl *string
- ApiKey *string
- DefaultQualityProfileID *int32
- DefaultMetadataProfileID *int32
- DefaultRootFolderPath *string
- CreatedAt pgtype.Timestamptz
- UpdatedAt pgtype.Timestamptz
-}
-
-func (q *Queries) UpdateLidarrConfig(ctx context.Context, arg UpdateLidarrConfigParams) (UpdateLidarrConfigRow, error) {
- row := q.db.QueryRow(ctx, updateLidarrConfig,
- arg.Enabled,
- arg.BaseUrl,
- arg.ApiKey,
- arg.DefaultQualityProfileID,
- arg.DefaultMetadataProfileID,
- arg.DefaultRootFolderPath,
- )
- var i UpdateLidarrConfigRow
- err := row.Scan(
- &i.ID,
- &i.Enabled,
- &i.BaseUrl,
- &i.ApiKey,
- &i.DefaultQualityProfileID,
- &i.DefaultMetadataProfileID,
- &i.DefaultRootFolderPath,
- &i.CreatedAt,
- &i.UpdatedAt,
- )
- return i, err
-}
diff --git a/internal/db/dbq/merge.sql.go b/internal/db/dbq/merge.sql.go
index c73176f4..666795eb 100644
--- a/internal/db/dbq/merge.sql.go
+++ b/internal/db/dbq/merge.sql.go
@@ -12,11 +12,9 @@ import (
)
const listDuplicateGroupMergeMembers = `-- name: ListDuplicateGroupMergeMembers :many
-SELECT t.id, t.file_path, t.file_format, t.file_size, t.added_at, t.album_id,
- t.mbid, albums.mbid AS album_mbid
+SELECT t.id, t.file_path, t.file_format, t.file_size, t.added_at, t.album_id
FROM duplicate_group_members m
- JOIN tracks t ON t.id = m.track_id
- JOIN albums ON albums.id = t.album_id
+ JOIN tracks t ON t.id = m.track_id
WHERE m.group_id = $1
ORDER BY t.id
`
@@ -28,8 +26,6 @@ type ListDuplicateGroupMergeMembersRow struct {
FileSize int64
AddedAt pgtype.Timestamptz
AlbumID pgtype.UUID
- Mbid *string
- AlbumMbid *string
}
func (q *Queries) ListDuplicateGroupMergeMembers(ctx context.Context, groupID pgtype.UUID) ([]ListDuplicateGroupMergeMembersRow, error) {
@@ -48,8 +44,6 @@ func (q *Queries) ListDuplicateGroupMergeMembers(ctx context.Context, groupID pg
&i.FileSize,
&i.AddedAt,
&i.AlbumID,
- &i.Mbid,
- &i.AlbumMbid,
); err != nil {
return nil, err
}
diff --git a/internal/db/dbq/missing_pairs.sql.go b/internal/db/dbq/missing_pairs.sql.go
index 07042637..d03ce2f0 100644
--- a/internal/db/dbq/missing_pairs.sql.go
+++ b/internal/db/dbq/missing_pairs.sql.go
@@ -63,8 +63,8 @@ type ListMissingTrackPairsRow struct {
}
// Missing-pair pass (M485). A track marked missing whose replacement is already
-// on disk under another row — most often a Lidarr quality upgrade, mp3 → flac at
-// a new path — is folded into that replacement, so its history moves across and
+// on disk under another row — most often a quality upgrade, mp3 → flac at a new
+// path — is folded into that replacement, so its history moves across and
// re-acquisition stops chasing music the library already has.
//
// Move adoption (#2528) cannot catch these: it links a new file to a missing row
diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go
index abfd23f9..a159c56f 100644
--- a/internal/db/dbq/models.go
+++ b/internal/db/dbq/models.go
@@ -11,92 +11,92 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
-type LidarrQuarantineActionKind string
+type QuarantineActionKind string
const (
- LidarrQuarantineActionKindResolved LidarrQuarantineActionKind = "resolved"
- LidarrQuarantineActionKindDeletedFile LidarrQuarantineActionKind = "deleted_file"
- LidarrQuarantineActionKindDeletedViaLidarr LidarrQuarantineActionKind = "deleted_via_lidarr"
+ QuarantineActionKindResolved QuarantineActionKind = "resolved"
+ QuarantineActionKindDeletedFile QuarantineActionKind = "deleted_file"
+ QuarantineActionKindDeletedAlbum QuarantineActionKind = "deleted_album"
)
-func (e *LidarrQuarantineActionKind) Scan(src interface{}) error {
+func (e *QuarantineActionKind) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
- *e = LidarrQuarantineActionKind(s)
+ *e = QuarantineActionKind(s)
case string:
- *e = LidarrQuarantineActionKind(s)
+ *e = QuarantineActionKind(s)
default:
- return fmt.Errorf("unsupported scan type for LidarrQuarantineActionKind: %T", src)
+ return fmt.Errorf("unsupported scan type for QuarantineActionKind: %T", src)
}
return nil
}
-type NullLidarrQuarantineActionKind struct {
- LidarrQuarantineActionKind LidarrQuarantineActionKind
- Valid bool // Valid is true if LidarrQuarantineActionKind is not NULL
+type NullQuarantineActionKind struct {
+ QuarantineActionKind QuarantineActionKind
+ Valid bool // Valid is true if QuarantineActionKind is not NULL
}
// Scan implements the Scanner interface.
-func (ns *NullLidarrQuarantineActionKind) Scan(value interface{}) error {
+func (ns *NullQuarantineActionKind) Scan(value interface{}) error {
if value == nil {
- ns.LidarrQuarantineActionKind, ns.Valid = "", false
+ ns.QuarantineActionKind, ns.Valid = "", false
return nil
}
ns.Valid = true
- return ns.LidarrQuarantineActionKind.Scan(value)
+ return ns.QuarantineActionKind.Scan(value)
}
// Value implements the driver Valuer interface.
-func (ns NullLidarrQuarantineActionKind) Value() (driver.Value, error) {
+func (ns NullQuarantineActionKind) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
- return string(ns.LidarrQuarantineActionKind), nil
+ return string(ns.QuarantineActionKind), nil
}
-type LidarrQuarantineReason string
+type QuarantineReason string
const (
- LidarrQuarantineReasonBadRip LidarrQuarantineReason = "bad_rip"
- LidarrQuarantineReasonWrongFile LidarrQuarantineReason = "wrong_file"
- LidarrQuarantineReasonWrongTags LidarrQuarantineReason = "wrong_tags"
- LidarrQuarantineReasonDuplicate LidarrQuarantineReason = "duplicate"
- LidarrQuarantineReasonOther LidarrQuarantineReason = "other"
+ QuarantineReasonBadRip QuarantineReason = "bad_rip"
+ QuarantineReasonWrongFile QuarantineReason = "wrong_file"
+ QuarantineReasonWrongTags QuarantineReason = "wrong_tags"
+ QuarantineReasonDuplicate QuarantineReason = "duplicate"
+ QuarantineReasonOther QuarantineReason = "other"
)
-func (e *LidarrQuarantineReason) Scan(src interface{}) error {
+func (e *QuarantineReason) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
- *e = LidarrQuarantineReason(s)
+ *e = QuarantineReason(s)
case string:
- *e = LidarrQuarantineReason(s)
+ *e = QuarantineReason(s)
default:
- return fmt.Errorf("unsupported scan type for LidarrQuarantineReason: %T", src)
+ return fmt.Errorf("unsupported scan type for QuarantineReason: %T", src)
}
return nil
}
-type NullLidarrQuarantineReason struct {
- LidarrQuarantineReason LidarrQuarantineReason
- Valid bool // Valid is true if LidarrQuarantineReason is not NULL
+type NullQuarantineReason struct {
+ QuarantineReason QuarantineReason
+ Valid bool // Valid is true if QuarantineReason is not NULL
}
// Scan implements the Scanner interface.
-func (ns *NullLidarrQuarantineReason) Scan(value interface{}) error {
+func (ns *NullQuarantineReason) Scan(value interface{}) error {
if value == nil {
- ns.LidarrQuarantineReason, ns.Valid = "", false
+ ns.QuarantineReason, ns.Valid = "", false
return nil
}
ns.Valid = true
- return ns.LidarrQuarantineReason.Scan(value)
+ return ns.QuarantineReason.Scan(value)
}
// Value implements the driver Valuer interface.
-func (ns NullLidarrQuarantineReason) Value() (driver.Value, error) {
+func (ns NullQuarantineReason) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
- return string(ns.LidarrQuarantineReason), nil
+ return string(ns.QuarantineReason), nil
}
type RequestKind string
@@ -506,39 +506,6 @@ type LibraryChange struct {
ChangedAt pgtype.Timestamptz
}
-type LidarrConfig struct {
- ID int16
- Enabled bool
- BaseUrl *string
- ApiKey *string
- DefaultQualityProfileID *int32
- DefaultRootFolderPath *string
- CreatedAt pgtype.Timestamptz
- UpdatedAt pgtype.Timestamptz
- DefaultMetadataProfileID *int32
-}
-
-type LidarrQuarantine struct {
- UserID pgtype.UUID
- TrackID pgtype.UUID
- Reason LidarrQuarantineReason
- Notes *string
- CreatedAt pgtype.Timestamptz
-}
-
-type LidarrQuarantineAction struct {
- ID pgtype.UUID
- TrackID pgtype.UUID
- TrackTitle string
- ArtistName string
- AlbumTitle *string
- Action LidarrQuarantineActionKind
- AdminID pgtype.UUID
- LidarrAlbumMbid *string
- AffectedUsers int32
- CreatedAt pgtype.Timestamptz
-}
-
type ListenbrainzSimilarRecording struct {
SeedTrackID pgtype.UUID
RecordingMbid string
@@ -696,6 +663,27 @@ type PlaylistTrack struct {
PickKind *string
}
+type Quarantine struct {
+ UserID pgtype.UUID
+ TrackID pgtype.UUID
+ Reason QuarantineReason
+ Notes *string
+ CreatedAt pgtype.Timestamptz
+}
+
+type QuarantineAction struct {
+ ID pgtype.UUID
+ TrackID pgtype.UUID
+ TrackTitle string
+ ArtistName string
+ AlbumTitle *string
+ Action QuarantineActionKind
+ AdminID pgtype.UUID
+ AlbumMbid *string
+ AffectedUsers int32
+ CreatedAt pgtype.Timestamptz
+}
+
type ReacquisitionSetting struct {
ID bool
Enabled bool
diff --git a/internal/db/dbq/lidarr_quarantine.sql.go b/internal/db/dbq/quarantine.sql.go
similarity index 81%
rename from internal/db/dbq/lidarr_quarantine.sql.go
rename to internal/db/dbq/quarantine.sql.go
index eac2ac6d..2e814f34 100644
--- a/internal/db/dbq/lidarr_quarantine.sql.go
+++ b/internal/db/dbq/quarantine.sql.go
@@ -1,7 +1,7 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
-// source: lidarr_quarantine.sql
+// source: quarantine.sql
package dbq
@@ -12,7 +12,7 @@ import (
)
const countQuarantineForTrack = `-- name: CountQuarantineForTrack :one
-SELECT count(*)::int FROM lidarr_quarantine WHERE track_id = $1
+SELECT count(*)::int FROM quarantine WHERE track_id = $1
`
// Reads affected_users for the audit row before the delete fires.
@@ -24,7 +24,7 @@ func (q *Queries) CountQuarantineForTrack(ctx context.Context, trackID pgtype.UU
}
const deleteQuarantine = `-- name: DeleteQuarantine :one
-DELETE FROM lidarr_quarantine
+DELETE FROM quarantine
WHERE user_id = $1 AND track_id = $2
RETURNING user_id, track_id, reason, notes, created_at
`
@@ -36,9 +36,9 @@ type DeleteQuarantineParams struct {
// Removes the caller's row. Returns the deleted row so the handler can
// distinguish "no row existed" (zero rows -> ErrNoRows) from success.
-func (q *Queries) DeleteQuarantine(ctx context.Context, arg DeleteQuarantineParams) (LidarrQuarantine, error) {
+func (q *Queries) DeleteQuarantine(ctx context.Context, arg DeleteQuarantineParams) (Quarantine, error) {
row := q.db.QueryRow(ctx, deleteQuarantine, arg.UserID, arg.TrackID)
- var i LidarrQuarantine
+ var i Quarantine
err := row.Scan(
&i.UserID,
&i.TrackID,
@@ -50,7 +50,7 @@ func (q *Queries) DeleteQuarantine(ctx context.Context, arg DeleteQuarantinePara
}
const deleteQuarantineForTrack = `-- name: DeleteQuarantineForTrack :exec
-DELETE FROM lidarr_quarantine WHERE track_id = $1
+DELETE FROM quarantine WHERE track_id = $1
`
// Clears all per-user rows for a given track. Used by Resolve and the
@@ -68,26 +68,24 @@ SELECT
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)::timestamptz AS latest_at
-FROM lidarr_quarantine q
+FROM 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
+GROUP BY t.id, ar.name, al.title, al.id
ORDER BY max(q.created_at) DESC
`
type ListAdminQuarantineQueueRow struct {
- TrackID pgtype.UUID
- TrackTitle string
- ArtistName string
- AlbumTitle string
- AlbumID pgtype.UUID
- LidarrAlbumMbid *string
- ReportCount int32
- LatestAt pgtype.Timestamptz
+ TrackID pgtype.UUID
+ TrackTitle string
+ ArtistName string
+ AlbumTitle string
+ AlbumID pgtype.UUID
+ ReportCount int32
+ LatestAt pgtype.Timestamptz
}
// Aggregated admin queue. One row per track. The handler post-processes
@@ -108,7 +106,6 @@ func (q *Queries) ListAdminQuarantineQueue(ctx context.Context) ([]ListAdminQuar
&i.ArtistName,
&i.AlbumTitle,
&i.AlbumID,
- &i.LidarrAlbumMbid,
&i.ReportCount,
&i.LatestAt,
); err != nil {
@@ -123,20 +120,20 @@ func (q *Queries) ListAdminQuarantineQueue(ctx context.Context) ([]ListAdminQuar
}
const listQuarantineActions = `-- name: ListQuarantineActions :many
-SELECT id, track_id, track_title, artist_name, album_title, action, admin_id, lidarr_album_mbid, affected_users, created_at FROM lidarr_quarantine_actions
+SELECT id, track_id, track_title, artist_name, album_title, action, admin_id, album_mbid, affected_users, created_at FROM quarantine_actions
ORDER BY created_at DESC
LIMIT $1
`
-func (q *Queries) ListQuarantineActions(ctx context.Context, limit int32) ([]LidarrQuarantineAction, error) {
+func (q *Queries) ListQuarantineActions(ctx context.Context, limit int32) ([]QuarantineAction, error) {
rows, err := q.db.Query(ctx, listQuarantineActions, limit)
if err != nil {
return nil, err
}
defer rows.Close()
- var items []LidarrQuarantineAction
+ var items []QuarantineAction
for rows.Next() {
- var i LidarrQuarantineAction
+ var i QuarantineAction
if err := rows.Scan(
&i.ID,
&i.TrackID,
@@ -145,7 +142,7 @@ func (q *Queries) ListQuarantineActions(ctx context.Context, limit int32) ([]Lid
&i.AlbumTitle,
&i.Action,
&i.AdminID,
- &i.LidarrAlbumMbid,
+ &i.AlbumMbid,
&i.AffectedUsers,
&i.CreatedAt,
); err != nil {
@@ -170,7 +167,7 @@ SELECT
al.cover_art_path AS album_cover_art_path,
ar.id AS artist_id,
ar.name AS artist_name
-FROM lidarr_quarantine q
+FROM 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
@@ -179,7 +176,7 @@ ORDER BY q.created_at DESC
`
type ListQuarantineForUserRow struct {
- LidarrQuarantine LidarrQuarantine
+ Quarantine Quarantine
TrackIDJoin pgtype.UUID
TrackTitle string
TrackDurationMs int32
@@ -204,11 +201,11 @@ func (q *Queries) ListQuarantineForUser(ctx context.Context, userID pgtype.UUID)
for rows.Next() {
var i ListQuarantineForUserRow
if err := rows.Scan(
- &i.LidarrQuarantine.UserID,
- &i.LidarrQuarantine.TrackID,
- &i.LidarrQuarantine.Reason,
- &i.LidarrQuarantine.Notes,
- &i.LidarrQuarantine.CreatedAt,
+ &i.Quarantine.UserID,
+ &i.Quarantine.TrackID,
+ &i.Quarantine.Reason,
+ &i.Quarantine.Notes,
+ &i.Quarantine.CreatedAt,
&i.TrackIDJoin,
&i.TrackTitle,
&i.TrackDurationMs,
@@ -235,7 +232,7 @@ SELECT
q.reason,
q.notes,
q.created_at
-FROM lidarr_quarantine q
+FROM quarantine q
JOIN users u ON u.id = q.user_id
WHERE q.track_id = $1
ORDER BY q.created_at DESC
@@ -244,7 +241,7 @@ ORDER BY q.created_at DESC
type ListQuarantineReportsForTrackRow struct {
UserID pgtype.UUID
Username string
- Reason LidarrQuarantineReason
+ Reason QuarantineReason
Notes *string
CreatedAt pgtype.Timestamptz
}
@@ -278,7 +275,7 @@ func (q *Queries) ListQuarantineReportsForTrack(ctx context.Context, trackID pgt
}
const upsertQuarantine = `-- name: UpsertQuarantine :one
-INSERT INTO lidarr_quarantine (user_id, track_id, reason, notes)
+INSERT INTO quarantine (user_id, track_id, reason, notes)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, track_id) DO UPDATE SET
reason = EXCLUDED.reason,
@@ -290,20 +287,20 @@ RETURNING user_id, track_id, reason, notes, created_at
type UpsertQuarantineParams struct {
UserID pgtype.UUID
TrackID pgtype.UUID
- Reason LidarrQuarantineReason
+ Reason QuarantineReason
Notes *string
}
// Insert a new quarantine row, or update reason/notes if the user has
// already flagged this track.
-func (q *Queries) UpsertQuarantine(ctx context.Context, arg UpsertQuarantineParams) (LidarrQuarantine, error) {
+func (q *Queries) UpsertQuarantine(ctx context.Context, arg UpsertQuarantineParams) (Quarantine, error) {
row := q.db.QueryRow(ctx, upsertQuarantine,
arg.UserID,
arg.TrackID,
arg.Reason,
arg.Notes,
)
- var i LidarrQuarantine
+ var i Quarantine
err := row.Scan(
&i.UserID,
&i.TrackID,
@@ -315,26 +312,25 @@ func (q *Queries) UpsertQuarantine(ctx context.Context, arg UpsertQuarantinePara
}
const writeQuarantineAction = `-- name: WriteQuarantineAction :one
-INSERT INTO lidarr_quarantine_actions (
+INSERT INTO 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 id, track_id, track_title, artist_name, album_title, action, admin_id, lidarr_album_mbid, affected_users, created_at
+ action, admin_id, affected_users
+) VALUES ($1, $2, $3, $4, $5, $6, $7)
+RETURNING id, track_id, track_title, artist_name, album_title, action, admin_id, album_mbid, affected_users, created_at
`
type WriteQuarantineActionParams struct {
- TrackID pgtype.UUID
- TrackTitle string
- ArtistName string
- AlbumTitle *string
- Action LidarrQuarantineActionKind
- AdminID pgtype.UUID
- LidarrAlbumMbid *string
- AffectedUsers int32
+ TrackID pgtype.UUID
+ TrackTitle string
+ ArtistName string
+ AlbumTitle *string
+ Action QuarantineActionKind
+ AdminID pgtype.UUID
+ AffectedUsers int32
}
// Audit row for an admin destructive action.
-func (q *Queries) WriteQuarantineAction(ctx context.Context, arg WriteQuarantineActionParams) (LidarrQuarantineAction, error) {
+func (q *Queries) WriteQuarantineAction(ctx context.Context, arg WriteQuarantineActionParams) (QuarantineAction, error) {
row := q.db.QueryRow(ctx, writeQuarantineAction,
arg.TrackID,
arg.TrackTitle,
@@ -342,10 +338,9 @@ func (q *Queries) WriteQuarantineAction(ctx context.Context, arg WriteQuarantine
arg.AlbumTitle,
arg.Action,
arg.AdminID,
- arg.LidarrAlbumMbid,
arg.AffectedUsers,
)
- var i LidarrQuarantineAction
+ var i QuarantineAction
err := row.Scan(
&i.ID,
&i.TrackID,
@@ -354,7 +349,7 @@ func (q *Queries) WriteQuarantineAction(ctx context.Context, arg WriteQuarantine
&i.AlbumTitle,
&i.Action,
&i.AdminID,
- &i.LidarrAlbumMbid,
+ &i.AlbumMbid,
&i.AffectedUsers,
&i.CreatedAt,
)
diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go
index 9e1687b6..09bd0b99 100644
--- a/internal/db/dbq/recommendation.sql.go
+++ b/internal/db/dbq/recommendation.sql.go
@@ -218,7 +218,7 @@ JOIN artists ON artists.id = t.artist_id
WHERE t.artist_id = $1
AND t.missing_since IS NULL -- #2523: never offer a file that is gone
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $2 AND q.track_id = t.id
)
ORDER BY p.cnt DESC, t.id
@@ -304,7 +304,7 @@ JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY p.cnt DESC, t.id
@@ -728,7 +728,7 @@ WHERE t.id <> $2
AND started_at > now() - $3 * interval '1 hour'
)
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
`
@@ -827,7 +827,7 @@ excluded_ids AS (
WHERE pe.user_id = $1 AND pe.started_at > now() - $3 * interval '1 hour'
UNION ALL
SELECT q.track_id AS id
- FROM lidarr_quarantine q
+ FROM quarantine q
WHERE q.user_id = $1
),
lb_similar AS (
diff --git a/internal/db/dbq/system_mixes.sql.go b/internal/db/dbq/system_mixes.sql.go
index d8046f1b..da852842 100644
--- a/internal/db/dbq/system_mixes.sql.go
+++ b/internal/db/dbq/system_mixes.sql.go
@@ -43,7 +43,7 @@ SELECT t.id, t.album_id, t.artist_id
AND COALESCE(pc.c, 0) <= 2
AND COALESCE(sc.c, 0) < 2
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY md5(t.id::text || $2::text)
@@ -129,7 +129,7 @@ SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
AND alt.tier IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM attempted a WHERE a.track_id = t.id)
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY
@@ -229,7 +229,7 @@ SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
JOIN albums_tiered alt ON alt.album_id = t.album_id
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY alt.tier, alt.created_at DESC, t.album_id,
@@ -308,7 +308,7 @@ SELECT t.id, t.album_id, t.artist_id
JOIN windowed w ON w.track_id = t.id
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY w.c DESC, md5(t.id::text || $2::text)
@@ -375,7 +375,7 @@ SELECT t.id, t.album_id, t.artist_id
AND s.c >= 3
AND s.last_at <= now() - interval '30 days'
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
-- Declined-resurfacing cooldown (#1258): a skip on a
diff --git a/internal/db/dbq/taste_profile.sql.go b/internal/db/dbq/taste_profile.sql.go
index 473af444..28ac1866 100644
--- a/internal/db/dbq/taste_profile.sql.go
+++ b/internal/db/dbq/taste_profile.sql.go
@@ -197,7 +197,7 @@ JOIN albums a ON a.id = t.album_id
WHERE pe.user_id = $1
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
`
diff --git a/internal/db/dbq/tracks.sql.go b/internal/db/dbq/tracks.sql.go
index a26f2f9a..1280fbda 100644
--- a/internal/db/dbq/tracks.sql.go
+++ b/internal/db/dbq/tracks.sql.go
@@ -46,8 +46,8 @@ UPDATE tracks
AND NOT regexp_replace(file_path, '^.*/', '') ~* $1::text
`
-// A held-back track whose name no longer carries a marker (renamed, retagged
-// by Lidarr) is released again.
+// A held-back track whose name no longer carries a marker (renamed or
+// retagged) is released again.
func (q *Queries) ClearStaleSuspectSourceTracks(ctx context.Context, pattern string) (int64, error) {
result, err := q.db.Exec(ctx, clearStaleSuspectSourceTracks, pattern)
if err != nil {
@@ -130,7 +130,7 @@ const countTracksMatching = `-- name: CountTracksMatching :one
SELECT COUNT(*) FROM tracks
WHERE title ILIKE '%' || $1::text || '%'
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $2 AND q.track_id = tracks.id
)
`
@@ -162,8 +162,8 @@ type DeleteTrackRow struct {
}
// M7 #372: hard delete with FK cascade. The CASCADE on track_id from
-// play_events / general_likes_tracks / lidarr_quarantine /
-// lidarr_quarantine_actions handles their cleanup. RETURNING gives us
+// play_events / general_likes_tracks / quarantine /
+// quarantine_actions handles their cleanup. RETURNING gives us
// album_id + artist_id for the album-empty / artist-empty cascade
// checks the service does next.
func (q *Queries) DeleteTrack(ctx context.Context, id pgtype.UUID) (DeleteTrackRow, error) {
@@ -414,7 +414,7 @@ JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE t.artist_id = $1
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $2 AND q.track_id = t.id
)
ORDER BY albums.release_date NULLS LAST, albums.sort_title,
@@ -433,7 +433,7 @@ type ListArtistTracksForUserRow struct {
}
// M6a: every track for the artist across their albums, with album_title
-// and artist_name joined. Honors per-user lidarr_quarantine. Used by
+// and artist_name joined. Honors per-user quarantine. Used by
// /api/artists/{id}/tracks for the artist-card play affordance, which
// shuffles client-side. Ordering matches album/track natural order so
// the shuffle has a deterministic input.
@@ -578,7 +578,7 @@ FROM tracks t
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY random()
@@ -786,7 +786,7 @@ const listTracksByAlbum = `-- name: ListTracksByAlbum :many
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at FROM tracks
WHERE album_id = $1
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $2 AND q.track_id = tracks.id
)
ORDER BY disc_number NULLS LAST, track_number NULLS LAST
@@ -910,7 +910,7 @@ const searchTracks = `-- name: SearchTracks :many
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at FROM tracks
WHERE title ILIKE '%' || $1::text || '%'
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $2 AND q.track_id = tracks.id
)
ORDER BY title
diff --git a/internal/db/dbq/users.sql.go b/internal/db/dbq/users.sql.go
index 21b6bcac..2dd5632f 100644
--- a/internal/db/dbq/users.sql.go
+++ b/internal/db/dbq/users.sql.go
@@ -205,7 +205,7 @@ DELETE FROM users WHERE id = $1
// Hard delete with cascade. The schema's ON DELETE CASCADE foreign keys
// handle plays/likes/sessions/etc. Caller must enforce the last-admin
// guard at the HTTP layer (count admins, refuse if target is the only
-// admin). The lidarr_quarantine, general_likes*, play_events,
+// admin). The quarantine, general_likes*, play_events,
// sessions, and other user-FK tables all have ON DELETE CASCADE so
// this is a clean drop.
func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error {
diff --git a/internal/db/migrations/0091_remove_lidarr.down.sql b/internal/db/migrations/0091_remove_lidarr.down.sql
new file mode 100644
index 00000000..dceb707f
--- /dev/null
+++ b/internal/db/migrations/0091_remove_lidarr.down.sql
@@ -0,0 +1,29 @@
+ALTER TABLE quarantine_actions RENAME CONSTRAINT quarantine_actions_admin_id_fkey TO lidarr_quarantine_actions_admin_id_fkey;
+ALTER TABLE quarantine RENAME CONSTRAINT quarantine_track_id_fkey TO lidarr_quarantine_track_id_fkey;
+ALTER TABLE quarantine RENAME CONSTRAINT quarantine_user_id_fkey TO lidarr_quarantine_user_id_fkey;
+
+ALTER INDEX quarantine_actions_created_idx RENAME TO lidarr_quarantine_actions_created_idx;
+ALTER INDEX quarantine_actions_track_idx RENAME TO lidarr_quarantine_actions_track_idx;
+ALTER INDEX quarantine_actions_pkey RENAME TO lidarr_quarantine_actions_pkey;
+ALTER TABLE quarantine_actions RENAME COLUMN album_mbid TO lidarr_album_mbid;
+ALTER TYPE quarantine_action_kind RENAME VALUE 'deleted_album' TO 'deleted_via_lidarr';
+ALTER TYPE quarantine_action_kind RENAME TO lidarr_quarantine_action_kind;
+ALTER TABLE quarantine_actions RENAME TO lidarr_quarantine_actions;
+
+ALTER INDEX quarantine_pkey RENAME TO lidarr_quarantine_pkey;
+ALTER INDEX quarantine_track_idx RENAME TO lidarr_quarantine_track_idx;
+ALTER TYPE quarantine_reason RENAME TO lidarr_quarantine_reason;
+ALTER TABLE quarantine RENAME TO lidarr_quarantine;
+
+CREATE TABLE lidarr_config (
+ id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1),
+ enabled boolean NOT NULL DEFAULT false,
+ base_url text,
+ api_key text,
+ default_quality_profile_id int,
+ default_root_folder_path text,
+ default_metadata_profile_id integer,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+INSERT INTO lidarr_config (id, enabled) VALUES (1, false);
diff --git a/internal/db/migrations/0091_remove_lidarr.up.sql b/internal/db/migrations/0091_remove_lidarr.up.sql
new file mode 100644
index 00000000..6d41e6fc
--- /dev/null
+++ b/internal/db/migrations/0091_remove_lidarr.up.sql
@@ -0,0 +1,29 @@
+-- Minstrel no longer talks to Lidarr (M502 #5525): it finds, downloads,
+-- checks and files music itself.
+--
+-- lidarr_config the connection's settings: dropped.
+-- lidarr_quarantine(_actions) the per-user track reports and the admin
+-- actions on them, which never needed Lidarr:
+-- renamed to quarantine(_actions), with their
+-- types and indexes.
+-- 'deleted_via_lidarr' the history of an album removed through
+-- Lidarr reads as what it did: 'deleted_album'.
+-- lidarr_album_mbid the album id that removal recorded: album_mbid.
+DROP TABLE lidarr_config;
+
+ALTER TABLE lidarr_quarantine RENAME TO quarantine;
+ALTER TYPE lidarr_quarantine_reason RENAME TO quarantine_reason;
+ALTER INDEX lidarr_quarantine_track_idx RENAME TO quarantine_track_idx;
+ALTER INDEX lidarr_quarantine_pkey RENAME TO quarantine_pkey;
+
+ALTER TABLE lidarr_quarantine_actions RENAME TO quarantine_actions;
+ALTER TYPE lidarr_quarantine_action_kind RENAME TO quarantine_action_kind;
+ALTER TYPE quarantine_action_kind RENAME VALUE 'deleted_via_lidarr' TO 'deleted_album';
+ALTER TABLE quarantine_actions RENAME COLUMN lidarr_album_mbid TO album_mbid;
+ALTER INDEX lidarr_quarantine_actions_pkey RENAME TO quarantine_actions_pkey;
+ALTER INDEX lidarr_quarantine_actions_track_idx RENAME TO quarantine_actions_track_idx;
+ALTER INDEX lidarr_quarantine_actions_created_idx RENAME TO quarantine_actions_created_idx;
+
+ALTER TABLE quarantine RENAME CONSTRAINT lidarr_quarantine_user_id_fkey TO quarantine_user_id_fkey;
+ALTER TABLE quarantine RENAME CONSTRAINT lidarr_quarantine_track_id_fkey TO quarantine_track_id_fkey;
+ALTER TABLE quarantine_actions RENAME CONSTRAINT lidarr_quarantine_actions_admin_id_fkey TO quarantine_actions_admin_id_fkey;
diff --git a/internal/db/queries/admin_summary.sql b/internal/db/queries/admin_summary.sql
index 14ff1f24..97d91f86 100644
--- a/internal/db/queries/admin_summary.sql
+++ b/internal/db/queries/admin_summary.sql
@@ -11,11 +11,11 @@ SELECT
-- it, or it failed to play (#5606). Counted by track, as the Reports
-- page lists them.
(SELECT count(*) FROM (
- SELECT track_id FROM lidarr_quarantine
+ SELECT track_id FROM quarantine
UNION
SELECT track_id FROM playback_errors WHERE resolved_at IS NULL) r)::bigint AS reports,
(SELECT min(at) FROM (
- SELECT created_at AS at FROM lidarr_quarantine
+ SELECT created_at AS at FROM quarantine
UNION ALL
SELECT occurred_at FROM playback_errors WHERE resolved_at IS NULL) r)::timestamptz AS reports_since,
(SELECT count(*) FROM duplicate_groups g
diff --git a/internal/db/queries/audit_log.sql b/internal/db/queries/audit_log.sql
index 153a5cb6..6322ec70 100644
--- a/internal/db/queries/audit_log.sql
+++ b/internal/db/queries/audit_log.sql
@@ -15,8 +15,8 @@ SELECT count(*)::bigint FROM audit_log
-- name: ListAuditLogByActions :many
-- One page of the given actions, newest first: the duplicates report's
--- "resolved automatically" activity (M498) reads the resolver's merges and
--- Lidarr release changes from here.
+-- "resolved automatically" activity (M498) reads the resolver's merges from
+-- here.
SELECT * FROM audit_log
WHERE action = ANY(sqlc.arg(actions)::text[])
AND (NOT sqlc.arg(system_only)::boolean OR actor_id IS NULL)
diff --git a/internal/db/queries/discover.sql b/internal/db/queries/discover.sql
index 2c297610..ac9e057f 100644
--- a/internal/db/queries/discover.sql
+++ b/internal/db/queries/discover.sql
@@ -41,7 +41,7 @@ SELECT t.id, t.album_id, t.artist_id
WHERE gl.user_id = $1 AND gl.track_id = t.id
)
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY md5(t.id::text || $2::text)
@@ -74,7 +74,7 @@ SELECT t.id, t.album_id, t.artist_id
WHERE ml.user_id = $1 AND ml.track_id = t.id
)
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
GROUP BY t.id, t.album_id, t.artist_id
@@ -100,7 +100,7 @@ SELECT t.id, t.album_id, t.artist_id
WHERE gl.user_id = $1 AND gl.track_id = t.id
)
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY md5(t.id::text || $2::text)
@@ -144,7 +144,7 @@ WITH scored AS (
WHERE gl.user_id = sqlc.arg(user_id) AND gl.track_id = t.id
)
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id
)
GROUP BY t.id, t.album_id, t.artist_id
diff --git a/internal/db/queries/history.sql b/internal/db/queries/history.sql
index dd6176e4..bd56e828 100644
--- a/internal/db/queries/history.sql
+++ b/internal/db/queries/history.sql
@@ -16,7 +16,7 @@ SELECT pe.id AS event_id,
WHERE pe.user_id = $1
AND pe.was_skipped = false
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY pe.started_at DESC
diff --git a/internal/db/queries/lidarr_config.sql b/internal/db/queries/lidarr_config.sql
deleted file mode 100644
index 5b31c633..00000000
--- a/internal/db/queries/lidarr_config.sql
+++ /dev/null
@@ -1,20 +0,0 @@
--- name: GetLidarrConfig :one
-SELECT id, enabled, base_url, api_key, default_quality_profile_id,
- default_metadata_profile_id, default_root_folder_path,
- created_at, updated_at
-FROM lidarr_config
-WHERE id = 1;
-
--- name: UpdateLidarrConfig :one
-UPDATE lidarr_config
- SET enabled = $1,
- base_url = $2,
- api_key = $3,
- default_quality_profile_id = $4,
- default_metadata_profile_id = $5,
- default_root_folder_path = $6,
- updated_at = now()
- WHERE id = 1
- RETURNING id, enabled, base_url, api_key, default_quality_profile_id,
- default_metadata_profile_id, default_root_folder_path,
- created_at, updated_at;
diff --git a/internal/db/queries/merge.sql b/internal/db/queries/merge.sql
index 4ea902c6..914f81fd 100644
--- a/internal/db/queries/merge.sql
+++ b/internal/db/queries/merge.sql
@@ -13,11 +13,9 @@ SELECT id, tier, status
FOR UPDATE;
-- name: ListDuplicateGroupMergeMembers :many
-SELECT t.id, t.file_path, t.file_format, t.file_size, t.added_at, t.album_id,
- t.mbid, albums.mbid AS album_mbid
+SELECT t.id, t.file_path, t.file_format, t.file_size, t.added_at, t.album_id
FROM duplicate_group_members m
- JOIN tracks t ON t.id = m.track_id
- JOIN albums ON albums.id = t.album_id
+ JOIN tracks t ON t.id = m.track_id
WHERE m.group_id = sqlc.arg(group_id)
ORDER BY t.id;
diff --git a/internal/db/queries/missing_pairs.sql b/internal/db/queries/missing_pairs.sql
index 322a5dfb..ef8e3eb0 100644
--- a/internal/db/queries/missing_pairs.sql
+++ b/internal/db/queries/missing_pairs.sql
@@ -1,6 +1,6 @@
-- Missing-pair pass (M485). A track marked missing whose replacement is already
--- on disk under another row — most often a Lidarr quality upgrade, mp3 → flac at
--- a new path — is folded into that replacement, so its history moves across and
+-- on disk under another row — most often a quality upgrade, mp3 → flac at a new
+-- path — is folded into that replacement, so its history moves across and
-- re-acquisition stops chasing music the library already has.
--
-- Move adoption (#2528) cannot catch these: it links a new file to a missing row
diff --git a/internal/db/queries/lidarr_quarantine.sql b/internal/db/queries/quarantine.sql
similarity index 84%
rename from internal/db/queries/lidarr_quarantine.sql
rename to internal/db/queries/quarantine.sql
index 8c43fc9c..85cb8c92 100644
--- a/internal/db/queries/lidarr_quarantine.sql
+++ b/internal/db/queries/quarantine.sql
@@ -1,7 +1,7 @@
-- 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)
+INSERT INTO quarantine (user_id, track_id, reason, notes)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, track_id) DO UPDATE SET
reason = EXCLUDED.reason,
@@ -12,7 +12,7 @@ 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
+DELETE FROM quarantine
WHERE user_id = $1 AND track_id = $2
RETURNING user_id, track_id, reason, notes, created_at;
@@ -31,7 +31,7 @@ SELECT
al.cover_art_path AS album_cover_art_path,
ar.id AS artist_id,
ar.name AS artist_name
-FROM lidarr_quarantine q
+FROM 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
@@ -48,14 +48,13 @@ SELECT
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)::timestamptz AS latest_at
-FROM lidarr_quarantine q
+FROM 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
+GROUP BY t.id, ar.name, al.title, al.id
ORDER BY max(q.created_at) DESC;
-- name: ListQuarantineReportsForTrack :many
@@ -67,7 +66,7 @@ SELECT
q.reason,
q.notes,
q.created_at
-FROM lidarr_quarantine q
+FROM quarantine q
JOIN users u ON u.id = q.user_id
WHERE q.track_id = $1
ORDER BY q.created_at DESC;
@@ -76,21 +75,21 @@ ORDER BY q.created_at DESC;
-- 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;
+DELETE FROM 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;
+SELECT count(*)::int FROM quarantine WHERE track_id = $1;
-- name: WriteQuarantineAction :one
-- Audit row for an admin destructive action.
-INSERT INTO lidarr_quarantine_actions (
+INSERT INTO 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)
+ action, admin_id, affected_users
+) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *;
-- name: ListQuarantineActions :many
-SELECT * FROM lidarr_quarantine_actions
+SELECT * FROM quarantine_actions
ORDER BY created_at DESC
LIMIT $1;
diff --git a/internal/db/queries/recommendation.sql b/internal/db/queries/recommendation.sql
index f4a6e63c..3fd2a34e 100644
--- a/internal/db/queries/recommendation.sql
+++ b/internal/db/queries/recommendation.sql
@@ -31,7 +31,7 @@ WHERE t.id <> $2
AND started_at > now() - $3 * interval '1 hour'
)
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
);
@@ -83,7 +83,7 @@ excluded_ids AS (
WHERE pe.user_id = $1 AND pe.started_at > now() - $3 * interval '1 hour'
UNION ALL
SELECT q.track_id AS id
- FROM lidarr_quarantine q
+ FROM quarantine q
WHERE q.user_id = $1
),
lb_similar AS (
@@ -416,7 +416,7 @@ JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY p.cnt DESC, t.id
@@ -443,7 +443,7 @@ JOIN artists ON artists.id = t.artist_id
WHERE t.artist_id = sqlc.arg(artist_id)
AND t.missing_since IS NULL -- #2523: never offer a file that is gone
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id
)
ORDER BY p.cnt DESC, t.id
diff --git a/internal/db/queries/system_mixes.sql b/internal/db/queries/system_mixes.sql
index 6469ae06..bd5126da 100644
--- a/internal/db/queries/system_mixes.sql
+++ b/internal/db/queries/system_mixes.sql
@@ -44,7 +44,7 @@ SELECT t.id, t.album_id, t.artist_id
AND COALESCE(pc.c, 0) <= 2
AND COALESCE(sc.c, 0) < 2
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY md5(t.id::text || $2::text)
@@ -75,7 +75,7 @@ SELECT t.id, t.album_id, t.artist_id
AND s.c >= 3
AND s.last_at <= now() - interval '30 days'
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
-- Declined-resurfacing cooldown (#1258): a skip on a
@@ -153,7 +153,7 @@ SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
JOIN albums_tiered alt ON alt.album_id = t.album_id
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY alt.tier, alt.created_at DESC, t.album_id,
@@ -192,7 +192,7 @@ SELECT t.id, t.album_id, t.artist_id
JOIN windowed w ON w.track_id = t.id
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY w.c DESC, md5(t.id::text || $2::text)
@@ -248,7 +248,7 @@ SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
AND alt.tier IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM attempted a WHERE a.track_id = t.id)
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY
diff --git a/internal/db/queries/taste_profile.sql b/internal/db/queries/taste_profile.sql
index d9ddb987..77746987 100644
--- a/internal/db/queries/taste_profile.sql
+++ b/internal/db/queries/taste_profile.sql
@@ -24,7 +24,7 @@ JOIN albums a ON a.id = t.album_id
WHERE pe.user_id = $1
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
);
diff --git a/internal/db/queries/tracks.sql b/internal/db/queries/tracks.sql
index df0a7c91..275125ca 100644
--- a/internal/db/queries/tracks.sql
+++ b/internal/db/queries/tracks.sql
@@ -62,7 +62,7 @@ SELECT * FROM tracks WHERE file_path = $1;
SELECT * FROM tracks
WHERE album_id = $1
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $2 AND q.track_id = tracks.id
)
ORDER BY disc_number NULLS LAST, track_number NULLS LAST;
@@ -76,7 +76,7 @@ SELECT count(*) FROM tracks WHERE album_id = $1;
SELECT * FROM tracks
WHERE title ILIKE '%' || $1::text || '%'
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $2 AND q.track_id = tracks.id
)
ORDER BY title
@@ -87,13 +87,13 @@ LIMIT $3 OFFSET $4;
SELECT COUNT(*) FROM tracks
WHERE title ILIKE '%' || $1::text || '%'
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $2 AND q.track_id = tracks.id
);
-- name: ListArtistTracksForUser :many
-- M6a: every track for the artist across their albums, with album_title
--- and artist_name joined. Honors per-user lidarr_quarantine. Used by
+-- and artist_name joined. Honors per-user quarantine. Used by
-- /api/artists/{id}/tracks for the artist-card play affordance, which
-- shuffles client-side. Ordering matches album/track natural order so
-- the shuffle has a deterministic input.
@@ -105,7 +105,7 @@ JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE t.artist_id = $1
AND NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $2 AND q.track_id = t.id
)
ORDER BY albums.release_date NULLS LAST, albums.sort_title,
@@ -122,7 +122,7 @@ FROM tracks t
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE NOT EXISTS (
- SELECT 1 FROM lidarr_quarantine q
+ SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY random()
@@ -136,8 +136,8 @@ SELECT COUNT(*) FROM tracks WHERE artist_id = $1;
-- name: DeleteTrack :one
-- M7 #372: hard delete with FK cascade. The CASCADE on track_id from
--- play_events / general_likes_tracks / lidarr_quarantine /
--- lidarr_quarantine_actions handles their cleanup. RETURNING gives us
+-- play_events / general_likes_tracks / quarantine /
+-- quarantine_actions handles their cleanup. RETURNING gives us
-- album_id + artist_id for the album-empty / artist-empty cascade
-- checks the service does next.
DELETE FROM tracks WHERE id = $1
@@ -321,8 +321,8 @@ UPDATE tracks
AND regexp_replace(file_path, '^.*/', '') ~* sqlc.arg(pattern)::text;
-- name: ClearStaleSuspectSourceTracks :execrows
--- A held-back track whose name no longer carries a marker (renamed, retagged
--- by Lidarr) is released again.
+-- A held-back track whose name no longer carries a marker (renamed or
+-- retagged) is released again.
UPDATE tracks
SET source_verdict = NULL, source_verdict_at = NULL
WHERE source_verdict = 'suspect'
diff --git a/internal/db/queries/users.sql b/internal/db/queries/users.sql
index 534fb0d1..a3dd36b3 100644
--- a/internal/db/queries/users.sql
+++ b/internal/db/queries/users.sql
@@ -96,7 +96,7 @@ RETURNING *;
-- Hard delete with cascade. The schema's ON DELETE CASCADE foreign keys
-- handle plays/likes/sessions/etc. Caller must enforce the last-admin
-- guard at the HTTP layer (count admins, refuse if target is the only
--- admin). The lidarr_quarantine, general_likes*, play_events,
+-- admin). The quarantine, general_likes*, play_events,
-- sessions, and other user-FK tables all have ON DELETE CASCADE so
-- this is a clean drop.
DELETE FROM users WHERE id = $1;
diff --git a/internal/dbtest/reset.go b/internal/dbtest/reset.go
index b066fb04..d6cffc58 100644
--- a/internal/dbtest/reset.go
+++ b/internal/dbtest/reset.go
@@ -53,8 +53,8 @@ var dataTables = []string{
"skip_events",
"play_sessions",
"sessions",
- "lidarr_quarantine_actions",
- "lidarr_quarantine",
+ "quarantine_actions",
+ "quarantine",
// #2374. Keyed by (user_id, candidate_mbid) with no FK to artists —
// candidates are out-of-library — so the CASCADE from artists/users
// does NOT reach it for a leftover row whose user survived. Truncate
@@ -108,7 +108,7 @@ var dataTables = []string{
"mb_artist_release_groups", // M502 #5519
"mb_release_groups", // M502 #5519
"mb_artists", // M502 #5519
- "requests", // M502 #5520 (was lidarr_requests)
+ "requests", // M502 #5520
"download_interventions", // M502 #5523
"library_album_matches", // #5569
"release_blocks", // M502 #5521
diff --git a/internal/importer/name.go b/internal/importer/name.go
index c9342fef..60bc9f4d 100644
--- a/internal/importer/name.go
+++ b/internal/importer/name.go
@@ -20,9 +20,8 @@ type Placement struct {
Recording string
}
-// Plan names every file's destination under root the way Lidarr named the
-// library Minstrel took over (note #5527), so new arrivals sit beside what
-// is there:
+// Plan names every file's destination under root the way the library is
+// already named (note #5527), so new arrivals sit beside what is there:
//
// {Artist}/{Album} ({Year})/{Artist} - {Album} - {track:00} - {Title}.ext
//
@@ -83,11 +82,11 @@ const segmentMax = 200
var illegal = strings.NewReplacer(
`\`, "+", "/", "+", "<", "", ">", "", "?", "!", "*", "-", "|", "", `"`, "",
- // Lidarr's default "smart" colon: "Title: Subtitle" reads "Title - Subtitle".
+ // The "smart" colon: "Title: Subtitle" reads "Title - Subtitle".
": ", " - ", ":", "-",
)
-// Segment makes a name safe as one path segment, the way Lidarr and Sonarr
+// Segment makes a name safe as one path segment, the way the *arr programs
// clean file names: their illegal-character mapping, no leading or trailing
// spaces or dots, at most segmentMax bytes, never "" or "..".
func Segment(s string) string {
diff --git a/internal/importer/verify_test.go b/internal/importer/verify_test.go
index 94ec335f..82565a79 100644
--- a/internal/importer/verify_test.go
+++ b/internal/importer/verify_test.go
@@ -161,7 +161,7 @@ func TestVerify_WithoutAcoustIDTheReasonSaysSo(t *testing.T) {
}
}
-func TestPlan_LidarrsNaming(t *testing.T) {
+func TestPlan_LibraryNaming(t *testing.T) {
e := edition("rel-cd", 2)
e.Tracks[1].Title = "Within: Part 2/3?"
pl := Plan("/music", ram, &e, download(2), Verify(ram, []Edition{e}, download(2), true, false).Pairs)
diff --git a/internal/library/duplicate_merge.go b/internal/library/duplicate_merge.go
index fc016620..2ba26079 100644
--- a/internal/library/duplicate_merge.go
+++ b/internal/library/duplicate_merge.go
@@ -27,10 +27,8 @@ var ErrSurvivorNotInGroup = errors.New("library: survivor is not a member of the
// MergedCopy is one copy a merge kept or removed.
type MergedCopy struct {
- TrackID pgtype.UUID
- FilePath string
- TrackMbid *string
- AlbumMbid *string
+ TrackID pgtype.UUID
+ FilePath string
}
// MergeResult says what a merge did.
@@ -296,7 +294,7 @@ func splitSurvivor(
}
func mergedCopyOf(m dbq.ListDuplicateGroupMergeMembersRow) MergedCopy {
- return MergedCopy{TrackID: m.ID, FilePath: m.FilePath, TrackMbid: m.Mbid, AlbumMbid: m.AlbumMbid}
+ return MergedCopy{TrackID: m.ID, FilePath: m.FilePath}
}
// mergeChanges collects the sync-log entries a merge owes clients' caches, each
diff --git a/internal/library/duplicate_resolve.go b/internal/library/duplicate_resolve.go
index 8a81d80d..bc39bc35 100644
--- a/internal/library/duplicate_resolve.go
+++ b/internal/library/duplicate_resolve.go
@@ -17,7 +17,7 @@ import (
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
-// Duplicate resolver (M498 #5435, #5437; without Lidarr since M502 #5517).
+// Duplicate resolver (M498 #5435, #5437; M502 #5517).
//
// The sweep proposes duplicate groups; the resolver decides what each one is
// and settles the ones that are safe to settle without asking. Minstrel is the
diff --git a/internal/library/duplicate_resolve_test.go b/internal/library/duplicate_resolve_test.go
index 6f184056..82d52f77 100644
--- a/internal/library/duplicate_resolve_test.go
+++ b/internal/library/duplicate_resolve_test.go
@@ -93,9 +93,8 @@ func autoMergeAudit(t *testing.T, pool *pgxpool.Pool, groupID pgtype.UUID) (n in
}
// One album holding a song twice: the extra copy goes, the copy whose name and
-// tags fit the album stays, and the merge is recorded as the resolver's. No
-// Lidarr is asked (M502 #5517): Minstrel is the only program writing the
-// library.
+// tags fit the album stays, and the merge is recorded as the resolver's
+// (M502 #5517): Minstrel is the only program writing the library.
func TestResolveDuplicates_MergesASameReleaseCopy_Integration(t *testing.T) {
f := newResolveFixture(t)
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", true)
@@ -192,8 +191,7 @@ func newCrossReleaseRipFixture(t *testing.T) crossReleaseRipFixture {
}
// A video rip on one release with the clean copy on another: the rip is no
-// release's copy, so it goes and the album cut stays (#5439, now without
-// Lidarr).
+// release's copy, so it goes and the album cut stays (#5439).
func TestResolveDuplicates_MergesARipAcrossReleases_Integration(t *testing.T) {
f := newCrossReleaseRipFixture(t)
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", true)
diff --git a/internal/library/fingerprint_settings.go b/internal/library/fingerprint_settings.go
index b451441a..69f94c7d 100644
--- a/internal/library/fingerprint_settings.go
+++ b/internal/library/fingerprint_settings.go
@@ -56,8 +56,8 @@ type FingerprintSettings struct {
BackfillConcurrency int32
SweepIntervalHours int32
// AutoResolve lets the duplicate resolver (M498) act on its own: merge
- // copies Lidarr does not need and change a Lidarr release that lists songs
- // twice. Off, it only classifies, and every group waits for the operator.
+ // copies no release needs. Off, it only classifies, and every group waits
+ // for the operator.
AutoResolve bool
// UpdatedAt is when the settings were last saved. Set by the database;
// ignored by Set.
diff --git a/internal/library/mbids.go b/internal/library/mbids.go
index e63550b7..f6ef768e 100644
--- a/internal/library/mbids.go
+++ b/internal/library/mbids.go
@@ -44,7 +44,7 @@ func extractRecordingMBID(m tag.Metadata) string {
// extractReleaseGroupMBID reads the MusicBrainz *release-group* ID —
// Picard's musicbrainz_releasegroupid, surfaced by dhowden/tag as
// mbz.ReleaseGroup. mbz.Album (what albums.mbid stores) is the release: one
-// edition. Lidarr names albums by release group, so re-acquisition and
+// edition. Wants name albums by release group, so re-acquisition and
// request completion key on this one (#5241). Separate from extractMBIDs for
// the same reason extractRecordingMBID is.
func extractReleaseGroupMBID(m tag.Metadata) string {
diff --git a/internal/library/missing_pairs.go b/internal/library/missing_pairs.go
index 9d91d624..0764572c 100644
--- a/internal/library/missing_pairs.go
+++ b/internal/library/missing_pairs.go
@@ -18,8 +18,8 @@ import (
// A track marked missing whose replacement already sits on disk under another
// row is folded into that replacement: its likes, plays, playlist entries and
// tags move across and the missing row goes. On the operator's library this was
-// 355 of 451 missing tracks (#5274), nearly all Lidarr quality upgrades — the
-// mp3 removed, a flac imported at a new path.
+// 355 of 451 missing tracks (#5274), nearly all quality upgrades — the mp3
+// removed, a flac imported at a new path.
//
// Move adoption (moved.go) can't catch these. It links a new file to a missing
// row when the file is first inserted, matching on the recording MBID or the
diff --git a/internal/library/missing_pairs_test.go b/internal/library/missing_pairs_test.go
index 01126f9f..a356f995 100644
--- a/internal/library/missing_pairs_test.go
+++ b/internal/library/missing_pairs_test.go
@@ -231,7 +231,7 @@ func TestPairMissingTracks_Integration(t *testing.T) {
}
}
-// The case that made M485: a Lidarr quality upgrade. The mp3 is removed and a
+// The case that made M485: a quality upgrade. The mp3 is removed and a
// re-encode of it arrives at a new path with no recording MBID, so move adoption
// has nothing to match. The scan must still end with one row, carrying the old
// row's history.
diff --git a/internal/library/scanner.go b/internal/library/scanner.go
index 2c986caa..53b25e9e 100644
--- a/internal/library/scanner.go
+++ b/internal/library/scanner.go
@@ -57,7 +57,7 @@ var audioExtensions = map[string]bool{
// to new files only because taste_profile.sql reads tracks.genre directly,
// so a half-repaired library would carry both spellings as separate tags.
// 3: the MusicBrainz release-group id is read into albums.release_group_mbid
-// (#5241). Lidarr names albums by release group, not by the release id
+// (#5241). Wants name albums by release group, not by the release id
// albums.mbid holds, so re-acquisition and request completion need it for
// the albums already in the library, not only for files added from now on.
// 4: every value of a repeated genre field is read for FLAC, Ogg Vorbis, Opus
diff --git a/internal/lidarr/client.go b/internal/lidarr/client.go
deleted file mode 100644
index 9d90cc51..00000000
--- a/internal/lidarr/client.go
+++ /dev/null
@@ -1,511 +0,0 @@
-package lidarr
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "strconv"
- "strings"
- "time"
-)
-
-// Client wraps Lidarr's v1 HTTP API. BaseURL is the Lidarr instance
-// (e.g. http://lidarr.lan:8686), APIKey comes from Lidarr's settings.
-type Client struct {
- BaseURL string
- APIKey string
- HTTP *http.Client
-}
-
-// NewClient builds a Client with sensible HTTP defaults (30s timeout,
-// matching the pattern in internal/scrobble/listenbrainz). Call sites
-// that need a custom client can construct the Client struct directly.
-func NewClient(baseURL, apiKey string) *Client {
- return &Client{
- BaseURL: baseURL,
- APIKey: apiKey,
- HTTP: &http.Client{Timeout: 30 * time.Second},
- }
-}
-
-// get executes an authenticated GET request and maps HTTP status codes to
-// typed errors. The caller is responsible for closing the returned body.
-func (c *Client) get(ctx context.Context, path string, q url.Values) (*http.Response, error) {
- u, err := url.Parse(c.BaseURL)
- if err != nil {
- return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
- }
- u.Path = strings.TrimRight(u.Path, "/") + path
- if q != nil {
- u.RawQuery = q.Encode()
- }
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
- if err != nil {
- return nil, 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.StatusCode == http.StatusForbidden {
- _ = resp.Body.Close()
- return nil, ErrAuthFailed
- }
- if resp.StatusCode >= 500 {
- body := readBodySnippet(resp.Body)
- _ = resp.Body.Close()
- return nil, fmt.Errorf("%w: %d: %s", ErrServerError, resp.StatusCode, body)
- }
- if resp.StatusCode >= 400 {
- body := readBodySnippet(resp.Body)
- _ = resp.Body.Close()
- return nil, fmt.Errorf("%w: %d: %s", ErrLookupFailed, resp.StatusCode, body)
- }
- return resp, nil
-}
-
-// readBodySnippet reads up to 512 bytes from an error response body so the
-// wrapped error message can include Lidarr's actual complaint (e.g.
-// `{"errorMessage":"Artist already exists in your library"}`). Without this,
-// callers see only the status-code bucket and can't diagnose without
-// cross-referencing Lidarr's own logs.
-func readBodySnippet(r io.Reader) string {
- buf, _ := io.ReadAll(io.LimitReader(r, 512))
- return strings.TrimSpace(string(buf))
-}
-
-// post is the shared POST helper. body must be marshaled JSON. It maps HTTP
-// status codes to typed errors; the caller is responsible for closing the
-// returned body.
-func (c *Client) post(ctx context.Context, path string, body []byte) (*http.Response, error) {
- return c.bodyRequest(ctx, http.MethodPost, path, body)
-}
-
-// put is the shared PUT helper. Body must be marshaled JSON. Mirrors post() —
-// same status-code mapping, same caller-closes-body contract.
-func (c *Client) put(ctx context.Context, path string, body []byte) (*http.Response, error) {
- return c.bodyRequest(ctx, http.MethodPut, path, body)
-}
-
-// bodyRequest is the shared core for POST and PUT. Lidarr's monitor toggle
-// is PUT-shaped, so factoring it out keeps the post/put helpers from
-// duplicating the status-code mapping.
-func (c *Client) bodyRequest(ctx context.Context, method, path string, body []byte) (*http.Response, error) {
- u, err := url.Parse(c.BaseURL)
- if err != nil {
- return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
- }
- u.Path = strings.TrimRight(u.Path, "/") + path
- req, err := http.NewRequestWithContext(ctx, method, u.String(), bytes.NewReader(body))
- if err != nil {
- return nil, err
- }
- req.Header.Set("X-Api-Key", c.APIKey)
- req.Header.Set("Content-Type", "application/json")
- resp, err := c.HTTP.Do(req)
- if err != nil {
- return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
- }
- if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
- _ = resp.Body.Close()
- return nil, ErrAuthFailed
- }
- if resp.StatusCode >= 500 {
- body := readBodySnippet(resp.Body)
- _ = resp.Body.Close()
- return nil, fmt.Errorf("%w: %d: %s", ErrServerError, resp.StatusCode, body)
- }
- if resp.StatusCode >= 400 {
- body := readBodySnippet(resp.Body)
- _ = resp.Body.Close()
- return nil, fmt.Errorf("%w: %d: %s", ErrLookupFailed, resp.StatusCode, body)
- }
- return resp, nil
-}
-
-// LookupArtist hits GET /api/v1/artist/lookup?term=. Returns normalized
-// LookupResults; Secondary is "{first genre} · {albumCount} albums" with
-// missing parts skipped.
-func (c *Client) LookupArtist(ctx context.Context, term string) ([]LookupResult, error) {
- resp, err := c.get(ctx, "/api/v1/artist/lookup", url.Values{"term": {term}})
- if err != nil {
- return nil, err
- }
- defer func() { _ = resp.Body.Close() }()
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
- }
- var raw []struct {
- ForeignArtistID string `json:"foreignArtistId"`
- ArtistName string `json:"artistName"`
- Genres []string `json:"genres"`
- AlbumCount int `json:"albumCount"`
- Images []lidarrImage `json:"images"`
- }
- if err := json.Unmarshal(body, &raw); err != nil {
- return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
- }
- out := make([]LookupResult, 0, len(raw))
- for _, r := range raw {
- secondary := ""
- if len(r.Genres) > 0 {
- secondary = r.Genres[0]
- }
- if r.AlbumCount > 0 {
- if secondary != "" {
- secondary += " · "
- }
- secondary += strconv.Itoa(r.AlbumCount) + " albums"
- }
- out = append(out, LookupResult{
- MBID: r.ForeignArtistID,
- Name: r.ArtistName,
- Secondary: secondary,
- ImageURL: pickPosterImage(r.Images),
- })
- }
- return out, nil
-}
-
-// LookupAlbum hits GET /api/v1/album/lookup?term=. Returns normalized
-// LookupResults; Secondary is "{artistName} · {year} · {trackCount} tracks".
-func (c *Client) LookupAlbum(ctx context.Context, term string) ([]LookupResult, error) {
- resp, err := c.get(ctx, "/api/v1/album/lookup", url.Values{"term": {term}})
- if err != nil {
- return nil, err
- }
- defer func() { _ = resp.Body.Close() }()
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
- }
- var raw []struct {
- ForeignAlbumID string `json:"foreignAlbumId"`
- ForeignArtistID string `json:"foreignArtistId"`
- Title string `json:"title"`
- ArtistName string `json:"artistName"`
- ReleaseDate string `json:"releaseDate"`
- TrackCount int `json:"trackCount"`
- Images []lidarrImage `json:"images"`
- }
- if err := json.Unmarshal(body, &raw); err != nil {
- return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
- }
- out := make([]LookupResult, 0, len(raw))
- for _, r := range raw {
- year := ""
- if len(r.ReleaseDate) >= 4 {
- year = r.ReleaseDate[:4]
- }
- secondary := ""
- if r.ArtistName != "" {
- secondary = r.ArtistName
- }
- if year != "" {
- if secondary != "" {
- secondary += " · "
- }
- secondary += year
- }
- if r.TrackCount > 0 {
- if secondary != "" {
- secondary += " · "
- }
- secondary += strconv.Itoa(r.TrackCount) + " tracks"
- }
- out = append(out, LookupResult{
- MBID: r.ForeignAlbumID,
- ArtistMBID: r.ForeignArtistID,
- Name: r.Title,
- Secondary: secondary,
- ImageURL: pickPosterImage(r.Images),
- })
- }
- return out, nil
-}
-
-// LookupTrack hits GET /api/v1/track/lookup?term=. Lidarr's track lookup is
-// per-album under the hood. Secondary is "{albumTitle} · {artistName}".
-func (c *Client) LookupTrack(ctx context.Context, term string) ([]LookupResult, error) {
- resp, err := c.get(ctx, "/api/v1/track/lookup", url.Values{"term": {term}})
- if err != nil {
- return nil, err
- }
- defer func() { _ = resp.Body.Close() }()
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
- }
- var raw []struct {
- ForeignTrackID string `json:"foreignTrackId"`
- ForeignAlbumID string `json:"foreignAlbumId"`
- ForeignArtistID string `json:"foreignArtistId"`
- Title string `json:"title"`
- AlbumTitle string `json:"albumTitle"`
- ArtistName string `json:"artistName"`
- }
- if err := json.Unmarshal(body, &raw); err != nil {
- return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
- }
- out := make([]LookupResult, 0, len(raw))
- for _, r := range raw {
- secondary := r.AlbumTitle
- if r.ArtistName != "" {
- if secondary != "" {
- secondary += " · "
- }
- secondary += r.ArtistName
- }
- out = append(out, LookupResult{
- MBID: r.ForeignTrackID,
- ArtistMBID: r.ForeignArtistID,
- AlbumMBID: r.ForeignAlbumID,
- Name: r.Title,
- Secondary: secondary,
- })
- }
- return out, nil
-}
-
-// AddArtist posts to POST /api/v1/artist. MonitorAll=true monitors every
-// album ("all"); false monitors only releases from now on ("future"). New
-// releases are monitored either way. Returns nil on 2xx; typed error
-// otherwise.
-//
-// All four of artistName, foreignArtistId, qualityProfileId, and
-// metadataProfileId are required by Lidarr. Omitting any one produces a
-// 400 with field-level validation messages (e.g. "'Metadata Profile Id'
-// must be greater than '0'").
-//
-// The monitor choice belongs in addOptions (AddArtistOptions, a
-// MonitoringOptions). ArtistResource has no top-level `monitor`, so a
-// choice sent there is dropped, AddOptions.Monitor stays Unknown and
-// Lidarr skips applying it (#5239).
-func (c *Client) AddArtist(ctx context.Context, p AddArtistParams) error {
- monitor := "future"
- if p.MonitorAll {
- monitor = "all"
- }
- body, err := json.Marshal(map[string]any{
- "foreignArtistId": p.ForeignArtistID,
- "artistName": p.ArtistName,
- "qualityProfileId": p.QualityProfileID,
- "metadataProfileId": p.MetadataProfileID,
- "rootFolderPath": p.RootFolderPath,
- "monitored": true,
- "monitorNewItems": "all",
- "addOptions": map[string]any{
- "monitor": monitor,
- "monitored": true,
- "searchForMissingAlbums": true,
- },
- })
- if err != nil {
- return fmt.Errorf("%w: marshal: %v", ErrInvalidPayload, err)
- }
- resp, err := c.post(ctx, "/api/v1/artist", body)
- if err != nil {
- if isAlreadyExists(err) {
- return ErrAlreadyExists
- }
- return err
- }
- _ = resp.Body.Close()
- return nil
-}
-
-// isAlreadyExists detects Lidarr's 400 "already in your library" rejection
-// from an add endpoint. The post() helper buckets it as ErrLookupFailed
-// but embeds Lidarr's errorMessage body snippet; matching the phrase lets
-// the idempotent retry path treat a re-add as success instead of error.
-func isAlreadyExists(err error) bool {
- if !errors.Is(err, ErrLookupFailed) {
- return false
- }
- m := strings.ToLower(err.Error())
- return strings.Contains(m, "already exists") ||
- strings.Contains(m, "already been added") ||
- strings.Contains(m, "already in your library")
-}
-
-// AddAlbum adds one album to Lidarr the way Lidarr's own UI does: look the
-// album up by MBID, then POST that album resource back with `monitored`,
-// `addOptions` and — when Lidarr doesn't have the artist yet — the
-// artist's profiles, root folder and monitoring filled in.
-//
-// POST /api/v1/album validates `artist` as a nested resource (#5234: a
-// flat payload is refused with "'Artist' must not be empty"), and the
-// lookup is what supplies it: the existing Lidarr artist when there is one,
-// otherwise a new one carrying its metadata. A new artist monitors only
-// this album (monitor "none" with albumsToMonitor, which Lidarr applies in
-// preference to the monitor type) and no future releases, so approving one
-// album never subscribes the library to the artist's whole catalogue.
-//
-// Returns nil on 2xx, ErrAlreadyExists when Lidarr already has the album,
-// ErrNotFound when Lidarr's metadata has no album under that MBID.
-func (c *Client) AddAlbum(ctx context.Context, p AddAlbumParams) error {
- album, err := c.lookupAlbumResource(ctx, p.ForeignAlbumID)
- if err != nil {
- return err
- }
- album["monitored"] = true
- album["addOptions"] = map[string]any{"searchForNewAlbum": true}
-
- artist, _ := album["artist"].(map[string]any)
- if artist == nil {
- artist = map[string]any{
- "foreignArtistId": p.ForeignArtistID,
- "artistName": p.ArtistName,
- }
- album["artist"] = artist
- }
- if id, _ := artist["id"].(float64); id == 0 {
- artist["qualityProfileId"] = p.QualityProfileID
- artist["metadataProfileId"] = p.MetadataProfileID
- artist["rootFolderPath"] = p.RootFolderPath
- artist["monitored"] = true
- artist["monitorNewItems"] = "none"
- artist["addOptions"] = map[string]any{
- "monitor": "none",
- "albumsToMonitor": []string{p.ForeignAlbumID},
- "monitored": true,
- "searchForMissingAlbums": false,
- }
- }
-
- body, err := json.Marshal(album)
- if err != nil {
- return fmt.Errorf("%w: marshal: %v", ErrInvalidPayload, err)
- }
- resp, err := c.post(ctx, "/api/v1/album", body)
- if err != nil {
- if isAlreadyExists(err) {
- return ErrAlreadyExists
- }
- return err
- }
- _ = resp.Body.Close()
- return nil
-}
-
-// ListMetadataProfiles hits GET /api/v1/metadataprofile and returns the
-// list of metadata profiles configured in Lidarr. The Approve flow uses
-// the first profile as a default when the operator hasn't pinned one.
-func (c *Client) ListMetadataProfiles(ctx context.Context) ([]MetadataProfile, error) {
- resp, err := c.get(ctx, "/api/v1/metadataprofile", nil)
- if err != nil {
- return nil, err
- }
- defer func() { _ = resp.Body.Close() }()
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
- }
- var raw []struct {
- ID int `json:"id"`
- Name string `json:"name"`
- }
- if err := json.Unmarshal(body, &raw); err != nil {
- return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
- }
- out := make([]MetadataProfile, len(raw))
- for i, r := range raw {
- out[i] = MetadataProfile{ID: r.ID, Name: r.Name}
- }
- return out, nil
-}
-
-// ListQualityProfiles hits GET /api/v1/qualityprofile and returns the full
-// list of quality profiles configured in Lidarr.
-func (c *Client) ListQualityProfiles(ctx context.Context) ([]QualityProfile, error) {
- resp, err := c.get(ctx, "/api/v1/qualityprofile", nil)
- if err != nil {
- return nil, err
- }
- defer func() { _ = resp.Body.Close() }()
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
- }
- var raw []struct {
- ID int `json:"id"`
- Name string `json:"name"`
- }
- if err := json.Unmarshal(body, &raw); err != nil {
- return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
- }
- out := make([]QualityProfile, len(raw))
- for i, r := range raw {
- out[i] = QualityProfile{ID: r.ID, Name: r.Name}
- }
- return out, nil
-}
-
-// ListRootFolders hits GET /api/v1/rootfolder and returns the list of root
-// folders configured in Lidarr.
-func (c *Client) ListRootFolders(ctx context.Context) ([]RootFolder, error) {
- resp, err := c.get(ctx, "/api/v1/rootfolder", nil)
- if err != nil {
- return nil, err
- }
- defer func() { _ = resp.Body.Close() }()
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
- }
- var raw []struct {
- Path string `json:"path"`
- Accessible bool `json:"accessible"`
- FreeSpace int64 `json:"freeSpace"`
- }
- if err := json.Unmarshal(body, &raw); err != nil {
- return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
- }
- out := make([]RootFolder, len(raw))
- for i, r := range raw {
- out[i] = RootFolder{Path: r.Path, Accessible: r.Accessible, FreeSpace: r.FreeSpace}
- }
- return out, nil
-}
-
-// Ping hits GET /api/v1/system/status to verify connectivity and returns
-// the Lidarr version string.
-func (c *Client) Ping(ctx context.Context) (PingResult, error) {
- resp, err := c.get(ctx, "/api/v1/system/status", nil)
- if err != nil {
- return PingResult{}, err
- }
- defer func() { _ = resp.Body.Close() }()
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return PingResult{}, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
- }
- var raw struct {
- Version string `json:"version"`
- }
- if err := json.Unmarshal(body, &raw); err != nil {
- return PingResult{}, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
- }
- return PingResult{Version: raw.Version}, nil
-}
-
-// pickPosterImage returns the remote URL (preferred) or local URL for the
-// first image with coverType=="poster". Returns "" when none match.
-func pickPosterImage(imgs []lidarrImage) string {
- for _, img := range imgs {
- if img.CoverType == "poster" {
- if img.RemoteURL != "" {
- return img.RemoteURL
- }
- return img.URL
- }
- }
- return ""
-}
diff --git a/internal/lidarr/client_test.go b/internal/lidarr/client_test.go
deleted file mode 100644
index 43e7b0af..00000000
--- a/internal/lidarr/client_test.go
+++ /dev/null
@@ -1,892 +0,0 @@
-package lidarr
-
-import (
- "context"
- "encoding/json"
- "errors"
- "net/http"
- "net/http/httptest"
- "os"
- "testing"
-)
-
-// newTestClient creates an httptest.Server and a Client wired to it.
-func newTestClient(handler http.HandlerFunc) (*Client, *httptest.Server) {
- srv := httptest.NewServer(handler)
- return &Client{BaseURL: srv.URL, APIKey: "key123", HTTP: srv.Client()}, srv
-}
-
-// mustReadFixture reads a file from testdata/ or fatals the test.
-func mustReadFixture(t *testing.T, name string) []byte {
- t.Helper()
- b, err := os.ReadFile("testdata/" + name)
- if err != nil {
- t.Fatalf("read fixture %q: %v", name, err)
- }
- return b
-}
-
-// --- LookupArtist ---
-
-func TestLookupArtist_HappyPath(t *testing.T) {
- body := mustReadFixture(t, "lookup_artist.json")
- c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
- if got := r.Header.Get("X-Api-Key"); got != "key123" {
- t.Errorf("X-Api-Key = %q, want key123", got)
- }
- if r.URL.Path != "/api/v1/artist/lookup" {
- t.Errorf("path = %q, want /api/v1/artist/lookup", r.URL.Path)
- }
- if got := r.URL.Query().Get("term"); got != "boards" {
- t.Errorf("term = %q, want boards", got)
- }
- _, _ = w.Write(body)
- })
- defer srv.Close()
-
- got, err := c.LookupArtist(context.Background(), "boards")
- if err != nil {
- t.Fatalf("LookupArtist: %v", err)
- }
- if len(got) != 2 {
- t.Fatalf("len = %d, want 2", len(got))
- }
-
- // First result — rich data.
- if got[0].MBID != "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" {
- t.Errorf("MBID = %q", got[0].MBID)
- }
- if got[0].Name != "Boards of Canada" {
- t.Errorf("Name = %q", got[0].Name)
- }
- if got[0].Secondary != "Electronic · 18 albums" {
- t.Errorf("Secondary = %q, want %q", got[0].Secondary, "Electronic · 18 albums")
- }
- if got[0].ImageURL != "https://example.invalid/boc.jpg" {
- t.Errorf("ImageURL = %q", got[0].ImageURL)
- }
-
- // Second result — empty genres and zero album count → empty secondary.
- if got[1].Secondary != "" {
- t.Errorf("expected empty secondary for no genres + 0 albums; got %q", got[1].Secondary)
- }
- if got[1].ImageURL != "" {
- t.Errorf("expected empty ImageURL; got %q", got[1].ImageURL)
- }
-}
-
-func TestLookupArtist_AuthFailed(t *testing.T) {
- c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusUnauthorized)
- })
- defer srv.Close()
- _, err := c.LookupArtist(context.Background(), "boards")
- if !errors.Is(err, ErrAuthFailed) {
- t.Fatalf("err = %v, want ErrAuthFailed", err)
- }
-}
-
-func TestLookupArtist_Forbidden(t *testing.T) {
- c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusForbidden)
- })
- defer srv.Close()
- _, err := c.LookupArtist(context.Background(), "boards")
- if !errors.Is(err, ErrAuthFailed) {
- t.Fatalf("err = %v, want ErrAuthFailed (403 must map to auth)", err)
- }
-}
-
-func TestLookupArtist_ServerError(t *testing.T) {
- c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusInternalServerError)
- })
- defer srv.Close()
- _, err := c.LookupArtist(context.Background(), "boards")
- if !errors.Is(err, ErrServerError) {
- t.Fatalf("err = %v, want ErrServerError", err)
- }
-}
-
-func TestLookupArtist_Unreachable(t *testing.T) {
- c := &Client{BaseURL: "http://127.0.0.1:1", APIKey: "x", HTTP: &http.Client{}}
- _, err := c.LookupArtist(context.Background(), "boards")
- if !errors.Is(err, ErrUnreachable) {
- t.Fatalf("err = %v, want ErrUnreachable", err)
- }
-}
-
-func TestLookupArtist_BadJSON(t *testing.T) {
- c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
- _, _ = w.Write([]byte(`not json`))
- })
- defer srv.Close()
- _, err := c.LookupArtist(context.Background(), "boards")
- if !errors.Is(err, ErrInvalidPayload) {
- t.Fatalf("err = %v, want ErrInvalidPayload", err)
- }
-}
-
-// --- LookupAlbum ---
-
-func TestLookupAlbum_HappyPath(t *testing.T) {
- body := mustReadFixture(t, "lookup_album.json")
- c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/api/v1/album/lookup" {
- t.Errorf("path = %q, want /api/v1/album/lookup", r.URL.Path)
- }
- if got := r.URL.Query().Get("term"); got != "geogaddi" {
- t.Errorf("term = %q, want geogaddi", got)
- }
- _, _ = w.Write(body)
- })
- defer srv.Close()
-
- got, err := c.LookupAlbum(context.Background(), "geogaddi")
- if err != nil {
- t.Fatalf("LookupAlbum: %v", err)
- }
- if len(got) != 2 {
- t.Fatalf("len = %d, want 2", len(got))
- }
-
- // First result: year from releaseDate, trackCount in secondary.
- if got[0].MBID != "a1b2c3d4-e5f6-7890-abcd-ef1234567890" {
- t.Errorf("MBID = %q", got[0].MBID)
- }
- if got[0].ArtistMBID != "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" {
- t.Errorf("ArtistMBID = %q, want 069b64b6-7884-4f6a-94cc-e4c1d6c87a01", got[0].ArtistMBID)
- }
- if got[0].AlbumMBID != "" {
- t.Errorf("AlbumMBID = %q, want empty on album result", got[0].AlbumMBID)
- }
- if got[0].Name != "Music Has the Right to Children" {
- t.Errorf("Name = %q", got[0].Name)
- }
- want0 := "Boards of Canada · 1998 · 18 tracks"
- if got[0].Secondary != want0 {
- t.Errorf("Secondary = %q, want %q", got[0].Secondary, want0)
- }
- // Poster image from second image entry.
- if got[0].ImageURL != "https://example.invalid/mhtrtc-poster.jpg" {
- t.Errorf("ImageURL = %q", got[0].ImageURL)
- }
-
- // Second result.
- want1 := "Boards of Canada · 2002 · 23 tracks"
- if got[1].Secondary != want1 {
- t.Errorf("Secondary = %q, want %q", got[1].Secondary, want1)
- }
- if got[1].ImageURL != "" {
- t.Errorf("expected empty ImageURL; got %q", got[1].ImageURL)
- }
-}
-
-func TestLookupAlbum_BadJSON(t *testing.T) {
- c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
- _, _ = w.Write([]byte(`{}`))
- })
- defer srv.Close()
- _, err := c.LookupAlbum(context.Background(), "x")
- if !errors.Is(err, ErrInvalidPayload) {
- t.Fatalf("err = %v, want ErrInvalidPayload (object, not array)", err)
- }
-}
-
-// --- LookupTrack ---
-
-func TestLookupTrack_HappyPath(t *testing.T) {
- body := mustReadFixture(t, "lookup_track.json")
- c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/api/v1/track/lookup" {
- t.Errorf("path = %q, want /api/v1/track/lookup", r.URL.Path)
- }
- _, _ = w.Write(body)
- })
- defer srv.Close()
-
- got, err := c.LookupTrack(context.Background(), "roygbiv")
- if err != nil {
- t.Fatalf("LookupTrack: %v", err)
- }
- if len(got) != 2 {
- t.Fatalf("len = %d, want 2", len(got))
- }
- if got[0].Name != "Roygbiv" {
- t.Errorf("Name = %q", got[0].Name)
- }
- want := "Music Has the Right to Children · Boards of Canada"
- if got[0].Secondary != want {
- t.Errorf("Secondary = %q, want %q", got[0].Secondary, want)
- }
- if got[0].ArtistMBID != "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" {
- t.Errorf("ArtistMBID = %q, want 069b64b6-7884-4f6a-94cc-e4c1d6c87a01", got[0].ArtistMBID)
- }
- if got[0].AlbumMBID != "a1b2c3d4-e5f6-7890-abcd-ef1234567890" {
- t.Errorf("AlbumMBID = %q, want a1b2c3d4-e5f6-7890-abcd-ef1234567890", got[0].AlbumMBID)
- }
- if got[0].ImageURL != "" {
- t.Errorf("tracks carry no images; ImageURL = %q", got[0].ImageURL)
- }
-}
-
-func TestLookupTrack_NoArtistName(t *testing.T) {
- // Verify secondary degrades gracefully when artistName is absent.
- c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
- _, _ = w.Write([]byte(`[{"foreignTrackId":"abc","title":"X","albumTitle":"AlbumY","artistName":""}]`))
- })
- defer srv.Close()
- got, err := c.LookupTrack(context.Background(), "x")
- if err != nil {
- t.Fatalf("err: %v", err)
- }
- if got[0].Secondary != "AlbumY" {
- t.Errorf("Secondary = %q, want %q", got[0].Secondary, "AlbumY")
- }
-}
-
-// --- AddArtist ---
-
-func TestAddArtist_PostsCorrectBody(t *testing.T) {
- var decoded map[string]any
- c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- t.Errorf("method = %q, want POST", r.Method)
- }
- if r.URL.Path != "/api/v1/artist" {
- t.Errorf("path = %q, want /api/v1/artist", r.URL.Path)
- }
- if ct := r.Header.Get("Content-Type"); ct != "application/json" {
- t.Errorf("Content-Type = %q, want application/json", ct)
- }
- if err := json.NewDecoder(r.Body).Decode(&decoded); err != nil {
- t.Errorf("decode body: %v", err)
- }
- w.WriteHeader(http.StatusCreated)
- })
- defer srv.Close()
-
- err := c.AddArtist(context.Background(), AddArtistParams{
- ForeignArtistID: "069b64b6-7884-4f6a-94cc-e4c1d6c87a01",
- QualityProfileID: 2,
- RootFolderPath: "/music",
- MonitorAll: true,
- })
- if err != nil {
- t.Fatalf("AddArtist: %v", err)
- }
-
- if decoded["foreignArtistId"] != "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" {
- t.Errorf("foreignArtistId = %v", decoded["foreignArtistId"])
- }
- if decoded["qualityProfileId"] != float64(2) {
- t.Errorf("qualityProfileId = %v", decoded["qualityProfileId"])
- }
- if decoded["rootFolderPath"] != "/music" {
- t.Errorf("rootFolderPath = %v", decoded["rootFolderPath"])
- }
- if decoded["monitored"] != true {
- t.Errorf("monitored = %v, want true", decoded["monitored"])
- }
- if decoded["monitorNewItems"] != "all" {
- t.Errorf("monitorNewItems = %v, want all", decoded["monitorNewItems"])
- }
- // #5239: ArtistResource has no top-level monitor; Lidarr reads the
- // choice from addOptions and silently drops it anywhere else.
- if _, set := decoded["monitor"]; set {
- t.Errorf("monitor sent at top level (%v), where Lidarr ignores it", decoded["monitor"])
- }
- opts, ok := decoded["addOptions"].(map[string]any)
- if !ok {
- t.Fatalf("addOptions missing or wrong type: %T", decoded["addOptions"])
- }
- if opts["monitor"] != "all" {
- t.Errorf("addOptions.monitor = %v, want all (MonitorAll=true)", opts["monitor"])
- }
- if opts["monitored"] != true {
- t.Errorf("addOptions.monitored = %v, want true", opts["monitored"])
- }
- if opts["searchForMissingAlbums"] != true {
- t.Errorf("addOptions.searchForMissingAlbums = %v, want true", opts["searchForMissingAlbums"])
- }
-}
-
-func TestAddArtist_MonitorFuture(t *testing.T) {
- var decoded map[string]any
- c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
- _ = json.NewDecoder(r.Body).Decode(&decoded)
- w.WriteHeader(http.StatusCreated)
- })
- defer srv.Close()
-
- _ = c.AddArtist(context.Background(), AddArtistParams{MonitorAll: false})
- if opts, _ := decoded["addOptions"].(map[string]any); opts["monitor"] != "future" {
- t.Errorf("addOptions.monitor = %v, want future (MonitorAll=false)", opts["monitor"])
- }
-}
-
-func TestAddArtist_ServerError(t *testing.T) {
- c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusInternalServerError)
- })
- defer srv.Close()
- err := c.AddArtist(context.Background(), AddArtistParams{})
- if !errors.Is(err, ErrServerError) {
- t.Fatalf("err = %v, want ErrServerError", err)
- }
-}
-
-// --- AddAlbum ---
-
-const (
- addAlbumMBID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
- addArtistMBID = "069b64b6-7884-4f6a-94cc-e4c1d6c87a01"
-)
-
-// albumLookupStub answers Lidarr's album lookup with lookupBody and records
-// the body of the album POST that follows it.
-func albumLookupStub(t *testing.T, lookupBody string, postStatus int, postBody string) (*Client, *httptest.Server, *map[string]any, *int) {
- t.Helper()
- var posted map[string]any
- posts := 0
- c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
- switch {
- case r.Method == http.MethodGet && r.URL.Path == "/api/v1/album/lookup":
- if got := r.URL.Query().Get("term"); got != "lidarr:"+addAlbumMBID {
- t.Errorf("lookup term = %q, want lidarr:%s", got, addAlbumMBID)
- }
- _, _ = w.Write([]byte(lookupBody))
- case r.Method == http.MethodPost && r.URL.Path == "/api/v1/album":
- posts++
- _ = json.NewDecoder(r.Body).Decode(&posted)
- w.WriteHeader(postStatus)
- _, _ = w.Write([]byte(postBody))
- default:
- t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
- w.WriteHeader(http.StatusNotFound)
- }
- })
- return c, srv, &posted, &posts
-}
-
-var addAlbumParams = AddAlbumParams{
- ForeignAlbumID: addAlbumMBID,
- ForeignArtistID: addArtistMBID,
- ArtistName: "Boards of Canada",
- QualityProfileID: 1,
- MetadataProfileID: 3,
- RootFolderPath: "/music",
-}
-
-// #5234: Lidarr refuses a flat album add with "'Artist' must not be empty".
-// The add has to be the looked-up album resource with its artist nested.
-func TestAddAlbum_NewArtist_PostsTheLookedUpAlbumWithTheArtistNested(t *testing.T) {
- lookup := `[
- {"foreignAlbumId": "someone-else", "title": "Other"},
- {"foreignAlbumId": "` + addAlbumMBID + `", "title": "Music Has the Right to Children",
- "releases": [{"foreignReleaseId": "r1", "monitored": true}],
- "artist": {"foreignArtistId": "` + addArtistMBID + `", "artistName": "Boards of Canada"}}
- ]`
- c, srv, posted, _ := albumLookupStub(t, lookup, http.StatusCreated, `{"id":9}`)
- defer srv.Close()
-
- if err := c.AddAlbum(context.Background(), addAlbumParams); err != nil {
- t.Fatalf("AddAlbum: %v", err)
- }
- body := *posted
- if body["foreignAlbumId"] != addAlbumMBID || body["title"] != "Music Has the Right to Children" {
- t.Errorf("posted album = %v / %v, want the looked-up album", body["foreignAlbumId"], body["title"])
- }
- if rel, _ := body["releases"].([]any); len(rel) != 1 {
- t.Errorf("releases = %v, want the lookup's releases passed through", body["releases"])
- }
- if body["monitored"] != true {
- t.Errorf("monitored = %v, want true", body["monitored"])
- }
- if opts, _ := body["addOptions"].(map[string]any); opts["searchForNewAlbum"] != true {
- t.Errorf("addOptions = %v, want searchForNewAlbum true", body["addOptions"])
- }
-
- artist, ok := body["artist"].(map[string]any)
- if !ok {
- t.Fatalf("artist = %T, want a nested object", body["artist"])
- }
- for k, want := range map[string]any{
- "foreignArtistId": addArtistMBID,
- "qualityProfileId": float64(1),
- "metadataProfileId": float64(3),
- "rootFolderPath": "/music",
- "monitored": true,
- "monitorNewItems": "none",
- } {
- if artist[k] != want {
- t.Errorf("artist.%s = %v, want %v", k, artist[k], want)
- }
- }
- // A new artist monitors this album only: albumsToMonitor wins over the
- // monitor type in Lidarr's AlbumMonitoredService.
- aOpts, _ := artist["addOptions"].(map[string]any)
- if aOpts["monitor"] != "none" || aOpts["searchForMissingAlbums"] != false {
- t.Errorf("artist.addOptions = %v, want monitor none, no catalogue search", aOpts)
- }
- if only, _ := aOpts["albumsToMonitor"].([]any); len(only) != 1 || only[0] != addAlbumMBID {
- t.Errorf("artist.addOptions.albumsToMonitor = %v, want [%s]", aOpts["albumsToMonitor"], addAlbumMBID)
- }
-}
-
-// An artist Lidarr already has keeps its own profiles and monitoring.
-func TestAddAlbum_ExistingArtist_IsLeftAsLidarrHasIt(t *testing.T) {
- lookup := `[{"foreignAlbumId": "` + addAlbumMBID + `",
- "artist": {"id": 42, "foreignArtistId": "` + addArtistMBID + `", "qualityProfileId": 5,
- "metadataProfileId": 2, "rootFolderPath": "/lib", "monitorNewItems": "all"}}]`
- c, srv, posted, _ := albumLookupStub(t, lookup, http.StatusCreated, `{"id":9}`)
- defer srv.Close()
-
- if err := c.AddAlbum(context.Background(), addAlbumParams); err != nil {
- t.Fatalf("AddAlbum: %v", err)
- }
- artist, _ := (*posted)["artist"].(map[string]any)
- if artist["qualityProfileId"] != float64(5) || artist["rootFolderPath"] != "/lib" || artist["monitorNewItems"] != "all" {
- t.Errorf("existing artist was rewritten: %v", artist)
- }
- if _, set := artist["addOptions"]; set {
- t.Errorf("existing artist got addOptions %v", artist["addOptions"])
- }
-}
-
-func TestAddAlbum_NotInLidarrsMetadata_IsNotFoundAndPostsNothing(t *testing.T) {
- c, srv, _, posts := albumLookupStub(t, `[{"foreignAlbumId": "someone-else"}]`, http.StatusCreated, "")
- defer srv.Close()
-
- err := c.AddAlbum(context.Background(), addAlbumParams)
- if !errors.Is(err, ErrNotFound) {
- t.Fatalf("err = %v, want ErrNotFound", err)
- }
- if *posts != 0 {
- t.Errorf("posted %d times, want 0", *posts)
- }
-}
-
-func TestAddAlbum_AlreadyAdded_IsErrAlreadyExists(t *testing.T) {
- lookup := `[{"foreignAlbumId": "` + addAlbumMBID + `", "artist": {"id": 42}}]`
- c, srv, _, _ := albumLookupStub(t, lookup, http.StatusBadRequest,
- `[{"propertyName":"ForeignAlbumId","errorMessage":"This album has already been added."}]`)
- defer srv.Close()
-
- if err := c.AddAlbum(context.Background(), addAlbumParams); !errors.Is(err, ErrAlreadyExists) {
- t.Fatalf("err = %v, want ErrAlreadyExists", err)
- }
-}
-
-func TestAddAlbum_AuthFailed(t *testing.T) {
- c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusUnauthorized)
- })
- defer srv.Close()
- err := c.AddAlbum(context.Background(), addAlbumParams)
- if !errors.Is(err, ErrAuthFailed) {
- t.Fatalf("err = %v, want ErrAuthFailed", err)
- }
-}
-
-// --- ListQualityProfiles ---
-
-func TestListQualityProfiles_HappyPath(t *testing.T) {
- body := mustReadFixture(t, "quality_profiles.json")
- c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/api/v1/qualityprofile" {
- t.Errorf("path = %q, want /api/v1/qualityprofile", r.URL.Path)
- }
- _, _ = w.Write(body)
- })
- defer srv.Close()
-
- got, err := c.ListQualityProfiles(context.Background())
- if err != nil {
- t.Fatalf("ListQualityProfiles: %v", err)
- }
- if len(got) != 3 {
- t.Fatalf("len = %d, want 3", len(got))
- }
- if got[0].ID != 1 || got[0].Name != "Any" {
- t.Errorf("first profile = %+v, want {ID:1 Name:Any}", got[0])
- }
- if got[1].ID != 2 || got[1].Name != "Lossless" {
- t.Errorf("second profile = %+v", got[1])
- }
-}
-
-func TestListQualityProfiles_BadJSON(t *testing.T) {
- c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
- _, _ = w.Write([]byte(`not json`))
- })
- defer srv.Close()
- _, err := c.ListQualityProfiles(context.Background())
- if !errors.Is(err, ErrInvalidPayload) {
- t.Fatalf("err = %v, want ErrInvalidPayload", err)
- }
-}
-
-// --- LookupArtist (additional) ---
-
-func TestLookupArtist_LookupFailed(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusNotFound)
- }))
- defer srv.Close()
- c := &Client{BaseURL: srv.URL, APIKey: "x", HTTP: srv.Client()}
- _, err := c.LookupArtist(context.Background(), "boards")
- if !errors.Is(err, ErrLookupFailed) {
- t.Fatalf("err = %v, want ErrLookupFailed", err)
- }
-}
-
-func TestGet_BaseURLWithTrailingSlash(t *testing.T) {
- body, _ := os.ReadFile("testdata/lookup_artist.json")
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // Verify path is /api/v1/artist/lookup, NOT //api/v1/artist/lookup
- if r.URL.Path != "/api/v1/artist/lookup" {
- t.Errorf("got path %q; trailing-slash BaseURL produced double-slash", r.URL.Path)
- }
- _, _ = w.Write(body)
- }))
- defer srv.Close()
- c := &Client{BaseURL: srv.URL + "/", APIKey: "k", HTTP: srv.Client()}
- _, err := c.LookupArtist(context.Background(), "boards")
- if err != nil {
- t.Fatalf("LookupArtist: %v", err)
- }
-}
-
-// --- AddArtist (additional) ---
-
-func TestAddArtist_PostFailed(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusUnprocessableEntity)
- }))
- defer srv.Close()
- c := &Client{BaseURL: srv.URL, APIKey: "x", HTTP: srv.Client()}
- err := c.AddArtist(context.Background(), AddArtistParams{ForeignArtistID: "x", QualityProfileID: 1, RootFolderPath: "/m"})
- if !errors.Is(err, ErrLookupFailed) {
- t.Fatalf("err = %v, want ErrLookupFailed", err)
- }
-}
-
-// --- ListRootFolders ---
-
-func TestListRootFolders_HappyPath(t *testing.T) {
- body := mustReadFixture(t, "root_folders.json")
- c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/api/v1/rootfolder" {
- t.Errorf("path = %q, want /api/v1/rootfolder", r.URL.Path)
- }
- _, _ = w.Write(body)
- })
- defer srv.Close()
-
- got, err := c.ListRootFolders(context.Background())
- if err != nil {
- t.Fatalf("ListRootFolders: %v", err)
- }
- if len(got) != 3 {
- t.Fatalf("len = %d, want 3", len(got))
- }
- if got[0].Path != "/music" {
- t.Errorf("Path = %q", got[0].Path)
- }
- if !got[0].Accessible {
- t.Errorf("Accessible = false for /music, want true")
- }
- if got[0].FreeSpace != 107374182400 {
- t.Errorf("FreeSpace = %d", got[0].FreeSpace)
- }
- if got[2].Accessible {
- t.Errorf("expected /music-offline to be inaccessible")
- }
-}
-
-func TestListRootFolders_NullBody(t *testing.T) {
- c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
- _, _ = w.Write([]byte(`null`))
- })
- defer srv.Close()
- // null decodes to a nil slice, no error — but len check should yield 0.
- got, err := c.ListRootFolders(context.Background())
- if err != nil {
- t.Fatalf("unexpected error on null body: %v", err)
- }
- if len(got) != 0 {
- t.Errorf("expected empty slice from null body, got %d", len(got))
- }
-}
-
-func TestListRootFolders_BadJSON(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- _, _ = w.Write([]byte("{not valid json"))
- }))
- defer srv.Close()
- c := &Client{BaseURL: srv.URL, APIKey: "x", HTTP: srv.Client()}
- _, err := c.ListRootFolders(context.Background())
- if !errors.Is(err, ErrInvalidPayload) {
- t.Fatalf("err = %v, want ErrInvalidPayload", err)
- }
-}
-
-// --- Ping ---
-
-func TestPing_ReturnsVersion(t *testing.T) {
- c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/api/v1/system/status" {
- t.Errorf("path = %q, want /api/v1/system/status", r.URL.Path)
- }
- _, _ = w.Write([]byte(`{"version":"2.4.3.3795","buildTime":"2024-01-15T00:00:00Z"}`))
- })
- defer srv.Close()
-
- res, err := c.Ping(context.Background())
- if err != nil {
- t.Fatalf("Ping: %v", err)
- }
- if res.Version != "2.4.3.3795" {
- t.Errorf("Version = %q, want 2.4.3.3795", res.Version)
- }
-}
-
-func TestPing_AuthFailed(t *testing.T) {
- c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusUnauthorized)
- })
- defer srv.Close()
- _, err := c.Ping(context.Background())
- if !errors.Is(err, ErrAuthFailed) {
- t.Fatalf("err = %v, want ErrAuthFailed", err)
- }
-}
-
-func TestPing_Unreachable(t *testing.T) {
- c := &Client{BaseURL: "http://127.0.0.1:1", APIKey: "x", HTTP: &http.Client{}}
- _, err := c.Ping(context.Background())
- if !errors.Is(err, ErrUnreachable) {
- t.Fatalf("err = %v, want ErrUnreachable", err)
- }
-}
-
-func TestPing_BadJSON(t *testing.T) {
- c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
- _, _ = w.Write([]byte(`{bad`))
- })
- defer srv.Close()
- _, err := c.Ping(context.Background())
- if !errors.Is(err, ErrInvalidPayload) {
- t.Fatalf("err = %v, want ErrInvalidPayload", err)
- }
-}
-
-// --- pickPosterImage ---
-
-func TestPickPosterImage_PreferRemoteURL(t *testing.T) {
- imgs := []lidarrImage{
- {CoverType: "poster", RemoteURL: "https://remote.example/img.jpg", URL: "https://local.example/img.jpg"},
- }
- got := pickPosterImage(imgs)
- if got != "https://remote.example/img.jpg" {
- t.Errorf("got %q, want remote URL", got)
- }
-}
-
-func TestPickPosterImage_FallsBackToLocalURL(t *testing.T) {
- imgs := []lidarrImage{
- {CoverType: "poster", RemoteURL: "", URL: "https://local.example/img.jpg"},
- }
- got := pickPosterImage(imgs)
- if got != "https://local.example/img.jpg" {
- t.Errorf("got %q, want local URL", got)
- }
-}
-
-func TestPickPosterImage_SkipsNonPoster(t *testing.T) {
- imgs := []lidarrImage{
- {CoverType: "banner", RemoteURL: "https://banner.example/img.jpg"},
- {CoverType: "cover", RemoteURL: "https://cover.example/img.jpg"},
- }
- got := pickPosterImage(imgs)
- if got != "" {
- t.Errorf("got %q, want empty (no poster)", got)
- }
-}
-
-// --- UnmonitorTrack ---
-
-// unmonitorMux is the three-endpoint stub UnmonitorTrack walks. The flags it
-// captures are inspected by the individual tests.
-type unmonitorMux struct {
- t *testing.T
- wantAlbumMbid string
- albumStatus int // override status for album lookup; 0 → 200 with body
- tracksStatus int // override status for tracks list; 0 → 200 with body
- putStatus int // override status for PUT /track/monitor; 0 → 200
- albumBody []byte // when albumStatus is 0
- tracksBody []byte // when tracksStatus is 0
- putBody map[string]any
- putHit bool
-}
-
-func (m *unmonitorMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/api/v1/album":
- if got := r.URL.Query().Get("foreignAlbumId"); got != m.wantAlbumMbid {
- m.t.Errorf("album lookup foreignAlbumId = %q, want %q", got, m.wantAlbumMbid)
- }
- if m.albumStatus != 0 {
- w.WriteHeader(m.albumStatus)
- return
- }
- _, _ = w.Write(m.albumBody)
- case "/api/v1/track":
- if got := r.URL.Query().Get("albumId"); got != "42" {
- m.t.Errorf("track list albumId = %q, want 42", got)
- }
- if m.tracksStatus != 0 {
- w.WriteHeader(m.tracksStatus)
- return
- }
- _, _ = w.Write(m.tracksBody)
- case "/api/v1/track/monitor":
- if r.Method != http.MethodPut {
- m.t.Errorf("monitor method = %q, want PUT", r.Method)
- }
- m.putHit = true
- if m.putStatus != 0 {
- w.WriteHeader(m.putStatus)
- return
- }
- _ = json.NewDecoder(r.Body).Decode(&m.putBody)
- w.WriteHeader(http.StatusAccepted)
- default:
- m.t.Errorf("unexpected path: %s", r.URL.Path)
- w.WriteHeader(http.StatusNotFound)
- }
-}
-
-func TestUnmonitorTrack_HappyPath(t *testing.T) {
- mux := &unmonitorMux{
- t: t,
- wantAlbumMbid: "album-mbid",
- albumBody: []byte(`[{"id":42,"foreignAlbumId":"album-mbid","title":"X","artistId":7}]`),
- tracksBody: []byte(`[{"id":1001,"foreignTrackId":"other-mbid"},{"id":1002,"foreignTrackId":"track-mbid"}]`),
- }
- srv := httptest.NewServer(mux)
- defer srv.Close()
- c := &Client{BaseURL: srv.URL, APIKey: "k", HTTP: srv.Client()}
-
- if err := c.UnmonitorTrack(context.Background(), "track-mbid", "album-mbid"); err != nil {
- t.Fatalf("UnmonitorTrack: %v", err)
- }
- if !mux.putHit {
- t.Fatal("PUT /api/v1/track/monitor was not called")
- }
- if mux.putBody["monitored"] != false {
- t.Errorf("PUT body monitored = %v, want false", mux.putBody["monitored"])
- }
- ids, ok := mux.putBody["trackIds"].([]any)
- if !ok || len(ids) != 1 {
- t.Fatalf("PUT body trackIds = %v, want [1002]", mux.putBody["trackIds"])
- }
- if ids[0] != float64(1002) {
- t.Errorf("PUT trackIds[0] = %v, want 1002 (the matching foreignTrackId)", ids[0])
- }
-}
-
-func TestUnmonitorTrack_EmptyMbid(t *testing.T) {
- c := &Client{BaseURL: "http://127.0.0.1:1", APIKey: "k", HTTP: &http.Client{}}
- if err := c.UnmonitorTrack(context.Background(), "", "album-mbid"); err == nil {
- t.Error("UnmonitorTrack with empty trackMbid: want error, got nil")
- }
- if err := c.UnmonitorTrack(context.Background(), "track-mbid", ""); err == nil {
- t.Error("UnmonitorTrack with empty albumMbid: want error, got nil")
- }
-}
-
-func TestUnmonitorTrack_AlbumNotInLidarr(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/api/v1/album" {
- t.Errorf("unexpected path %q", r.URL.Path)
- }
- _, _ = w.Write([]byte(`[]`))
- }))
- defer srv.Close()
- c := &Client{BaseURL: srv.URL, APIKey: "k", HTTP: srv.Client()}
- err := c.UnmonitorTrack(context.Background(), "track-mbid", "album-mbid")
- if !errors.Is(err, ErrNotFound) {
- t.Fatalf("err = %v, want ErrNotFound", err)
- }
-}
-
-func TestUnmonitorTrack_TrackNotInAlbum(t *testing.T) {
- mux := &unmonitorMux{
- t: t,
- wantAlbumMbid: "album-mbid",
- albumBody: []byte(`[{"id":42,"foreignAlbumId":"album-mbid","title":"X","artistId":7}]`),
- // No track with foreignTrackId="track-mbid"
- tracksBody: []byte(`[{"id":1001,"foreignTrackId":"other-mbid"}]`),
- }
- srv := httptest.NewServer(mux)
- defer srv.Close()
- c := &Client{BaseURL: srv.URL, APIKey: "k", HTTP: srv.Client()}
- err := c.UnmonitorTrack(context.Background(), "track-mbid", "album-mbid")
- if !errors.Is(err, ErrNotFound) {
- t.Fatalf("err = %v, want ErrNotFound", err)
- }
- if mux.putHit {
- t.Error("PUT was called even though track wasn't found")
- }
-}
-
-func TestUnmonitorTrack_ServerError(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusInternalServerError)
- }))
- defer srv.Close()
- c := &Client{BaseURL: srv.URL, APIKey: "k", HTTP: srv.Client()}
- err := c.UnmonitorTrack(context.Background(), "track-mbid", "album-mbid")
- if !errors.Is(err, ErrServerError) {
- t.Fatalf("err = %v, want ErrServerError", err)
- }
-}
-
-func TestUnmonitorTrack_AuthFailed(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusUnauthorized)
- }))
- defer srv.Close()
- c := &Client{BaseURL: srv.URL, APIKey: "k", HTTP: srv.Client()}
- err := c.UnmonitorTrack(context.Background(), "track-mbid", "album-mbid")
- if !errors.Is(err, ErrAuthFailed) {
- t.Fatalf("err = %v, want ErrAuthFailed", err)
- }
-}
-
-func TestUnmonitorTrack_Unreachable(t *testing.T) {
- c := &Client{BaseURL: "http://127.0.0.1:1", APIKey: "k", HTTP: &http.Client{}}
- err := c.UnmonitorTrack(context.Background(), "track-mbid", "album-mbid")
- if !errors.Is(err, ErrUnreachable) {
- t.Fatalf("err = %v, want ErrUnreachable", err)
- }
-}
-
-func TestUnmonitorTrack_PutServerError(t *testing.T) {
- mux := &unmonitorMux{
- t: t,
- wantAlbumMbid: "album-mbid",
- albumBody: []byte(`[{"id":42,"foreignAlbumId":"album-mbid","title":"X","artistId":7}]`),
- tracksBody: []byte(`[{"id":1002,"foreignTrackId":"track-mbid"}]`),
- putStatus: http.StatusInternalServerError,
- }
- srv := httptest.NewServer(mux)
- defer srv.Close()
- c := &Client{BaseURL: srv.URL, APIKey: "k", HTTP: srv.Client()}
- err := c.UnmonitorTrack(context.Background(), "track-mbid", "album-mbid")
- if !errors.Is(err, ErrServerError) {
- t.Fatalf("err = %v, want ErrServerError on PUT 5xx", err)
- }
-}
diff --git a/internal/lidarr/delete.go b/internal/lidarr/delete.go
deleted file mode 100644
index 9755c680..00000000
--- a/internal/lidarr/delete.go
+++ /dev/null
@@ -1,69 +0,0 @@
-package lidarr
-
-import (
- "context"
- "fmt"
- "net/http"
- "net/url"
- "strconv"
- "strings"
-)
-
-// del is the shared DELETE helper. Mirrors get()/post() in client.go: builds
-// the URL inline, sets the API key, maps status codes to typed errors. The
-// caller is responsible for closing the returned body.
-func (c *Client) del(ctx context.Context, path string, q url.Values) (*http.Response, error) {
- u, err := url.Parse(c.BaseURL)
- if err != nil {
- return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
- }
- u.Path = strings.TrimRight(u.Path, "/") + path
- if q != nil {
- u.RawQuery = q.Encode()
- }
- req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u.String(), nil)
- if err != nil {
- return nil, 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.StatusCode == http.StatusForbidden {
- _ = resp.Body.Close()
- return nil, ErrAuthFailed
- }
- if resp.StatusCode >= 500 {
- _ = resp.Body.Close()
- return nil, ErrServerError
- }
- if resp.StatusCode >= 400 {
- _ = resp.Body.Close()
- return nil, ErrLookupFailed
- }
- 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
-}
diff --git a/internal/lidarr/delete_test.go b/internal/lidarr/delete_test.go
deleted file mode 100644
index eb0edf0d..00000000
--- a/internal/lidarr/delete_test.go
+++ /dev/null
@@ -1,214 +0,0 @@
-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 != "key123" {
- t.Errorf("X-Api-Key = %q, want key123", 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 TestLookupAlbumByMBID_5xxReturnsErrServerError(t *testing.T) {
- c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusInternalServerError)
- })
- defer srv.Close()
-
- _, err := c.LookupAlbumByMBID(context.Background(), "x")
- if !errors.Is(err, ErrServerError) {
- t.Errorf("err = %v, want ErrServerError", err)
- }
-}
-
-func TestLookupAlbumByMBID_BadJSON(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("not json"))
- })
- defer srv.Close()
-
- _, err := c.LookupAlbumByMBID(context.Background(), "x")
- if !errors.Is(err, ErrInvalidPayload) {
- t.Errorf("err = %v, want ErrInvalidPayload", err)
- }
-}
-
-func TestLookupAlbumByMBID_EmptyMBIDRejected(t *testing.T) {
- c, srv := newTestClient(func(http.ResponseWriter, *http.Request) {
- t.Error("server should not be called with empty mbid")
- })
- defer srv.Close()
-
- if _, err := c.LookupAlbumByMBID(context.Background(), ""); err == nil {
- t.Error("err = nil, want empty-mbid rejection")
- }
-}
-
-func TestLookupArtistByMBID_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.LookupArtistByMBID(context.Background(), "x")
- if !errors.Is(err, ErrNotFound) {
- t.Errorf("err = %v, want ErrNotFound", err)
- }
-}
-
-func TestLookupArtistByMBID_EmptyMBIDRejected(t *testing.T) {
- c, srv := newTestClient(func(http.ResponseWriter, *http.Request) {
- t.Error("server should not be called with empty mbid")
- })
- defer srv.Close()
-
- if _, err := c.LookupArtistByMBID(context.Background(), ""); err == nil {
- t.Error("err = nil, want empty-mbid rejection")
- }
-}
-
-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_5xxReturnsErrServerError(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, ErrServerError) {
- t.Errorf("err = %v, want ErrServerError", 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, "key123")
-
- err := c.DeleteAlbum(context.Background(), 42, true, true)
- if !errors.Is(err, ErrUnreachable) {
- t.Errorf("err = %v, want ErrUnreachable", err)
- }
-}
-
-func TestDeleteAlbum_ZeroIDRejected(t *testing.T) {
- c, srv := newTestClient(func(_ http.ResponseWriter, _ *http.Request) {
- t.Error("server should not be called with zero id")
- })
- defer srv.Close()
-
- if err := c.DeleteAlbum(context.Background(), 0, true, true); err == nil {
- t.Error("err = nil, want zero-id rejection")
- }
-}
diff --git a/internal/lidarr/errors.go b/internal/lidarr/errors.go
deleted file mode 100644
index fc27d359..00000000
--- a/internal/lidarr/errors.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package lidarr
-
-import (
- "errors"
-
- "git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
-)
-
-// Sentinel errors. Callers branch on these via errors.Is, not on
-// HTTP status codes — the client maps codes to errors.
-var (
- ErrUnreachable = errors.New("lidarr: unreachable")
- ErrAuthFailed = errors.New("lidarr: auth failed") // 401 / 403
- ErrLookupFailed = errors.New("lidarr: lookup failed") // 4xx other than 401/403
- ErrServerError = errors.New("lidarr: server error") // 5xx
- ErrInvalidPayload = errors.New("lidarr: invalid payload")
- // ErrAlreadyExists is returned by AddArtist/AddAlbum when Lidarr
- // rejects the add with a 400 because the artist/album is already in
- // its library. This is the desired end state, not a failure — the
- // idempotent retry path (reconciler re-sending an add after a partial
- // success) treats it as confirmed rather than spinning forever.
- ErrAlreadyExists = errors.New("lidarr: already exists")
- // 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).
- //
- // Aliased to apierror.ErrNotFound so handler error mapping (errors.Is
- // against the shared sentinel) and existing per-package callsites both
- // resolve to the same pointer.
- ErrNotFound = apierror.ErrNotFound
-)
diff --git a/internal/lidarr/lookup_mbid.go b/internal/lidarr/lookup_mbid.go
deleted file mode 100644
index f419597c..00000000
--- a/internal/lidarr/lookup_mbid.go
+++ /dev/null
@@ -1,81 +0,0 @@
-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 func() { _ = resp.Body.Close() }()
-
- var rows []LidarrArtist
- if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil {
- return LidarrArtist{}, fmt.Errorf("%w: decode artist: %v", ErrInvalidPayload, 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 func() { _ = resp.Body.Close() }()
-
- var rows []LidarrAlbum
- if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil {
- return LidarrAlbum{}, fmt.Errorf("%w: decode album: %v", ErrInvalidPayload, err)
- }
- if len(rows) == 0 {
- return LidarrAlbum{}, ErrNotFound
- }
- return rows[0], nil
-}
-
-// lookupAlbumResource fetches the album resource Lidarr's metadata holds
-// under mbid (GET /api/v1/album/lookup?term=lidarr:
{#each activity as item (item.id)}
+ {@const removed = item.details.removed ?? []}