From 3a16732883c9a8acbad15bd30310a17f0694d7c1 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen 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 ?? []}