feat: a wanted list — follow artists on release-type profiles; requests and re-acquisition become wants (M502 #5520)
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 1m21s
release / go (push) Successful in 1m35s
release / integration (push) Failing after 4m57s
release / android (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m28s
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 1m21s
release / go (push) Successful in 1m35s
release / integration (push) Failing after 4m57s
release / android (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m28s
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
Minstrel keeps its own list of what it is to get, keyed by MusicBrainz release group. A want comes from an approved request, a new release by a followed artist, or an album whose files went missing, and is held once the library has a full edition of it. - Migration 0081: release_type_profiles (Standard seeded: Album, EP, Single, studio only), follows, follow_seen_groups and wants. lidarr_requests becomes requests with plain *_mbid columns; approved album and track requests move onto the wanted list, approved artist requests become follows, and the library's album artists start out followed. missing_reacquisitions and the retry settings go. - internal/wanted: follows are checked against MusicBrainz discographies daily. The first check sets a baseline, and only releases after it are wanted unless the follow asked for what already exists. Profiles filter by primary and secondary type, so live recordings stay out unless a profile ticks Live. - internal/requests (was lidarrrequests): approving puts the request on the wanted list, and an artist request can be approved on a chosen profile. A request completes when its want is held. - The re-acquisition sweeper wants a missing album back instead of asking Lidarr; searching for it is the search schedule's (#5521). - Web: an admin Wanted page (Wanted, Missing files, Held, Following), a follow control on artist pages, a release-type profiles card on Integrations, "Approve on a profile" on Requests, and want state on Missing files. The artist detail API returns the artist's MBID. - Android: the request body sends artist_mbid, album_mbid and track_mbid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,12 +2,10 @@ package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
|
||||
)
|
||||
|
||||
// missingTrackView is one track whose file the scan could not find.
|
||||
@@ -30,19 +28,13 @@ type missingTrackView struct {
|
||||
LastPlayedAt *string `json:"last_played_at"`
|
||||
}
|
||||
|
||||
// reacquisitionStateView is what the sweeper has done about one album
|
||||
// (milestone #290), attached to the group so the operator can tell "nothing
|
||||
// has happened yet" from "asked twice, still nothing" without cross-checking
|
||||
// the Requests queue.
|
||||
//
|
||||
// NextAttemptAt is computed rather than stored: the schedule is a function of
|
||||
// the attempt count and the current settings, so persisting it would go stale
|
||||
// the moment an operator edited the backoff.
|
||||
// reacquisitionStateView is what the wanted list says about one album
|
||||
// (milestone #290, M502 #5520): when it was wanted back, and when the library
|
||||
// held it again. A missing album with no want yet is still inside the grace
|
||||
// window, or MusicBrainz cannot name it.
|
||||
type reacquisitionStateView struct {
|
||||
Attempts int `json:"attempts"`
|
||||
LastAttemptAt *string `json:"last_attempt_at"`
|
||||
NextAttemptAt *string `json:"next_attempt_at"`
|
||||
GaveUpAt *string `json:"gave_up_at"`
|
||||
WantedAt *string `json:"wanted_at"`
|
||||
HeldAt *string `json:"held_at"`
|
||||
}
|
||||
|
||||
// missingGroupView is a directory's worth of missing tracks.
|
||||
@@ -159,15 +151,14 @@ func groupMissingByDirectory(rows []dbq.ListMissingTracksRow) []missingGroupView
|
||||
return groups
|
||||
}
|
||||
|
||||
// attachReacquisitionState decorates each group with what the sweeper has
|
||||
// done about its album (milestone #290).
|
||||
// attachReacquisitionState decorates each group with its album's want.
|
||||
//
|
||||
// Best-effort: this is context on a list whose primary job is showing what is
|
||||
// missing, so a failure here leaves the groups bare rather than failing the
|
||||
// page. One batched query for the whole page, not one per group.
|
||||
//
|
||||
// A group is keyed by directory while re-acquisition is keyed by album, and
|
||||
// those line up in practice (an album's files live in one folder) but are not
|
||||
// A group is keyed by directory while a want is keyed by album, and those
|
||||
// line up in practice (an album's files live in one folder) but are not
|
||||
// guaranteed to — a directory holding two albums takes the first album's
|
||||
// state, which is the same album its first track belongs to.
|
||||
func (h *handlers) attachReacquisitionState(
|
||||
@@ -191,60 +182,34 @@ func (h *handlers) attachReacquisitionState(
|
||||
ids = append(ids, row.AlbumID)
|
||||
}
|
||||
|
||||
states, err := q.GetReacquisitionForAlbums(r.Context(), ids)
|
||||
wants, err := q.GetWantsForAlbums(r.Context(), ids)
|
||||
if err != nil {
|
||||
h.logger.Warn("admin missing: reacquisition state lookup failed", "err", err)
|
||||
h.logger.Warn("admin missing: want lookup failed", "err", err)
|
||||
return
|
||||
}
|
||||
byAlbum := make(map[string]dbq.MissingReacquisition, len(states))
|
||||
for _, s := range states {
|
||||
byAlbum[uuidToString(s.AlbumID)] = s
|
||||
}
|
||||
|
||||
// The settings service is optional in contexts that only wire routing
|
||||
// (tests), and the backoff projection is decoration on a list whose real
|
||||
// job is elsewhere — so fall back to the shipped defaults rather than
|
||||
// making this a nil-pointer waiting to happen (rule #48).
|
||||
cfg := reacquisition.Defaults
|
||||
if h.reacqSettings != nil {
|
||||
cfg = h.reacqSettings.Get()
|
||||
// Newest want first, so the first seen per album is its current one.
|
||||
byAlbum := make(map[string]dbq.GetWantsForAlbumsRow, len(wants))
|
||||
for _, w := range wants {
|
||||
if _, seen := byAlbum[uuidToString(w.AlbumID)]; !seen {
|
||||
byAlbum[uuidToString(w.AlbumID)] = w
|
||||
}
|
||||
}
|
||||
for i := range groups {
|
||||
albumID, ok := dirAlbum[groups[i].Directory]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
state, ok := byAlbum[uuidToString(albumID)]
|
||||
w, ok := byAlbum[uuidToString(albumID)]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
groups[i].Reacquisition = buildReacquisitionState(state, cfg.Backoff(state.Attempts))
|
||||
}
|
||||
}
|
||||
|
||||
// buildReacquisitionState renders one album's attempt record, projecting the
|
||||
// next attempt from the last one plus the backoff the current settings imply.
|
||||
func buildReacquisitionState(
|
||||
state dbq.MissingReacquisition,
|
||||
backoff time.Duration,
|
||||
) *reacquisitionStateView {
|
||||
out := &reacquisitionStateView{Attempts: int(state.Attempts)}
|
||||
if state.LastAttemptAt.Valid {
|
||||
s := formatTimestamp(state.LastAttemptAt)
|
||||
out.LastAttemptAt = &s
|
||||
// Only meaningful while more attempts remain; an album that has given
|
||||
// up has no next attempt to promise.
|
||||
if !state.GaveUpAt.Valid {
|
||||
next := formatTimestamp(pgtype.Timestamptz{
|
||||
Time: state.LastAttemptAt.Time.Add(backoff),
|
||||
Valid: true,
|
||||
})
|
||||
out.NextAttemptAt = &next
|
||||
view := &reacquisitionStateView{}
|
||||
wantedAt := formatTimestamp(w.WantedAt)
|
||||
view.WantedAt = &wantedAt
|
||||
if w.FulfilledAt.Valid {
|
||||
heldAt := formatTimestamp(w.FulfilledAt)
|
||||
view.HeldAt = &heldAt
|
||||
}
|
||||
groups[i].Reacquisition = view
|
||||
}
|
||||
if state.GaveUpAt.Valid {
|
||||
s := formatTimestamp(state.GaveUpAt)
|
||||
out.GaveUpAt = &s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -13,18 +13,14 @@ import (
|
||||
// reacquisitionSettingsResp is the admin card's payload (milestone #290).
|
||||
//
|
||||
// Carries more than the stored settings: [UnnameableAlbums] is the count of
|
||||
// albums with missing files that can never be auto-requested because neither
|
||||
// they nor their artist has an MBID. The card states that number rather than
|
||||
// leaving the operator to wonder why some rows never get a request — a
|
||||
// feature that silently does nothing for part of its input reads as broken.
|
||||
// albums with missing files that can never be wanted automatically because
|
||||
// neither they nor their artist has an MBID. The card states that number
|
||||
// rather than leaving the operator to wonder why some rows never get wanted —
|
||||
// a feature that silently does nothing for part of its input reads as broken.
|
||||
type reacquisitionSettingsResp struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
GraceHours int32 `json:"grace_hours"`
|
||||
BackoffBaseHours int32 `json:"backoff_base_hours"`
|
||||
BackoffMaxHours int32 `json:"backoff_max_hours"`
|
||||
MaxAttempts int32 `json:"max_attempts"`
|
||||
MaxPerPass int32 `json:"max_per_pass"`
|
||||
AutoApprove bool `json:"auto_approve"`
|
||||
Enabled bool `json:"enabled"`
|
||||
GraceHours int32 `json:"grace_hours"`
|
||||
MaxPerPass int32 `json:"max_per_pass"`
|
||||
|
||||
UnnameableAlbums int64 `json:"unnameable_albums"`
|
||||
}
|
||||
@@ -42,13 +38,9 @@ func (h *handlers) handleUpdateReacquisitionSettings(w http.ResponseWriter, r *h
|
||||
return
|
||||
}
|
||||
_, err := h.reacqSettings.Set(r.Context(), reacquisition.Settings{
|
||||
Enabled: req.Enabled,
|
||||
GraceHours: req.GraceHours,
|
||||
BackoffBaseHours: req.BackoffBaseHours,
|
||||
BackoffMaxHours: req.BackoffMaxHours,
|
||||
MaxAttempts: req.MaxAttempts,
|
||||
MaxPerPass: req.MaxPerPass,
|
||||
AutoApprove: req.AutoApprove,
|
||||
Enabled: req.Enabled,
|
||||
GraceHours: req.GraceHours,
|
||||
MaxPerPass: req.MaxPerPass,
|
||||
})
|
||||
if err != nil {
|
||||
// The Go-side validation mirrors migration 0056's CHECKs so the
|
||||
@@ -69,13 +61,9 @@ func (h *handlers) handleUpdateReacquisitionSettings(w http.ResponseWriter, r *h
|
||||
func (h *handlers) reacquisitionPayload(r *http.Request) reacquisitionSettingsResp {
|
||||
cur := h.reacqSettings.Get()
|
||||
out := reacquisitionSettingsResp{
|
||||
Enabled: cur.Enabled,
|
||||
GraceHours: cur.GraceHours,
|
||||
BackoffBaseHours: cur.BackoffBaseHours,
|
||||
BackoffMaxHours: cur.BackoffMaxHours,
|
||||
MaxAttempts: cur.MaxAttempts,
|
||||
MaxPerPass: cur.MaxPerPass,
|
||||
AutoApprove: cur.AutoApprove,
|
||||
Enabled: cur.Enabled,
|
||||
GraceHours: cur.GraceHours,
|
||||
MaxPerPass: cur.MaxPerPass,
|
||||
}
|
||||
// Best-effort: the settings are the point of this endpoint, and failing
|
||||
// the whole card because a count query hiccuped would be the wrong trade.
|
||||
|
||||
@@ -7,12 +7,13 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"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/lidarrrequests"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
|
||||
)
|
||||
|
||||
// validRequestStatuses is the set of allowed values for the ?status= param.
|
||||
@@ -50,7 +51,7 @@ func (h *handlers) handleListAdminRequests(w http.ResponseWriter, r *http.Reques
|
||||
limit = v
|
||||
}
|
||||
|
||||
rows, err := h.lidarrRequests.ListByStatus(r.Context(), status, int32(limit))
|
||||
rows, err := h.requests.ListByStatus(r.Context(), status, int32(limit))
|
||||
if err != nil {
|
||||
h.logger.Error("admin: list requests", "err", err)
|
||||
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||
@@ -68,12 +69,15 @@ func (h *handlers) handleListAdminRequests(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
// approveRequestBody is the optional JSON body for POST /api/admin/requests/:id/approve.
|
||||
// profile_id picks the release-type profile an artist request is followed
|
||||
// on; omitted, the default profile.
|
||||
type approveRequestBody struct {
|
||||
QualityProfileID int `json:"quality_profile_id"`
|
||||
RootFolderPath string `json:"root_folder_path"`
|
||||
ProfileID string `json:"profile_id"`
|
||||
}
|
||||
|
||||
// handleApproveRequest implements POST /api/admin/requests/:id/approve.
|
||||
// handleApproveRequest implements POST /api/admin/requests/:id/approve. The
|
||||
// request goes on the wanted list (M502 #5520): an album or track request
|
||||
// wants its album, an artist request follows the artist.
|
||||
func (h *handlers) handleApproveRequest(w http.ResponseWriter, r *http.Request) {
|
||||
admin, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
@@ -90,35 +94,25 @@ func (h *handlers) handleApproveRequest(w http.ResponseWriter, r *http.Request)
|
||||
var body approveRequestBody
|
||||
// Body is entirely optional; ignore decode errors.
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
var profile pgtype.UUID
|
||||
if body.ProfileID != "" {
|
||||
if profile, ok = parseUUID(body.ProfileID); !ok {
|
||||
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_profile")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
row, err := h.lidarrRequests.Approve(r.Context(), id, admin.ID, lidarrrequests.ApproveOverrides{
|
||||
QualityProfileID: body.QualityProfileID,
|
||||
RootFolderPath: body.RootFolderPath,
|
||||
})
|
||||
row, err := h.requests.Approve(r.Context(), id, admin.ID, profile)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, lidarrrequests.ErrLidarrDisabled):
|
||||
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_disabled")
|
||||
case errors.Is(err, lidarrrequests.ErrDefaultsIncomplete):
|
||||
writeAdminJSONErr(w, http.StatusBadRequest, "lidarr_defaults_incomplete")
|
||||
case errors.Is(err, lidarrrequests.ErrNotFound):
|
||||
case errors.Is(err, requests.ErrNotFound):
|
||||
writeAdminJSONErr(w, http.StatusNotFound, "request_not_found")
|
||||
case errors.Is(err, lidarrrequests.ErrNotPending):
|
||||
case errors.Is(err, requests.ErrNotPending):
|
||||
writeAdminJSONErr(w, http.StatusConflict, "request_not_pending")
|
||||
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")
|
||||
case errors.Is(err, lidarr.ErrServerError):
|
||||
// Lidarr itself 5xx'd — its response body is in err's wrapped
|
||||
// message (see lidarr.readBodySnippet). Logged below for ops.
|
||||
h.logger.Error("admin: approve request: lidarr 5xx", "err", err)
|
||||
writeAdminJSONErr(w, http.StatusBadGateway, "lidarr_server_error")
|
||||
case errors.Is(err, lidarr.ErrLookupFailed):
|
||||
// Lidarr returned 4xx — usually "artist already exists" or
|
||||
// "no metadata found." Log so ops can see the actual reason.
|
||||
h.logger.Warn("admin: approve request: lidarr 4xx", "err", err)
|
||||
writeAdminJSONErr(w, http.StatusBadGateway, "lidarr_rejected")
|
||||
case errors.Is(err, wanted.ErrProfileNotFound):
|
||||
writeAdminJSONErr(w, http.StatusBadRequest, "profile_not_found")
|
||||
case errors.Is(err, wanted.ErrNotFollowable):
|
||||
writeAdminJSONErr(w, http.StatusBadRequest, "not_followable")
|
||||
default:
|
||||
h.logger.Error("admin: approve request", "err", err)
|
||||
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||
@@ -153,12 +147,12 @@ func (h *handlers) handleRejectRequest(w http.ResponseWriter, r *http.Request) {
|
||||
var body rejectRequestBody
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
|
||||
row, err := h.lidarrRequests.Reject(r.Context(), id, admin.ID, body.Notes)
|
||||
row, err := h.requests.Reject(r.Context(), id, admin.ID, body.Notes)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, lidarrrequests.ErrNotFound):
|
||||
case errors.Is(err, requests.ErrNotFound):
|
||||
writeAdminJSONErr(w, http.StatusNotFound, "request_not_found")
|
||||
case errors.Is(err, lidarrrequests.ErrNotPending):
|
||||
case errors.Is(err, requests.ErrNotPending):
|
||||
writeAdminJSONErr(w, http.StatusConflict, "request_not_pending")
|
||||
default:
|
||||
h.logger.Error("admin: reject request", "err", err)
|
||||
|
||||
@@ -6,16 +6,13 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"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"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||
)
|
||||
|
||||
// newAdminRequestsRouter builds a test chi router with the three admin/requests
|
||||
@@ -52,21 +49,10 @@ func doAdminRequestReq(t *testing.T, h *handlers, method, path string, body []by
|
||||
return w
|
||||
}
|
||||
|
||||
// testHandlersWithClientFn creates a handlers instance with a real clientFn
|
||||
// so Service.Approve actually calls Lidarr during tests.
|
||||
func testHandlersWithClientFn(t *testing.T) (*handlers, *lidarrconfig.Service) {
|
||||
// testHandlersWithClientFn is the request handlers' test fixture.
|
||||
func testHandlersWithClientFn(t *testing.T) (*handlers, *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
h, _ := testHandlers(t)
|
||||
lidarrCfg := lidarrconfig.New(h.pool)
|
||||
clientFn := func() *lidarr.Client {
|
||||
cfg, err := lidarrCfg.Get(context.Background())
|
||||
if err != nil || !cfg.Enabled || cfg.BaseURL == "" {
|
||||
return nil
|
||||
}
|
||||
return lidarr.NewClient(cfg.BaseURL, cfg.APIKey)
|
||||
}
|
||||
h.lidarrRequests = lidarrrequests.NewService(h.pool, lidarrCfg, clientFn, nil)
|
||||
return h, lidarrCfg
|
||||
return testHandlers(t)
|
||||
}
|
||||
|
||||
// seedPendingArtistRequest creates a pending artist-kind lidarr request row
|
||||
@@ -90,7 +76,7 @@ func TestHandleListAdminRequests_DefaultStatus_Pending(t *testing.T) {
|
||||
|
||||
// Reject one request directly via service.
|
||||
rv3 := createArtistRequest(t, h, alice, "mbid-reject-1", "Reject Me")
|
||||
_, err := h.lidarrRequests.Reject(context.Background(), rv3.ID, admin.ID, "test")
|
||||
_, err := h.requests.Reject(context.Background(), rv3.ID, admin.ID, "test")
|
||||
if err != nil {
|
||||
t.Fatalf("reject seed: %v", err)
|
||||
}
|
||||
@@ -126,7 +112,7 @@ func TestHandleListAdminRequests_StatusFilter(t *testing.T) {
|
||||
createArtistRequest(t, h, alice, "mbid-pending-f1", "Pending Filter 1")
|
||||
|
||||
rv2 := createArtistRequest(t, h, alice, "mbid-rejected-f1", "Rejected Filter 1")
|
||||
_, err := h.lidarrRequests.Reject(context.Background(), rv2.ID, admin.ID, "")
|
||||
_, err := h.requests.Reject(context.Background(), rv2.ID, admin.ID, "")
|
||||
if err != nil {
|
||||
t.Fatalf("reject seed: %v", err)
|
||||
}
|
||||
@@ -169,198 +155,19 @@ func TestHandleListAdminRequests_BadStatus_400(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleApproveRequest_HappyPath seeds a Lidarr stub, seeds a pending
|
||||
// artist request, and verifies POST /approve → 200 with row status=approved.
|
||||
func TestHandleApproveRequest_HappyPath(t *testing.T) {
|
||||
h, _ := testHandlersWithClientFn(t)
|
||||
resetLidarrState(t, h)
|
||||
|
||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
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)
|
||||
|
||||
saveLidarrConfig(t, h, stub.URL, true)
|
||||
|
||||
alice := seedUser(t, h.pool, "alice", "pw", false)
|
||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||
|
||||
rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-approve", "Approve Me")
|
||||
|
||||
w := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/approve", nil, admin)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var got requestView
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
|
||||
}
|
||||
if got.Status != "approved" {
|
||||
t.Errorf("status = %q, want approved", got.Status)
|
||||
}
|
||||
if got.ID != rv.ID {
|
||||
t.Errorf("id mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleApproveRequest_OverrideUsed seeds config with default qp=1/root="/m",
|
||||
// approves with override qp=5/root="/other", verifies the stub received those
|
||||
// values and the row reflects the overrides.
|
||||
func TestHandleApproveRequest_OverrideUsed(t *testing.T) {
|
||||
h, _ := testHandlersWithClientFn(t)
|
||||
resetLidarrState(t, h)
|
||||
|
||||
var capturedBody []byte
|
||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
buf := make([]byte, 4096)
|
||||
n, _ := r.Body.Read(buf)
|
||||
capturedBody = buf[:n]
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
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)
|
||||
|
||||
// Save config with default qp=1, root="/m".
|
||||
if err := lidarrconfig.New(h.pool).Save(context.Background(), lidarrconfig.Config{
|
||||
Enabled: true,
|
||||
BaseURL: stub.URL,
|
||||
APIKey: "test-key",
|
||||
DefaultQualityProfileID: 1,
|
||||
DefaultRootFolderPath: "/m",
|
||||
}); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
alice := seedUser(t, h.pool, "alice", "pw", false)
|
||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||
|
||||
rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-override", "Override Me")
|
||||
|
||||
body := []byte(`{"quality_profile_id":5,"root_folder_path":"/other"}`)
|
||||
w := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/approve", body, admin)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify stub received the override values.
|
||||
var stubPayload map[string]any
|
||||
if err := json.Unmarshal(capturedBody, &stubPayload); err != nil {
|
||||
t.Fatalf("decode stub payload: %v; body = %s", err, capturedBody)
|
||||
}
|
||||
if qp, _ := stubPayload["qualityProfileId"].(float64); int(qp) != 5 {
|
||||
t.Errorf("stub qualityProfileId = %v, want 5", stubPayload["qualityProfileId"])
|
||||
}
|
||||
if rf, _ := stubPayload["rootFolderPath"].(string); rf != "/other" {
|
||||
t.Errorf("stub rootFolderPath = %q, want /other", rf)
|
||||
}
|
||||
|
||||
// Verify the returned row reflects the overrides.
|
||||
var got requestView
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode response: %v; body = %s", err, w.Body.String())
|
||||
}
|
||||
if got.QualityProfileID == nil || *got.QualityProfileID != 5 {
|
||||
t.Errorf("quality_profile_id = %v, want 5", got.QualityProfileID)
|
||||
}
|
||||
if got.RootFolderPath == nil || *got.RootFolderPath != "/other" {
|
||||
t.Errorf("root_folder_path = %v, want /other", got.RootFolderPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleApproveRequest_LidarrUnreachable_ApprovesDeferred points config
|
||||
// at an unreachable port and verifies the durable-approve contract: POST
|
||||
// /approve still succeeds (200) and the request becomes approved with the
|
||||
// Lidarr add deferred (lidarr_add_confirmed_at NULL) for the reconciler to
|
||||
// retry — the admin's decision is never lost to a transient Lidarr outage.
|
||||
func TestHandleApproveRequest_LidarrUnreachable_ApprovesDeferred(t *testing.T) {
|
||||
h, _ := testHandlersWithClientFn(t)
|
||||
resetLidarrState(t, h)
|
||||
|
||||
saveLidarrConfig(t, h, "http://127.0.0.1:1", true)
|
||||
|
||||
alice := seedUser(t, h.pool, "alice", "pw", false)
|
||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||
|
||||
rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-unreachable", "Unreachable")
|
||||
|
||||
w := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/approve", nil, admin)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (durable approve despite Lidarr down); body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Must no longer be pending.
|
||||
pending, err := h.lidarrRequests.ListByStatus(context.Background(), "pending", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list pending: %v", err)
|
||||
}
|
||||
for _, r := range pending {
|
||||
if r.ID == rv.ID {
|
||||
t.Error("row still pending after durable approve")
|
||||
}
|
||||
}
|
||||
|
||||
// Must be approved with the add deferred (confirmed_at NULL) so the
|
||||
// reconciler retries it — not failed, not lost.
|
||||
approved, err := h.lidarrRequests.ListByStatus(context.Background(), "approved", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list approved: %v", err)
|
||||
}
|
||||
var got *dbq.LidarrRequest
|
||||
for i := range approved {
|
||||
if approved[i].ID == rv.ID {
|
||||
got = &approved[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("row %s not in approved after approve with Lidarr down", uuidToString(rv.ID))
|
||||
}
|
||||
if got.LidarrAddConfirmedAt.Valid {
|
||||
t.Error("lidarr_add_confirmed_at set despite Lidarr unreachable; want NULL (deferred to reconciler)")
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// Need an enabled Lidarr config so Approve reaches the not-pending check.
|
||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"id":1}`))
|
||||
}))
|
||||
t.Cleanup(stub.Close)
|
||||
saveLidarrConfig(t, h, stub.URL, true)
|
||||
|
||||
alice := seedUser(t, h.pool, "alice", "pw", false)
|
||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||
|
||||
rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-notpending", "Not Pending")
|
||||
|
||||
// Pre-reject the row.
|
||||
_, err := h.lidarrRequests.Reject(context.Background(), rv.ID, admin.ID, "pre-rejected")
|
||||
_, err := h.requests.Reject(context.Background(), rv.ID, admin.ID, "pre-rejected")
|
||||
if err != nil {
|
||||
t.Fatalf("pre-reject: %v", err)
|
||||
}
|
||||
@@ -470,3 +277,62 @@ 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)
|
||||
alice := seedUser(t, h.pool, "alice", "pw", false)
|
||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||
|
||||
w := doCreateRequest(h, alice, `{"kind":"album","artist_mbid":"artist-x","artist_name":"Gorillaz","album_mbid":"group-x","album_title":"Demon Days"}`)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var rv requestView
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &rv)
|
||||
|
||||
resp := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/approve", nil, admin)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("approve: %d %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
want, err := dbq.New(h.pool).GetWantByReleaseGroup(context.Background(), "group-x")
|
||||
if err != nil {
|
||||
t.Fatalf("no want recorded: %v", err)
|
||||
}
|
||||
if want.Title != "Demon Days" || want.CreatedBy != alice.ID || want.Reason != "request" {
|
||||
t.Errorf("want = %+v", want)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
alice := seedUser(t, h.pool, "alice", "pw", false)
|
||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||
ctx := context.Background()
|
||||
|
||||
profile, err := dbq.New(h.pool).CreateReleaseTypeProfile(ctx, dbq.CreateReleaseTypeProfileParams{
|
||||
Name: "Albums only", PrimaryTypes: []string{"Album"}, SecondaryTypes: []string{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rv := seedPendingArtistRequest(t, h, alice, "artist-profiled", "Profiled")
|
||||
path := "/api/admin/requests/" + uuidToString(rv.ID) + "/approve"
|
||||
|
||||
if resp := doAdminRequestReq(t, h, http.MethodPost, path, []byte(`{"profile_id":"00000000-0000-4000-8000-000000000000"}`), admin); resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unknown profile: %d %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
body := []byte(`{"profile_id":"` + uuidToString(profile.ID) + `"}`)
|
||||
if resp := doAdminRequestReq(t, h, http.MethodPost, path, body, admin); resp.Code != http.StatusOK {
|
||||
t.Fatalf("approve: %d %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
f, err := dbq.New(h.pool).GetFollow(ctx, "artist-profiled")
|
||||
if err != nil || f.ProfileID != profile.ID || !f.WantExisting {
|
||||
t.Errorf("follow = %+v, %v: on Albums only, wanting what exists", f, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
|
||||
)
|
||||
|
||||
// The wanted list's admin surface (M502 #5520): wants, follows and
|
||||
// release-type profiles.
|
||||
|
||||
// wantView is one row of GET /api/admin/wanted.
|
||||
type wantView struct {
|
||||
ID string `json:"id"`
|
||||
ReleaseGroupMBID string `json:"release_group_mbid"`
|
||||
Title string `json:"title"`
|
||||
ArtistMBID string `json:"artist_mbid"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
ArtistID string `json:"artist_id,omitempty"`
|
||||
PrimaryType string `json:"primary_type"`
|
||||
SecondaryTypes []string `json:"secondary_types"`
|
||||
Year string `json:"year"`
|
||||
Reason string `json:"reason"`
|
||||
RequestedBy string `json:"requested_by,omitempty"`
|
||||
AlbumID string `json:"album_id,omitempty"`
|
||||
WantedAt string `json:"wanted_at"`
|
||||
HeldAt *string `json:"held_at"`
|
||||
ImageURL string `json:"image_url"`
|
||||
}
|
||||
|
||||
// wantCounts sizes each tab of the Wanted page.
|
||||
type wantCounts struct {
|
||||
Wanted int64 `json:"wanted"`
|
||||
Missing int64 `json:"missing"`
|
||||
Held int64 `json:"held"`
|
||||
Following int64 `json:"following"`
|
||||
}
|
||||
|
||||
type wantsPage struct {
|
||||
Items []wantView `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
Counts wantCounts `json:"counts"`
|
||||
}
|
||||
|
||||
const wantsPageSize = 50
|
||||
|
||||
// handleListWants implements GET /api/admin/wanted?state=wanted|missing|held&offset=.
|
||||
// wanted is everything open but missing files; held is newest first.
|
||||
func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
|
||||
state := r.URL.Query().Get("state")
|
||||
if state == "" {
|
||||
state = "wanted"
|
||||
}
|
||||
if state != "wanted" && state != "missing" && state != "held" {
|
||||
writeErr(w, apierror.BadRequest("bad_state", "state must be wanted, missing or held"))
|
||||
return
|
||||
}
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
q := dbq.New(h.pool)
|
||||
rows, err := q.ListWants(r.Context(), dbq.ListWantsParams{State: state, Skip: int32(offset), MaxRows: wantsPageSize})
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "admin wanted: list", apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
counts, err := q.CountWants(r.Context())
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "admin wanted: count", apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
out := wantsPage{
|
||||
Items: make([]wantView, 0, len(rows)), Limit: wantsPageSize, Offset: offset,
|
||||
Counts: wantCounts{Wanted: counts.Wanted, Missing: counts.Missing, Held: counts.Held, Following: counts.Following},
|
||||
}
|
||||
switch state {
|
||||
case "missing":
|
||||
out.Total = counts.Missing
|
||||
case "held":
|
||||
out.Total = counts.Held
|
||||
default:
|
||||
out.Total = counts.Wanted
|
||||
}
|
||||
for _, row := range rows {
|
||||
v := wantView{
|
||||
ID: uuidToString(row.ID), ReleaseGroupMBID: row.ReleaseGroupMbid, Title: row.Title,
|
||||
ArtistMBID: row.ArtistMbid, ArtistName: row.ArtistName,
|
||||
PrimaryType: row.PrimaryType, SecondaryTypes: row.SecondaryTypes,
|
||||
Year: first4(row.FirstReleaseDate), Reason: row.Reason,
|
||||
WantedAt: formatTimestamp(row.WantedAt),
|
||||
ImageURL: groupArtURL(musicbrainz.ReleaseGroup{MBID: row.ReleaseGroupMbid, Title: row.Title, ArtistCredit: row.ArtistName}),
|
||||
}
|
||||
if row.LibraryArtistID.Valid {
|
||||
v.ArtistID = uuidToString(row.LibraryArtistID)
|
||||
}
|
||||
if row.AlbumID.Valid {
|
||||
v.AlbumID = uuidToString(row.AlbumID)
|
||||
}
|
||||
if row.RequestedBy != nil {
|
||||
v.RequestedBy = *row.RequestedBy
|
||||
}
|
||||
if row.FulfilledAt.Valid {
|
||||
held := formatTimestamp(row.FulfilledAt)
|
||||
v.HeldAt = &held
|
||||
}
|
||||
if v.SecondaryTypes == nil {
|
||||
v.SecondaryTypes = []string{}
|
||||
}
|
||||
out.Items = append(out.Items, v)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func first4(date string) string {
|
||||
if len(date) < 4 {
|
||||
return ""
|
||||
}
|
||||
return date[:4]
|
||||
}
|
||||
|
||||
// handleRemoveWant implements DELETE /api/admin/wanted/{id}.
|
||||
func (h *handlers) handleRemoveWant(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := requireURLUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
removed, err := h.wanted.RemoveWant(r.Context(), id)
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "admin wanted: remove", apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
if !removed {
|
||||
writeErr(w, apierror.NotFound("want"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// followView is a followed artist.
|
||||
type followView struct {
|
||||
ArtistMBID string `json:"artist_mbid"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
ArtistID string `json:"artist_id,omitempty"`
|
||||
ProfileID string `json:"profile_id"`
|
||||
ProfileName string `json:"profile_name"`
|
||||
FollowedAt string `json:"followed_at"`
|
||||
CheckedAt *string `json:"checked_at"`
|
||||
OpenWants int64 `json:"open_wants"`
|
||||
ImageURL string `json:"image_url"`
|
||||
}
|
||||
|
||||
// handleListFollows implements GET /api/admin/follows.
|
||||
func (h *handlers) handleListFollows(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := dbq.New(h.pool).ListFollows(r.Context())
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "admin follows: list", apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
out := make([]followView, 0, len(rows))
|
||||
for _, f := range rows {
|
||||
v := followView{
|
||||
ArtistMBID: f.ArtistMbid, ArtistName: f.ArtistName,
|
||||
ProfileID: uuidToString(f.ProfileID), ProfileName: f.ProfileName,
|
||||
FollowedAt: formatTimestamp(f.FollowedAt), OpenWants: f.OpenWants,
|
||||
ImageURL: artistArtURL(f.ArtistMbid, f.ArtistName),
|
||||
}
|
||||
if f.ArtistID.Valid {
|
||||
v.ArtistID = uuidToString(f.ArtistID)
|
||||
}
|
||||
if f.CheckedAt.Valid {
|
||||
checked := formatTimestamp(f.CheckedAt)
|
||||
v.CheckedAt = &checked
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// followStateView answers whether one artist is followed, for the control on
|
||||
// an artist page.
|
||||
type followStateView struct {
|
||||
Following bool `json:"following"`
|
||||
ProfileID string `json:"profile_id,omitempty"`
|
||||
ProfileName string `json:"profile_name,omitempty"`
|
||||
}
|
||||
|
||||
// handleGetFollow implements GET /api/admin/follows/{mbid}.
|
||||
func (h *handlers) handleGetFollow(w http.ResponseWriter, r *http.Request) {
|
||||
f, err := dbq.New(h.pool).GetFollow(r.Context(), chi.URLParam(r, "mbid"))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeJSON(w, http.StatusOK, followStateView{})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "admin follows: get", apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, followStateView{
|
||||
Following: true, ProfileID: uuidToString(f.ProfileID), ProfileName: f.ProfileName,
|
||||
})
|
||||
}
|
||||
|
||||
type putFollowBody struct {
|
||||
ArtistName string `json:"artist_name"`
|
||||
ProfileID string `json:"profile_id"`
|
||||
WantExisting bool `json:"want_existing"`
|
||||
}
|
||||
|
||||
// handlePutFollow implements PUT /api/admin/follows/{mbid}: follow an artist,
|
||||
// or change the profile it is followed on. want_existing also wants what the
|
||||
// artist has already released that the profile passes and isn't held.
|
||||
func (h *handlers) handlePutFollow(w http.ResponseWriter, r *http.Request) {
|
||||
admin, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body putFollowBody
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
var profile pgtype.UUID
|
||||
if body.ProfileID != "" {
|
||||
if profile, ok = parseUUID(body.ProfileID); !ok {
|
||||
writeErr(w, apierror.BadRequest("bad_profile", "profile_id is not an id"))
|
||||
return
|
||||
}
|
||||
}
|
||||
mbid := chi.URLParam(r, "mbid")
|
||||
if _, err := h.wanted.Follow(r.Context(), wanted.FollowParams{
|
||||
ArtistMBID: mbid, ArtistName: body.ArtistName, ProfileID: profile,
|
||||
By: admin.ID, WantExisting: body.WantExisting,
|
||||
}); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, wanted.ErrNotFollowable):
|
||||
writeErr(w, apierror.BadRequest("not_followable", "Various Artists and MusicBrainz placeholders can't be followed"))
|
||||
case errors.Is(err, wanted.ErrProfileNotFound):
|
||||
writeErr(w, apierror.BadRequest("profile_not_found", "no such profile"))
|
||||
default:
|
||||
writeErrWithLog(w, h.logger, "admin follows: put", apierror.Internal(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
h.handleGetFollow(w, r)
|
||||
}
|
||||
|
||||
// handleDeleteFollow implements DELETE /api/admin/follows/{mbid}. What the
|
||||
// follow already wanted stays on the list.
|
||||
func (h *handlers) handleDeleteFollow(w http.ResponseWriter, r *http.Request) {
|
||||
removed, err := h.wanted.Unfollow(r.Context(), chi.URLParam(r, "mbid"))
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "admin follows: delete", apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
if !removed {
|
||||
writeErr(w, apierror.NotFound("follow"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// releaseProfileView is a release-type profile.
|
||||
type releaseProfileView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
PrimaryTypes []string `json:"primary_types"`
|
||||
SecondaryTypes []string `json:"secondary_types"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
FollowCount int64 `json:"follow_count"`
|
||||
}
|
||||
|
||||
// releaseProfilesResponse also carries MusicBrainz's types, so the editor
|
||||
// offers exactly what a profile may hold.
|
||||
type releaseProfilesResponse struct {
|
||||
Profiles []releaseProfileView `json:"profiles"`
|
||||
PrimaryTypes []string `json:"primary_types"`
|
||||
SecondaryTypes []string `json:"secondary_types"`
|
||||
}
|
||||
|
||||
// handleListReleaseProfiles implements GET /api/admin/release-profiles.
|
||||
func (h *handlers) handleListReleaseProfiles(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := dbq.New(h.pool).ListReleaseTypeProfiles(r.Context())
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "admin release profiles: list", apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
out := releaseProfilesResponse{
|
||||
Profiles: make([]releaseProfileView, 0, len(rows)),
|
||||
PrimaryTypes: wanted.PrimaryTypes, SecondaryTypes: wanted.SecondaryTypes,
|
||||
}
|
||||
for _, p := range rows {
|
||||
out.Profiles = append(out.Profiles, releaseProfileView{
|
||||
ID: uuidToString(p.ID), Name: p.Name, PrimaryTypes: p.PrimaryTypes,
|
||||
SecondaryTypes: p.SecondaryTypes, IsDefault: p.IsDefault, FollowCount: p.FollowCount,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type releaseProfileBody struct {
|
||||
Name string `json:"name"`
|
||||
PrimaryTypes []string `json:"primary_types"`
|
||||
SecondaryTypes []string `json:"secondary_types"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
}
|
||||
|
||||
func (b releaseProfileBody) input() wanted.ProfileInput {
|
||||
return wanted.ProfileInput{
|
||||
Name: b.Name, PrimaryTypes: b.PrimaryTypes, SecondaryTypes: b.SecondaryTypes, IsDefault: b.IsDefault,
|
||||
}
|
||||
}
|
||||
|
||||
// handleCreateReleaseProfile implements POST /api/admin/release-profiles.
|
||||
func (h *handlers) handleCreateReleaseProfile(w http.ResponseWriter, r *http.Request) {
|
||||
var body releaseProfileBody
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
p, err := h.wanted.CreateProfile(r.Context(), body.input())
|
||||
if err != nil {
|
||||
h.writeProfileErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, releaseProfileView{
|
||||
ID: uuidToString(p.ID), Name: p.Name, PrimaryTypes: p.PrimaryTypes,
|
||||
SecondaryTypes: p.SecondaryTypes, IsDefault: p.IsDefault,
|
||||
})
|
||||
}
|
||||
|
||||
// handleUpdateReleaseProfile implements PUT /api/admin/release-profiles/{id}.
|
||||
// A follow on the profile judges new releases by the new types from its next
|
||||
// check; releases it has already judged are not judged again.
|
||||
func (h *handlers) handleUpdateReleaseProfile(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := requireURLUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body releaseProfileBody
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
p, err := h.wanted.UpdateProfile(r.Context(), id, body.input())
|
||||
if err != nil {
|
||||
h.writeProfileErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, releaseProfileView{
|
||||
ID: uuidToString(p.ID), Name: p.Name, PrimaryTypes: p.PrimaryTypes,
|
||||
SecondaryTypes: p.SecondaryTypes, IsDefault: p.IsDefault,
|
||||
})
|
||||
}
|
||||
|
||||
// handleDeleteReleaseProfile implements DELETE /api/admin/release-profiles/{id}.
|
||||
func (h *handlers) handleDeleteReleaseProfile(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := requireURLUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.wanted.DeleteProfile(r.Context(), id); err != nil {
|
||||
h.writeProfileErr(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *handlers) writeProfileErr(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, wanted.ErrProfileInvalid):
|
||||
writeErr(w, apierror.BadRequest("profile_invalid", err.Error()))
|
||||
case errors.Is(err, wanted.ErrProfileNotFound):
|
||||
writeErr(w, apierror.NotFound("profile"))
|
||||
case errors.Is(err, wanted.ErrProfileNameUsed):
|
||||
writeErr(w, apierror.Conflict("profile_name_taken", "a profile already has that name"))
|
||||
case errors.Is(err, wanted.ErrProfileInUse):
|
||||
writeErr(w, apierror.Conflict("profile_in_use", "artists are followed on this profile; move them first"))
|
||||
case errors.Is(err, wanted.ErrProfileDefault):
|
||||
writeErr(w, apierror.Conflict("profile_is_default", "the default profile can't be deleted"))
|
||||
default:
|
||||
writeErrWithLog(w, h.logger, "admin release profiles", apierror.Internal(err))
|
||||
}
|
||||
}
|
||||
+28
-9
@@ -22,7 +22,6 @@ import (
|
||||
"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/lidarrrequests"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||
@@ -31,14 +30,16 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
|
||||
)
|
||||
|
||||
// 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, lidarrReqs *lidarrrequests.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, 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) {
|
||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||
setupToken, err := auth.NewSetupToken()
|
||||
if err != nil {
|
||||
@@ -47,12 +48,13 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
panic("api: mint setup token: " + err.Error())
|
||||
}
|
||||
logSetupTokenIfNeeded(pool, logger, setupToken)
|
||||
cat := catalogue.New(pool, nil, logger)
|
||||
h := &handlers{
|
||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
||||
recSettings: recSettings,
|
||||
rng: rng.Float64,
|
||||
lidarrCfg: lidarrCfg,
|
||||
lidarrRequests: lidarrReqs,
|
||||
requests: requestSvc,
|
||||
lidarrQuarantine: lidarrQuar,
|
||||
tracks: tracksSvc,
|
||||
playlists: playlistsSvc,
|
||||
@@ -73,7 +75,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
loudnessSettings: loudSettings,
|
||||
acoustIDLookup: acoustIDLookup,
|
||||
acquisition: acquire.NewStore(pool),
|
||||
catalogue: catalogue.New(pool, nil, logger),
|
||||
catalogue: cat,
|
||||
wanted: wanted.New(pool, cat, logger),
|
||||
catalogueSearches: newSearchCache(),
|
||||
catalogueArt: catalogue.NewArt(dataDir, artSources(coverSettings)),
|
||||
leveled: newLeveledRenderer(dataDir, loudSettings, logger),
|
||||
@@ -240,6 +243,20 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
admin.Post("/requests/{id}/approve", h.handleApproveRequest)
|
||||
admin.Post("/requests/{id}/reject", h.handleRejectRequest)
|
||||
|
||||
// The wanted list (M502 #5520): what Minstrel is looking
|
||||
// for, the artists it follows, and the release-type
|
||||
// profiles that decide which of their releases count.
|
||||
admin.Get("/wanted", h.handleListWants)
|
||||
admin.Delete("/wanted/{id}", h.handleRemoveWant)
|
||||
admin.Get("/follows", h.handleListFollows)
|
||||
admin.Get("/follows/{mbid}", h.handleGetFollow)
|
||||
admin.Put("/follows/{mbid}", h.handlePutFollow)
|
||||
admin.Delete("/follows/{mbid}", h.handleDeleteFollow)
|
||||
admin.Get("/release-profiles", h.handleListReleaseProfiles)
|
||||
admin.Post("/release-profiles", h.handleCreateReleaseProfile)
|
||||
admin.Put("/release-profiles/{id}", h.handleUpdateReleaseProfile)
|
||||
admin.Delete("/release-profiles/{id}", h.handleDeleteReleaseProfile)
|
||||
|
||||
admin.Get("/quarantine", h.handleListAdminQuarantine)
|
||||
admin.Post("/quarantine/{track_id}/resolve", h.handleResolveQuarantine)
|
||||
|
||||
@@ -257,10 +274,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
admin.Get("/network-settings", h.handleGetNetworkSettings)
|
||||
admin.Put("/network-settings", h.handleUpdateNetworkSettings)
|
||||
|
||||
// Policy for turning a missing file back into a Lidarr
|
||||
// request (#290). Sits beside the missing-files list it
|
||||
// governs rather than under /lidarr, because the operator
|
||||
// meets it on the missing-files surface.
|
||||
// Policy for wanting back an album whose files went missing
|
||||
// (#290). Sits beside the missing-files list it governs,
|
||||
// because the operator meets it on that surface.
|
||||
admin.Get("/library/reacquisition", h.handleGetReacquisitionSettings)
|
||||
admin.Put("/library/reacquisition", h.handleUpdateReacquisitionSettings)
|
||||
|
||||
@@ -361,7 +377,7 @@ type handlers struct {
|
||||
// full table scan, and library size only moves when a scan runs.
|
||||
librarySize *recommendation.LibrarySize
|
||||
lidarrCfg *lidarrconfig.Service
|
||||
lidarrRequests *lidarrrequests.Service
|
||||
requests *requests.Service
|
||||
lidarrQuarantine *lidarrquarantine.Service
|
||||
tracks *tracks.Service
|
||||
playlists *playlists.Service
|
||||
@@ -397,6 +413,9 @@ type handlers struct {
|
||||
// catalogueArt the images for what isn't held (M502 #5519).
|
||||
catalogue *catalogue.Service
|
||||
catalogueArt *catalogue.Art
|
||||
// wanted is the wanted list: follows, release-type profiles and wants
|
||||
// (M502 #5520). Its worker runs in main; the API reads and edits.
|
||||
wanted *wanted.Service
|
||||
// catalogueSearches reuses recent MusicBrainz answers; nil asks every
|
||||
// time, which is what handler tests get.
|
||||
catalogueSearches *searchCache
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
@@ -25,13 +26,14 @@ import (
|
||||
"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/lidarrrequests"
|
||||
"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/recsettings"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
|
||||
)
|
||||
|
||||
// testHandlers spins up a handlers instance against MINSTREL_TEST_DATABASE_URL.
|
||||
@@ -65,7 +67,8 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
||||
t.Fatalf("recsettings: %v", err)
|
||||
}
|
||||
lidarrCfg := lidarrconfig.New(pool)
|
||||
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
|
||||
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
|
||||
@@ -73,7 +76,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
||||
dataDir := t.TempDir()
|
||||
tracksSvc := tracks.NewService(pool, logger, nil, 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, lidarrRequests: lidarrReqs, 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 }, 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)}
|
||||
return h, pool
|
||||
}
|
||||
|
||||
|
||||
@@ -154,9 +154,9 @@ func TestCatalogueSearch_MarksWhatIsHeldAndWhatIsRequested(t *testing.T) {
|
||||
|
||||
user := seedUser(t, h.pool, "requester", "pw", false)
|
||||
track := searchRecMBID
|
||||
if _, err := q.CreateLidarrRequest(ctx, dbq.CreateLidarrRequestParams{
|
||||
UserID: user.ID, Kind: dbq.LidarrRequestKindTrack, LidarrArtistMbid: searchArtistMBID,
|
||||
LidarrTrackMbid: &track, ArtistName: "Radiohead",
|
||||
if _, err := q.CreateRequest(ctx, dbq.CreateRequestParams{
|
||||
UserID: user.ID, Kind: dbq.RequestKindTrack, ArtistMbid: searchArtistMBID,
|
||||
TrackMbid: &track, ArtistName: "Radiohead",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ func (h *handlers) publishSystemRebuilt(userID pgtype.UUID) {
|
||||
// new state without manual refresh. Admin actors (approve / reject) still
|
||||
// route to the requester's user_id, not the admin's — the original user
|
||||
// is who needs to see the update.
|
||||
func (h *handlers) publishRequestStatusChanged(row dbq.LidarrRequest) {
|
||||
func (h *handlers) publishRequestStatusChanged(row dbq.Request) {
|
||||
if h.eventbus == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -132,6 +132,9 @@ func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) {
|
||||
Albums: refs,
|
||||
Genres: nonNilStrings(genres),
|
||||
}
|
||||
if artist.Mbid != nil {
|
||||
detail.MBID = *artist.Mbid
|
||||
}
|
||||
writeJSON(w, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.lidarrRequests, 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.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)
|
||||
|
||||
paths := []string{
|
||||
"/api/artists",
|
||||
|
||||
@@ -13,13 +13,13 @@ import (
|
||||
// Lidarr search they once sat beside is now the catalogue search
|
||||
// (catalogue_test.go, M502 #5519).
|
||||
|
||||
// resetLidarrState clears lidarr_requests rows and resets lidarr_config
|
||||
// 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 lidarr_requests; UPDATE lidarr_config SET enabled=false, base_url=NULL, api_key=NULL WHERE id=1",
|
||||
"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)
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
|
||||
)
|
||||
|
||||
// Helpers for the handlers that produce notifications (M489). Every call is
|
||||
@@ -22,17 +22,17 @@ func userLabel(u dbq.User) string {
|
||||
return u.Username
|
||||
}
|
||||
|
||||
func requestPayload(row dbq.LidarrRequest) notifications.Payload {
|
||||
func requestPayload(row dbq.Request) notifications.Payload {
|
||||
return notifications.Payload{
|
||||
RequestID: uuidToString(row.ID),
|
||||
RequestKind: string(row.Kind),
|
||||
Name: lidarrrequests.DisplayName(row),
|
||||
Name: requests.DisplayName(row),
|
||||
}
|
||||
}
|
||||
|
||||
// notifyRequestDecided tells the requester an admin approved or declined
|
||||
// their request. An admin deciding their own request needs no notice.
|
||||
func (h *handlers) notifyRequestDecided(ctx context.Context, kind notifications.Kind, admin dbq.User, row dbq.LidarrRequest, reason string) {
|
||||
func (h *handlers) notifyRequestDecided(ctx context.Context, kind notifications.Kind, admin dbq.User, row dbq.Request, reason string) {
|
||||
if row.UserID == admin.ID {
|
||||
return
|
||||
}
|
||||
|
||||
+75
-86
@@ -9,34 +9,32 @@ import (
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
|
||||
)
|
||||
|
||||
// requestView is the JSON shape returned by all /api/requests handlers.
|
||||
// Field names follow the lowercase snake_case convention for /api/* responses.
|
||||
type requestView struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
Status string `json:"status"`
|
||||
Kind string `json:"kind"`
|
||||
LidarrArtistMBID string `json:"lidarr_artist_mbid"`
|
||||
LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"`
|
||||
LidarrTrackMBID *string `json:"lidarr_track_mbid,omitempty"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumTitle *string `json:"album_title,omitempty"`
|
||||
TrackTitle *string `json:"track_title,omitempty"`
|
||||
QualityProfileID *int32 `json:"quality_profile_id,omitempty"`
|
||||
RootFolderPath *string `json:"root_folder_path,omitempty"`
|
||||
DecidedAt pgtype.Timestamptz `json:"decided_at,omitempty"`
|
||||
DecidedBy pgtype.UUID `json:"decided_by,omitempty"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
CompletedAt pgtype.Timestamptz `json:"completed_at,omitempty"`
|
||||
MatchedTrackID pgtype.UUID `json:"matched_track_id,omitempty"`
|
||||
MatchedAlbumID pgtype.UUID `json:"matched_album_id,omitempty"`
|
||||
MatchedArtistID pgtype.UUID `json:"matched_artist_id,omitempty"`
|
||||
RequestedAt pgtype.Timestamptz `json:"requested_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
Status string `json:"status"`
|
||||
Kind string `json:"kind"`
|
||||
ArtistMBID string `json:"artist_mbid"`
|
||||
AlbumMBID *string `json:"album_mbid,omitempty"`
|
||||
TrackMBID *string `json:"track_mbid,omitempty"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumTitle *string `json:"album_title,omitempty"`
|
||||
TrackTitle *string `json:"track_title,omitempty"`
|
||||
DecidedAt pgtype.Timestamptz `json:"decided_at,omitempty"`
|
||||
DecidedBy pgtype.UUID `json:"decided_by,omitempty"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
CompletedAt pgtype.Timestamptz `json:"completed_at,omitempty"`
|
||||
MatchedTrackID pgtype.UUID `json:"matched_track_id,omitempty"`
|
||||
MatchedAlbumID pgtype.UUID `json:"matched_album_id,omitempty"`
|
||||
MatchedArtistID pgtype.UUID `json:"matched_artist_id,omitempty"`
|
||||
RequestedAt pgtype.Timestamptz `json:"requested_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
// Progress counters, computed at response time from the matched
|
||||
// entity's children. 0 when nothing has been ingested yet (or when
|
||||
// the request has no matched entity yet). For artist requests both
|
||||
@@ -45,29 +43,27 @@ type requestView struct {
|
||||
ImportedTrackCount int `json:"imported_track_count"`
|
||||
}
|
||||
|
||||
func requestViewFrom(row dbq.LidarrRequest) requestView {
|
||||
func requestViewFrom(row dbq.Request) requestView {
|
||||
return requestView{
|
||||
ID: row.ID,
|
||||
UserID: row.UserID,
|
||||
Status: string(row.Status),
|
||||
Kind: string(row.Kind),
|
||||
LidarrArtistMBID: row.LidarrArtistMbid,
|
||||
LidarrAlbumMBID: row.LidarrAlbumMbid,
|
||||
LidarrTrackMBID: row.LidarrTrackMbid,
|
||||
ArtistName: row.ArtistName,
|
||||
AlbumTitle: row.AlbumTitle,
|
||||
TrackTitle: row.TrackTitle,
|
||||
QualityProfileID: row.QualityProfileID,
|
||||
RootFolderPath: row.RootFolderPath,
|
||||
DecidedAt: row.DecidedAt,
|
||||
DecidedBy: row.DecidedBy,
|
||||
Notes: row.Notes,
|
||||
CompletedAt: row.CompletedAt,
|
||||
MatchedTrackID: row.MatchedTrackID,
|
||||
MatchedAlbumID: row.MatchedAlbumID,
|
||||
MatchedArtistID: row.MatchedArtistID,
|
||||
RequestedAt: row.RequestedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
ID: row.ID,
|
||||
UserID: row.UserID,
|
||||
Status: string(row.Status),
|
||||
Kind: string(row.Kind),
|
||||
ArtistMBID: row.ArtistMbid,
|
||||
AlbumMBID: row.AlbumMbid,
|
||||
TrackMBID: row.TrackMbid,
|
||||
ArtistName: row.ArtistName,
|
||||
AlbumTitle: row.AlbumTitle,
|
||||
TrackTitle: row.TrackTitle,
|
||||
DecidedAt: row.DecidedAt,
|
||||
DecidedBy: row.DecidedBy,
|
||||
Notes: row.Notes,
|
||||
CompletedAt: row.CompletedAt,
|
||||
MatchedTrackID: row.MatchedTrackID,
|
||||
MatchedAlbumID: row.MatchedAlbumID,
|
||||
MatchedArtistID: row.MatchedArtistID,
|
||||
RequestedAt: row.RequestedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,9 +77,9 @@ func requestViewFrom(row dbq.LidarrRequest) requestView {
|
||||
// N+1 against the request list: acceptable at admin scale (dozens of
|
||||
// in-flight requests at most). Errors are swallowed — progress is a
|
||||
// reporting nicety, not a correctness gate.
|
||||
func fillProgress(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest, view *requestView) {
|
||||
func fillProgress(ctx context.Context, q *dbq.Queries, row dbq.Request, view *requestView) {
|
||||
switch row.Kind {
|
||||
case dbq.LidarrRequestKindArtist:
|
||||
case dbq.RequestKindArtist:
|
||||
if !row.MatchedArtistID.Valid {
|
||||
return
|
||||
}
|
||||
@@ -93,7 +89,7 @@ func fillProgress(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest, vi
|
||||
if n, err := q.CountTracksByArtist(ctx, row.MatchedArtistID); err == nil {
|
||||
view.ImportedTrackCount = int(n)
|
||||
}
|
||||
case dbq.LidarrRequestKindAlbum:
|
||||
case dbq.RequestKindAlbum:
|
||||
if !row.MatchedAlbumID.Valid {
|
||||
return
|
||||
}
|
||||
@@ -101,22 +97,23 @@ func fillProgress(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest, vi
|
||||
if n, err := q.CountTracksByAlbum(ctx, row.MatchedAlbumID); err == nil {
|
||||
view.ImportedTrackCount = int(n)
|
||||
}
|
||||
case dbq.LidarrRequestKindTrack:
|
||||
case dbq.RequestKindTrack:
|
||||
if row.MatchedTrackID.Valid {
|
||||
view.ImportedTrackCount = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// createRequestBody is the decoded JSON for POST /api/requests.
|
||||
// createRequestBody is the decoded JSON for POST /api/requests. album_mbid
|
||||
// is the album's MusicBrainz release group, as catalogue search returns it.
|
||||
type createRequestBody struct {
|
||||
Kind string `json:"kind"`
|
||||
LidarrArtistMBID string `json:"lidarr_artist_mbid"`
|
||||
LidarrAlbumMBID string `json:"lidarr_album_mbid"`
|
||||
LidarrTrackMBID string `json:"lidarr_track_mbid"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumTitle string `json:"album_title"`
|
||||
TrackTitle string `json:"track_title"`
|
||||
Kind string `json:"kind"`
|
||||
ArtistMBID string `json:"artist_mbid"`
|
||||
AlbumMBID string `json:"album_mbid"`
|
||||
TrackMBID string `json:"track_mbid"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumTitle string `json:"album_title"`
|
||||
TrackTitle string `json:"track_title"`
|
||||
}
|
||||
|
||||
// handleCreateRequest implements POST /api/requests.
|
||||
@@ -131,17 +128,17 @@ func (h *handlers) handleCreateRequest(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
row, created, err := h.lidarrRequests.CreateTracked(r.Context(), user.ID, lidarrrequests.CreateParams{
|
||||
Kind: body.Kind,
|
||||
LidarrArtistMBID: body.LidarrArtistMBID,
|
||||
LidarrAlbumMBID: body.LidarrAlbumMBID,
|
||||
LidarrTrackMBID: body.LidarrTrackMBID,
|
||||
ArtistName: body.ArtistName,
|
||||
AlbumTitle: body.AlbumTitle,
|
||||
TrackTitle: body.TrackTitle,
|
||||
row, created, err := h.requests.CreateTracked(r.Context(), user.ID, requests.CreateParams{
|
||||
Kind: body.Kind,
|
||||
ArtistMBID: body.ArtistMBID,
|
||||
AlbumMBID: body.AlbumMBID,
|
||||
TrackMBID: body.TrackMBID,
|
||||
ArtistName: body.ArtistName,
|
||||
AlbumTitle: body.AlbumTitle,
|
||||
TrackTitle: body.TrackTitle,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, lidarrrequests.ErrInvalidKindFields) {
|
||||
if errors.Is(err, requests.ErrInvalidKindFields) {
|
||||
writeErr(w, apierror.BadRequest("mbid_required", err.Error()))
|
||||
return
|
||||
}
|
||||
@@ -150,22 +147,14 @@ func (h *handlers) handleCreateRequest(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-approve flow (#355 / #376 U2.5): when the user has the
|
||||
// auto_approve_requests flag set by an admin, transition the
|
||||
// just-created pending row through Approve so it dispatches to
|
||||
// Lidarr immediately. Failures here (Lidarr disabled, defaults
|
||||
// missing, network error) leave the row pending — same fallback
|
||||
// as a manual admin approve that hits the same error path. The
|
||||
// user gets a 201 with whatever status Approve produced; admin
|
||||
// can still resolve the row in /admin/requests if it stayed
|
||||
// pending.
|
||||
//
|
||||
// The "actor" id passed to Approve is the user themselves — the
|
||||
// auto-approval is the user acting under the privilege the admin
|
||||
// granted them via the toggle. The trail of "admin set the flag"
|
||||
// already lives in audit_log (ActionAutoApproveToggle from U2).
|
||||
// Auto-approve flow (#355 / #376 U2.5): when an admin has set the user's
|
||||
// auto_approve_requests flag, the new request goes straight onto the
|
||||
// wanted list as if approved. A failure leaves it pending for an admin,
|
||||
// the same as a manual approval hitting the same error. The approver is
|
||||
// the user themselves, acting under the privilege the admin granted; the
|
||||
// grant is in audit_log (ActionAutoApproveToggle).
|
||||
if user.AutoApproveRequests {
|
||||
approved, aerr := h.lidarrRequests.Approve(r.Context(), row.ID, user.ID, lidarrrequests.ApproveOverrides{})
|
||||
approved, aerr := h.requests.Approve(r.Context(), row.ID, user.ID, pgtype.UUID{})
|
||||
if aerr == nil {
|
||||
row = approved
|
||||
} else {
|
||||
@@ -180,7 +169,7 @@ func (h *handlers) handleCreateRequest(w http.ResponseWriter, r *http.Request) {
|
||||
// (M489). One that deduped into a request already in flight was
|
||||
// announced when it was first made, and the requester, if they are an
|
||||
// admin themselves, needs no notice of their own request.
|
||||
if created && row.Status == dbq.LidarrRequestStatusPending {
|
||||
if created && row.Status == dbq.RequestStatusPending {
|
||||
p := requestPayload(row)
|
||||
p.Actor = userLabel(user)
|
||||
h.notifier.NotifyLogged(r.Context(), notifications.KindRequestPending, notifications.ToAdmins(user.ID), p.Map())
|
||||
@@ -197,7 +186,7 @@ func (h *handlers) handleListRequests(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.lidarrRequests.ListForUser(r.Context(), user.ID, 100)
|
||||
rows, err := h.requests.ListForUser(r.Context(), user.ID, 100)
|
||||
if err != nil {
|
||||
h.logger.Error("api: list requests", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("list failed", err))
|
||||
@@ -222,7 +211,7 @@ func (h *handlers) handleGetRequest(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
row, apiErr := resolveByID(r, "id", dbq.New(h.pool).GetLidarrRequestByID, "request")
|
||||
row, apiErr := resolveByID(r, "id", dbq.New(h.pool).GetRequestByID, "request")
|
||||
if apiErr != nil {
|
||||
writeErr(w, apiErr)
|
||||
return
|
||||
@@ -252,12 +241,12 @@ func (h *handlers) handleCancelRequest(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
row, err := h.lidarrRequests.Cancel(r.Context(), id, user.ID)
|
||||
row, err := h.requests.Cancel(r.Context(), id, user.ID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, lidarrrequests.ErrNotPending):
|
||||
case errors.Is(err, requests.ErrNotPending):
|
||||
writeErr(w, apierror.Conflict("request_not_pending", "request is not pending"))
|
||||
case errors.Is(err, lidarrrequests.ErrNotFound):
|
||||
case errors.Is(err, requests.ErrNotFound):
|
||||
writeErr(w, apierror.NotFound("request"))
|
||||
default:
|
||||
h.logger.Error("api: cancel request", "err", err)
|
||||
|
||||
@@ -73,7 +73,7 @@ func doCancelRequest(h *handlers, user dbq.User, id pgtype.UUID) *httptest.Respo
|
||||
// via the handler and returns the parsed response.
|
||||
func createArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMBID, artistName string) requestView {
|
||||
t.Helper()
|
||||
body := fmt.Sprintf(`{"kind":"artist","lidarr_artist_mbid":%q,"artist_name":%q}`, artistMBID, artistName)
|
||||
body := fmt.Sprintf(`{"kind":"artist","artist_mbid":%q,"artist_name":%q}`, artistMBID, artistName)
|
||||
w := doCreateRequest(h, user, body)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("createArtistRequest: status = %d, want 201; body = %s", w.Code, w.Body.String())
|
||||
@@ -85,13 +85,10 @@ func createArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMBID, a
|
||||
return rv
|
||||
}
|
||||
|
||||
// TestHandleCreateRequest_AutoApprove_LidarrDisabled_StaysPending verifies
|
||||
// that a user with auto_approve_requests=true still receives a 201 Created
|
||||
// when Lidarr is disabled in the test environment — the auto-approve attempt
|
||||
// fails inside Service.Approve with ErrLidarrDisabled, which the handler
|
||||
// catches and logs, leaving the row in pending state. This is the U2.5
|
||||
// fallback contract: a misconfigured Lidarr must not break request creation.
|
||||
func TestHandleCreateRequest_AutoApprove_LidarrDisabled_StaysPending(t *testing.T) {
|
||||
// TestHandleCreateRequest_AutoApprove_GoesStraightOnTheWantedList verifies
|
||||
// that a user with auto_approve_requests=true gets their request approved on
|
||||
// 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)
|
||||
|
||||
@@ -105,13 +102,9 @@ func TestHandleCreateRequest_AutoApprove_LidarrDisabled_StaysPending(t *testing.
|
||||
if err != nil {
|
||||
t.Fatalf("reload user: %v", err)
|
||||
}
|
||||
if !refreshed.AutoApproveRequests {
|
||||
t.Fatalf("auto_approve flag did not persist — bailing")
|
||||
}
|
||||
|
||||
body := `{"kind":"artist","lidarr_artist_mbid":"auto-mbid-1","artist_name":"Autoband"}`
|
||||
body := `{"kind":"artist","artist_mbid":"auto-mbid-1","artist_name":"Autoband"}`
|
||||
w := doCreateRequest(h, refreshed, body)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
@@ -119,8 +112,11 @@ func TestHandleCreateRequest_AutoApprove_LidarrDisabled_StaysPending(t *testing.
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &rv); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if rv.Status != "pending" {
|
||||
t.Errorf("status = %q, want pending (auto-approve falls back when Lidarr disabled)", rv.Status)
|
||||
if rv.Status != "approved" {
|
||||
t.Errorf("status = %q, want approved", rv.Status)
|
||||
}
|
||||
if _, err := dbq.New(pool).GetFollow(context.Background(), "auto-mbid-1"); err != nil {
|
||||
t.Errorf("the artist should be followed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +127,7 @@ func TestHandleCreateRequest_Artist_HappyPath(t *testing.T) {
|
||||
resetLidarrState(t, h)
|
||||
|
||||
alice := seedUser(t, pool, "alice", "pw", false)
|
||||
body := `{"kind":"artist","lidarr_artist_mbid":"artist-mbid-1","artist_name":"The Beatles"}`
|
||||
body := `{"kind":"artist","artist_mbid":"artist-mbid-1","artist_name":"The Beatles"}`
|
||||
w := doCreateRequest(h, alice, body)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
@@ -147,8 +143,8 @@ func TestHandleCreateRequest_Artist_HappyPath(t *testing.T) {
|
||||
if rv.Status != "pending" {
|
||||
t.Errorf("status = %q, want pending", rv.Status)
|
||||
}
|
||||
if rv.LidarrArtistMBID != "artist-mbid-1" {
|
||||
t.Errorf("lidarr_artist_mbid = %q", rv.LidarrArtistMBID)
|
||||
if rv.ArtistMBID != "artist-mbid-1" {
|
||||
t.Errorf("artist_mbid = %q", rv.ArtistMBID)
|
||||
}
|
||||
if rv.ArtistName != "The Beatles" {
|
||||
t.Errorf("artist_name = %q", rv.ArtistName)
|
||||
@@ -159,14 +155,14 @@ func TestHandleCreateRequest_Artist_HappyPath(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestHandleCreateRequest_AlbumWithoutAlbumMBID_400 verifies that sending
|
||||
// kind=album without lidarr_album_mbid returns 400 with mbid_required.
|
||||
// kind=album without album_mbid returns 400 with mbid_required.
|
||||
func TestHandleCreateRequest_AlbumWithoutAlbumMBID_400(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
resetLidarrState(t, h)
|
||||
|
||||
alice := seedUser(t, pool, "alice", "pw", false)
|
||||
// Missing lidarr_album_mbid and album_title.
|
||||
body := `{"kind":"album","lidarr_artist_mbid":"artist-mbid-1","artist_name":"The Beatles"}`
|
||||
// Missing album_mbid and album_title.
|
||||
body := `{"kind":"album","artist_mbid":"artist-mbid-1","artist_name":"The Beatles"}`
|
||||
w := doCreateRequest(h, alice, body)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
@@ -193,7 +189,7 @@ func TestHandleCreateRequest_TrackWithoutTrackFields_400(t *testing.T) {
|
||||
|
||||
alice := seedUser(t, pool, "alice", "pw", false)
|
||||
// Provides album fields but misses track_mbid and track_title.
|
||||
body := `{"kind":"track","lidarr_artist_mbid":"artist-mbid-1","artist_name":"Beatles","lidarr_album_mbid":"album-mbid-1","album_title":"Abbey Road"}`
|
||||
body := `{"kind":"track","artist_mbid":"artist-mbid-1","artist_name":"Beatles","album_mbid":"album-mbid-1","album_title":"Abbey Road"}`
|
||||
w := doCreateRequest(h, alice, body)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
|
||||
@@ -102,6 +102,9 @@ type ArtistDetail struct {
|
||||
// Genres carried by this artist's tracks, for quick-jump chips (#367).
|
||||
// Always non-nil at JSON so the client can iterate without a null check.
|
||||
Genres []string `json:"genres"`
|
||||
// MBID is the artist's MusicBrainz id, which following is keyed by
|
||||
// (M502 #5520). Empty when the tags never named one.
|
||||
MBID string `json:"mbid"`
|
||||
}
|
||||
|
||||
// AlbumDetail is the response body of GET /api/albums/{id}.
|
||||
|
||||
Reference in New Issue
Block a user