feat: Minstrel no longer talks to Lidarr: its connection, Delete via Lidarr and the unmonitor options are gone, and the track reports are plain quarantine (M502 #5525)
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 17s
release / go (push) Successful in 1m13s
release / integration (push) Successful in 4m56s
release / android (push) Successful in 5m9s
release / Build signed APK (releases and dev) (push) Successful in 5m19s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped

Migration 0091 drops lidarr_config and renames the quarantine tables, types,
indexes and the history column off the Lidarr name. The Integrations Lidarr
card, the Reports "Delete via Lidarr" action, the merge and remove-track
unmonitor options, the Lidarr audit actions and error codes go, on the
server, web and Android. The README describes acquisition as it is now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 13:48:20 -04:00
co-authored by Claude Opus 5.5
parent c54792c51e
commit 3a16732883
170 changed files with 994 additions and 6242 deletions
+7 -14
View File
@@ -165,7 +165,7 @@ func (h *handlers) handleListDuplicates(w http.ResponseWriter, r *http.Request)
}
// resolvedActions are the audit actions the "Resolved automatically" tab lists.
var resolvedActions = []string{string(audit.ActionDuplicateMerge), string(audit.ActionLidarrReleaseChange)}
var resolvedActions = []string{string(audit.ActionDuplicateMerge)}
func duplicateCounts(ctx context.Context, q *dbq.Queries) (duplicateViewCounts, error) {
var c duplicateViewCounts
@@ -180,9 +180,8 @@ func duplicateCounts(ctx context.Context, q *dbq.Queries) (duplicateViewCounts,
return c, err
}
// resolvedActivityView is one thing the resolver did on its own: a merge
// (action duplicate_merge) or a Lidarr release change (lidarr_release_change).
// Details is the audit row's metadata, which names the files or releases.
// resolvedActivityView is one merge the resolver made on its own (action
// duplicate_merge). Details is the audit row's metadata, which names the files.
type resolvedActivityView struct {
ID string `json:"id"`
Action string `json:"action"`
@@ -370,18 +369,16 @@ func (h *handlers) dismissDuplicateGroup(ctx context.Context, id pgtype.UUID) (i
// keeps the report's proposal.
type mergeDuplicateRequest struct {
SurvivorTrackID string `json:"survivor_track_id"`
Unmonitor bool `json:"unmonitor"`
}
// mergeDuplicateResponse reports what the merge removed. RemovedPaths are files
// deleted from disk; the operator reads them to know exactly what went.
type mergeDuplicateResponse struct {
SurvivorTrackID string `json:"survivor_track_id"`
RemovedPaths []string `json:"removed_paths"`
LidarrUnmonitorFailed *bool `json:"lidarr_unmonitor_failed,omitempty"`
SurvivorTrackID string `json:"survivor_track_id"`
RemovedPaths []string `json:"removed_paths"`
}
// mergeRequestBodyLimit bounds the request body. It holds one id and a flag.
// mergeRequestBodyLimit bounds the request body. It holds one id.
const mergeRequestBodyLimit = 1 << 16
// handleMergeDuplicateGroup implements POST /api/admin/library/duplicates/{id}/merge
@@ -416,7 +413,7 @@ func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Requ
}
}
res, unmonitorFailed, err := h.tracks.MergeDuplicates(r.Context(), groupID, survivorID, admin.ID, body.Unmonitor)
res, err := h.tracks.MergeDuplicates(r.Context(), groupID, survivorID, admin.ID)
if err != nil {
if apiErr, ok := fileRemoveAPIError(err); ok {
logFileRemoveFailure(h.logger, apiErr, "group_id", uuidToString(groupID))
@@ -442,9 +439,5 @@ func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Requ
for _, c := range res.Removed {
resp.RemovedPaths = append(resp.RemovedPaths, c.FilePath)
}
if body.Unmonitor && unmonitorFailed {
failed := true
resp.LidarrUnmonitorFailed = &failed
}
writeJSON(w, http.StatusOK, resp)
}
-392
View File
@@ -1,392 +0,0 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/url"
"sync"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
)
// lidarrConfigView is the JSON shape returned by GET /api/admin/lidarr/config
// and PUT /api/admin/lidarr/config. api_key is always masked as "***" when set.
type lidarrConfigView struct {
Enabled bool `json:"enabled"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
DefaultQualityProfileID int `json:"default_quality_profile_id"`
DefaultMetadataProfileID int `json:"default_metadata_profile_id"`
DefaultRootFolderPath string `json:"default_root_folder_path"`
}
// maskAPIKey converts an api_key for external response: non-empty keys become
// "***"; empty (never set) keys remain "".
func maskAPIKey(key string) string {
if key == "" {
return ""
}
return "***"
}
// configToView converts a lidarrconfig.Config to a lidarrConfigView with the
// api_key masked.
func configToView(cfg lidarrconfig.Config) lidarrConfigView {
return lidarrConfigView{
Enabled: cfg.Enabled,
BaseURL: cfg.BaseURL,
APIKey: maskAPIKey(cfg.APIKey),
DefaultQualityProfileID: cfg.DefaultQualityProfileID,
DefaultMetadataProfileID: cfg.DefaultMetadataProfileID,
DefaultRootFolderPath: cfg.DefaultRootFolderPath,
}
}
// handleGetLidarrConfig implements GET /api/admin/lidarr/config.
// Returns the current Lidarr config with api_key masked.
func (h *handlers) handleGetLidarrConfig(w http.ResponseWriter, r *http.Request) {
cfg, err := h.lidarrCfg.Get(r.Context())
if err != nil {
h.logger.Error("admin: get lidarr config", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error")
return
}
writeJSON(w, http.StatusOK, configToView(cfg))
}
// putLidarrConfigBody is the decoded JSON body for PUT /api/admin/lidarr/config.
type putLidarrConfigBody struct {
Enabled bool `json:"enabled"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
DefaultQualityProfileID int `json:"default_quality_profile_id"`
DefaultMetadataProfileID int `json:"default_metadata_profile_id"`
DefaultRootFolderPath string `json:"default_root_folder_path"`
}
// handlePutLidarrConfig implements PUT /api/admin/lidarr/config.
// Empty api_key in the body preserves the currently saved api_key.
func (h *handlers) handlePutLidarrConfig(w http.ResponseWriter, r *http.Request) {
var body putLidarrConfigBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeAdminJSONErr(w, http.StatusBadRequest, "bad_request")
return
}
// Resolve the effective api_key: empty in body = preserve saved value.
apiKey := body.APIKey
if apiKey == "" {
saved, err := h.lidarrCfg.Get(r.Context())
if err != nil {
h.logger.Error("admin: put lidarr config: load saved", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error")
return
}
apiKey = saved.APIKey
}
// Validate: enabled=true requires base_url AND api_key.
if body.Enabled {
if body.BaseURL == "" || apiKey == "" {
writeAdminJSONErr(w, http.StatusBadRequest, "missing_required_field")
return
}
// Validate that the URL parses.
if _, err := url.ParseRequestURI(body.BaseURL); err != nil {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_url")
return
}
// Defaults must be present so future Approve calls don't dispatch
// invalid POST bodies to Lidarr (which produces 4xx with field
// errors). The frontend pre-selects first values so this almost
// never fires for non-malicious traffic, but it's the right gate.
if body.DefaultQualityProfileID == 0 ||
body.DefaultMetadataProfileID == 0 ||
body.DefaultRootFolderPath == "" {
writeAdminJSONErr(w, http.StatusBadRequest, "missing_defaults")
return
}
}
cfg := lidarrconfig.Config{
Enabled: body.Enabled,
BaseURL: body.BaseURL,
APIKey: apiKey,
DefaultQualityProfileID: body.DefaultQualityProfileID,
DefaultMetadataProfileID: body.DefaultMetadataProfileID,
DefaultRootFolderPath: body.DefaultRootFolderPath,
}
if err := h.lidarrCfg.Save(r.Context(), cfg); err != nil {
h.logger.Error("admin: put lidarr config: save", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error")
return
}
writeJSON(w, http.StatusOK, configToView(cfg))
}
// testLidarrBody is the optional JSON body for POST /api/admin/lidarr/test.
type testLidarrBody struct {
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
}
// testLidarrResponse is the JSON shape returned by POST /api/admin/lidarr/test.
// All fields except Ok are optional. On a failed connection (Ok=false), only
// Ok and Error are populated. On success (Ok=true), Version is populated;
// QualityProfiles / MetadataProfiles / RootFolders carry the live Lidarr
// data and ListErrors maps any per-list fetch failures by list name. The
// integrations page consumes the lists to pre-fill its dropdowns on
// first-time setup, fixing the chicken-and-egg where the dropdowns
// previously gated on cfg.Enabled.
type testLidarrResponse struct {
Ok bool `json:"ok"`
Version string `json:"version,omitempty"`
Error string `json:"error,omitempty"`
QualityProfiles []qualityProfileView `json:"quality_profiles,omitempty"`
MetadataProfiles []metadataProfileView `json:"metadata_profiles,omitempty"`
RootFolders []rootFolderView `json:"root_folders,omitempty"`
ListErrors map[string]string `json:"list_errors,omitempty"`
}
// handleTestLidarrConnection implements POST /api/admin/lidarr/test.
// Always returns 200; the ok/error fields in the response body indicate
// connection success or failure.
func (h *handlers) handleTestLidarrConnection(w http.ResponseWriter, r *http.Request) {
var body testLidarrBody
// Ignore decode errors — an empty body is valid (all fields optional).
_ = json.NewDecoder(r.Body).Decode(&body)
// Fall back to saved config for any empty field.
baseURL := body.BaseURL
apiKey := body.APIKey
if baseURL == "" || apiKey == "" {
saved, err := h.lidarrCfg.Get(r.Context())
if err != nil {
h.logger.Error("admin: test lidarr: load config", "err", err)
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": "internal_error"})
return
}
if baseURL == "" {
baseURL = saved.BaseURL
}
if apiKey == "" {
apiKey = saved.APIKey
}
}
client := lidarr.NewClient(baseURL, apiKey)
result, err := client.Ping(r.Context())
if err != nil {
errCode := lidarrErrCode(err)
// Warn (not Error): a failed connection test is expected when the
// admin is configuring the integration and may have a typo'd URL
// or wrong key. Without this log line the underlying *net.DNSError
// / *net.OpError / TLS error is invisible to operators — they only
// see the bucket code in the UI. base_url is safe to log; api_key
// must NEVER be logged.
h.logger.Warn("admin: test lidarr ping failed",
"err", err, "base_url", baseURL, "code", errCode)
writeJSON(w, http.StatusOK, testLidarrResponse{Ok: false, Error: errCode})
return
}
quality, metadata, folders, listErrs := fetchLidarrLists(r.Context(), client)
writeJSON(w, http.StatusOK, testLidarrResponse{
Ok: true,
Version: result.Version,
QualityProfiles: quality,
MetadataProfiles: metadata,
RootFolders: folders,
ListErrors: listErrs,
})
}
// fetchLidarrLists fans out the three list calls in parallel and returns
// the lists plus a map of any per-list errors. Empty map (returned as nil
// to omit the JSON field) if all succeeded. Used by the test handler to
// pre-populate the integrations page dropdowns on the same round-trip as
// the connection check.
func fetchLidarrLists(ctx context.Context, client *lidarr.Client) (
[]qualityProfileView, []metadataProfileView, []rootFolderView, map[string]string,
) {
var (
quality []qualityProfileView
metadata []metadataProfileView
folders []rootFolderView
errMu sync.Mutex
errs = map[string]string{}
wg sync.WaitGroup
)
recordErr := func(name string, err error) {
errMu.Lock()
errs[name] = lidarrErrCode(err)
errMu.Unlock()
}
wg.Add(3)
go func() {
defer wg.Done()
ps, err := client.ListQualityProfiles(ctx)
if err != nil {
recordErr("quality_profiles", err)
return
}
quality = make([]qualityProfileView, len(ps))
for i, p := range ps {
quality[i] = qualityProfileView{ID: p.ID, Name: p.Name}
}
}()
go func() {
defer wg.Done()
ps, err := client.ListMetadataProfiles(ctx)
if err != nil {
recordErr("metadata_profiles", err)
return
}
metadata = make([]metadataProfileView, len(ps))
for i, p := range ps {
metadata[i] = metadataProfileView{ID: p.ID, Name: p.Name}
}
}()
go func() {
defer wg.Done()
fs, err := client.ListRootFolders(ctx)
if err != nil {
recordErr("root_folders", err)
return
}
folders = make([]rootFolderView, len(fs))
for i, f := range fs {
folders[i] = rootFolderView{Path: f.Path, Accessible: f.Accessible, FreeSpace: f.FreeSpace}
}
}()
wg.Wait()
if len(errs) == 0 {
return quality, metadata, folders, nil
}
return quality, metadata, folders, errs
}
// lidarrErrCode maps a lidarr client error to the stable string code used in
// admin API responses.
func lidarrErrCode(err error) string {
switch {
case errors.Is(err, lidarr.ErrUnreachable):
return "lidarr_unreachable"
case errors.Is(err, lidarr.ErrAuthFailed):
return "lidarr_auth_failed"
case errors.Is(err, lidarr.ErrLookupFailed):
return "lidarr_lookup_failed"
default:
return "internal_error"
}
}
// qualityProfileView is the JSON shape for a single quality profile in the
// GET /api/admin/lidarr/quality-profiles response.
type qualityProfileView struct {
ID int `json:"id"`
Name string `json:"name"`
}
// handleListQualityProfiles implements GET /api/admin/lidarr/quality-profiles.
func (h *handlers) handleListQualityProfiles(w http.ResponseWriter, r *http.Request) {
_, client, ok := h.lidarrClientFromConfig(w, r)
if !ok {
return
}
profiles, err := client.ListQualityProfiles(r.Context())
if err != nil {
writeAdminJSONErr(w, http.StatusServiceUnavailable, lidarrErrCode(err))
return
}
out := make([]qualityProfileView, len(profiles))
for i, p := range profiles {
out[i] = qualityProfileView{ID: p.ID, Name: p.Name}
}
writeJSON(w, http.StatusOK, out)
}
// metadataProfileView is the JSON shape for a single metadata profile in the
// GET /api/admin/lidarr/metadata-profiles response.
type metadataProfileView struct {
ID int `json:"id"`
Name string `json:"name"`
}
// handleListMetadataProfiles implements GET /api/admin/lidarr/metadata-profiles.
func (h *handlers) handleListMetadataProfiles(w http.ResponseWriter, r *http.Request) {
_, client, ok := h.lidarrClientFromConfig(w, r)
if !ok {
return
}
profiles, err := client.ListMetadataProfiles(r.Context())
if err != nil {
writeAdminJSONErr(w, http.StatusServiceUnavailable, lidarrErrCode(err))
return
}
out := make([]metadataProfileView, len(profiles))
for i, p := range profiles {
out[i] = metadataProfileView{ID: p.ID, Name: p.Name}
}
writeJSON(w, http.StatusOK, out)
}
// rootFolderView is the JSON shape for a single root folder in the
// GET /api/admin/lidarr/root-folders response.
type rootFolderView struct {
Path string `json:"path"`
Accessible bool `json:"accessible"`
FreeSpace int64 `json:"free_space"`
}
// handleListRootFolders implements GET /api/admin/lidarr/root-folders.
func (h *handlers) handleListRootFolders(w http.ResponseWriter, r *http.Request) {
_, client, ok := h.lidarrClientFromConfig(w, r)
if !ok {
return
}
folders, err := client.ListRootFolders(r.Context())
if err != nil {
writeAdminJSONErr(w, http.StatusServiceUnavailable, lidarrErrCode(err))
return
}
out := make([]rootFolderView, len(folders))
for i, f := range folders {
out[i] = rootFolderView{Path: f.Path, Accessible: f.Accessible, FreeSpace: f.FreeSpace}
}
writeJSON(w, http.StatusOK, out)
}
// lidarrClientFromConfig is a shared helper for handlers that need to proxy a
// request to Lidarr. It loads the saved config, returns 503 when disabled, and
// constructs a Client. Returns (cfg, client, true) on success; (_, nil, false)
// when a response has already been written.
func (h *handlers) lidarrClientFromConfig(w http.ResponseWriter, r *http.Request) (lidarrconfig.Config, *lidarr.Client, bool) {
cfg, err := h.lidarrCfg.Get(r.Context())
if err != nil {
h.logger.Error("admin: lidarr proxy: load config", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error")
return lidarrconfig.Config{}, nil, false
}
if !cfg.Enabled {
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_disabled")
return lidarrconfig.Config{}, nil, false
}
return cfg, lidarr.NewClient(cfg.BaseURL, cfg.APIKey), true
}
// writeAdminJSONErr writes a flat {"error":"<code>"} 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})
}
-455
View File
@@ -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"])
}
})
}
}
+49 -94
View File
@@ -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)
+6 -194
View File
@@ -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 {
+19 -26
View File
@@ -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()
+1 -1
View File
@@ -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()
+8 -31
View File
@@ -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)
}
+6 -100
View File
@@ -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")
}
}
+1 -2
View File
@@ -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"`
+5 -5
View File
@@ -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)
+16 -26
View File
@@ -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
+4 -9
View File
@@ -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
}
+3 -4
View File
@@ -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"`
+1 -1
View File
@@ -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
+9
View File
@@ -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":"<code>"} 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})
}
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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{
+2 -2
View File
@@ -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)
-55
View File
@@ -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
}
+1 -1
View File
@@ -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 {
+5 -25
View File
@@ -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)
+12 -12
View File
@@ -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),
+11 -11
View File
@@ -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)
+14
View File
@@ -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)
}
}
-10
View File
@@ -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"
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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 != "" {
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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,
+4 -4
View File
@@ -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
+1 -1
View File
@@ -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
-109
View File
@@ -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
}
+2 -8
View File
@@ -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
}
+2 -2
View File
@@ -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
+55 -67
View File
@@ -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
@@ -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,
)
+4 -4
View File
@@ -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 (
+5 -5
View File
@@ -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
+1 -1
View File
@@ -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
)
`
+10 -10
View File
@@ -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
+1 -1
View File
@@ -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 {
@@ -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);
@@ -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;
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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)
+4 -4
View File
@@ -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
+1 -1
View File
@@ -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
-20
View File
@@ -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;
+2 -4
View File
@@ -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;
+2 -2
View File
@@ -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
@@ -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;
+4 -4
View File
@@ -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
+5 -5
View File
@@ -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
+1 -1
View File
@@ -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
);
+10 -10
View File
@@ -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'
+1 -1
View File
@@ -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;
+3 -3
View File
@@ -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
+4 -5
View File
@@ -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 {
+1 -1
View File
@@ -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)
+3 -5
View File
@@ -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
+1 -1
View File
@@ -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
+3 -5
View File
@@ -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)
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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
-511
View File
@@ -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 ""
}
-892
View File
@@ -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)
}
}
-69
View File
@@ -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
}
-214
View File
@@ -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")
}
}
-33
View File
@@ -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
)
-81
View File
@@ -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:<mbid>), kept as raw
// JSON so AddAlbum can POST it back with every field Lidarr sent — the
// round trip Lidarr's own add-album UI makes. Returns ErrNotFound when no
// result carries that MBID.
func (c *Client) lookupAlbumResource(ctx context.Context, mbid string) (map[string]any, error) {
if mbid == "" {
return nil, fmt.Errorf("lidarr: empty album mbid")
}
resp, err := c.get(ctx, "/api/v1/album/lookup", url.Values{"term": {"lidarr:" + mbid}})
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
var rows []map[string]any
if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil {
return nil, fmt.Errorf("%w: decode album lookup: %v", ErrInvalidPayload, err)
}
for _, row := range rows {
if id, _ := row["foreignAlbumId"].(string); id == mbid {
return row, nil
}
}
return nil, fmt.Errorf("%w: album %s in Lidarr's metadata", ErrNotFound, mbid)
}
-8
View File
@@ -1,8 +0,0 @@
[
{
"id": 42,
"foreignAlbumId": "3a2c2c8c-7e6f-4f8a-b1d2-9a8b6c4e3f1d",
"title": "Music Has The Right To Children",
"artistId": 7
}
]
-7
View File
@@ -1,7 +0,0 @@
[
{
"id": 7,
"foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01",
"artistName": "Boards of Canada"
}
]
-23
View File
@@ -1,23 +0,0 @@
[
{
"foreignAlbumId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01",
"title": "Music Has the Right to Children",
"artistName": "Boards of Canada",
"releaseDate": "1998-04-27",
"trackCount": 18,
"images": [
{"coverType": "cover", "remoteUrl": "https://example.invalid/mhtrtc.jpg", "url": ""},
{"coverType": "poster", "remoteUrl": "https://example.invalid/mhtrtc-poster.jpg", "url": ""}
]
},
{
"foreignAlbumId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01",
"title": "Geogaddi",
"artistName": "Boards of Canada",
"releaseDate": "2002-02-11",
"trackCount": 23,
"images": []
}
]
-19
View File
@@ -1,19 +0,0 @@
[
{
"foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01",
"artistName": "Boards of Canada",
"genres": ["Electronic", "IDM"],
"albumCount": 18,
"images": [
{"coverType": "poster", "remoteUrl": "https://example.invalid/boc.jpg", "url": ""},
{"coverType": "banner", "remoteUrl": "https://example.invalid/boc-banner.jpg", "url": ""}
]
},
{
"foreignArtistId": "f54ba20c-7da3-4b8a-9b12-22f09b9e2c1c",
"artistName": "Bored of Education",
"genres": [],
"albumCount": 0,
"images": []
}
]
-18
View File
@@ -1,18 +0,0 @@
[
{
"foreignTrackId": "t1a2b3c4-d5e6-7890-abcd-ef1234567890",
"foreignAlbumId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01",
"title": "Roygbiv",
"albumTitle": "Music Has the Right to Children",
"artistName": "Boards of Canada"
},
{
"foreignTrackId": "t2b3c4d5-e6f7-8901-bcde-f12345678901",
"foreignAlbumId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01",
"title": "Aquarius",
"albumTitle": "Music Has the Right to Children",
"artistName": "Boards of Canada"
}
]
-5
View File
@@ -1,5 +0,0 @@
[
{"id": 1, "name": "Any"},
{"id": 2, "name": "Lossless"},
{"id": 3, "name": "Standard"}
]
-5
View File
@@ -1,5 +0,0 @@
[
{"path": "/music", "accessible": true, "freeSpace": 107374182400},
{"path": "/music-lossy", "accessible": true, "freeSpace": 53687091200},
{"path": "/music-offline", "accessible": false, "freeSpace": 0}
]
-97
View File
@@ -1,97 +0,0 @@
// Package lidarr is a typed HTTP client for Lidarr's v1 API. It is the
// only place in the codebase that knows about Lidarr's wire format.
// Callers receive value structs, never raw JSON.
package lidarr
// LookupResult is the normalized shape returned by Lookup{Artist,Album,Track}.
// MBID is the kind's primary identifier (foreignArtistId / foreignAlbumId /
// foreignTrackId from Lidarr). ArtistMBID and AlbumMBID carry parent
// identifiers when the kind has them — request creation and admin
// approval need them to construct the lidarr_requests row and the
// AddArtist/AddAlbum API calls.
type LookupResult struct {
MBID string // foreignArtistId / foreignAlbumId / foreignTrackId
ArtistMBID string // parent artist's foreignArtistId — set on album/track results, empty on artist results
AlbumMBID string // parent album's foreignAlbumId — set on track results only
Name string // artist name; album/track returns Title here too
Secondary string // genre + album count for artist; year for album; album for track
ImageURL string // cover-art URL Lidarr surfaced (may be empty)
}
// lidarrImage is the wire shape for image entries in Lidarr's API responses.
type lidarrImage struct {
CoverType string `json:"coverType"`
RemoteURL string `json:"remoteUrl"`
URL string `json:"url"`
}
// QualityProfile is the dropdown choice on Admin › Acquisition.
type QualityProfile struct {
ID int
Name string
}
// MetadataProfile controls which release types Lidarr tracks for an artist
// (album / single / EP / compilation). It's a separate Lidarr concept from
// quality profile and is required on POST /api/v1/artist — Lidarr 4xx's
// the request with "'Metadata Profile Id' must be greater than '0'" if
// it's missing or zero.
type MetadataProfile struct {
ID int
Name string
}
// RootFolder is the dropdown choice on Admin › Acquisition.
type RootFolder struct {
Path string
Accessible bool
FreeSpace int64
}
// AddArtistParams are the fields Lidarr requires on POST /api/v1/artist.
// ArtistName and MetadataProfileID are both required; omitting either
// produces a 400 with field-validation errors.
type AddArtistParams struct {
ForeignArtistID string
ArtistName string
QualityProfileID int
MetadataProfileID int
RootFolderPath string
MonitorAll bool // true => addOptions.monitor="all"; false => "future"
}
// AddAlbumParams identify the album to add and carry the settings Lidarr
// needs when it has to create the parent artist on the fly (an album by an
// artist not yet in Lidarr). The artist fields are a fallback only: the
// album lookup normally supplies the artist itself.
type AddAlbumParams struct {
ForeignAlbumID string
ForeignArtistID string // Lidarr requires the artist's foreign id too
ArtistName string
QualityProfileID int
MetadataProfileID int
RootFolderPath string
}
// PingResult is the response shape from GET /api/v1/system/status.
type PingResult struct {
Version string
}
// LidarrArtist is the subset of Lidarr's artist resource used by M5b
// admin actions. The "id" field is Lidarr's internal numeric ID — needed
// for DELETE /api/v1/artist/{id} calls.
type LidarrArtist struct {
ID int `json:"id"`
ForeignArtistID string `json:"foreignArtistId"` // MBID
ArtistName string `json:"artistName"`
}
// LidarrAlbum is the subset of Lidarr's album resource used by M5b
// admin actions.
type LidarrAlbum struct {
ID int `json:"id"`
ForeignAlbumID string `json:"foreignAlbumId"` // MBID
Title string `json:"title"`
ArtistID int `json:"artistId"`
}
-91
View File
@@ -1,91 +0,0 @@
package lidarr
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strconv"
)
// UnmonitorTrack flips Lidarr's `monitored` flag to false on the track
// matching trackMbid (a MusicBrainz id, aka Lidarr's `foreignTrackId`).
// Used by the M7 #372 admin "Remove from library" flow when the operator
// requests `?unmonitor=true` so Lidarr's monitor doesn't search for a
// replacement after we delete the local file + DB rows.
//
// Lidarr's monitor API takes its own internal numeric id, not the mbid,
// so this is a three-step walk:
//
// 1. GET /api/v1/album?foreignAlbumId={albumMbid} — find the album's
// internal Lidarr id. albumMbid is the parent album's mbid; the
// caller (tracks service) passes it because looking it up from the
// trackMbid alone would mean either a dedicated Lidarr endpoint
// that doesn't exist or scanning the entire library.
// 2. GET /api/v1/track?albumId={lidarr_album_id} — list the album's
// tracks, each with its internal id and foreignTrackId.
// 3. PUT /api/v1/track/monitor — body `{trackIds:[id], monitored:false}`.
//
// Returns ErrNotFound when the album or track isn't in Lidarr's library
// (already removed, or the track was never imported). Network / auth /
// 5xx errors propagate as typed sentinel errors so the tracks service
// can decide whether to surface a `lidarr_unmonitor_failed` flag.
func (c *Client) UnmonitorTrack(ctx context.Context, trackMbid, albumMbid string) error {
if trackMbid == "" {
return fmt.Errorf("lidarr: empty track mbid")
}
if albumMbid == "" {
return fmt.Errorf("lidarr: empty album mbid")
}
// Step 1: resolve album mbid → Lidarr album id.
album, err := c.LookupAlbumByMBID(ctx, albumMbid)
if err != nil {
return err
}
if album.ID == 0 {
return ErrNotFound
}
// Step 2: list tracks under that album, find the one whose
// foreignTrackId matches.
q := url.Values{"albumId": []string{strconv.Itoa(album.ID)}}
resp, err := c.get(ctx, "/api/v1/track", q)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
var tracks []struct {
ID int `json:"id"`
ForeignTrackID string `json:"foreignTrackId"`
}
if err := json.NewDecoder(resp.Body).Decode(&tracks); err != nil {
return fmt.Errorf("%w: decode tracks: %v", ErrInvalidPayload, err)
}
var lidarrTrackID int
for _, t := range tracks {
if t.ForeignTrackID == trackMbid {
lidarrTrackID = t.ID
break
}
}
if lidarrTrackID == 0 {
return ErrNotFound
}
// Step 3: PUT the monitor toggle.
body, err := json.Marshal(map[string]any{
"trackIds": []int{lidarrTrackID},
"monitored": false,
})
if err != nil {
return fmt.Errorf("%w: marshal: %v", ErrInvalidPayload, err)
}
putResp, err := c.put(ctx, "/api/v1/track/monitor", body)
if err != nil {
return err
}
_ = putResp.Body.Close()
return nil
}
-94
View File
@@ -1,94 +0,0 @@
// Package lidarrconfig is a thin wrapper over the singleton lidarr_config
// row. Get returns a typed Config; Save updates it. Callers branch on
// Config.Enabled — never on raw NULL fields.
package lidarrconfig
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// Config is the typed projection of lidarr_config (no NULL fields exposed
// to callers — empty strings / zero ints carry the "unset" meaning).
type Config struct {
Enabled bool
BaseURL string
APIKey string
DefaultQualityProfileID int
DefaultMetadataProfileID int
DefaultRootFolderPath string
}
// Service reads and writes the singleton.
type Service struct {
pool *pgxpool.Pool
}
func New(pool *pgxpool.Pool) *Service { return &Service{pool: pool} }
func (s *Service) Get(ctx context.Context) (Config, error) {
row, err := dbq.New(s.pool).GetLidarrConfig(ctx)
if err != nil {
return Config{}, fmt.Errorf("lidarrconfig: %w", err)
}
cfg := Config{Enabled: row.Enabled}
if row.BaseUrl != nil {
cfg.BaseURL = *row.BaseUrl
}
if row.ApiKey != nil {
cfg.APIKey = *row.ApiKey
}
if row.DefaultQualityProfileID != nil {
cfg.DefaultQualityProfileID = int(*row.DefaultQualityProfileID)
}
if row.DefaultMetadataProfileID != nil {
cfg.DefaultMetadataProfileID = int(*row.DefaultMetadataProfileID)
}
if row.DefaultRootFolderPath != nil {
cfg.DefaultRootFolderPath = *row.DefaultRootFolderPath
}
return cfg, nil
}
// Save writes the entire row. Callers pass the full Config they want
// stored — this is not a partial update.
func (s *Service) Save(ctx context.Context, cfg Config) error {
var (
baseURL = strPtr(cfg.BaseURL)
apiKey = strPtr(cfg.APIKey)
qpID = int32Ptr(cfg.DefaultQualityProfileID)
mpID = int32Ptr(cfg.DefaultMetadataProfileID)
rootPath = strPtr(cfg.DefaultRootFolderPath)
)
_, err := dbq.New(s.pool).UpdateLidarrConfig(ctx, dbq.UpdateLidarrConfigParams{
Enabled: cfg.Enabled,
BaseUrl: baseURL,
ApiKey: apiKey,
DefaultQualityProfileID: qpID,
DefaultMetadataProfileID: mpID,
DefaultRootFolderPath: rootPath,
})
if err != nil {
return fmt.Errorf("lidarrconfig: %w", err)
}
return nil
}
func strPtr(s string) *string {
if s == "" {
return nil
}
return &s
}
func int32Ptr(i int) *int32 {
if i == 0 {
return nil
}
v := int32(i)
return &v
}
-88
View File
@@ -1,88 +0,0 @@
package lidarrconfig
import (
"context"
"io"
"log/slog"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
)
func newTestPool(t *testing.T) *pgxpool.Pool {
t.Helper()
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
// Reset singleton to default state before each test by replacing
// the row contents via UPDATE (TRUNCATE would violate the CHECK).
if _, err := pool.Exec(context.Background(),
"UPDATE lidarr_config SET enabled=false, base_url=NULL, api_key=NULL, default_quality_profile_id=NULL, default_root_folder_path=NULL WHERE id=1",
); err != nil {
t.Fatalf("reset: %v", err)
}
return pool
}
func TestGet_DefaultRowReturnsZeroValueConfig(t *testing.T) {
pool := newTestPool(t)
cfg, err := New(pool).Get(context.Background())
if err != nil {
t.Fatalf("Get: %v", err)
}
if cfg.Enabled || cfg.BaseURL != "" || cfg.APIKey != "" {
t.Errorf("expected zero-value Config, got %+v", cfg)
}
}
func TestSaveThenGet_RoundTrip(t *testing.T) {
pool := newTestPool(t)
s := New(pool)
want := Config{
Enabled: true,
BaseURL: "http://lidarr.lan:8686",
APIKey: "secret",
DefaultQualityProfileID: 4,
DefaultRootFolderPath: "/music",
}
if err := s.Save(context.Background(), want); err != nil {
t.Fatalf("Save: %v", err)
}
got, err := s.Get(context.Background())
if err != nil {
t.Fatalf("Get: %v", err)
}
if got != want {
t.Errorf("round-trip mismatch:\n got = %+v\nwant = %+v", got, want)
}
}
func TestSave_EmptyValuesPersistAsNULL(t *testing.T) {
pool := newTestPool(t)
s := New(pool)
if err := s.Save(context.Background(), Config{Enabled: false}); err != nil {
t.Fatalf("Save: %v", err)
}
got, err := s.Get(context.Background())
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.BaseURL != "" || got.APIKey != "" || got.DefaultRootFolderPath != "" {
t.Errorf("expected empty strings on round-trip; got %+v", got)
}
}
-360
View File
@@ -1,360 +0,0 @@
// Package lidarrquarantine owns the per-user track quarantine workflow.
// Users flag a track as broken (Flag/Unflag), the SPA hides the track
// from their views, and admins resolve the resulting reports via the
// Service's admin actions (Resolve / DeleteFile / DeleteViaLidarr).
package lidarrquarantine
import (
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
)
// Public errors. Handlers map these to API codes.
var (
ErrBadReason = errors.New("lidarrquarantine: invalid reason")
ErrTrackNotFound = errors.New("lidarrquarantine: track not found")
ErrQuarantineNotFound = errors.New("lidarrquarantine: quarantine row not found")
ErrAlbumMBIDMissing = errors.New("lidarrquarantine: track has no parent album mbid")
ErrLidarrAlbumNotFound = errors.New("lidarrquarantine: lidarr has no album for that mbid")
ErrLidarrDisabled = errors.New("lidarrquarantine: lidarr is not configured")
)
// Service owns the request lifecycle. clientFn is a per-call factory so
// config changes in lidarrconfig take effect immediately. clientFn returns
// nil when Lidarr is disabled.
type Service struct {
// dataDir lets a Delete file that empties an artist clear that artist's
// cached art, the same as the admin remove-track path.
dataDir string
pool *pgxpool.Pool
lidarrCfg *lidarrconfig.Service
clientFn func() *lidarr.Client
}
// NewService constructs a Service. Pass nil for clientFn to disable the
// Lidarr-using methods (DeleteViaLidarr will return ErrLidarrDisabled).
func NewService(pool *pgxpool.Pool, cfg *lidarrconfig.Service, clientFn func() *lidarr.Client, dataDir string) *Service {
if clientFn == nil {
clientFn = func() *lidarr.Client { return nil }
}
return &Service{pool: pool, lidarrCfg: cfg, clientFn: clientFn, dataDir: dataDir}
}
// Flag inserts or updates a quarantine row for the caller. Re-flagging
// the same (user, track) overwrites reason+notes (and bumps created_at).
func (s *Service) Flag(ctx context.Context, userID, trackID pgtype.UUID, reason string, notes string) (dbq.LidarrQuarantine, error) {
if !validReason(reason) {
return dbq.LidarrQuarantine{}, ErrBadReason
}
var notesPtr *string
if notes != "" {
notesPtr = &notes
}
row, err := dbq.New(s.pool).UpsertQuarantine(ctx, dbq.UpsertQuarantineParams{
UserID: userID,
TrackID: trackID,
Reason: dbq.LidarrQuarantineReason(reason),
Notes: notesPtr,
})
if err != nil {
// FK violation on track_id (no such track) → ErrTrackNotFound so the
// handler can return 404 instead of a generic 500. Code 23503 is the
// Postgres SQLSTATE for foreign_key_violation; the constraint check
// guards against future FKs (e.g. user_id) firing the same code.
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23503" &&
(pgErr.ConstraintName == "" || strings.Contains(pgErr.ConstraintName, "track")) {
return dbq.LidarrQuarantine{}, ErrTrackNotFound
}
return dbq.LidarrQuarantine{}, fmt.Errorf("upsert: %w", err)
}
return row, nil
}
// Unflag removes the caller's row. Returns ErrQuarantineNotFound if no
// row exists for that (user, track).
func (s *Service) Unflag(ctx context.Context, userID, trackID pgtype.UUID) error {
_, err := dbq.New(s.pool).DeleteQuarantine(ctx, dbq.DeleteQuarantineParams{
UserID: userID, TrackID: trackID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrQuarantineNotFound
}
return fmt.Errorf("delete: %w", err)
}
return nil
}
// ListMine returns the caller's quarantines with track + album + artist
// detail. Drives /library/hidden. Ordered newest-first.
func (s *Service) ListMine(ctx context.Context, userID pgtype.UUID) ([]dbq.ListQuarantineForUserRow, error) {
return dbq.New(s.pool).ListQuarantineForUser(ctx, userID)
}
// AdminQueueRow is the assembled aggregated row served by the admin
// queue endpoint. The handler post-processes the SQL result + a per-track
// ListQuarantineReports call to materialize reason_counts and the
// per-user reports list.
type AdminQueueRow struct {
TrackID pgtype.UUID
TrackTitle string
ArtistName string
AlbumTitle string
AlbumID pgtype.UUID
LidarrAlbumMBID *string
ReportCount int32
LatestAt pgtype.Timestamptz
ReasonCounts map[string]int
Reports []UserReport
}
// UserReport is the per-user detail under an aggregated admin row.
type UserReport struct {
UserID pgtype.UUID
Username string
Reason string
Notes *string
CreatedAt pgtype.Timestamptz
}
// ListAdminQueue returns the aggregated admin queue. One row per track.
// Performs an N+1 (one ListQuarantineReportsForTrack per aggregated row)
// because materializing reason_counts in pure SQL is awkward; the queue
// is small in practice (≲100 tracks at household scale). Revisit with
// json_object_agg if the queue grows.
func (s *Service) ListAdminQueue(ctx context.Context) ([]AdminQueueRow, error) {
q := dbq.New(s.pool)
aggregated, err := q.ListAdminQuarantineQueue(ctx)
if err != nil {
return nil, fmt.Errorf("aggregate: %w", err)
}
out := make([]AdminQueueRow, 0, len(aggregated))
for _, r := range aggregated {
reports, err := q.ListQuarantineReportsForTrack(ctx, r.TrackID)
if err != nil {
return nil, fmt.Errorf("reports for track %v: %w", r.TrackID, err)
}
rc := make(map[string]int, len(reports))
userReports := make([]UserReport, 0, len(reports))
for _, rep := range reports {
rc[string(rep.Reason)]++
userReports = append(userReports, UserReport{
UserID: rep.UserID,
Username: rep.Username,
Reason: string(rep.Reason),
Notes: rep.Notes,
CreatedAt: rep.CreatedAt,
})
}
out = append(out, AdminQueueRow{
TrackID: r.TrackID,
TrackTitle: r.TrackTitle,
ArtistName: r.ArtistName,
AlbumTitle: r.AlbumTitle,
AlbumID: r.AlbumID,
LidarrAlbumMBID: r.LidarrAlbumMbid,
ReportCount: r.ReportCount,
LatestAt: r.LatestAt,
ReasonCounts: rc,
Reports: userReports,
})
}
return out, nil
}
func validReason(r string) bool {
switch r {
case "bad_rip", "wrong_file", "wrong_tags", "duplicate", "other":
return true
}
return false
}
// Resolve clears all per-user quarantine rows for a track and writes an
// audit log row. Idempotent — a track with no rows still writes an audit
// entry with affected_users=0 (so admin can see "I clicked resolve on a
// track that already had no reports").
func (s *Service) Resolve(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, error) {
q := dbq.New(s.pool)
track, err := q.GetTrackByID(ctx, trackID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return dbq.LidarrQuarantineAction{}, ErrTrackNotFound
}
return dbq.LidarrQuarantineAction{}, fmt.Errorf("get track: %w", err)
}
snap, err := s.snapshot(ctx, q, track)
if err != nil {
return dbq.LidarrQuarantineAction{}, err
}
affected, err := q.CountQuarantineForTrack(ctx, trackID)
if err != nil {
return dbq.LidarrQuarantineAction{}, fmt.Errorf("count: %w", err)
}
if err := q.DeleteQuarantineForTrack(ctx, trackID); err != nil {
return dbq.LidarrQuarantineAction{}, fmt.Errorf("delete rows: %w", err)
}
// LidarrAlbumMbid stays nil here: Lidarr was not contacted. The audit
// log uses the field as a "this row triggered Lidarr" marker, so only
// DeleteViaLidarr sets it.
return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{
TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName,
AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionKindResolved,
AdminID: adminID, LidarrAlbumMbid: nil, AffectedUsers: affected,
})
}
// quarantineSnapshot is the audit-row-ready snapshot of track + album +
// artist text columns. Used by the three admin actions.
type quarantineSnapshot struct {
TrackTitle string
ArtistName string
AlbumTitle *string
LidarrAlbumMBID *string
}
// snapshot pulls the audit-row text columns from track / album / artist.
// AlbumTitle is materialized as *string because WriteQuarantineActionParams
// expects nullable; we coerce the schema's NOT NULL value into a pointer.
func (s *Service) snapshot(ctx context.Context, q *dbq.Queries, track dbq.Track) (quarantineSnapshot, error) {
album, err := q.GetAlbumByID(ctx, track.AlbumID)
if err != nil {
return quarantineSnapshot{}, fmt.Errorf("get album: %w", err)
}
artist, err := q.GetArtistByID(ctx, track.ArtistID)
if err != nil {
return quarantineSnapshot{}, fmt.Errorf("get artist: %w", err)
}
titlePtr := album.Title
return quarantineSnapshot{
TrackTitle: track.Title,
ArtistName: artist.Name,
AlbumTitle: &titlePtr,
LidarrAlbumMBID: album.Mbid,
}, nil
}
// DeleteFile removes the track file from disk and the tracks row (tidying away
// an album or artist that leaves empty), then — via FK ON DELETE CASCADE —
// clears every per-user quarantine row for that track and writes an audit row.
//
// If the file cannot be removed, nothing is deleted and the per-user rows stay
// so the admin can retry: the error wraps a *library.FileRemoveError, which the
// handler turns into an answer naming the cause (#3918). No partial state.
func (s *Service) DeleteFile(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, error) {
q := dbq.New(s.pool)
track, err := q.GetTrackByID(ctx, trackID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return dbq.LidarrQuarantineAction{}, ErrTrackNotFound
}
return dbq.LidarrQuarantineAction{}, fmt.Errorf("get track: %w", err)
}
snap, err := s.snapshot(ctx, q, track)
if err != nil {
return dbq.LidarrQuarantineAction{}, err
}
affected, err := q.CountQuarantineForTrack(ctx, trackID)
if err != nil {
return dbq.LidarrQuarantineAction{}, fmt.Errorf("count: %w", err)
}
if _, err := library.DeleteTrackFile(ctx, s.pool, nil, s.dataDir, trackID); err != nil {
return dbq.LidarrQuarantineAction{}, fmt.Errorf("delete file: %w", err)
}
// tracks row is gone; ON DELETE CASCADE on lidarr_quarantine.track_id
// already cleared the per-user rows. We don't call
// DeleteQuarantineForTrack separately. LidarrAlbumMbid stays nil —
// Lidarr was not contacted.
return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{
TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName,
AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionKindDeletedFile,
AdminID: adminID, LidarrAlbumMbid: nil, AffectedUsers: affected,
})
}
// DeleteViaLidarr is the destructive admin path: tells Lidarr to remove
// the parent album with deleteFiles=true + addImportListExclusion=true,
// then removes Minstrel rows for all tracks of that album. The cascade
// on lidarr_quarantine.track_id clears per-user rows automatically.
//
// On Lidarr failure (unreachable, auth-failed, lookup-empty), nothing
// changes locally. Admin retries.
//
// Returns the audit row + the count of tracks deleted from Minstrel.
func (s *Service) DeleteViaLidarr(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, int, error) {
cfg, err := s.lidarrCfg.Get(ctx)
if err != nil {
return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("load config: %w", err)
}
client := s.clientFn()
if !cfg.Enabled || client == nil {
return dbq.LidarrQuarantineAction{}, 0, ErrLidarrDisabled
}
q := dbq.New(s.pool)
track, err := q.GetTrackByID(ctx, trackID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return dbq.LidarrQuarantineAction{}, 0, ErrTrackNotFound
}
return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("get track: %w", err)
}
snap, err := s.snapshot(ctx, q, track)
if err != nil {
return dbq.LidarrQuarantineAction{}, 0, err
}
if snap.LidarrAlbumMBID == nil || *snap.LidarrAlbumMBID == "" {
return dbq.LidarrQuarantineAction{}, 0, ErrAlbumMBIDMissing
}
affected, err := q.CountQuarantineForTrack(ctx, trackID)
if err != nil {
return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("count: %w", err)
}
// Lidarr lookup: MBID -> internal album ID.
album, err := client.LookupAlbumByMBID(ctx, *snap.LidarrAlbumMBID)
if err != nil {
if errors.Is(err, lidarr.ErrNotFound) {
return dbq.LidarrQuarantineAction{}, 0, ErrLidarrAlbumNotFound
}
return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("lidarr lookup: %w", err)
}
// Lidarr DELETE — both flags true.
if err := client.DeleteAlbum(ctx, album.ID, true, true); err != nil {
return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("lidarr delete: %w", err)
}
// Now remove the local rows. CASCADE on lidarr_quarantine.track_id
// handles per-user rows.
res, err := s.pool.Exec(ctx, "DELETE FROM tracks WHERE album_id = $1", track.AlbumID)
if err != nil {
// Lidarr is already done; we couldn't update locally. Operator-
// recoverable: re-running picks up where we left off.
return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("delete tracks: %w", err)
}
deletedCount := int(res.RowsAffected())
action, err := q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{
TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName,
AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionKindDeletedViaLidarr,
AdminID: adminID, LidarrAlbumMbid: snap.LidarrAlbumMBID, AffectedUsers: affected,
})
return action, deletedCount, err
}
+1 -1
View File
@@ -18,7 +18,7 @@ type Artist struct {
}
// ReleaseGroup is what a person calls an album: one work, released any
// number of times. Lidarr's albums were release groups too.
// number of times.
type ReleaseGroup struct {
MBID string
Title string
+2 -2
View File
@@ -23,8 +23,8 @@ const (
KindScanFailed Kind = "scan_failed"
KindTracksMissing Kind = "tracks_missing"
KindDuplicatesFound Kind = "duplicates_found"
// KindDuplicatesResolved: the duplicate resolver (M498) merged copies or
// changed a Lidarr release on its own. The operator asked for that to
// KindDuplicatesResolved: the duplicate resolver (M498) merged copies on
// its own. The operator asked for that to
// happen automatically; this is how it stays visible.
KindDuplicatesResolved Kind = "duplicates_resolved"
KindPlaybackErrors Kind = "playback_errors"
+2 -2
View File
@@ -38,7 +38,7 @@ type Payload struct {
// Count is the coalesced kinds' running total.
Count int64 `json:"count,omitempty"`
// Detail is free text: scan_failed's error message, and what the duplicate
// resolver changed in Lidarr for duplicates_resolved.
// resolver merged for duplicates_resolved.
Detail string `json:"detail,omitempty"`
}
@@ -117,7 +117,7 @@ func Render(kind Kind, payload []byte) Rendered {
Link: "/admin/duplicates",
}
case KindDuplicatesResolved:
body := "Copies Lidarr does not need were merged into the one it tracks."
body := "Extra copies of a song were merged into the one the album keeps."
if p.Detail != "" {
body = p.Detail
}
+1 -1
View File
@@ -459,7 +459,7 @@ func TestSnapshotPersistsAfterUpstreamTrackDelete(t *testing.T) {
pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false)
_ = svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{tk.ID})
// Delete the track row directly (simulating Lidarr re-import / file removal).
// Delete the track row directly (simulating a re-import / file removal).
if _, err := pool.Exec(context.Background(), `DELETE FROM tracks WHERE id = $1`, tk.ID); err != nil {
t.Fatalf("delete track: %v", err)
}
+2 -2
View File
@@ -42,11 +42,11 @@ func seedPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID
}
}
// seedQuarantine inserts a lidarr_quarantine row for (user, track).
// seedQuarantine inserts a quarantine row for (user, track).
func seedQuarantine(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)
INSERT INTO quarantine (user_id, track_id, reason)
VALUES ($1, $2, 'other')
`, userID, trackID)
if err != nil {
+267
View File
@@ -0,0 +1,267 @@
// Package quarantine owns the per-user track quarantine workflow.
// Users flag a track as broken (Flag/Unflag), the SPA hides the track
// from their views, and admins resolve the resulting reports via the
// Service's admin actions (Resolve / DeleteFile).
package quarantine
import (
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
// Public errors. Handlers map these to API codes.
var (
ErrBadReason = errors.New("quarantine: invalid reason")
ErrTrackNotFound = errors.New("quarantine: track not found")
ErrQuarantineNotFound = errors.New("quarantine: quarantine row not found")
)
// Service owns the quarantine lifecycle.
type Service struct {
// dataDir lets a Delete file that empties an artist clear that artist's
// cached art, the same as the admin remove-track path.
dataDir string
pool *pgxpool.Pool
}
// NewService constructs a Service.
func NewService(pool *pgxpool.Pool, dataDir string) *Service {
return &Service{pool: pool, dataDir: dataDir}
}
// Flag inserts or updates a quarantine row for the caller. Re-flagging
// the same (user, track) overwrites reason+notes (and bumps created_at).
func (s *Service) Flag(ctx context.Context, userID, trackID pgtype.UUID, reason string, notes string) (dbq.Quarantine, error) {
if !validReason(reason) {
return dbq.Quarantine{}, ErrBadReason
}
var notesPtr *string
if notes != "" {
notesPtr = &notes
}
row, err := dbq.New(s.pool).UpsertQuarantine(ctx, dbq.UpsertQuarantineParams{
UserID: userID,
TrackID: trackID,
Reason: dbq.QuarantineReason(reason),
Notes: notesPtr,
})
if err != nil {
// FK violation on track_id (no such track) → ErrTrackNotFound so the
// handler can return 404 instead of a generic 500. Code 23503 is the
// Postgres SQLSTATE for foreign_key_violation; the constraint check
// guards against future FKs (e.g. user_id) firing the same code.
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23503" &&
(pgErr.ConstraintName == "" || strings.Contains(pgErr.ConstraintName, "track")) {
return dbq.Quarantine{}, ErrTrackNotFound
}
return dbq.Quarantine{}, fmt.Errorf("upsert: %w", err)
}
return row, nil
}
// Unflag removes the caller's row. Returns ErrQuarantineNotFound if no
// row exists for that (user, track).
func (s *Service) Unflag(ctx context.Context, userID, trackID pgtype.UUID) error {
_, err := dbq.New(s.pool).DeleteQuarantine(ctx, dbq.DeleteQuarantineParams{
UserID: userID, TrackID: trackID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrQuarantineNotFound
}
return fmt.Errorf("delete: %w", err)
}
return nil
}
// ListMine returns the caller's quarantines with track + album + artist
// detail. Drives /library/hidden. Ordered newest-first.
func (s *Service) ListMine(ctx context.Context, userID pgtype.UUID) ([]dbq.ListQuarantineForUserRow, error) {
return dbq.New(s.pool).ListQuarantineForUser(ctx, userID)
}
// AdminQueueRow is the assembled aggregated row served by the admin
// queue endpoint. The handler post-processes the SQL result + a per-track
// ListQuarantineReports call to materialize reason_counts and the
// per-user reports list.
type AdminQueueRow struct {
TrackID pgtype.UUID
TrackTitle string
ArtistName string
AlbumTitle string
AlbumID pgtype.UUID
ReportCount int32
LatestAt pgtype.Timestamptz
ReasonCounts map[string]int
Reports []UserReport
}
// UserReport is the per-user detail under an aggregated admin row.
type UserReport struct {
UserID pgtype.UUID
Username string
Reason string
Notes *string
CreatedAt pgtype.Timestamptz
}
// ListAdminQueue returns the aggregated admin queue. One row per track.
// Performs an N+1 (one ListQuarantineReportsForTrack per aggregated row)
// because materializing reason_counts in pure SQL is awkward; the queue
// is small in practice (≲100 tracks at household scale). Revisit with
// json_object_agg if the queue grows.
func (s *Service) ListAdminQueue(ctx context.Context) ([]AdminQueueRow, error) {
q := dbq.New(s.pool)
aggregated, err := q.ListAdminQuarantineQueue(ctx)
if err != nil {
return nil, fmt.Errorf("aggregate: %w", err)
}
out := make([]AdminQueueRow, 0, len(aggregated))
for _, r := range aggregated {
reports, err := q.ListQuarantineReportsForTrack(ctx, r.TrackID)
if err != nil {
return nil, fmt.Errorf("reports for track %v: %w", r.TrackID, err)
}
rc := make(map[string]int, len(reports))
userReports := make([]UserReport, 0, len(reports))
for _, rep := range reports {
rc[string(rep.Reason)]++
userReports = append(userReports, UserReport{
UserID: rep.UserID,
Username: rep.Username,
Reason: string(rep.Reason),
Notes: rep.Notes,
CreatedAt: rep.CreatedAt,
})
}
out = append(out, AdminQueueRow{
TrackID: r.TrackID,
TrackTitle: r.TrackTitle,
ArtistName: r.ArtistName,
AlbumTitle: r.AlbumTitle,
AlbumID: r.AlbumID,
ReportCount: r.ReportCount,
LatestAt: r.LatestAt,
ReasonCounts: rc,
Reports: userReports,
})
}
return out, nil
}
func validReason(r string) bool {
switch r {
case "bad_rip", "wrong_file", "wrong_tags", "duplicate", "other":
return true
}
return false
}
// Resolve clears all per-user quarantine rows for a track and writes an
// audit log row. Idempotent — a track with no rows still writes an audit
// entry with affected_users=0 (so admin can see "I clicked resolve on a
// track that already had no reports").
func (s *Service) Resolve(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.QuarantineAction, error) {
q := dbq.New(s.pool)
track, err := q.GetTrackByID(ctx, trackID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return dbq.QuarantineAction{}, ErrTrackNotFound
}
return dbq.QuarantineAction{}, fmt.Errorf("get track: %w", err)
}
snap, err := s.snapshot(ctx, q, track)
if err != nil {
return dbq.QuarantineAction{}, err
}
affected, err := q.CountQuarantineForTrack(ctx, trackID)
if err != nil {
return dbq.QuarantineAction{}, fmt.Errorf("count: %w", err)
}
if err := q.DeleteQuarantineForTrack(ctx, trackID); err != nil {
return dbq.QuarantineAction{}, fmt.Errorf("delete rows: %w", err)
}
return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{
TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName,
AlbumTitle: snap.AlbumTitle, Action: dbq.QuarantineActionKindResolved,
AdminID: adminID, AffectedUsers: affected,
})
}
// quarantineSnapshot is the audit-row-ready snapshot of track + album +
// artist text columns. Used by the admin actions.
type quarantineSnapshot struct {
TrackTitle string
ArtistName string
AlbumTitle *string
}
// snapshot pulls the audit-row text columns from track / album / artist.
// AlbumTitle is materialized as *string because WriteQuarantineActionParams
// expects nullable; we coerce the schema's NOT NULL value into a pointer.
func (s *Service) snapshot(ctx context.Context, q *dbq.Queries, track dbq.Track) (quarantineSnapshot, error) {
album, err := q.GetAlbumByID(ctx, track.AlbumID)
if err != nil {
return quarantineSnapshot{}, fmt.Errorf("get album: %w", err)
}
artist, err := q.GetArtistByID(ctx, track.ArtistID)
if err != nil {
return quarantineSnapshot{}, fmt.Errorf("get artist: %w", err)
}
titlePtr := album.Title
return quarantineSnapshot{
TrackTitle: track.Title,
ArtistName: artist.Name,
AlbumTitle: &titlePtr,
}, nil
}
// DeleteFile removes the track file from disk and the tracks row (tidying away
// an album or artist that leaves empty), then — via FK ON DELETE CASCADE —
// clears every per-user quarantine row for that track and writes an audit row.
//
// If the file cannot be removed, nothing is deleted and the per-user rows stay
// so the admin can retry: the error wraps a *library.FileRemoveError, which the
// handler turns into an answer naming the cause (#3918). No partial state.
func (s *Service) DeleteFile(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.QuarantineAction, error) {
q := dbq.New(s.pool)
track, err := q.GetTrackByID(ctx, trackID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return dbq.QuarantineAction{}, ErrTrackNotFound
}
return dbq.QuarantineAction{}, fmt.Errorf("get track: %w", err)
}
snap, err := s.snapshot(ctx, q, track)
if err != nil {
return dbq.QuarantineAction{}, err
}
affected, err := q.CountQuarantineForTrack(ctx, trackID)
if err != nil {
return dbq.QuarantineAction{}, fmt.Errorf("count: %w", err)
}
if _, err := library.DeleteTrackFile(ctx, s.pool, nil, s.dataDir, trackID); err != nil {
return dbq.QuarantineAction{}, fmt.Errorf("delete file: %w", err)
}
// tracks row is gone; ON DELETE CASCADE on quarantine.track_id already
// cleared the per-user rows. We don't call DeleteQuarantineForTrack
// separately.
return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{
TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName,
AlbumTitle: snap.AlbumTitle, Action: dbq.QuarantineActionKindDeletedFile,
AdminID: adminID, AffectedUsers: affected,
})
}
@@ -1,12 +1,10 @@
package lidarrquarantine
package quarantine
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
@@ -17,8 +15,6 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
)
func newPool(t *testing.T) *pgxpool.Pool {
@@ -87,7 +83,7 @@ func TestFlag_HappyPath(t *testing.T) {
user := seedUser(t, pool, "alice")
track, _, _ := seedTrack(t, pool, "Bad Track", "abc")
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
svc := NewService(pool, "")
row, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "crackly")
if err != nil {
t.Fatalf("Flag: %v", err)
@@ -105,7 +101,7 @@ func TestFlag_UpsertOnSecondFlag(t *testing.T) {
user := seedUser(t, pool, "alice")
track, _, _ := seedTrack(t, pool, "T", "x")
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
svc := NewService(pool, "")
if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "first"); err != nil {
t.Fatalf("first flag: %v", err)
}
@@ -129,7 +125,7 @@ func TestFlag_NonexistentTrackReturnsErrTrackNotFound(t *testing.T) {
bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
bogus.Valid = true
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
svc := NewService(pool, "")
_, err := svc.Flag(context.Background(), user.ID, bogus, "bad_rip", "")
if !errors.Is(err, ErrTrackNotFound) {
t.Errorf("err = %v, want ErrTrackNotFound", err)
@@ -141,7 +137,7 @@ func TestFlag_BadReasonRejected(t *testing.T) {
user := seedUser(t, pool, "alice")
track, _, _ := seedTrack(t, pool, "T", "x")
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
svc := NewService(pool, "")
_, err := svc.Flag(context.Background(), user.ID, track.ID, "garbage", "")
if !errors.Is(err, ErrBadReason) {
t.Errorf("err = %v, want ErrBadReason", err)
@@ -153,7 +149,7 @@ func TestUnflag_DeletesRow(t *testing.T) {
user := seedUser(t, pool, "alice")
track, _, _ := seedTrack(t, pool, "T", "x")
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
svc := NewService(pool, "")
if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", ""); err != nil {
t.Fatalf("Flag: %v", err)
}
@@ -171,7 +167,7 @@ func TestListMine_OrderedNewestFirst(t *testing.T) {
t1, _, _ := seedTrack(t, pool, "T1", "x")
t2, _, _ := seedTrack(t, pool, "T2", "y")
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
svc := NewService(pool, "")
if _, err := svc.Flag(context.Background(), user.ID, t1.ID, "bad_rip", ""); err != nil {
t.Fatalf("Flag t1: %v", err)
}
@@ -187,8 +183,8 @@ func TestListMine_OrderedNewestFirst(t *testing.T) {
t.Fatalf("len = %d, want 2", len(rows))
}
// T2 was flagged second — newest first.
if rows[0].LidarrQuarantine.TrackID != t2.ID {
t.Errorf("first row track = %v, want T2 (%v)", rows[0].LidarrQuarantine.TrackID, t2.ID)
if rows[0].Quarantine.TrackID != t2.ID {
t.Errorf("first row track = %v, want T2 (%v)", rows[0].Quarantine.TrackID, t2.ID)
}
}
@@ -199,7 +195,7 @@ func TestListAdminQueue_AggregatesByTrackWithReasonCounts(t *testing.T) {
carol := seedUser(t, pool, "carol")
track, _, _ := seedTrack(t, pool, "Hot Mess", "abc")
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
svc := NewService(pool, "")
if _, err := svc.Flag(context.Background(), alice.ID, track.ID, "bad_rip", ""); err != nil {
t.Fatalf("alice flag: %v", err)
}
@@ -235,7 +231,7 @@ func TestResolve_ClearsRowsAndWritesAudit(t *testing.T) {
bob := seedUser(t, pool, "bob")
track, _, _ := seedTrack(t, pool, "T", "x")
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
svc := NewService(pool, "")
if _, err := svc.Flag(context.Background(), alice.ID, track.ID, "bad_rip", ""); err != nil {
t.Fatalf("alice flag: %v", err)
}
@@ -250,12 +246,9 @@ func TestResolve_ClearsRowsAndWritesAudit(t *testing.T) {
if audit.AffectedUsers != 2 {
t.Errorf("affected_users = %d, want 2", audit.AffectedUsers)
}
if audit.Action != dbq.LidarrQuarantineActionKindResolved {
if audit.Action != dbq.QuarantineActionKindResolved {
t.Errorf("action = %v, want resolved", audit.Action)
}
if audit.LidarrAlbumMbid != nil {
t.Errorf("lidarr_album_mbid = %v, want nil for resolve", audit.LidarrAlbumMbid)
}
n, _ := dbq.New(pool).CountQuarantineForTrack(context.Background(), track.ID)
if n != 0 {
t.Errorf("rows after resolve = %d, want 0", n)
@@ -267,7 +260,7 @@ func TestResolve_NoExistingRowsStillWritesAudit(t *testing.T) {
user := seedUser(t, pool, "alice")
track, _, _ := seedTrack(t, pool, "T", "x")
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
svc := NewService(pool, "")
// No flags applied — resolve a track with zero quarantine rows.
audit, err := svc.Resolve(context.Background(), track.ID, user.ID)
if err != nil {
@@ -276,12 +269,9 @@ func TestResolve_NoExistingRowsStillWritesAudit(t *testing.T) {
if audit.AffectedUsers != 0 {
t.Errorf("affected_users = %d, want 0", audit.AffectedUsers)
}
if audit.Action != dbq.LidarrQuarantineActionKindResolved {
if audit.Action != dbq.QuarantineActionKindResolved {
t.Errorf("action = %v, want resolved", audit.Action)
}
if audit.LidarrAlbumMbid != nil {
t.Errorf("lidarr_album_mbid = %v, want nil for resolve", audit.LidarrAlbumMbid)
}
}
func TestResolve_TrackNotFound(t *testing.T) {
@@ -291,7 +281,7 @@ func TestResolve_TrackNotFound(t *testing.T) {
bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
bogus.Valid = true
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
svc := NewService(pool, "")
_, err := svc.Resolve(context.Background(), bogus, user.ID)
if !errors.Is(err, ErrTrackNotFound) {
t.Errorf("err = %v, want ErrTrackNotFound", err)
@@ -315,7 +305,7 @@ func TestDeleteFile_RemovesFileAndAuditsAffected(t *testing.T) {
DurationMs: 1000, FilePath: path, FileSize: 1, FileFormat: "mp3",
})
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
svc := NewService(pool, "")
if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", ""); err != nil {
t.Fatalf("Flag: %v", err)
}
@@ -327,12 +317,9 @@ func TestDeleteFile_RemovesFileAndAuditsAffected(t *testing.T) {
if audit.AffectedUsers != 1 {
t.Errorf("affected_users = %d, want 1", audit.AffectedUsers)
}
if audit.Action != dbq.LidarrQuarantineActionKindDeletedFile {
if audit.Action != dbq.QuarantineActionKindDeletedFile {
t.Errorf("action = %v, want deleted_file", audit.Action)
}
if audit.LidarrAlbumMbid != nil {
t.Errorf("lidarr_album_mbid = %v, want nil for delete_file", audit.LidarrAlbumMbid)
}
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Errorf("file still exists: %v", err)
}
@@ -342,156 +329,6 @@ func TestDeleteFile_RemovesFileAndAuditsAffected(t *testing.T) {
}
}
func TestDeleteViaLidarr_FullCascade(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
var captured []string
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
captured = append(captured, r.Method+" "+r.URL.Path+"?"+r.URL.RawQuery)
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/v1/album" && r.Method == http.MethodGet {
_, _ = w.Write([]byte(`[{"id":42,"foreignAlbumId":"al-mbid","title":"Al","artistId":7}]`))
return
}
// DELETE /api/v1/album/42 -> 200.
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(stub.Close)
cfg := lidarrconfig.New(pool)
if err := cfg.Save(context.Background(), lidarrconfig.Config{
Enabled: true, BaseURL: stub.URL, APIKey: "k",
}); err != nil {
t.Fatalf("save config: %v", err)
}
clientFn := func() *lidarr.Client { return lidarr.NewClient(stub.URL, "k") }
svc := NewService(pool, cfg, clientFn, "")
q := dbq.New(pool)
artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "A", SortName: "A"})
albumMBID := "al-mbid"
album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
Title: "Al", SortTitle: "Al", ArtistID: artist.ID, Mbid: &albumMBID,
})
dir := t.TempDir()
path := filepath.Join(dir, "T.mp3")
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
track, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: "T", AlbumID: album.ID, ArtistID: artist.ID,
DurationMs: 1000, FilePath: path, FileSize: 1, FileFormat: "mp3",
})
if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", ""); err != nil {
t.Fatalf("Flag: %v", err)
}
audit, deleted, err := svc.DeleteViaLidarr(context.Background(), track.ID, user.ID)
if err != nil {
t.Fatalf("DeleteViaLidarr: %v", err)
}
if deleted != 1 {
t.Errorf("deleted = %d, want 1 track removed", deleted)
}
if audit.Action != dbq.LidarrQuarantineActionKindDeletedViaLidarr {
t.Errorf("action = %v", audit.Action)
}
if audit.AffectedUsers != 1 {
t.Errorf("affected_users = %d, want 1", audit.AffectedUsers)
}
if audit.LidarrAlbumMbid == nil || *audit.LidarrAlbumMbid != "al-mbid" {
t.Errorf("lidarr_album_mbid = %v", audit.LidarrAlbumMbid)
}
if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil {
t.Errorf("track row still exists")
}
// Verify Lidarr received DELETE with both flags true (query order may vary).
foundDelete := false
for _, c := range captured {
if c == "DELETE /api/v1/album/42?addImportListExclusion=true&deleteFiles=true" ||
c == "DELETE /api/v1/album/42?deleteFiles=true&addImportListExclusion=true" {
foundDelete = true
}
}
if !foundDelete {
t.Errorf("Lidarr DELETE not called with both flags true; captured = %v", captured)
}
}
func TestDeleteViaLidarr_LidarrDisabled(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
track, _, _ := seedTrack(t, pool, "T", "x")
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
_, _, err := svc.DeleteViaLidarr(context.Background(), track.ID, user.ID)
if !errors.Is(err, ErrLidarrDisabled) {
t.Errorf("err = %v, want ErrLidarrDisabled", err)
}
}
func TestDeleteViaLidarr_AlbumMBIDMissing(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
q := dbq.New(pool)
artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "A", SortName: "A"})
// Album with NO mbid set.
album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
Title: "Al", SortTitle: "Al", ArtistID: artist.ID, // Mbid: nil
})
dir := t.TempDir()
path := filepath.Join(dir, "T.mp3")
_ = os.WriteFile(path, []byte("x"), 0o644)
track, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: "T", AlbumID: album.ID, ArtistID: artist.ID,
DurationMs: 1000, FilePath: path, FileSize: 1, FileFormat: "mp3",
})
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(stub.Close)
cfg := lidarrconfig.New(pool)
_ = cfg.Save(context.Background(), lidarrconfig.Config{Enabled: true, BaseURL: stub.URL, APIKey: "k"})
clientFn := func() *lidarr.Client { return lidarr.NewClient(stub.URL, "k") }
svc := NewService(pool, cfg, clientFn, "")
_, _ = svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "")
_, _, err := svc.DeleteViaLidarr(context.Background(), track.ID, user.ID)
if !errors.Is(err, ErrAlbumMBIDMissing) {
t.Errorf("err = %v, want ErrAlbumMBIDMissing", err)
}
}
func TestDeleteViaLidarr_LidarrAlbumNotFound(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
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("[]")) // Lidarr returns empty -> ErrNotFound
return
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(stub.Close)
cfg := lidarrconfig.New(pool)
_ = cfg.Save(context.Background(), lidarrconfig.Config{Enabled: true, BaseURL: stub.URL, APIKey: "k"})
clientFn := func() *lidarr.Client { return lidarr.NewClient(stub.URL, "k") }
svc := NewService(pool, cfg, clientFn, "")
track, _, _ := seedTrack(t, pool, "T", "x")
_, _ = svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "")
_, _, err := svc.DeleteViaLidarr(context.Background(), track.ID, user.ID)
if !errors.Is(err, ErrLidarrAlbumNotFound) {
t.Errorf("err = %v, want ErrLidarrAlbumNotFound", err)
}
}
func TestDeleteFile_TrackNotFound(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
@@ -499,36 +336,9 @@ func TestDeleteFile_TrackNotFound(t *testing.T) {
bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
bogus.Valid = true
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
svc := NewService(pool, "")
_, err := svc.DeleteFile(context.Background(), bogus, user.ID)
if !errors.Is(err, ErrTrackNotFound) {
t.Errorf("err = %v, want ErrTrackNotFound", err)
}
}
func TestDeleteViaLidarr_TrackNotFound(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(stub.Close)
cfg := lidarrconfig.New(pool)
if err := cfg.Save(context.Background(), lidarrconfig.Config{
Enabled: true, BaseURL: stub.URL, APIKey: "k",
}); err != nil {
t.Fatalf("save config: %v", err)
}
clientFn := func() *lidarr.Client { return lidarr.NewClient(stub.URL, "k") }
svc := NewService(pool, cfg, clientFn, "")
var bogus pgtype.UUID
bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
bogus.Valid = true
_, _, err := svc.DeleteViaLidarr(context.Background(), bogus, user.ID)
if !errors.Is(err, ErrTrackNotFound) {
t.Errorf("err = %v, want ErrTrackNotFound", err)
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
// Package reacquisition wants an album back when its files go missing,
// without anyone pressing anything (milestone #290; a want rather than a
// Lidarr request since M502 #5520).
// without anyone pressing anything (milestone #290; a want since M502
// #5520).
//
// The unit of work is the ALBUM, not the track. Music is acquired as
// releases; there is no meaningful "fetch me one track", and a recording
@@ -158,7 +158,7 @@ func TestHomeData_MostPlayed_RanksByCount_HonorsQuarantine(t *testing.T) {
insertPlayEvent(t, pool, user.ID, tQuar.ID, now)
}
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, tQuar.ID,
); err != nil {
t.Fatalf("insert quarantine: %v", err)
+4 -4
View File
@@ -6,7 +6,7 @@ import (
"strings"
)
// Tier is a rung of the quality ladder, Lidarr's names for them.
// Tier is a rung of the quality ladder.
type Tier string
// The ladder, best first.
@@ -43,8 +43,8 @@ var Tiers = []TierInfo{
{TierUnknown, "Unknown", "Nothing says what format it is"},
}
// DefaultTiers is what a new install allows: Lidarr's "Any" profile, every
// format from trash lossy to lossless (note #5527). Unknown is left out; a
// DefaultTiers is what a new install allows: every format from trash lossy
// to lossless (note #5527). Unknown is left out; a
// release that names no format has its .torrent read to find out.
var DefaultTiers = []Tier{
TierLosslessHiRes, TierLossless, TierLossyHigh, TierLossyStandard,
@@ -182,7 +182,7 @@ type HeldFormat struct {
}
// HeldCopy judges an album's copy from its tracks grouped by format: the
// lowest tier among them, as Lidarr judged an album by its worst file.
// lowest tier among them: an album is as good as its worst file.
// Summing a format's tracks first keeps a near-silent VBR interlude from
// dragging the album down. ok is false when no format can be placed.
func HeldCopy(formats []HeldFormat) (Held, bool) {
+1 -1
View File
@@ -16,7 +16,7 @@ func TestParse_ReadsWhatTheNameSays(t *testing.T) {
rate float64
tier Tier
}{
// The wrong edition Lidarr grabbed on 2026-10-09 (note #5527).
// The wrong edition grabbed on 2026-10-09 (note #5527).
{"Daft Punk Random Access Memories (Drumless Edition 2023) [Flac 24-88]", FormatFLAC, "", "", 0, 24, 2023, 88.2, TierLosslessHiRes},
{"Gorillaz - Humanz - (Japanese Edition) - [CD-FLAC] 2017", FormatFLAC, "", SourceCD, 0, 0, 2017, 0, TierLossless},
{"Radiohead - OK Computer (1997) [MP3 320]", FormatMP3, "", "", 320, 0, 1997, 0, TierLossyHigh},
+6 -42
View File
@@ -3,7 +3,6 @@ package server
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
@@ -20,13 +19,11 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"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/netsettings"
"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/recsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
@@ -36,27 +33,6 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/web"
)
// lidarrUnmonitorAdapter wires the per-call lidarrClientFn factory pattern
// (used elsewhere in this package so admin Lidarr config edits take effect
// without restart) into the tracks.LidarrUnmonitorer interface that
// internal/tracks expects. When the factory returns nil (Lidarr disabled)
// we surface a sentinel error — tracks.Service treats UnmonitorTrack
// failures as non-fatal, so this collapses to a `lidarr_unmonitor_failed`
// flag in the response and the destructive part still runs.
type lidarrUnmonitorAdapter struct {
fn func() *lidarr.Client
}
var errLidarrDisabled = errors.New("lidarr disabled")
func (a lidarrUnmonitorAdapter) UnmonitorTrack(ctx context.Context, trackMbid, albumMbid string) error {
c := a.fn()
if c == nil {
return errLidarrDisabled
}
return c.UnmonitorTrack(ctx, trackMbid, albumMbid)
}
// ScanTrigger is the subset of the scanner the HTTP handler needs. Kept as an
// interface so tests can stub it without touching the DB. The progressCb
// parameter (added in m7-scan-progress) lets the orchestrator drive partial-
@@ -84,7 +60,7 @@ type Server struct {
LibraryScanner *library.Scanner
ScanCfg library.RunScanConfig
// Bus is the live-event bus shared with background workers (the
// lidarr reconciler, scan workers) constructed in cmd/minstrel/main.go.
// request reconciler, scan workers) constructed in cmd/minstrel/main.go.
// When nil, Router() constructs a local fallback (test contexts).
Bus *eventbus.Bus
// PlaylistScheduler fires per-user daily system-playlist builds at
@@ -159,18 +135,6 @@ func (s *Server) Router() http.Handler {
s.EventsCfg.SkipMaxCompletionRatio,
s.EventsCfg.SkipMaxDurationPlayedMs,
)
lidarrCfg := lidarrconfig.New(s.Pool)
// Per-call client factory: re-reads config so an admin save in
// Admin › Acquisition takes effect immediately without restart.
// Returns nil when Lidarr is disabled, which the Service-layer
// methods translate to ErrLidarrDisabled.
lidarrClientFn := func() *lidarr.Client {
cfg, err := lidarrCfg.Get(context.Background())
if err != nil || !cfg.Enabled || cfg.BaseURL == "" || cfg.APIKey == "" {
return nil
}
return lidarr.NewClient(cfg.BaseURL, cfg.APIKey)
}
requestSvc := requests.NewService(s.Pool)
reacqSettings := s.ReacqSettings
if reacqSettings == nil {
@@ -185,13 +149,13 @@ func (s *Server) Router() http.Handler {
s.Logger.Warn("reacquisition settings unavailable; serving defaults", "err", raErr)
}
}
lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, lidarrClientFn, s.DataDir)
tracksSvc := tracks.NewService(s.Pool, s.Logger, lidarrUnmonitorAdapter{fn: lidarrClientFn}, s.DataDir)
quarantineSvc := quarantine.NewService(s.Pool, s.DataDir)
tracksSvc := tracks.NewService(s.Pool, s.Logger, s.DataDir)
playlistsSvc := playlists.NewService(s.Pool, s.Logger, s.DataDir)
smtpSender := mailer.NewSMTPSender(s.Pool, s.Logger.With("component", "mailer"))
// Live-event bus for SSE subscribers (#392). Constructed per-process;
// producers in playevents / requests / scanner publish into
// the same instance. Background workers (lidarr reconciler, scan
// the same instance. Background workers (request reconciler, scan
// scheduler) live in cmd/minstrel/main.go where they're constructed
// before Router() runs; they read s.Bus directly so they share this
// process's bus. When Router() runs before main set the bus (tests,
@@ -237,7 +201,7 @@ func (s *Server) Router() http.Handler {
}
acoustIDLookup = library.NewAcoustIDLookupWorker(s.Pool, s.Logger, acoustIDSettings, nil)
}
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, requestSvc, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret, netSettings, reacqSettings, fpSettings, loudSettings, acoustIDLookup)
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, requestSvc, quarantineSvc, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret, netSettings, reacqSettings, fpSettings, loudSettings, acoustIDLookup)
// /api/admin/scan is the only admin route owned by the server package
// (it needs the Scanner). Register it as a single inline-middleware
// route — using r.Route("/api/admin", ...) here would create a second
+4 -4
View File
@@ -155,7 +155,7 @@ func TestRouter_RestPathNotSwallowedBySPA(t *testing.T) {
// in server.go was creating a second chi subtree at the same prefix as the
// nested admin Route inside api.Mount. chi.Walk shows both subtrees as
// registered, but runtime routing dispatch only matches one — every admin
// endpoint registered by api.Mount (Lidarr config, quarantine, etc.)
// endpoint registered by api.Mount (summary, quarantine, etc.)
// silently 404'd for authenticated callers in production.
//
// chi.Walk enumeration would NOT have caught this (both branches are in
@@ -212,7 +212,7 @@ func TestRouter_AdminSubtreeNotShadowed(t *testing.T) {
ts := httptest.NewServer(s.Router())
defer ts.Close()
// Each path is registered by a different package: lidarr/config from
// Each path is registered by a different package: admin/summary from
// api.Mount and admin/scan from server.Router. If chi runtime dispatch
// only routes one /api/admin subtree, one of these returns 404 — that
// IS the bug we shipped in 06a1fe1.
@@ -221,9 +221,9 @@ func TestRouter_AdminSubtreeNotShadowed(t *testing.T) {
path string
owner string
}{
{http.MethodGet, "/api/admin/lidarr/config", "api.Mount"},
{http.MethodGet, "/api/admin/summary", "api.Mount"},
// /api/admin/scan would actually trigger a real library scan via
// stubScanner; we don't include it. The lidarr/config path is the
// stubScanner; we don't include it. The admin/summary path is the
// canonical regression check.
}
for _, tc := range cases {

Some files were not shown because too many files have changed in this diff Show More