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
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": 42,
|
||||
"foreignAlbumId": "3a2c2c8c-7e6f-4f8a-b1d2-9a8b6c4e3f1d",
|
||||
"title": "Music Has The Right To Children",
|
||||
"artistId": 7
|
||||
}
|
||||
]
|
||||
@@ -1,7 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": 7,
|
||||
"foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01",
|
||||
"artistName": "Boards of Canada"
|
||||
}
|
||||
]
|
||||
-23
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
[
|
||||
{"id": 1, "name": "Any"},
|
||||
{"id": 2, "name": "Lossless"},
|
||||
{"id": 3, "name": "Standard"}
|
||||
]
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
[
|
||||
{"path": "/music", "accessible": true, "freeSpace": 107374182400},
|
||||
{"path": "/music-lossy", "accessible": true, "freeSpace": 53687091200},
|
||||
{"path": "/music-offline", "accessible": false, "freeSpace": 0}
|
||||
]
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 = ¬es
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = ¬es
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user