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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user