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

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:
2026-10-10 00:46:22 -04:00
co-authored by Claude Opus 5.5
parent 867f52358c
commit 04515a977c
93 changed files with 6522 additions and 4484 deletions
@@ -120,6 +120,8 @@ object ErrorCopy {
"album_mbid_missing" to "This track has no Lidarr album to remove.",
"request_not_pending" to "This request is no longer pending.",
"request_not_found" to "That request no longer exists.",
"not_followable" to "Various Artists and MusicBrainz placeholders can't be followed.",
"profile_not_found" to "That release-type profile no longer exists.",
"track_not_found" to "That track no longer exists.",
"library_not_writable" to "The music library isn't writable by the server.",
"file_delete_failed" to "The file couldn't be deleted.",
@@ -94,10 +94,10 @@ data class SnoozeSuggestionBody(
@Serializable
data class CreateRequestBody(
val kind: String,
@SerialName("lidarr_artist_mbid") val artistMbid: String,
@SerialName("artist_mbid") val artistMbid: String,
@SerialName("artist_name") val artistName: String,
@SerialName("lidarr_album_mbid") val albumMbid: String? = null,
@SerialName("album_mbid") val albumMbid: String? = null,
@SerialName("album_title") val albumTitle: String? = null,
@SerialName("lidarr_track_mbid") val trackMbid: String? = null,
@SerialName("track_mbid") val trackMbid: String? = null,
@SerialName("track_title") val trackTitle: String? = null,
)
+18 -35
View File
@@ -23,15 +23,13 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
"git.fabledsword.com/bvandeusen/minstrel/internal/gc"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble"
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
"git.fabledsword.com/bvandeusen/minstrel/internal/server"
@@ -39,6 +37,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
)
func main() {
@@ -292,10 +291,8 @@ func run() error {
gcWorker := gc.NewWorker(pool, logger.With("component", "gc"))
go gcWorker.Run(ctx)
// Start the Lidarr reconciler worker. Per spec §M5a, polls pending Lidarr
// import requests and reconciles them against the library. Short-circuits
// to no-op when lidarr_config.enabled = false.
lidarrCfg := lidarrconfig.New(pool)
// The request reconciler completes approved requests once the library
// holds what they asked for (M502 #5520: wants, not Lidarr adds).
// Live-event bus shared between SSE subscribers (api.Mount) and
// background workers that publish (reconciler today; scanner later).
// Constructed before any service that publishes so they all share the
@@ -305,31 +302,25 @@ func run() error {
// threading the bus through RunScan + TryStartScan + the Scheduler
// + every test caller. Per-process singleton, set once at startup.
library.SetEventBus(bus)
// Per-tick Lidarr client factory: re-reads config so an admin save
// takes effect without a restart, returning nil while Lidarr is
// disabled/unconfigured. Mirrors server.go's lidarrClientFn; the
// reconciler uses it to (re)send unconfirmed adds.
lidarrClientFn := func() *lidarr.Client {
c, cerr := lidarrCfg.Get(ctx)
if cerr != nil || !c.Enabled || c.BaseURL == "" || c.APIKey == "" {
return nil
}
return lidarr.NewClient(c.BaseURL, c.APIKey)
}
// The notifications inbox's one writer (M489), shared by every
// background producer started here. The API builds its own over the
// same pool and bus.
notifier := notifications.New(pool, bus, logger.With("component", "notifications"))
lidarrReconciler := lidarrrequests.NewReconciler(pool, lidarrCfg, lidarrClientFn, logger.With("component", "lidarr"), bus)
lidarrReconciler.SetNotifier(notifier)
requestReconciler := requests.NewReconciler(pool, logger.With("component", "requests"), bus)
requestReconciler.SetNotifier(notifier)
library.SetNotifier(notifier)
go lidarrReconciler.Run(ctx)
go requestReconciler.Run(ctx)
// Release catalogue (M502 #5519): re-reads discographies older than a
// week from MusicBrainz, a batch an hour, through the process-wide
// MusicBrainz pacer it shares with tag enrichment.
go catalogue.New(pool, nil, logger.With("component", "catalogue")).Run(ctx)
cat := catalogue.New(pool, nil, logger.With("component", "catalogue"))
go cat.Run(ctx)
// The wanted list (M502 #5520): reads followed artists' discographies
// daily for new releases and marks wants held once the library has them.
go wanted.New(pool, cat, logger.With("component", "wanted")).Run(ctx)
// Duplicate resolver (M498, M502): classifies the sweep's groups and, with
// the operator's auto-resolve on, merges the copies that are safe to merge.
@@ -338,11 +329,10 @@ func run() error {
go library.NewDuplicateResolveWorker(pool, logger.With("component", "duplicate_resolve"),
cfg.Storage.DataDir, fpSettings).Run(ctx)
// Missing-file re-acquisition (milestone #290). Turns albums whose files
// have been gone longer than the grace window into Lidarr requests, on an
// exponential per-album backoff. Hourly tick — the shortest meaningful
// backoff is measured in hours, so waking more often would only re-read
// settings and find nothing due.
// Missing-file re-acquisition (milestone #290, M502 #5520). Wants back
// albums whose files have been gone longer than the grace window; the
// search schedule looks for them. Hourly tick — the grace window is
// measured in hours.
//
// A settings-load failure is logged, not fatal: NewSettingsService always
// returns a usable service holding the defaults, and running on defaults
@@ -353,14 +343,7 @@ func run() error {
if reacqErr != nil {
logger.Warn("reacquisition: using default settings", "err", reacqErr)
}
reacqSweeper := reacquisition.NewSweeper(
pool,
reacqSettings,
lidarrrequests.NewService(pool, lidarrCfg, lidarrClientFn, nil),
logger.With("component", "reacquisition"),
)
reacqSweeper.SetNotifier(notifier)
go reacqSweeper.Run(ctx)
go reacquisition.NewSweeper(pool, reacqSettings, logger.With("component", "reacquisition")).Run(ctx)
// library_changes compactor (#357 follow-up). Daily tick; deletes
// rows older than the configured retention so the change-log table
+25 -60
View File
@@ -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 -25
View File
@@ -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.
+27 -33
View File
@@ -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)
+66 -200
View File
@@ -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)
}
}
+392
View File
@@ -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
View File
@@ -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
+6 -3
View File
@@ -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
}
+3 -3
View File
@@ -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)
}
+1 -1
View File
@@ -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
}
+3
View File
@@ -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)
}
+1 -1
View File
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
r := chi.NewRouter()
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
30*time.Minute, 0.5, 30000)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.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",
+2 -2
View File
@@ -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)
}
+4 -4
View File
@@ -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
View File
@@ -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)
+18 -22
View File
@@ -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 {
+3
View File
@@ -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}.
+8 -1
View File
@@ -66,6 +66,13 @@ type Discography struct {
// DiscographyMaxAge. When MusicBrainz is unreachable, a copy already held is
// returned marked Stale; with nothing held, the error is returned.
func (s *Service) Discography(ctx context.Context, artistMBID string) (Discography, error) {
return s.DiscographyWithin(ctx, artistMBID, DiscographyMaxAge)
}
// DiscographyWithin is Discography with the age past which the held copy is
// read again: a followed artist is checked for new releases more often than
// one the catalogue merely knows.
func (s *Service) DiscographyWithin(ctx context.Context, artistMBID string, maxAge time.Duration) (Discography, error) {
q := dbq.New(s.pool)
artist, err := q.GetMBArtist(ctx, artistMBID)
held := err == nil && artist.DiscographyRefreshedAt.Valid
@@ -73,7 +80,7 @@ func (s *Service) Discography(ctx context.Context, artistMBID string) (Discograp
return Discography{}, fmt.Errorf("catalogue: read artist: %w", err)
}
stale := false
if !held || s.now().Sub(artist.DiscographyRefreshedAt.Time) > DiscographyMaxAge {
if !held || s.now().Sub(artist.DiscographyRefreshedAt.Time) > maxAge {
if rerr := s.RefreshArtist(ctx, artistMBID); rerr != nil {
if !held || !errors.Is(rerr, musicbrainz.ErrUnreachable) {
return Discography{}, rerr
-582
View File
@@ -1,582 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: lidarr_requests.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const approveLidarrRequest = `-- name: ApproveLidarrRequest :one
UPDATE lidarr_requests
SET status = 'approved',
quality_profile_id = $2,
root_folder_path = $3,
decided_at = now(),
decided_by = $4,
updated_at = now()
WHERE id = $1 AND status = 'pending'
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at
`
type ApproveLidarrRequestParams struct {
ID pgtype.UUID
QualityProfileID *int32
RootFolderPath *string
DecidedBy pgtype.UUID
}
func (q *Queries) ApproveLidarrRequest(ctx context.Context, arg ApproveLidarrRequestParams) (LidarrRequest, error) {
row := q.db.QueryRow(ctx, approveLidarrRequest,
arg.ID,
arg.QualityProfileID,
arg.RootFolderPath,
arg.DecidedBy,
)
var i LidarrRequest
err := row.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.LidarrArtistMbid,
&i.LidarrAlbumMbid,
&i.LidarrTrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.QualityProfileID,
&i.RootFolderPath,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
)
return i, err
}
const cancelLidarrRequest = `-- name: CancelLidarrRequest :one
UPDATE lidarr_requests
SET status = 'rejected',
notes = 'cancelled by user',
decided_at = now(),
decided_by = $2,
updated_at = now()
WHERE id = $1 AND user_id = $2 AND status = 'pending'
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at
`
type CancelLidarrRequestParams struct {
ID pgtype.UUID
DecidedBy pgtype.UUID
}
// $2 serves as both the ownership guard (user_id = $2) and the
// decided_by audit field. The generated parameter struct names it
// DecidedBy — callers must pass the cancelling user's UUID, and
// a wrong UUID will silently no-op (zero rows updated) rather than
// erroring at the SQL layer.
func (q *Queries) CancelLidarrRequest(ctx context.Context, arg CancelLidarrRequestParams) (LidarrRequest, error) {
row := q.db.QueryRow(ctx, cancelLidarrRequest, arg.ID, arg.DecidedBy)
var i LidarrRequest
err := row.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.LidarrArtistMbid,
&i.LidarrAlbumMbid,
&i.LidarrTrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.QualityProfileID,
&i.RootFolderPath,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
)
return i, err
}
const completeLidarrRequest = `-- name: CompleteLidarrRequest :one
UPDATE lidarr_requests
SET status = 'completed',
matched_track_id = $2,
matched_album_id = $3,
matched_artist_id = $4,
completed_at = now(),
updated_at = now()
WHERE id = $1 AND status = 'approved'
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at
`
type CompleteLidarrRequestParams struct {
ID pgtype.UUID
MatchedTrackID pgtype.UUID
MatchedAlbumID pgtype.UUID
MatchedArtistID pgtype.UUID
}
// Reconciler transitions an approved request to completed when its
// target track/album/artist has appeared in the library. Callers
// should set ONLY the matched_*_id corresponding to the request's
// kind and pass pgtype.UUID{} (Valid: false) for the others —
// per-kind one-of-three is the contract, NULL for the others
// preserves auditability if the matched row is later evicted.
func (q *Queries) CompleteLidarrRequest(ctx context.Context, arg CompleteLidarrRequestParams) (LidarrRequest, error) {
row := q.db.QueryRow(ctx, completeLidarrRequest,
arg.ID,
arg.MatchedTrackID,
arg.MatchedAlbumID,
arg.MatchedArtistID,
)
var i LidarrRequest
err := row.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.LidarrArtistMbid,
&i.LidarrAlbumMbid,
&i.LidarrTrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.QualityProfileID,
&i.RootFolderPath,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
)
return i, err
}
const createLidarrRequest = `-- name: CreateLidarrRequest :one
INSERT INTO lidarr_requests (
user_id, kind,
lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid,
artist_name, album_title, track_title
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at
`
type CreateLidarrRequestParams struct {
UserID pgtype.UUID
Kind LidarrRequestKind
LidarrArtistMbid string
LidarrAlbumMbid *string
LidarrTrackMbid *string
ArtistName string
AlbumTitle *string
TrackTitle *string
}
func (q *Queries) CreateLidarrRequest(ctx context.Context, arg CreateLidarrRequestParams) (LidarrRequest, error) {
row := q.db.QueryRow(ctx, createLidarrRequest,
arg.UserID,
arg.Kind,
arg.LidarrArtistMbid,
arg.LidarrAlbumMbid,
arg.LidarrTrackMbid,
arg.ArtistName,
arg.AlbumTitle,
arg.TrackTitle,
)
var i LidarrRequest
err := row.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.LidarrArtistMbid,
&i.LidarrAlbumMbid,
&i.LidarrTrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.QualityProfileID,
&i.RootFolderPath,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
)
return i, err
}
const getLidarrRequestByID = `-- name: GetLidarrRequestByID :one
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at FROM lidarr_requests WHERE id = $1
`
func (q *Queries) GetLidarrRequestByID(ctx context.Context, id pgtype.UUID) (LidarrRequest, error) {
row := q.db.QueryRow(ctx, getLidarrRequestByID, id)
var i LidarrRequest
err := row.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.LidarrArtistMbid,
&i.LidarrAlbumMbid,
&i.LidarrTrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.QualityProfileID,
&i.RootFolderPath,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
)
return i, err
}
const getNonTerminalRequestForMBID = `-- name: GetNonTerminalRequestForMBID :one
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at FROM lidarr_requests
WHERE status IN ('pending', 'approved', 'completed')
AND ((kind = 'artist' AND lidarr_artist_mbid = $1)
OR (kind = 'album' AND lidarr_album_mbid = $1)
OR (kind = 'track' AND lidarr_track_mbid = $1))
ORDER BY requested_at ASC
LIMIT 1
`
// Returns the oldest non-terminal (pending/approved/completed) request
// whose own-kind MBID matches @mbid, or pgx.ErrNoRows when none. Create()
// uses this to dedupe: a re-request for something already in flight
// returns the existing row instead of inserting a duplicate. MBIDs are
// globally unique per entity type, so the cross-column OR cannot
// false-match (an artist MBID never collides with an album/track MBID).
func (q *Queries) GetNonTerminalRequestForMBID(ctx context.Context, mbid string) (LidarrRequest, error) {
row := q.db.QueryRow(ctx, getNonTerminalRequestForMBID, mbid)
var i LidarrRequest
err := row.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.LidarrArtistMbid,
&i.LidarrAlbumMbid,
&i.LidarrTrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.QualityProfileID,
&i.RootFolderPath,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
)
return i, err
}
const hasNonTerminalRequestForMBID = `-- name: HasNonTerminalRequestForMBID :one
SELECT EXISTS (
SELECT 1 FROM lidarr_requests
WHERE status IN ('pending', 'approved', 'completed')
AND ((kind = 'artist' AND lidarr_artist_mbid = $1)
OR (kind = 'album' AND lidarr_album_mbid = $1)
OR (kind = 'track' AND lidarr_track_mbid = $1))
)
`
// Returns true if any user has a pending/approved/completed request
// whose MBID matches at the given level. Used to set the `requested`
// flag on /api/lidarr/search responses. Terminal-status (rejected,
// failed) rows do not count. The same MBID parameter is checked
// against artist/album/track columns based on kind.
func (q *Queries) HasNonTerminalRequestForMBID(ctx context.Context, mbid string) (bool, error) {
row := q.db.QueryRow(ctx, hasNonTerminalRequestForMBID, mbid)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const listApprovedLidarrRequestsForReconcile = `-- name: ListApprovedLidarrRequestsForReconcile :many
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at FROM lidarr_requests
WHERE status = 'approved'
ORDER BY decided_at ASC
LIMIT $1
`
func (q *Queries) ListApprovedLidarrRequestsForReconcile(ctx context.Context, limit int32) ([]LidarrRequest, error) {
rows, err := q.db.Query(ctx, listApprovedLidarrRequestsForReconcile, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []LidarrRequest
for rows.Next() {
var i LidarrRequest
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.LidarrArtistMbid,
&i.LidarrAlbumMbid,
&i.LidarrTrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.QualityProfileID,
&i.RootFolderPath,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listLidarrRequestsByStatus = `-- name: ListLidarrRequestsByStatus :many
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at FROM lidarr_requests
WHERE status = $1
ORDER BY requested_at DESC
LIMIT $2
`
type ListLidarrRequestsByStatusParams struct {
Status LidarrRequestStatus
Limit int32
}
func (q *Queries) ListLidarrRequestsByStatus(ctx context.Context, arg ListLidarrRequestsByStatusParams) ([]LidarrRequest, error) {
rows, err := q.db.Query(ctx, listLidarrRequestsByStatus, arg.Status, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []LidarrRequest
for rows.Next() {
var i LidarrRequest
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.LidarrArtistMbid,
&i.LidarrAlbumMbid,
&i.LidarrTrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.QualityProfileID,
&i.RootFolderPath,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listLidarrRequestsForUser = `-- name: ListLidarrRequestsForUser :many
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at FROM lidarr_requests
WHERE user_id = $1
ORDER BY requested_at DESC
LIMIT $2
`
type ListLidarrRequestsForUserParams struct {
UserID pgtype.UUID
Limit int32
}
func (q *Queries) ListLidarrRequestsForUser(ctx context.Context, arg ListLidarrRequestsForUserParams) ([]LidarrRequest, error) {
rows, err := q.db.Query(ctx, listLidarrRequestsForUser, arg.UserID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []LidarrRequest
for rows.Next() {
var i LidarrRequest
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.LidarrArtistMbid,
&i.LidarrAlbumMbid,
&i.LidarrTrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.QualityProfileID,
&i.RootFolderPath,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markLidarrRequestAddConfirmed = `-- name: MarkLidarrRequestAddConfirmed :exec
UPDATE lidarr_requests
SET lidarr_add_confirmed_at = now(),
updated_at = now()
WHERE id = $1
AND status = 'approved'
AND lidarr_add_confirmed_at IS NULL
`
// Idempotently records that Lidarr accepted the add for an approved
// request. The lidarr_add_confirmed_at IS NULL guard makes a redundant
// reconciler retry a no-op rather than bumping updated_at every tick.
func (q *Queries) MarkLidarrRequestAddConfirmed(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, markLidarrRequestAddConfirmed, id)
return err
}
const rejectLidarrRequest = `-- name: RejectLidarrRequest :one
UPDATE lidarr_requests
SET status = 'rejected',
notes = $2,
decided_at = now(),
decided_by = $3,
updated_at = now()
WHERE id = $1 AND status = 'pending'
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at
`
type RejectLidarrRequestParams struct {
ID pgtype.UUID
Notes *string
DecidedBy pgtype.UUID
}
func (q *Queries) RejectLidarrRequest(ctx context.Context, arg RejectLidarrRequestParams) (LidarrRequest, error) {
row := q.db.QueryRow(ctx, rejectLidarrRequest, arg.ID, arg.Notes, arg.DecidedBy)
var i LidarrRequest
err := row.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.LidarrArtistMbid,
&i.LidarrAlbumMbid,
&i.LidarrTrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.QualityProfileID,
&i.RootFolderPath,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
)
return i, err
}
const setLidarrRequestAlbumMbid = `-- name: SetLidarrRequestAlbumMbid :exec
UPDATE lidarr_requests
SET lidarr_album_mbid = $2,
updated_at = now()
WHERE id = $1
`
type SetLidarrRequestAlbumMbidParams struct {
ID pgtype.UUID
LidarrAlbumMbid *string
}
// #5241: repoint a request at the album's release group. Requests made before
// the release-group read named albums by MusicBrainz release id, which Lidarr
// does not index; the reconciler rewrites them once it has resolved the group.
func (q *Queries) SetLidarrRequestAlbumMbid(ctx context.Context, arg SetLidarrRequestAlbumMbidParams) error {
_, err := q.db.Exec(ctx, setLidarrRequestAlbumMbid, arg.ID, arg.LidarrAlbumMbid)
return err
}
+18 -18
View File
@@ -233,24 +233,6 @@ func (q *Queries) MergeRepointContextualLikes(ctx context.Context, arg MergeRepo
return result.RowsAffected(), nil
}
const mergeRepointLidarrRequests = `-- name: MergeRepointLidarrRequests :execrows
UPDATE lidarr_requests SET matched_track_id = $1::uuid
WHERE matched_track_id = $2::uuid
`
type MergeRepointLidarrRequestsParams struct {
SurvivorID pgtype.UUID
LoserID pgtype.UUID
}
func (q *Queries) MergeRepointLidarrRequests(ctx context.Context, arg MergeRepointLidarrRequestsParams) (int64, error) {
result, err := q.db.Exec(ctx, mergeRepointLidarrRequests, arg.SurvivorID, arg.LoserID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const mergeRepointPlayEvents = `-- name: MergeRepointPlayEvents :execrows
UPDATE play_events SET track_id = $1::uuid WHERE track_id = $2::uuid
@@ -321,6 +303,24 @@ func (q *Queries) MergeRepointPlaylistTracks(ctx context.Context, arg MergeRepoi
return items, nil
}
const mergeRepointRequests = `-- name: MergeRepointRequests :execrows
UPDATE requests SET matched_track_id = $1::uuid
WHERE matched_track_id = $2::uuid
`
type MergeRepointRequestsParams struct {
SurvivorID pgtype.UUID
LoserID pgtype.UUID
}
func (q *Queries) MergeRepointRequests(ctx context.Context, arg MergeRepointRequestsParams) (int64, error) {
result, err := q.db.Exec(ctx, mergeRepointRequests, arg.SurvivorID, arg.LoserID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const mergeRepointSkipEvents = `-- name: MergeRepointSkipEvents :execrows
UPDATE skip_events SET track_id = $1::uuid WHERE track_id = $2::uuid
`
+105 -77
View File
@@ -99,92 +99,92 @@ func (ns NullLidarrQuarantineReason) Value() (driver.Value, error) {
return string(ns.LidarrQuarantineReason), nil
}
type LidarrRequestKind string
type RequestKind string
const (
LidarrRequestKindArtist LidarrRequestKind = "artist"
LidarrRequestKindAlbum LidarrRequestKind = "album"
LidarrRequestKindTrack LidarrRequestKind = "track"
RequestKindArtist RequestKind = "artist"
RequestKindAlbum RequestKind = "album"
RequestKindTrack RequestKind = "track"
)
func (e *LidarrRequestKind) Scan(src interface{}) error {
func (e *RequestKind) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = LidarrRequestKind(s)
*e = RequestKind(s)
case string:
*e = LidarrRequestKind(s)
*e = RequestKind(s)
default:
return fmt.Errorf("unsupported scan type for LidarrRequestKind: %T", src)
return fmt.Errorf("unsupported scan type for RequestKind: %T", src)
}
return nil
}
type NullLidarrRequestKind struct {
LidarrRequestKind LidarrRequestKind
Valid bool // Valid is true if LidarrRequestKind is not NULL
type NullRequestKind struct {
RequestKind RequestKind
Valid bool // Valid is true if RequestKind is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullLidarrRequestKind) Scan(value interface{}) error {
func (ns *NullRequestKind) Scan(value interface{}) error {
if value == nil {
ns.LidarrRequestKind, ns.Valid = "", false
ns.RequestKind, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.LidarrRequestKind.Scan(value)
return ns.RequestKind.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullLidarrRequestKind) Value() (driver.Value, error) {
func (ns NullRequestKind) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.LidarrRequestKind), nil
return string(ns.RequestKind), nil
}
type LidarrRequestStatus string
type RequestStatus string
const (
LidarrRequestStatusPending LidarrRequestStatus = "pending"
LidarrRequestStatusApproved LidarrRequestStatus = "approved"
LidarrRequestStatusRejected LidarrRequestStatus = "rejected"
LidarrRequestStatusCompleted LidarrRequestStatus = "completed"
LidarrRequestStatusFailed LidarrRequestStatus = "failed"
RequestStatusPending RequestStatus = "pending"
RequestStatusApproved RequestStatus = "approved"
RequestStatusRejected RequestStatus = "rejected"
RequestStatusCompleted RequestStatus = "completed"
RequestStatusFailed RequestStatus = "failed"
)
func (e *LidarrRequestStatus) Scan(src interface{}) error {
func (e *RequestStatus) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = LidarrRequestStatus(s)
*e = RequestStatus(s)
case string:
*e = LidarrRequestStatus(s)
*e = RequestStatus(s)
default:
return fmt.Errorf("unsupported scan type for LidarrRequestStatus: %T", src)
return fmt.Errorf("unsupported scan type for RequestStatus: %T", src)
}
return nil
}
type NullLidarrRequestStatus struct {
LidarrRequestStatus LidarrRequestStatus
Valid bool // Valid is true if LidarrRequestStatus is not NULL
type NullRequestStatus struct {
RequestStatus RequestStatus
Valid bool // Valid is true if RequestStatus is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullLidarrRequestStatus) Scan(value interface{}) error {
func (ns *NullRequestStatus) Scan(value interface{}) error {
if value == nil {
ns.LidarrRequestStatus, ns.Valid = "", false
ns.RequestStatus, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.LidarrRequestStatus.Scan(value)
return ns.RequestStatus.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullLidarrRequestStatus) Value() (driver.Value, error) {
func (ns NullRequestStatus) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.LidarrRequestStatus), nil
return string(ns.RequestStatus), nil
}
type AcoustidSetting struct {
@@ -384,6 +384,24 @@ type FingerprintSetting struct {
AutoResolve bool
}
type Follow struct {
ArtistMbid string
ArtistName string
ProfileID pgtype.UUID
FollowedBy pgtype.UUID
FollowedAt pgtype.Timestamptz
BaselineAt pgtype.Timestamptz
WantExisting bool
CheckedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type FollowSeenGroup struct {
ArtistMbid string
ReleaseGroupMbid string
SeenAt pgtype.Timestamptz
}
type GeneralLike struct {
UserID pgtype.UUID
TrackID pgtype.UUID
@@ -443,31 +461,6 @@ type LidarrQuarantineAction struct {
CreatedAt pgtype.Timestamptz
}
type LidarrRequest struct {
ID pgtype.UUID
UserID pgtype.UUID
Status LidarrRequestStatus
Kind LidarrRequestKind
LidarrArtistMbid string
LidarrAlbumMbid *string
LidarrTrackMbid *string
ArtistName string
AlbumTitle *string
TrackTitle *string
QualityProfileID *int32
RootFolderPath *string
DecidedAt pgtype.Timestamptz
DecidedBy pgtype.UUID
Notes *string
CompletedAt pgtype.Timestamptz
MatchedTrackID pgtype.UUID
MatchedAlbumID pgtype.UUID
MatchedArtistID pgtype.UUID
RequestedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
LidarrAddConfirmedAt pgtype.Timestamptz
}
type ListenbrainzSimilarRecording struct {
SeedTrackID pgtype.UUID
RecordingMbid string
@@ -535,16 +528,6 @@ type MbReleaseTrack struct {
RecordingMbid string
}
type MissingReacquisition struct {
AlbumID pgtype.UUID
Attempts int32
LastAttemptAt pgtype.Timestamptz
LastRequestID pgtype.UUID
GaveUpAt pgtype.Timestamptz
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type NetworkSetting struct {
ID bool
TrustedProxyHops int32
@@ -636,14 +619,10 @@ type PlaylistTrack struct {
}
type ReacquisitionSetting struct {
ID bool
Enabled bool
GraceHours int32
BackoffBaseHours int32
BackoffMaxHours int32
MaxAttempts int32
MaxPerPass int32
AutoApprove bool
ID bool
Enabled bool
GraceHours int32
MaxPerPass int32
}
type RecommendationTuningAudit struct {
@@ -673,6 +652,38 @@ type RegistrationSetting struct {
Mode string
}
type ReleaseTypeProfile struct {
ID pgtype.UUID
Name string
PrimaryTypes []string
SecondaryTypes []string
IsDefault bool
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type Request struct {
ID pgtype.UUID
UserID pgtype.UUID
Status RequestStatus
Kind RequestKind
ArtistMbid string
AlbumMbid *string
TrackMbid *string
ArtistName string
AlbumTitle *string
TrackTitle *string
DecidedAt pgtype.Timestamptz
DecidedBy pgtype.UUID
Notes *string
CompletedAt pgtype.Timestamptz
MatchedTrackID pgtype.UUID
MatchedAlbumID pgtype.UUID
MatchedArtistID pgtype.UUID
RequestedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type ScanRun struct {
ID pgtype.UUID
StartedAt pgtype.Timestamptz
@@ -950,6 +961,23 @@ type UserNotificationPref struct {
UpdatedAt pgtype.Timestamptz
}
type Want struct {
ID pgtype.UUID
ReleaseGroupMbid string
ArtistMbid string
ArtistName string
Title string
PrimaryType string
FirstReleaseDate string
Reason string
AlbumID pgtype.UUID
CreatedBy pgtype.UUID
WantedAt pgtype.Timestamptz
FulfilledAt pgtype.Timestamptz
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type YouMightLikeAlbum struct {
UserID pgtype.UUID
AlbumID pgtype.UUID
+129 -188
View File
@@ -11,27 +11,6 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const clearRecoveredReacquisitions = `-- name: ClearRecoveredReacquisitions :execrows
DELETE FROM missing_reacquisitions r
WHERE NOT EXISTS (
SELECT 1 FROM tracks
WHERE tracks.album_id = r.album_id
AND tracks.missing_since IS NOT NULL
)
`
// Drops state for albums that no longer have any missing track — the files
// came back, or the scanner adopted them at a new path (#2528). Deleting
// rather than resetting counters means a future loss starts from a clean
// budget, which is right: it is a new problem, not a continuation.
func (q *Queries) ClearRecoveredReacquisitions(ctx context.Context) (int64, error) {
result, err := q.db.Exec(ctx, clearRecoveredReacquisitions)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const countAlbumsMissingWithoutMbid = `-- name: CountAlbumsMissingWithoutMbid :one
SELECT COUNT(DISTINCT albums.id)::bigint
FROM albums
@@ -41,10 +20,9 @@ SELECT COUNT(DISTINCT albums.id)::bigint
AND (albums.mbid IS NULL OR artists.mbid IS NULL)
`
// Albums with missing files that can never be auto-requested because nothing
// identifies them to MusicBrainz. Surfaced on the admin card so the gap is
// visible: silently doing nothing for these would read as the feature being
// broken.
// Albums with missing files that can never be wanted automatically because
// nothing identifies them to MusicBrainz. Surfaced on the admin card so the
// gap is visible: silently doing nothing for these would read as broken.
func (q *Queries) CountAlbumsMissingWithoutMbid(ctx context.Context) (int64, error) {
row := q.db.QueryRow(ctx, countAlbumsMissingWithoutMbid)
var column_1 int64
@@ -52,29 +30,56 @@ func (q *Queries) CountAlbumsMissingWithoutMbid(ctx context.Context) (int64, err
return column_1, err
}
const getReacquisitionForAlbums = `-- name: GetReacquisitionForAlbums :many
SELECT album_id, attempts, last_attempt_at, last_request_id, gave_up_at, created_at, updated_at FROM missing_reacquisitions WHERE album_id = ANY($1::uuid[])
const getReacquisitionSettings = `-- name: GetReacquisitionSettings :one
SELECT id, enabled, grace_hours, max_per_pass FROM reacquisition_settings WHERE id = true
`
// State for the admin missing-files surface, so each directory group can say
// whether a re-acquisition is in flight, waiting, or given up.
func (q *Queries) GetReacquisitionForAlbums(ctx context.Context, albumIds []pgtype.UUID) ([]MissingReacquisition, error) {
rows, err := q.db.Query(ctx, getReacquisitionForAlbums, albumIds)
// Re-acquisition of missing files (milestone #290, reworked for M502 #5520).
// The unit is the album: an album whose files went missing becomes a want for
// its release group, and the search schedule (#5521) looks for it.
func (q *Queries) GetReacquisitionSettings(ctx context.Context) (ReacquisitionSetting, error) {
row := q.db.QueryRow(ctx, getReacquisitionSettings)
var i ReacquisitionSetting
err := row.Scan(
&i.ID,
&i.Enabled,
&i.GraceHours,
&i.MaxPerPass,
)
return i, err
}
const getWantsForAlbums = `-- name: GetWantsForAlbums :many
SELECT a.id AS album_id, w.id AS want_id, w.wanted_at, w.fulfilled_at
FROM albums a
JOIN wants w ON w.album_id = a.id OR w.release_group_mbid = a.release_group_mbid
WHERE a.id = ANY($1::uuid[])
ORDER BY w.wanted_at DESC
`
type GetWantsForAlbumsRow struct {
AlbumID pgtype.UUID
WantID pgtype.UUID
WantedAt pgtype.Timestamptz
FulfilledAt pgtype.Timestamptz
}
// What the wanted list says about each album on the missing-files page.
func (q *Queries) GetWantsForAlbums(ctx context.Context, albumIds []pgtype.UUID) ([]GetWantsForAlbumsRow, error) {
rows, err := q.db.Query(ctx, getWantsForAlbums, albumIds)
if err != nil {
return nil, err
}
defer rows.Close()
var items []MissingReacquisition
var items []GetWantsForAlbumsRow
for rows.Next() {
var i MissingReacquisition
var i GetWantsForAlbumsRow
if err := rows.Scan(
&i.AlbumID,
&i.Attempts,
&i.LastAttemptAt,
&i.LastRequestID,
&i.GaveUpAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.WantID,
&i.WantedAt,
&i.FulfilledAt,
); err != nil {
return nil, err
}
@@ -86,67 +91,35 @@ func (q *Queries) GetReacquisitionForAlbums(ctx context.Context, albumIds []pgty
return items, nil
}
const getReacquisitionSettings = `-- name: GetReacquisitionSettings :one
SELECT id, enabled, grace_hours, backoff_base_hours, backoff_max_hours, max_attempts, max_per_pass, auto_approve FROM reacquisition_settings WHERE id = true
`
// Auto re-acquisition of missing files (milestone #290). The unit is the
// album: Lidarr acquires releases, and grouping collapses "40 missing files"
// into "3 albums to ask for".
func (q *Queries) GetReacquisitionSettings(ctx context.Context) (ReacquisitionSetting, error) {
row := q.db.QueryRow(ctx, getReacquisitionSettings)
var i ReacquisitionSetting
err := row.Scan(
&i.ID,
&i.Enabled,
&i.GraceHours,
&i.BackoffBaseHours,
&i.BackoffMaxHours,
&i.MaxAttempts,
&i.MaxPerPass,
&i.AutoApprove,
)
return i, err
}
const listAlbumsDueReacquisition = `-- name: ListAlbumsDueReacquisition :many
SELECT albums.id AS album_id,
albums.title AS album_title,
albums.mbid AS album_mbid,
SELECT albums.id AS album_id,
albums.title AS album_title,
albums.mbid AS album_mbid,
albums.release_group_mbid AS album_release_group_mbid,
artists.id AS artist_id,
artists.name AS artist_name,
artists.mbid AS artist_mbid,
COUNT(tracks.id)::bigint AS missing_track_count,
COALESCE(r.attempts, 0)::int AS attempts
artists.name AS artist_name,
artists.mbid AS artist_mbid,
COUNT(tracks.id)::bigint AS missing_track_count,
MAX(tracks.missing_since)::timestamptz AS last_missing_since
FROM albums
JOIN artists ON artists.id = albums.artist_id
JOIN tracks ON tracks.album_id = albums.id
LEFT JOIN missing_reacquisitions r ON r.album_id = albums.id
WHERE tracks.missing_since IS NOT NULL
AND tracks.missing_since <= now() - make_interval(hours => $1::int)
AND albums.mbid IS NOT NULL
AND artists.mbid IS NOT NULL
AND (r.gave_up_at IS NULL)
AND (
r.last_attempt_at IS NULL
OR r.last_attempt_at <= now() - make_interval(hours => LEAST(
($2::int
* POWER(2, GREATEST(COALESCE(r.attempts, 0) - 1, 0)))::int,
$3::int))
)
GROUP BY albums.id, albums.title, albums.mbid, albums.release_group_mbid,
artists.id, artists.name, artists.mbid, r.attempts, r.last_attempt_at
ORDER BY r.last_attempt_at NULLS FIRST, albums.sort_title
LIMIT $4
artists.name, artists.mbid
HAVING NOT EXISTS (
SELECT 1 FROM wants w
WHERE (w.album_id = albums.id OR w.release_group_mbid = albums.release_group_mbid)
AND (w.fulfilled_at IS NULL OR w.fulfilled_at > MAX(tracks.missing_since)))
ORDER BY MIN(tracks.missing_since), albums.sort_title
LIMIT $2
`
type ListAlbumsDueReacquisitionParams struct {
GraceHours int32
BackoffBaseHours int32
BackoffMaxHours int32
PageLimit int32
GraceHours int32
PageLimit int32
}
type ListAlbumsDueReacquisitionRow struct {
@@ -154,37 +127,24 @@ type ListAlbumsDueReacquisitionRow struct {
AlbumTitle string
AlbumMbid *string
AlbumReleaseGroupMbid *string
ArtistID pgtype.UUID
ArtistName string
ArtistMbid *string
MissingTrackCount int64
Attempts int32
LastMissingSince pgtype.Timestamptz
}
// The sweeper's selection. An album qualifies when:
//
// - it still has at least one track whose file has been missing longer than
// the grace window. Measured on missing_since, which reconcile never
// re-stamps (#2523), so it is a genuine "gone since" clock rather than
// "when we last noticed";
// - MusicBrainz can name it. Both the album and its artist MBID are
// required — Create rejects an album-kind request without them, and there
// is nothing to ask Lidarr for anyway. Albums failing this are counted
// separately (CountAlbumsMissingWithoutMbid) rather than vanishing;
// - it has not spent its attempt budget (gave_up_at IS NULL);
// - its backoff has elapsed: base * 2^(attempts-1) hours since the last
// attempt, clamped to the configured maximum. First attempt (no row, or
// last_attempt_at NULL) is always due.
//
// Oldest attempt first, never-attempted first, so a large loss drains in a
// stable order across passes instead of re-picking the same head each time.
// re-stamps (#2523), so it is a genuine "gone since" clock;
// - MusicBrainz can name it: the album and its artist have MBIDs. Albums
// failing this are counted separately (CountAlbumsMissingWithoutMbid);
// - no want already covers it: none open, and none held since its files
// went missing. A want held after the loss means the library has a full
// copy despite the missing rows (#5245), so asking again would loop.
func (q *Queries) ListAlbumsDueReacquisition(ctx context.Context, arg ListAlbumsDueReacquisitionParams) ([]ListAlbumsDueReacquisitionRow, error) {
rows, err := q.db.Query(ctx, listAlbumsDueReacquisition,
arg.GraceHours,
arg.BackoffBaseHours,
arg.BackoffMaxHours,
arg.PageLimit,
)
rows, err := q.db.Query(ctx, listAlbumsDueReacquisition, arg.GraceHours, arg.PageLimit)
if err != nil {
return nil, err
}
@@ -197,11 +157,10 @@ func (q *Queries) ListAlbumsDueReacquisition(ctx context.Context, arg ListAlbums
&i.AlbumTitle,
&i.AlbumMbid,
&i.AlbumReleaseGroupMbid,
&i.ArtistID,
&i.ArtistName,
&i.ArtistMbid,
&i.MissingTrackCount,
&i.Attempts,
&i.LastMissingSince,
); err != nil {
return nil, err
}
@@ -213,101 +172,83 @@ func (q *Queries) ListAlbumsDueReacquisition(ctx context.Context, arg ListAlbums
return items, nil
}
const markReacquisitionGaveUp = `-- name: MarkReacquisitionGaveUp :exec
UPDATE missing_reacquisitions
SET gave_up_at = now(),
updated_at = now()
WHERE album_id = $1
AND gave_up_at IS NULL
`
// Stamped when the attempt budget is spent. Stored as a timestamp rather than
// inferred from `attempts >= max_attempts` so the verdict survives an operator
// later raising the maximum, and so the admin surface can say when.
func (q *Queries) MarkReacquisitionGaveUp(ctx context.Context, albumID pgtype.UUID) error {
_, err := q.db.Exec(ctx, markReacquisitionGaveUp, albumID)
return err
}
const recordReacquisitionAttempt = `-- name: RecordReacquisitionAttempt :one
INSERT INTO missing_reacquisitions (album_id, attempts, last_attempt_at, last_request_id)
VALUES ($1, 1, now(), $2)
ON CONFLICT (album_id) DO UPDATE
SET attempts = missing_reacquisitions.attempts + 1,
last_attempt_at = now(),
last_request_id = COALESCE(EXCLUDED.last_request_id,
missing_reacquisitions.last_request_id),
updated_at = now()
RETURNING album_id, attempts, last_attempt_at, last_request_id, gave_up_at, created_at, updated_at
`
type RecordReacquisitionAttemptParams struct {
AlbumID pgtype.UUID
LastRequestID pgtype.UUID
}
// Bumps the attempt counter and stamps the clock the backoff measures from.
// Upsert because the first attempt has no row yet.
func (q *Queries) RecordReacquisitionAttempt(ctx context.Context, arg RecordReacquisitionAttemptParams) (MissingReacquisition, error) {
row := q.db.QueryRow(ctx, recordReacquisitionAttempt, arg.AlbumID, arg.LastRequestID)
var i MissingReacquisition
err := row.Scan(
&i.AlbumID,
&i.Attempts,
&i.LastAttemptAt,
&i.LastRequestID,
&i.GaveUpAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const updateReacquisitionSettings = `-- name: UpdateReacquisitionSettings :one
UPDATE reacquisition_settings
SET enabled = $1,
grace_hours = $2,
backoff_base_hours = $3,
backoff_max_hours = $4,
max_attempts = $5,
max_per_pass = $6,
auto_approve = $7
SET enabled = $1,
grace_hours = $2,
max_per_pass = $3
WHERE id = true
RETURNING id, enabled, grace_hours, backoff_base_hours, backoff_max_hours, max_attempts, max_per_pass, auto_approve
RETURNING id, enabled, grace_hours, max_per_pass
`
type UpdateReacquisitionSettingsParams struct {
Enabled bool
GraceHours int32
BackoffBaseHours int32
BackoffMaxHours int32
MaxAttempts int32
MaxPerPass int32
AutoApprove bool
Enabled bool
GraceHours int32
MaxPerPass int32
}
// Whole-row write from the admin card; the CHECKs in migration 0056 are the
// validation, so a bad value fails loudly rather than being clamped silently.
func (q *Queries) UpdateReacquisitionSettings(ctx context.Context, arg UpdateReacquisitionSettingsParams) (ReacquisitionSetting, error) {
row := q.db.QueryRow(ctx, updateReacquisitionSettings,
arg.Enabled,
arg.GraceHours,
arg.BackoffBaseHours,
arg.BackoffMaxHours,
arg.MaxAttempts,
arg.MaxPerPass,
arg.AutoApprove,
)
row := q.db.QueryRow(ctx, updateReacquisitionSettings, arg.Enabled, arg.GraceHours, arg.MaxPerPass)
var i ReacquisitionSetting
err := row.Scan(
&i.ID,
&i.Enabled,
&i.GraceHours,
&i.BackoffBaseHours,
&i.BackoffMaxHours,
&i.MaxAttempts,
&i.MaxPerPass,
&i.AutoApprove,
)
return i, err
}
const wantMissingAlbum = `-- name: WantMissingAlbum :one
INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, reason, album_id)
VALUES ($1, $2, $3,
$4, 'missing', $5)
ON CONFLICT (release_group_mbid) DO UPDATE
SET reason = 'missing',
album_id = EXCLUDED.album_id,
wanted_at = now(),
fulfilled_at = NULL,
updated_at = now()
WHERE wants.fulfilled_at IS NOT NULL
RETURNING id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at
`
type WantMissingAlbumParams struct {
ReleaseGroupMbid string
ArtistMbid string
ArtistName string
Title string
AlbumID pgtype.UUID
}
// Records an album with missing files as wanted. A held want for the same
// group is reopened; an open one is left as it is, and no row comes back.
func (q *Queries) WantMissingAlbum(ctx context.Context, arg WantMissingAlbumParams) (Want, error) {
row := q.db.QueryRow(ctx, wantMissingAlbum,
arg.ReleaseGroupMbid,
arg.ArtistMbid,
arg.ArtistName,
arg.Title,
arg.AlbumID,
)
var i Want
err := row.Scan(
&i.ID,
&i.ReleaseGroupMbid,
&i.ArtistMbid,
&i.ArtistName,
&i.Title,
&i.PrimaryType,
&i.FirstReleaseDate,
&i.Reason,
&i.AlbumID,
&i.CreatedBy,
&i.WantedAt,
&i.FulfilledAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+2 -2
View File
@@ -1142,9 +1142,9 @@ contributions AS (
JOIN seeds ON seeds.artist_id = u.seed_artist_id
WHERE NOT EXISTS (SELECT 1 FROM artists WHERE mbid = u.candidate_mbid)
AND NOT EXISTS (
SELECT 1 FROM lidarr_requests r
SELECT 1 FROM requests r
WHERE r.user_id = $1
AND r.lidarr_artist_mbid = u.candidate_mbid
AND r.artist_mbid = u.candidate_mbid
AND r.status NOT IN ('rejected', 'failed')
)
-- Snoozed by this user and not yet expired (#2374). Time-boxed and
+528
View File
@@ -0,0 +1,528 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: requests.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const approveRequest = `-- name: ApproveRequest :one
UPDATE requests
SET status = 'approved',
decided_at = now(),
decided_by = $2,
updated_at = now()
WHERE id = $1 AND status = 'pending'
RETURNING id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at
`
type ApproveRequestParams struct {
ID pgtype.UUID
DecidedBy pgtype.UUID
}
func (q *Queries) ApproveRequest(ctx context.Context, arg ApproveRequestParams) (Request, error) {
row := q.db.QueryRow(ctx, approveRequest, arg.ID, arg.DecidedBy)
var i Request
err := row.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.ArtistMbid,
&i.AlbumMbid,
&i.TrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
)
return i, err
}
const cancelRequest = `-- name: CancelRequest :one
UPDATE requests
SET status = 'rejected',
notes = 'cancelled by user',
decided_at = now(),
decided_by = $2,
updated_at = now()
WHERE id = $1 AND user_id = $2 AND status = 'pending'
RETURNING id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at
`
type CancelRequestParams struct {
ID pgtype.UUID
DecidedBy pgtype.UUID
}
// $2 serves as both the ownership guard (user_id = $2) and the
// decided_by audit field. The generated parameter struct names it
// DecidedBy — callers must pass the cancelling user's UUID, and
// a wrong UUID will silently no-op (zero rows updated) rather than
// erroring at the SQL layer.
func (q *Queries) CancelRequest(ctx context.Context, arg CancelRequestParams) (Request, error) {
row := q.db.QueryRow(ctx, cancelRequest, arg.ID, arg.DecidedBy)
var i Request
err := row.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.ArtistMbid,
&i.AlbumMbid,
&i.TrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
)
return i, err
}
const completeRequest = `-- name: CompleteRequest :one
UPDATE requests
SET status = 'completed',
matched_track_id = $2,
matched_album_id = $3,
matched_artist_id = $4,
completed_at = now(),
updated_at = now()
WHERE id = $1 AND status = 'approved'
RETURNING id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at
`
type CompleteRequestParams struct {
ID pgtype.UUID
MatchedTrackID pgtype.UUID
MatchedAlbumID pgtype.UUID
MatchedArtistID pgtype.UUID
}
// Reconciler transitions an approved request to completed when its
// target track/album/artist has appeared in the library. Callers
// should set ONLY the matched_*_id corresponding to the request's
// kind and pass pgtype.UUID{} (Valid: false) for the others —
// per-kind one-of-three is the contract, NULL for the others
// preserves auditability if the matched row is later evicted.
func (q *Queries) CompleteRequest(ctx context.Context, arg CompleteRequestParams) (Request, error) {
row := q.db.QueryRow(ctx, completeRequest,
arg.ID,
arg.MatchedTrackID,
arg.MatchedAlbumID,
arg.MatchedArtistID,
)
var i Request
err := row.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.ArtistMbid,
&i.AlbumMbid,
&i.TrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
)
return i, err
}
const createRequest = `-- name: CreateRequest :one
INSERT INTO requests (
user_id, kind,
artist_mbid, album_mbid, track_mbid,
artist_name, album_title, track_title
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at
`
type CreateRequestParams struct {
UserID pgtype.UUID
Kind RequestKind
ArtistMbid string
AlbumMbid *string
TrackMbid *string
ArtistName string
AlbumTitle *string
TrackTitle *string
}
func (q *Queries) CreateRequest(ctx context.Context, arg CreateRequestParams) (Request, error) {
row := q.db.QueryRow(ctx, createRequest,
arg.UserID,
arg.Kind,
arg.ArtistMbid,
arg.AlbumMbid,
arg.TrackMbid,
arg.ArtistName,
arg.AlbumTitle,
arg.TrackTitle,
)
var i Request
err := row.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.ArtistMbid,
&i.AlbumMbid,
&i.TrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
)
return i, err
}
const getNonTerminalRequestForMBID = `-- name: GetNonTerminalRequestForMBID :one
SELECT id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at FROM requests
WHERE status IN ('pending', 'approved', 'completed')
AND ((kind = 'artist' AND artist_mbid = $1)
OR (kind = 'album' AND album_mbid = $1)
OR (kind = 'track' AND track_mbid = $1))
ORDER BY requested_at ASC
LIMIT 1
`
// Returns the oldest non-terminal (pending/approved/completed) request
// whose own-kind MBID matches @mbid, or pgx.ErrNoRows when none. Create()
// uses this to dedupe: a re-request for something already in flight
// returns the existing row instead of inserting a duplicate.
func (q *Queries) GetNonTerminalRequestForMBID(ctx context.Context, mbid string) (Request, error) {
row := q.db.QueryRow(ctx, getNonTerminalRequestForMBID, mbid)
var i Request
err := row.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.ArtistMbid,
&i.AlbumMbid,
&i.TrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
)
return i, err
}
const getRequestByID = `-- name: GetRequestByID :one
SELECT id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at FROM requests WHERE id = $1
`
func (q *Queries) GetRequestByID(ctx context.Context, id pgtype.UUID) (Request, error) {
row := q.db.QueryRow(ctx, getRequestByID, id)
var i Request
err := row.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.ArtistMbid,
&i.AlbumMbid,
&i.TrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
)
return i, err
}
const hasNonTerminalRequestForMBID = `-- name: HasNonTerminalRequestForMBID :one
SELECT (EXISTS (
SELECT 1 FROM requests r
WHERE r.status IN ('pending', 'approved', 'completed')
AND ((r.kind = 'artist' AND r.artist_mbid = $1)
OR (r.kind = 'album' AND r.album_mbid = $1)
OR (r.kind = 'track' AND r.track_mbid = $1))
) OR EXISTS (
SELECT 1 FROM wants w WHERE w.release_group_mbid = $1 AND w.fulfilled_at IS NULL
) OR EXISTS (
SELECT 1 FROM follows f WHERE f.artist_mbid = $1
))::bool AS requested
`
// Whether something is already on its way: a pending, approved or completed
// request at the given level, an open want for the release group, or a
// follow of the artist. Sets the `requested` flag on catalogue search.
// Terminal-status (rejected, failed) requests do not count. MBIDs are unique
// per entity type, so checking one id against every column cannot
// false-match.
func (q *Queries) HasNonTerminalRequestForMBID(ctx context.Context, mbid string) (bool, error) {
row := q.db.QueryRow(ctx, hasNonTerminalRequestForMBID, mbid)
var requested bool
err := row.Scan(&requested)
return requested, err
}
const listApprovedRequestsForReconcile = `-- name: ListApprovedRequestsForReconcile :many
SELECT id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at FROM requests
WHERE status = 'approved'
ORDER BY decided_at ASC
LIMIT $1
`
func (q *Queries) ListApprovedRequestsForReconcile(ctx context.Context, limit int32) ([]Request, error) {
rows, err := q.db.Query(ctx, listApprovedRequestsForReconcile, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Request
for rows.Next() {
var i Request
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.ArtistMbid,
&i.AlbumMbid,
&i.TrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRequestsByStatus = `-- name: ListRequestsByStatus :many
SELECT id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at FROM requests
WHERE status = $1
ORDER BY requested_at DESC
LIMIT $2
`
type ListRequestsByStatusParams struct {
Status RequestStatus
Limit int32
}
func (q *Queries) ListRequestsByStatus(ctx context.Context, arg ListRequestsByStatusParams) ([]Request, error) {
rows, err := q.db.Query(ctx, listRequestsByStatus, arg.Status, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Request
for rows.Next() {
var i Request
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.ArtistMbid,
&i.AlbumMbid,
&i.TrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRequestsForUser = `-- name: ListRequestsForUser :many
SELECT id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at FROM requests
WHERE user_id = $1
ORDER BY requested_at DESC
LIMIT $2
`
type ListRequestsForUserParams struct {
UserID pgtype.UUID
Limit int32
}
func (q *Queries) ListRequestsForUser(ctx context.Context, arg ListRequestsForUserParams) ([]Request, error) {
rows, err := q.db.Query(ctx, listRequestsForUser, arg.UserID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Request
for rows.Next() {
var i Request
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.ArtistMbid,
&i.AlbumMbid,
&i.TrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const rejectRequest = `-- name: RejectRequest :one
UPDATE requests
SET status = 'rejected',
notes = $2,
decided_at = now(),
decided_by = $3,
updated_at = now()
WHERE id = $1 AND status = 'pending'
RETURNING id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at
`
type RejectRequestParams struct {
ID pgtype.UUID
Notes *string
DecidedBy pgtype.UUID
}
func (q *Queries) RejectRequest(ctx context.Context, arg RejectRequestParams) (Request, error) {
row := q.db.QueryRow(ctx, rejectRequest, arg.ID, arg.Notes, arg.DecidedBy)
var i Request
err := row.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.ArtistMbid,
&i.AlbumMbid,
&i.TrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
)
return i, err
}
const repointRequestsAlbumMbid = `-- name: RepointRequestsAlbumMbid :exec
UPDATE requests
SET album_mbid = $1,
updated_at = now()
WHERE album_mbid = $2
`
type RepointRequestsAlbumMbidParams struct {
NewMbid *string
OldMbid *string
}
// A want moved from a release id to its group (#5241) takes the requests
// that name it along, so the reconciler finds it.
func (q *Queries) RepointRequestsAlbumMbid(ctx context.Context, arg RepointRequestsAlbumMbidParams) error {
_, err := q.db.Exec(ctx, repointRequestsAlbumMbid, arg.NewMbid, arg.OldMbid)
return err
}
-37
View File
@@ -239,43 +239,6 @@ func (q *Queries) GetListenBrainzConfig(ctx context.Context, id pgtype.UUID) (Ge
return i, err
}
const getOldestAdmin = `-- name: GetOldestAdmin :one
SELECT id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash FROM users
WHERE is_admin = true
ORDER BY created_at, id
LIMIT 1
`
// The account a system-initiated action is attributed to (milestone #290).
// lidarr_requests.user_id is NOT NULL and a re-acquisition has no requesting
// human, so the row is owned by the longest-standing admin: it keeps the
// request auditable and puts it in the same admin queue as everything else,
// without inventing a synthetic principal the rest of the schema would have
// to understand. Ordered by id as a tiebreak so the choice is stable across
// calls rather than depending on scan order.
func (q *Queries) GetOldestAdmin(ctx context.Context) (User, error) {
row := q.db.QueryRow(ctx, getOldestAdmin)
var i User
err := row.Scan(
&i.ID,
&i.Username,
&i.PasswordHash,
&i.IsAdmin,
&i.CreatedAt,
&i.SubsonicPassword,
&i.ListenbrainzToken,
&i.ListenbrainzEnabled,
&i.DisplayName,
&i.AutoApproveRequests,
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
&i.ApiTokenHash,
)
return i, err
}
const getUserByAPITokenHash = `-- name: GetUserByAPITokenHash :one
SELECT id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash FROM users WHERE api_token_hash = $1
`
+839
View File
@@ -0,0 +1,839 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: wanted.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const clearDefaultReleaseTypeProfile = `-- name: ClearDefaultReleaseTypeProfile :exec
UPDATE release_type_profiles SET is_default = false, updated_at = now() WHERE is_default
`
func (q *Queries) ClearDefaultReleaseTypeProfile(ctx context.Context) error {
_, err := q.db.Exec(ctx, clearDefaultReleaseTypeProfile)
return err
}
const countWants = `-- name: CountWants :one
SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::bigint AS wanted,
COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason = 'missing')::bigint AS missing,
COUNT(*) FILTER (WHERE fulfilled_at IS NOT NULL)::bigint AS held,
(SELECT COUNT(*) FROM follows)::bigint AS following
FROM wants
`
type CountWantsRow struct {
Wanted int64
Missing int64
Held int64
Following int64
}
func (q *Queries) CountWants(ctx context.Context) (CountWantsRow, error) {
row := q.db.QueryRow(ctx, countWants)
var i CountWantsRow
err := row.Scan(
&i.Wanted,
&i.Missing,
&i.Held,
&i.Following,
)
return i, err
}
const createReleaseTypeProfile = `-- name: CreateReleaseTypeProfile :one
INSERT INTO release_type_profiles (name, primary_types, secondary_types)
VALUES ($1, $2, $3)
RETURNING id, name, primary_types, secondary_types, is_default, created_at, updated_at
`
type CreateReleaseTypeProfileParams struct {
Name string
PrimaryTypes []string
SecondaryTypes []string
}
func (q *Queries) CreateReleaseTypeProfile(ctx context.Context, arg CreateReleaseTypeProfileParams) (ReleaseTypeProfile, error) {
row := q.db.QueryRow(ctx, createReleaseTypeProfile, arg.Name, arg.PrimaryTypes, arg.SecondaryTypes)
var i ReleaseTypeProfile
err := row.Scan(
&i.ID,
&i.Name,
&i.PrimaryTypes,
&i.SecondaryTypes,
&i.IsDefault,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteFollow = `-- name: DeleteFollow :execrows
DELETE FROM follows WHERE artist_mbid = $1
`
func (q *Queries) DeleteFollow(ctx context.Context, artistMbid string) (int64, error) {
result, err := q.db.Exec(ctx, deleteFollow, artistMbid)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const deleteReleaseTypeProfile = `-- name: DeleteReleaseTypeProfile :execrows
DELETE FROM release_type_profiles WHERE id = $1 AND NOT is_default
`
// The default stays; one in use is refused by the follows foreign key.
func (q *Queries) DeleteReleaseTypeProfile(ctx context.Context, id pgtype.UUID) (int64, error) {
result, err := q.db.Exec(ctx, deleteReleaseTypeProfile, id)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const deleteWant = `-- name: DeleteWant :execrows
DELETE FROM wants WHERE id = $1
`
func (q *Queries) DeleteWant(ctx context.Context, id pgtype.UUID) (int64, error) {
result, err := q.db.Exec(ctx, deleteWant, id)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const fulfillWant = `-- name: FulfillWant :exec
UPDATE wants
SET fulfilled_at = now(),
album_id = $1,
updated_at = now()
WHERE id = $2 AND fulfilled_at IS NULL
`
type FulfillWantParams struct {
AlbumID pgtype.UUID
ID pgtype.UUID
}
func (q *Queries) FulfillWant(ctx context.Context, arg FulfillWantParams) error {
_, err := q.db.Exec(ctx, fulfillWant, arg.AlbumID, arg.ID)
return err
}
const getDefaultReleaseTypeProfile = `-- name: GetDefaultReleaseTypeProfile :one
SELECT id, name, primary_types, secondary_types, is_default, created_at, updated_at FROM release_type_profiles WHERE is_default
`
func (q *Queries) GetDefaultReleaseTypeProfile(ctx context.Context) (ReleaseTypeProfile, error) {
row := q.db.QueryRow(ctx, getDefaultReleaseTypeProfile)
var i ReleaseTypeProfile
err := row.Scan(
&i.ID,
&i.Name,
&i.PrimaryTypes,
&i.SecondaryTypes,
&i.IsDefault,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getFollow = `-- name: GetFollow :one
SELECT f.artist_mbid, f.artist_name, f.profile_id, f.followed_by, f.followed_at, f.baseline_at, f.want_existing, f.checked_at, f.updated_at, p.name AS profile_name
FROM follows f
JOIN release_type_profiles p ON p.id = f.profile_id
WHERE f.artist_mbid = $1
`
type GetFollowRow struct {
ArtistMbid string
ArtistName string
ProfileID pgtype.UUID
FollowedBy pgtype.UUID
FollowedAt pgtype.Timestamptz
BaselineAt pgtype.Timestamptz
WantExisting bool
CheckedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
ProfileName string
}
func (q *Queries) GetFollow(ctx context.Context, artistMbid string) (GetFollowRow, error) {
row := q.db.QueryRow(ctx, getFollow, artistMbid)
var i GetFollowRow
err := row.Scan(
&i.ArtistMbid,
&i.ArtistName,
&i.ProfileID,
&i.FollowedBy,
&i.FollowedAt,
&i.BaselineAt,
&i.WantExisting,
&i.CheckedAt,
&i.UpdatedAt,
&i.ProfileName,
)
return i, err
}
const getReleaseTypeProfile = `-- name: GetReleaseTypeProfile :one
SELECT id, name, primary_types, secondary_types, is_default, created_at, updated_at FROM release_type_profiles WHERE id = $1
`
func (q *Queries) GetReleaseTypeProfile(ctx context.Context, id pgtype.UUID) (ReleaseTypeProfile, error) {
row := q.db.QueryRow(ctx, getReleaseTypeProfile, id)
var i ReleaseTypeProfile
err := row.Scan(
&i.ID,
&i.Name,
&i.PrimaryTypes,
&i.SecondaryTypes,
&i.IsDefault,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getWantByReleaseGroup = `-- name: GetWantByReleaseGroup :one
SELECT id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at FROM wants WHERE release_group_mbid = $1
`
func (q *Queries) GetWantByReleaseGroup(ctx context.Context, releaseGroupMbid string) (Want, error) {
row := q.db.QueryRow(ctx, getWantByReleaseGroup, releaseGroupMbid)
var i Want
err := row.Scan(
&i.ID,
&i.ReleaseGroupMbid,
&i.ArtistMbid,
&i.ArtistName,
&i.Title,
&i.PrimaryType,
&i.FirstReleaseDate,
&i.Reason,
&i.AlbumID,
&i.CreatedBy,
&i.WantedAt,
&i.FulfilledAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const libraryHoldsReleaseGroup = `-- name: LibraryHoldsReleaseGroup :one
SELECT EXISTS (
SELECT 1 FROM albums a
WHERE (a.release_group_mbid = $1::text
OR a.mbid = $1
OR a.mbid IN (SELECT r.mbid FROM mb_releases r WHERE r.release_group_mbid = $1)
OR (lower(a.title) = lower($2::text)
AND a.artist_id IN (SELECT ar.id FROM artists ar WHERE ar.mbid = $3::text)))
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id AND t.missing_since IS NULL)
)
`
type LibraryHoldsReleaseGroupParams struct {
GroupMbid string
Title string
ArtistMbid string
}
// Whether the library already has the group: an album tagged with it or
// with one of its releases, or failing that an album of the artist's with
// the same title. Only albums with a track on disk count.
func (q *Queries) LibraryHoldsReleaseGroup(ctx context.Context, arg LibraryHoldsReleaseGroupParams) (bool, error) {
row := q.db.QueryRow(ctx, libraryHoldsReleaseGroup, arg.GroupMbid, arg.Title, arg.ArtistMbid)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const listFollowGroups = `-- name: ListFollowGroups :many
SELECT g.mbid, g.title, g.primary_type, g.secondary_types, g.first_release_date,
EXISTS (SELECT 1 FROM follow_seen_groups s
WHERE s.artist_mbid = $1 AND s.release_group_mbid = g.mbid) AS seen
FROM mb_artist_release_groups ag
JOIN mb_release_groups g ON g.mbid = ag.release_group_mbid
WHERE ag.artist_mbid = $1
ORDER BY g.first_release_date, g.title
`
type ListFollowGroupsRow struct {
Mbid string
Title string
PrimaryType string
SecondaryTypes []string
FirstReleaseDate string
Seen bool
}
// An artist's release groups from the catalogue, each with whether the
// follow has judged it before.
func (q *Queries) ListFollowGroups(ctx context.Context, artistMbid string) ([]ListFollowGroupsRow, error) {
rows, err := q.db.Query(ctx, listFollowGroups, artistMbid)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListFollowGroupsRow
for rows.Next() {
var i ListFollowGroupsRow
if err := rows.Scan(
&i.Mbid,
&i.Title,
&i.PrimaryType,
&i.SecondaryTypes,
&i.FirstReleaseDate,
&i.Seen,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listFollows = `-- name: ListFollows :many
SELECT f.artist_mbid, f.artist_name, f.profile_id, f.followed_at, f.baseline_at, f.checked_at,
p.name AS profile_name,
(SELECT a.id FROM artists a WHERE a.mbid = f.artist_mbid) AS artist_id,
(SELECT COUNT(*) FROM wants w
WHERE w.artist_mbid = f.artist_mbid AND w.fulfilled_at IS NULL)::bigint AS open_wants
FROM follows f
JOIN release_type_profiles p ON p.id = f.profile_id
ORDER BY lower(f.artist_name), f.artist_mbid
`
type ListFollowsRow struct {
ArtistMbid string
ArtistName string
ProfileID pgtype.UUID
FollowedAt pgtype.Timestamptz
BaselineAt pgtype.Timestamptz
CheckedAt pgtype.Timestamptz
ProfileName string
ArtistID pgtype.UUID
OpenWants int64
}
func (q *Queries) ListFollows(ctx context.Context) ([]ListFollowsRow, error) {
rows, err := q.db.Query(ctx, listFollows)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListFollowsRow
for rows.Next() {
var i ListFollowsRow
if err := rows.Scan(
&i.ArtistMbid,
&i.ArtistName,
&i.ProfileID,
&i.FollowedAt,
&i.BaselineAt,
&i.CheckedAt,
&i.ProfileName,
&i.ArtistID,
&i.OpenWants,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listFollowsDue = `-- name: ListFollowsDue :many
SELECT f.artist_mbid, f.artist_name, f.profile_id, f.followed_by, f.followed_at, f.baseline_at, f.want_existing, f.checked_at, f.updated_at
FROM follows f
WHERE f.baseline_at IS NULL OR f.checked_at IS NULL OR f.checked_at < $1
ORDER BY f.baseline_at NULLS FIRST, f.checked_at NULLS FIRST, f.artist_mbid
LIMIT $2
`
type ListFollowsDueParams struct {
Before pgtype.Timestamptz
MaxRows int32
}
// Follows whose discography has not been applied since @before, never-read
// first, so a new follow's baseline is not stuck behind daily re-checks.
func (q *Queries) ListFollowsDue(ctx context.Context, arg ListFollowsDueParams) ([]Follow, error) {
rows, err := q.db.Query(ctx, listFollowsDue, arg.Before, arg.MaxRows)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Follow
for rows.Next() {
var i Follow
if err := rows.Scan(
&i.ArtistMbid,
&i.ArtistName,
&i.ProfileID,
&i.FollowedBy,
&i.FollowedAt,
&i.BaselineAt,
&i.WantExisting,
&i.CheckedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listOpenWants = `-- name: ListOpenWants :many
SELECT id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at FROM wants
WHERE fulfilled_at IS NULL
ORDER BY wanted_at, id
LIMIT $1
`
func (q *Queries) ListOpenWants(ctx context.Context, limit int32) ([]Want, error) {
rows, err := q.db.Query(ctx, listOpenWants, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Want
for rows.Next() {
var i Want
if err := rows.Scan(
&i.ID,
&i.ReleaseGroupMbid,
&i.ArtistMbid,
&i.ArtistName,
&i.Title,
&i.PrimaryType,
&i.FirstReleaseDate,
&i.Reason,
&i.AlbumID,
&i.CreatedBy,
&i.WantedAt,
&i.FulfilledAt,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listReleaseTypeProfiles = `-- name: ListReleaseTypeProfiles :many
SELECT p.id, p.name, p.primary_types, p.secondary_types, p.is_default, p.created_at, p.updated_at,
(SELECT COUNT(*) FROM follows f WHERE f.profile_id = p.id)::bigint AS follow_count
FROM release_type_profiles p
ORDER BY p.is_default DESC, p.name
`
type ListReleaseTypeProfilesRow struct {
ID pgtype.UUID
Name string
PrimaryTypes []string
SecondaryTypes []string
IsDefault bool
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
FollowCount int64
}
// The wanted list (M502 #5520): release-type profiles, follows and wants.
func (q *Queries) ListReleaseTypeProfiles(ctx context.Context) ([]ListReleaseTypeProfilesRow, error) {
rows, err := q.db.Query(ctx, listReleaseTypeProfiles)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListReleaseTypeProfilesRow
for rows.Next() {
var i ListReleaseTypeProfilesRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.PrimaryTypes,
&i.SecondaryTypes,
&i.IsDefault,
&i.CreatedAt,
&i.UpdatedAt,
&i.FollowCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listWants = `-- name: ListWants :many
SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reason,
w.album_id, w.wanted_at, w.fulfilled_at,
-- A request names an album before the catalogue has read it: the
-- catalogue's type and date fill in once it has.
COALESCE(NULLIF(w.primary_type, ''), g.primary_type, '')::text AS primary_type,
COALESCE(g.secondary_types, '{}')::text[] AS secondary_types,
COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, '')::text AS first_release_date,
u.username AS requested_by,
(SELECT ar.id FROM artists ar WHERE ar.mbid = w.artist_mbid) AS library_artist_id
FROM wants w
LEFT JOIN users u ON u.id = w.created_by
LEFT JOIN mb_release_groups g ON g.mbid = w.release_group_mbid
WHERE CASE $1::text
WHEN 'held' THEN w.fulfilled_at IS NOT NULL
WHEN 'missing' THEN w.fulfilled_at IS NULL AND w.reason = 'missing'
ELSE w.fulfilled_at IS NULL AND w.reason <> 'missing'
END
ORDER BY COALESCE(w.fulfilled_at, w.wanted_at) DESC, w.id
LIMIT $3 OFFSET $2
`
type ListWantsParams struct {
State string
Skip int32
MaxRows int32
}
type ListWantsRow struct {
ID pgtype.UUID
ReleaseGroupMbid string
ArtistMbid string
ArtistName string
Title string
Reason string
AlbumID pgtype.UUID
WantedAt pgtype.Timestamptz
FulfilledAt pgtype.Timestamptz
PrimaryType string
SecondaryTypes []string
FirstReleaseDate string
RequestedBy *string
LibraryArtistID pgtype.UUID
}
// The Wanted page: open wants (all but missing ones, or only missing ones),
// or what was held most recently.
func (q *Queries) ListWants(ctx context.Context, arg ListWantsParams) ([]ListWantsRow, error) {
rows, err := q.db.Query(ctx, listWants, arg.State, arg.Skip, arg.MaxRows)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListWantsRow
for rows.Next() {
var i ListWantsRow
if err := rows.Scan(
&i.ID,
&i.ReleaseGroupMbid,
&i.ArtistMbid,
&i.ArtistName,
&i.Title,
&i.Reason,
&i.AlbumID,
&i.WantedAt,
&i.FulfilledAt,
&i.PrimaryType,
&i.SecondaryTypes,
&i.FirstReleaseDate,
&i.RequestedBy,
&i.LibraryArtistID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markFollowChecked = `-- name: MarkFollowChecked :exec
UPDATE follows
SET checked_at = now(),
baseline_at = COALESCE(baseline_at, now()),
want_existing = false,
updated_at = now()
WHERE artist_mbid = $1
`
func (q *Queries) MarkFollowChecked(ctx context.Context, artistMbid string) error {
_, err := q.db.Exec(ctx, markFollowChecked, artistMbid)
return err
}
const markGroupSeen = `-- name: MarkGroupSeen :exec
INSERT INTO follow_seen_groups (artist_mbid, release_group_mbid)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
`
type MarkGroupSeenParams struct {
ArtistMbid string
ReleaseGroupMbid string
}
func (q *Queries) MarkGroupSeen(ctx context.Context, arg MarkGroupSeenParams) error {
_, err := q.db.Exec(ctx, markGroupSeen, arg.ArtistMbid, arg.ReleaseGroupMbid)
return err
}
const repointWant = `-- name: RepointWant :exec
UPDATE wants SET release_group_mbid = $2, updated_at = now() WHERE id = $1
`
type RepointWantParams struct {
ID pgtype.UUID
ReleaseGroupMbid string
}
// A want recorded under a release id before #5241 is moved to its group.
func (q *Queries) RepointWant(ctx context.Context, arg RepointWantParams) error {
_, err := q.db.Exec(ctx, repointWant, arg.ID, arg.ReleaseGroupMbid)
return err
}
const setDefaultReleaseTypeProfile = `-- name: SetDefaultReleaseTypeProfile :execrows
UPDATE release_type_profiles SET is_default = true, updated_at = now() WHERE id = $1
`
func (q *Queries) SetDefaultReleaseTypeProfile(ctx context.Context, id pgtype.UUID) (int64, error) {
result, err := q.db.Exec(ctx, setDefaultReleaseTypeProfile, id)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const updateReleaseTypeProfile = `-- name: UpdateReleaseTypeProfile :one
UPDATE release_type_profiles
SET name = $2,
primary_types = $3,
secondary_types = $4,
updated_at = now()
WHERE id = $1
RETURNING id, name, primary_types, secondary_types, is_default, created_at, updated_at
`
type UpdateReleaseTypeProfileParams struct {
ID pgtype.UUID
Name string
PrimaryTypes []string
SecondaryTypes []string
}
func (q *Queries) UpdateReleaseTypeProfile(ctx context.Context, arg UpdateReleaseTypeProfileParams) (ReleaseTypeProfile, error) {
row := q.db.QueryRow(ctx, updateReleaseTypeProfile,
arg.ID,
arg.Name,
arg.PrimaryTypes,
arg.SecondaryTypes,
)
var i ReleaseTypeProfile
err := row.Scan(
&i.ID,
&i.Name,
&i.PrimaryTypes,
&i.SecondaryTypes,
&i.IsDefault,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const upsertFollow = `-- name: UpsertFollow :one
INSERT INTO follows (artist_mbid, artist_name, profile_id, followed_by, want_existing)
VALUES ($1, $2, $3,
$4, $5)
ON CONFLICT (artist_mbid) DO UPDATE
SET artist_name = EXCLUDED.artist_name,
profile_id = EXCLUDED.profile_id,
want_existing = follows.want_existing OR EXCLUDED.want_existing,
baseline_at = CASE WHEN EXCLUDED.want_existing THEN NULL ELSE follows.baseline_at END,
updated_at = now()
RETURNING artist_mbid, artist_name, profile_id, followed_by, followed_at, baseline_at, want_existing, checked_at, updated_at
`
type UpsertFollowParams struct {
ArtistMbid string
ArtistName string
ProfileID pgtype.UUID
FollowedBy pgtype.UUID
WantExisting bool
}
// Following an artist already followed changes its profile. want_existing
// only ever turns on here; the worker turns it off once applied, and turning
// it on again re-reads the baseline so the existing releases are judged.
func (q *Queries) UpsertFollow(ctx context.Context, arg UpsertFollowParams) (Follow, error) {
row := q.db.QueryRow(ctx, upsertFollow,
arg.ArtistMbid,
arg.ArtistName,
arg.ProfileID,
arg.FollowedBy,
arg.WantExisting,
)
var i Follow
err := row.Scan(
&i.ArtistMbid,
&i.ArtistName,
&i.ProfileID,
&i.FollowedBy,
&i.FollowedAt,
&i.BaselineAt,
&i.WantExisting,
&i.CheckedAt,
&i.UpdatedAt,
)
return i, err
}
const wantHoldings = `-- name: WantHoldings :many
SELECT a.id AS album_id,
COUNT(*) FILTER (WHERE t.missing_since IS NULL)::int AS present,
COALESCE(bool_or(t.missing_since IS NULL AND t.added_at > $1::timestamptz), false)::bool AS arrived_since,
COALESCE(bool_or(t.missing_since IS NOT NULL AND t.missing_since < $1::timestamptz), false)::bool AS missing_before,
-- Missing tracks no track on disk stands in for, by position or
-- title. A missing row whose song is back at a new path (#5245) is
-- covered; a lost bonus track is not.
COUNT(*) FILTER (WHERE t.missing_since IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM tracks p
WHERE p.album_id = a.id AND p.missing_since IS NULL
AND ((p.track_number = t.track_number AND p.disc_number IS NOT DISTINCT FROM t.disc_number)
OR lower(p.title) = lower(t.title))))::int AS uncovered_missing
FROM albums a
JOIN tracks t ON t.album_id = a.id
WHERE a.release_group_mbid = $2::text
OR a.mbid = $2
OR a.id = $3
OR a.mbid IN (SELECT r.mbid FROM mb_releases r WHERE r.release_group_mbid = $2)
GROUP BY a.id
ORDER BY present DESC, a.id
`
type WantHoldingsParams struct {
Since pgtype.Timestamptz
GroupMbid string
AlbumID pgtype.UUID
}
type WantHoldingsRow struct {
AlbumID pgtype.UUID
Present int32
ArrivedSince bool
MissingBefore bool
UncoveredMissing int32
}
// Each library album that may answer a want, with what it has on disk:
// tracks present, whether one arrived since the want, and whether a track
// missing before the want is still missing.
func (q *Queries) WantHoldings(ctx context.Context, arg WantHoldingsParams) ([]WantHoldingsRow, error) {
rows, err := q.db.Query(ctx, wantHoldings, arg.Since, arg.GroupMbid, arg.AlbumID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []WantHoldingsRow
for rows.Next() {
var i WantHoldingsRow
if err := rows.Scan(
&i.AlbumID,
&i.Present,
&i.ArrivedSince,
&i.MissingBefore,
&i.UncoveredMissing,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const wantReleaseGroup = `-- name: WantReleaseGroup :one
INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, primary_type,
first_release_date, reason, created_by)
VALUES ($1, $2, $3, $4,
$5, $6, $7, $8)
ON CONFLICT (release_group_mbid) DO UPDATE SET updated_at = wants.updated_at
RETURNING id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at
`
type WantReleaseGroupParams struct {
ReleaseGroupMbid string
ArtistMbid string
ArtistName string
Title string
PrimaryType string
FirstReleaseDate string
Reason string
CreatedBy pgtype.UUID
}
// Wants a release group, or returns the want already recorded for it. A
// held want is not reopened here: the library has it.
func (q *Queries) WantReleaseGroup(ctx context.Context, arg WantReleaseGroupParams) (Want, error) {
row := q.db.QueryRow(ctx, wantReleaseGroup,
arg.ReleaseGroupMbid,
arg.ArtistMbid,
arg.ArtistName,
arg.Title,
arg.PrimaryType,
arg.FirstReleaseDate,
arg.Reason,
arg.CreatedBy,
)
var i Want
err := row.Scan(
&i.ID,
&i.ReleaseGroupMbid,
&i.ArtistMbid,
&i.ArtistName,
&i.Title,
&i.PrimaryType,
&i.FirstReleaseDate,
&i.Reason,
&i.AlbumID,
&i.CreatedBy,
&i.WantedAt,
&i.FulfilledAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
@@ -0,0 +1,38 @@
ALTER TABLE reacquisition_settings
ADD COLUMN backoff_base_hours int NOT NULL DEFAULT 6,
ADD COLUMN backoff_max_hours int NOT NULL DEFAULT 168,
ADD COLUMN max_attempts int NOT NULL DEFAULT 3,
ADD COLUMN auto_approve boolean NOT NULL DEFAULT true;
ALTER INDEX requests_user_id_idx RENAME TO lidarr_requests_user_id_idx;
ALTER INDEX requests_status_idx RENAME TO lidarr_requests_status_idx;
ALTER INDEX requests_artist_mbid_idx RENAME TO lidarr_requests_artist_mbid_idx;
ALTER INDEX requests_album_mbid_idx RENAME TO lidarr_requests_album_mbid_idx;
ALTER TABLE requests ADD COLUMN lidarr_add_confirmed_at timestamptz;
ALTER TABLE requests ADD COLUMN root_folder_path text;
ALTER TABLE requests ADD COLUMN quality_profile_id int;
ALTER TABLE requests RENAME COLUMN track_mbid TO lidarr_track_mbid;
ALTER TABLE requests RENAME COLUMN album_mbid TO lidarr_album_mbid;
ALTER TABLE requests RENAME COLUMN artist_mbid TO lidarr_artist_mbid;
ALTER TYPE request_kind RENAME TO lidarr_request_kind;
ALTER TYPE request_status RENAME TO lidarr_request_status;
ALTER TABLE requests RENAME TO lidarr_requests;
CREATE TABLE missing_reacquisitions (
album_id uuid PRIMARY KEY REFERENCES albums (id) ON DELETE CASCADE,
attempts int NOT NULL DEFAULT 0,
last_attempt_at timestamptz,
last_request_id uuid REFERENCES lidarr_requests (id) ON DELETE SET NULL,
gave_up_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT missing_reacquisitions_attempts_nonneg CHECK (attempts >= 0)
);
CREATE INDEX missing_reacquisitions_due_idx
ON missing_reacquisitions (last_attempt_at NULLS FIRST)
WHERE gave_up_at IS NULL;
DROP TABLE wants;
DROP TABLE follow_seen_groups;
DROP TABLE follows;
DROP TABLE release_type_profiles;
+165
View File
@@ -0,0 +1,165 @@
-- 0081_wanted.up.sql — the wanted list (Scribe milestone #502, #5520).
--
-- With Lidarr gone, Minstrel keeps its own record of what it is looking for:
--
-- release_type_profiles which kinds of release a follow wants: primary
-- types (Album, EP, Single…) and the secondary types
-- allowed (Live, Compilation, Remix…). An empty
-- secondary list means studio releases only, which
-- is how live recordings are left out.
-- follows artists whose new releases are wanted, each on a
-- profile.
-- follow_seen_groups release groups a follow has already judged, so
-- each is judged once.
-- wants release groups wanted: a request, a followed
-- artist's new release, or an album whose files went
-- missing. A want is held once the library has a
-- full copy (internal/wanted).
--
-- lidarr_requests becomes requests with no Lidarr columns: approving a request
-- now records a want or a follow instead of sending an add to Lidarr. The
-- missing-file sweeper's attempt bookkeeping goes too; retrying belongs to the
-- search schedule (#5521), which works per want.
CREATE TABLE release_type_profiles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL UNIQUE,
primary_types text[] NOT NULL,
secondary_types text[] NOT NULL DEFAULT '{}',
is_default boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT release_type_profiles_primary_nonempty CHECK (cardinality(primary_types) > 0)
);
-- At most one default; the service keeps exactly one by moving it.
CREATE UNIQUE INDEX release_type_profiles_one_default
ON release_type_profiles (is_default) WHERE is_default;
-- Lidarr's "Standard" metadata profile, which every followed artist used
-- (note #5527): albums, EPs and singles, studio releases only.
INSERT INTO release_type_profiles (name, primary_types, secondary_types, is_default)
VALUES ('Standard', ARRAY['Album', 'EP', 'Single'], '{}', true);
CREATE TABLE follows (
artist_mbid text PRIMARY KEY,
artist_name text NOT NULL,
-- RESTRICT: a profile in use cannot be deleted out from under a follow.
profile_id uuid NOT NULL REFERENCES release_type_profiles (id) ON DELETE RESTRICT,
followed_by uuid REFERENCES users (id) ON DELETE SET NULL,
followed_at timestamptz NOT NULL DEFAULT now(),
-- When the first discography read marked everything then listed as seen.
-- NULL until then: a follow wants only what appears after it.
baseline_at timestamptz,
-- Whether the baseline also wants what the artist had already released
-- that the profile passes and the library lacks: an approved artist
-- request, or the operator ticking "also want…". Cleared once applied.
want_existing boolean NOT NULL DEFAULT false,
-- The discography read this follow last applied.
checked_at timestamptz,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX follows_profile_idx ON follows (profile_id);
CREATE TABLE follow_seen_groups (
artist_mbid text NOT NULL REFERENCES follows (artist_mbid) ON DELETE CASCADE,
release_group_mbid text NOT NULL,
seen_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (artist_mbid, release_group_mbid)
);
CREATE TABLE wants (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
release_group_mbid text NOT NULL UNIQUE,
artist_mbid text NOT NULL,
artist_name text NOT NULL,
title text NOT NULL,
primary_type text NOT NULL DEFAULT '',
first_release_date text NOT NULL DEFAULT '',
reason text NOT NULL
CONSTRAINT wants_reason_check CHECK (reason IN ('request', 'follow', 'missing')),
-- The library album this want is about (a missing want), or the one that
-- answered it.
album_id uuid REFERENCES albums (id) ON DELETE SET NULL,
created_by uuid REFERENCES users (id) ON DELETE SET NULL,
-- When it was last wanted: set again when a held want is reopened, so
-- "arrived since it was wanted" measures from the reopening.
wanted_at timestamptz NOT NULL DEFAULT now(),
fulfilled_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX wants_open_idx ON wants (wanted_at) WHERE fulfilled_at IS NULL;
CREATE INDEX wants_artist_idx ON wants (artist_mbid);
-- Requests lose Lidarr.
DROP TABLE missing_reacquisitions;
ALTER TABLE lidarr_requests RENAME TO requests;
ALTER TYPE lidarr_request_status RENAME TO request_status;
ALTER TYPE lidarr_request_kind RENAME TO request_kind;
ALTER TABLE requests RENAME COLUMN lidarr_artist_mbid TO artist_mbid;
ALTER TABLE requests RENAME COLUMN lidarr_album_mbid TO album_mbid;
ALTER TABLE requests RENAME COLUMN lidarr_track_mbid TO track_mbid;
ALTER TABLE requests DROP COLUMN quality_profile_id;
ALTER TABLE requests DROP COLUMN root_folder_path;
ALTER TABLE requests DROP COLUMN lidarr_add_confirmed_at;
ALTER INDEX lidarr_requests_user_id_idx RENAME TO requests_user_id_idx;
ALTER INDEX lidarr_requests_status_idx RENAME TO requests_status_idx;
ALTER INDEX lidarr_requests_artist_mbid_idx RENAME TO requests_artist_mbid_idx;
ALTER INDEX lidarr_requests_album_mbid_idx RENAME TO requests_album_mbid_idx;
-- Requests made before #5241 name albums by release id. A want is keyed by
-- release group, so repoint every request the library can translate.
UPDATE requests r
SET album_mbid = a.release_group_mbid,
updated_at = now()
FROM albums a
WHERE a.mbid = r.album_mbid
AND a.release_group_mbid IS NOT NULL
AND a.release_group_mbid <> r.album_mbid;
-- Approved album and track requests still waiting become wants, judged by
-- the same test as any other (internal/wanted).
INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, reason, created_by, wanted_at)
SELECT DISTINCT ON (r.album_mbid)
r.album_mbid, r.artist_mbid, r.artist_name, COALESCE(r.album_title, ''), 'request', r.user_id, r.requested_at
FROM requests r
WHERE r.status = 'approved'
AND r.kind IN ('album', 'track')
AND r.album_mbid IS NOT NULL
ORDER BY r.album_mbid, r.requested_at
ON CONFLICT (release_group_mbid) DO NOTHING;
-- The library's album artists start out followed on the default profile
-- (operator, 2026-10-09: "we already have artists in minstrel those start as
-- the wanted artists"). Various Artists and MusicBrainz's bracketed
-- placeholders ([unknown], [anonymous]…) are not artists anyone follows.
INSERT INTO follows (artist_mbid, artist_name, profile_id)
SELECT ar.mbid, ar.name, p.id
FROM artists ar
CROSS JOIN (SELECT id FROM release_type_profiles WHERE is_default) p
WHERE ar.mbid IS NOT NULL AND ar.mbid <> ''
AND ar.mbid <> '89ad4ac3-39f7-470e-963a-56509c546377'
AND ar.name NOT LIKE '[%]'
AND EXISTS (SELECT 1 FROM albums al WHERE al.artist_id = ar.id)
ON CONFLICT (artist_mbid) DO NOTHING;
-- An approved artist request was an artist added to Lidarr: it is followed.
INSERT INTO follows (artist_mbid, artist_name, profile_id, followed_by)
SELECT DISTINCT ON (r.artist_mbid) r.artist_mbid, r.artist_name, p.id, r.decided_by
FROM requests r
CROSS JOIN (SELECT id FROM release_type_profiles WHERE is_default) p
WHERE r.status = 'approved' AND r.kind = 'artist'
ORDER BY r.artist_mbid, r.requested_at
ON CONFLICT (artist_mbid) DO NOTHING;
-- The sweeper now only records a want; how often to look again is the
-- search schedule's business.
ALTER TABLE reacquisition_settings
DROP COLUMN backoff_base_hours,
DROP COLUMN backoff_max_hours,
DROP COLUMN max_attempts,
DROP COLUMN auto_approve;
-130
View File
@@ -1,130 +0,0 @@
-- name: CreateLidarrRequest :one
INSERT INTO lidarr_requests (
user_id, kind,
lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid,
artist_name, album_title, track_title
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *;
-- name: GetLidarrRequestByID :one
SELECT * FROM lidarr_requests WHERE id = $1;
-- name: ListLidarrRequestsForUser :many
SELECT * FROM lidarr_requests
WHERE user_id = $1
ORDER BY requested_at DESC
LIMIT $2;
-- name: ListLidarrRequestsByStatus :many
SELECT * FROM lidarr_requests
WHERE status = $1
ORDER BY requested_at DESC
LIMIT $2;
-- name: ListApprovedLidarrRequestsForReconcile :many
SELECT * FROM lidarr_requests
WHERE status = 'approved'
ORDER BY decided_at ASC
LIMIT $1;
-- name: ApproveLidarrRequest :one
UPDATE lidarr_requests
SET status = 'approved',
quality_profile_id = $2,
root_folder_path = $3,
decided_at = now(),
decided_by = $4,
updated_at = now()
WHERE id = $1 AND status = 'pending'
RETURNING *;
-- name: RejectLidarrRequest :one
UPDATE lidarr_requests
SET status = 'rejected',
notes = $2,
decided_at = now(),
decided_by = $3,
updated_at = now()
WHERE id = $1 AND status = 'pending'
RETURNING *;
-- name: CancelLidarrRequest :one
-- $2 serves as both the ownership guard (user_id = $2) and the
-- decided_by audit field. The generated parameter struct names it
-- DecidedBy — callers must pass the cancelling user's UUID, and
-- a wrong UUID will silently no-op (zero rows updated) rather than
-- erroring at the SQL layer.
UPDATE lidarr_requests
SET status = 'rejected',
notes = 'cancelled by user',
decided_at = now(),
decided_by = $2,
updated_at = now()
WHERE id = $1 AND user_id = $2 AND status = 'pending'
RETURNING *;
-- name: CompleteLidarrRequest :one
-- Reconciler transitions an approved request to completed when its
-- target track/album/artist has appeared in the library. Callers
-- should set ONLY the matched_*_id corresponding to the request's
-- kind and pass pgtype.UUID{} (Valid: false) for the others —
-- per-kind one-of-three is the contract, NULL for the others
-- preserves auditability if the matched row is later evicted.
UPDATE lidarr_requests
SET status = 'completed',
matched_track_id = $2,
matched_album_id = $3,
matched_artist_id = $4,
completed_at = now(),
updated_at = now()
WHERE id = $1 AND status = 'approved'
RETURNING *;
-- name: HasNonTerminalRequestForMBID :one
-- Returns true if any user has a pending/approved/completed request
-- whose MBID matches at the given level. Used to set the `requested`
-- flag on /api/lidarr/search responses. Terminal-status (rejected,
-- failed) rows do not count. The same MBID parameter is checked
-- against artist/album/track columns based on kind.
SELECT EXISTS (
SELECT 1 FROM lidarr_requests
WHERE status IN ('pending', 'approved', 'completed')
AND ((kind = 'artist' AND lidarr_artist_mbid = @mbid)
OR (kind = 'album' AND lidarr_album_mbid = @mbid)
OR (kind = 'track' AND lidarr_track_mbid = @mbid))
);
-- name: GetNonTerminalRequestForMBID :one
-- Returns the oldest non-terminal (pending/approved/completed) request
-- whose own-kind MBID matches @mbid, or pgx.ErrNoRows when none. Create()
-- uses this to dedupe: a re-request for something already in flight
-- returns the existing row instead of inserting a duplicate. MBIDs are
-- globally unique per entity type, so the cross-column OR cannot
-- false-match (an artist MBID never collides with an album/track MBID).
SELECT * FROM lidarr_requests
WHERE status IN ('pending', 'approved', 'completed')
AND ((kind = 'artist' AND lidarr_artist_mbid = @mbid)
OR (kind = 'album' AND lidarr_album_mbid = @mbid)
OR (kind = 'track' AND lidarr_track_mbid = @mbid))
ORDER BY requested_at ASC
LIMIT 1;
-- name: MarkLidarrRequestAddConfirmed :exec
-- Idempotently records that Lidarr accepted the add for an approved
-- request. The lidarr_add_confirmed_at IS NULL guard makes a redundant
-- reconciler retry a no-op rather than bumping updated_at every tick.
UPDATE lidarr_requests
SET lidarr_add_confirmed_at = now(),
updated_at = now()
WHERE id = $1
AND status = 'approved'
AND lidarr_add_confirmed_at IS NULL;
-- name: SetLidarrRequestAlbumMbid :exec
-- #5241: repoint a request at the album's release group. Requests made before
-- the release-group read named albums by MusicBrainz release id, which Lidarr
-- does not index; the reconciler rewrites them once it has resolved the group.
UPDATE lidarr_requests
SET lidarr_album_mbid = $2,
updated_at = now()
WHERE id = $1;
+2 -2
View File
@@ -35,8 +35,8 @@ UPDATE contextual_likes SET track_id = sqlc.arg(survivor_id)::uuid WHERE track_i
-- name: MergeRepointPlaybackErrors :execrows
UPDATE playback_errors SET track_id = sqlc.arg(survivor_id)::uuid WHERE track_id = sqlc.arg(loser_id)::uuid;
-- name: MergeRepointLidarrRequests :execrows
UPDATE lidarr_requests SET matched_track_id = sqlc.arg(survivor_id)::uuid
-- name: MergeRepointRequests :execrows
UPDATE requests SET matched_track_id = sqlc.arg(survivor_id)::uuid
WHERE matched_track_id = sqlc.arg(loser_id)::uuid;
-- name: MergeRepointPlaylistTracks :many
+48 -83
View File
@@ -1,6 +1,6 @@
-- Auto re-acquisition of missing files (milestone #290). The unit is the
-- album: Lidarr acquires releases, and grouping collapses "40 missing files"
-- into "3 albums to ask for".
-- Re-acquisition of missing files (milestone #290, reworked for M502 #5520).
-- The unit is the album: an album whose files went missing becomes a want for
-- its release group, and the search schedule (#5521) looks for it.
-- name: GetReacquisitionSettings :one
SELECT * FROM reacquisition_settings WHERE id = true;
@@ -9,13 +9,9 @@ SELECT * FROM reacquisition_settings WHERE id = true;
-- Whole-row write from the admin card; the CHECKs in migration 0056 are the
-- validation, so a bad value fails loudly rather than being clamped silently.
UPDATE reacquisition_settings
SET enabled = sqlc.arg(enabled),
grace_hours = sqlc.arg(grace_hours),
backoff_base_hours = sqlc.arg(backoff_base_hours),
backoff_max_hours = sqlc.arg(backoff_max_hours),
max_attempts = sqlc.arg(max_attempts),
max_per_pass = sqlc.arg(max_per_pass),
auto_approve = sqlc.arg(auto_approve)
SET enabled = sqlc.arg(enabled),
grace_hours = sqlc.arg(grace_hours),
max_per_pass = sqlc.arg(max_per_pass)
WHERE id = true
RETURNING *;
@@ -24,54 +20,40 @@ RETURNING *;
--
-- * it still has at least one track whose file has been missing longer than
-- the grace window. Measured on missing_since, which reconcile never
-- re-stamps (#2523), so it is a genuine "gone since" clock rather than
-- "when we last noticed";
-- * MusicBrainz can name it. Both the album and its artist MBID are
-- required — Create rejects an album-kind request without them, and there
-- is nothing to ask Lidarr for anyway. Albums failing this are counted
-- separately (CountAlbumsMissingWithoutMbid) rather than vanishing;
-- * it has not spent its attempt budget (gave_up_at IS NULL);
-- * its backoff has elapsed: base * 2^(attempts-1) hours since the last
-- attempt, clamped to the configured maximum. First attempt (no row, or
-- last_attempt_at NULL) is always due.
--
-- Oldest attempt first, never-attempted first, so a large loss drains in a
-- stable order across passes instead of re-picking the same head each time.
SELECT albums.id AS album_id,
albums.title AS album_title,
albums.mbid AS album_mbid,
-- re-stamps (#2523), so it is a genuine "gone since" clock;
-- * MusicBrainz can name it: the album and its artist have MBIDs. Albums
-- failing this are counted separately (CountAlbumsMissingWithoutMbid);
-- * no want already covers it: none open, and none held since its files
-- went missing. A want held after the loss means the library has a full
-- copy despite the missing rows (#5245), so asking again would loop.
SELECT albums.id AS album_id,
albums.title AS album_title,
albums.mbid AS album_mbid,
albums.release_group_mbid AS album_release_group_mbid,
artists.id AS artist_id,
artists.name AS artist_name,
artists.mbid AS artist_mbid,
COUNT(tracks.id)::bigint AS missing_track_count,
COALESCE(r.attempts, 0)::int AS attempts
artists.name AS artist_name,
artists.mbid AS artist_mbid,
COUNT(tracks.id)::bigint AS missing_track_count,
MAX(tracks.missing_since)::timestamptz AS last_missing_since
FROM albums
JOIN artists ON artists.id = albums.artist_id
JOIN tracks ON tracks.album_id = albums.id
LEFT JOIN missing_reacquisitions r ON r.album_id = albums.id
WHERE tracks.missing_since IS NOT NULL
AND tracks.missing_since <= now() - make_interval(hours => sqlc.arg(grace_hours)::int)
AND albums.mbid IS NOT NULL
AND artists.mbid IS NOT NULL
AND (r.gave_up_at IS NULL)
AND (
r.last_attempt_at IS NULL
OR r.last_attempt_at <= now() - make_interval(hours => LEAST(
(sqlc.arg(backoff_base_hours)::int
* POWER(2, GREATEST(COALESCE(r.attempts, 0) - 1, 0)))::int,
sqlc.arg(backoff_max_hours)::int))
)
GROUP BY albums.id, albums.title, albums.mbid, albums.release_group_mbid,
artists.id, artists.name, artists.mbid, r.attempts, r.last_attempt_at
ORDER BY r.last_attempt_at NULLS FIRST, albums.sort_title
artists.name, artists.mbid
HAVING NOT EXISTS (
SELECT 1 FROM wants w
WHERE (w.album_id = albums.id OR w.release_group_mbid = albums.release_group_mbid)
AND (w.fulfilled_at IS NULL OR w.fulfilled_at > MAX(tracks.missing_since)))
ORDER BY MIN(tracks.missing_since), albums.sort_title
LIMIT sqlc.arg(page_limit);
-- name: CountAlbumsMissingWithoutMbid :one
-- Albums with missing files that can never be auto-requested because nothing
-- identifies them to MusicBrainz. Surfaced on the admin card so the gap is
-- visible: silently doing nothing for these would read as the feature being
-- broken.
-- Albums with missing files that can never be wanted automatically because
-- nothing identifies them to MusicBrainz. Surfaced on the admin card so the
-- gap is visible: silently doing nothing for these would read as broken.
SELECT COUNT(DISTINCT albums.id)::bigint
FROM albums
JOIN artists ON artists.id = albums.artist_id
@@ -79,42 +61,25 @@ SELECT COUNT(DISTINCT albums.id)::bigint
WHERE tracks.missing_since IS NOT NULL
AND (albums.mbid IS NULL OR artists.mbid IS NULL);
-- name: RecordReacquisitionAttempt :one
-- Bumps the attempt counter and stamps the clock the backoff measures from.
-- Upsert because the first attempt has no row yet.
INSERT INTO missing_reacquisitions (album_id, attempts, last_attempt_at, last_request_id)
VALUES (sqlc.arg(album_id), 1, now(), sqlc.narg(last_request_id))
ON CONFLICT (album_id) DO UPDATE
SET attempts = missing_reacquisitions.attempts + 1,
last_attempt_at = now(),
last_request_id = COALESCE(EXCLUDED.last_request_id,
missing_reacquisitions.last_request_id),
updated_at = now()
-- name: WantMissingAlbum :one
-- Records an album with missing files as wanted. A held want for the same
-- group is reopened; an open one is left as it is, and no row comes back.
INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, reason, album_id)
VALUES (sqlc.arg(release_group_mbid), sqlc.arg(artist_mbid), sqlc.arg(artist_name),
sqlc.arg(title), 'missing', sqlc.arg(album_id))
ON CONFLICT (release_group_mbid) DO UPDATE
SET reason = 'missing',
album_id = EXCLUDED.album_id,
wanted_at = now(),
fulfilled_at = NULL,
updated_at = now()
WHERE wants.fulfilled_at IS NOT NULL
RETURNING *;
-- name: MarkReacquisitionGaveUp :exec
-- Stamped when the attempt budget is spent. Stored as a timestamp rather than
-- inferred from `attempts >= max_attempts` so the verdict survives an operator
-- later raising the maximum, and so the admin surface can say when.
UPDATE missing_reacquisitions
SET gave_up_at = now(),
updated_at = now()
WHERE album_id = sqlc.arg(album_id)
AND gave_up_at IS NULL;
-- name: ClearRecoveredReacquisitions :execrows
-- Drops state for albums that no longer have any missing track — the files
-- came back, or the scanner adopted them at a new path (#2528). Deleting
-- rather than resetting counters means a future loss starts from a clean
-- budget, which is right: it is a new problem, not a continuation.
DELETE FROM missing_reacquisitions r
WHERE NOT EXISTS (
SELECT 1 FROM tracks
WHERE tracks.album_id = r.album_id
AND tracks.missing_since IS NOT NULL
);
-- name: GetReacquisitionForAlbums :many
-- State for the admin missing-files surface, so each directory group can say
-- whether a re-acquisition is in flight, waiting, or given up.
SELECT * FROM missing_reacquisitions WHERE album_id = ANY(sqlc.arg(album_ids)::uuid[]);
-- name: GetWantsForAlbums :many
-- What the wanted list says about each album on the missing-files page.
SELECT a.id AS album_id, w.id AS want_id, w.wanted_at, w.fulfilled_at
FROM albums a
JOIN wants w ON w.album_id = a.id OR w.release_group_mbid = a.release_group_mbid
WHERE a.id = ANY(sqlc.arg(album_ids)::uuid[])
ORDER BY w.wanted_at DESC;
+2 -2
View File
@@ -362,9 +362,9 @@ contributions AS (
JOIN seeds ON seeds.artist_id = u.seed_artist_id
WHERE NOT EXISTS (SELECT 1 FROM artists WHERE mbid = u.candidate_mbid)
AND NOT EXISTS (
SELECT 1 FROM lidarr_requests r
SELECT 1 FROM requests r
WHERE r.user_id = $1
AND r.lidarr_artist_mbid = u.candidate_mbid
AND r.artist_mbid = u.candidate_mbid
AND r.status NOT IN ('rejected', 'failed')
)
-- Snoozed by this user and not yet expired (#2374). Time-boxed and
+119
View File
@@ -0,0 +1,119 @@
-- name: CreateRequest :one
INSERT INTO requests (
user_id, kind,
artist_mbid, album_mbid, track_mbid,
artist_name, album_title, track_title
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *;
-- name: GetRequestByID :one
SELECT * FROM requests WHERE id = $1;
-- name: ListRequestsForUser :many
SELECT * FROM requests
WHERE user_id = $1
ORDER BY requested_at DESC
LIMIT $2;
-- name: ListRequestsByStatus :many
SELECT * FROM requests
WHERE status = $1
ORDER BY requested_at DESC
LIMIT $2;
-- name: ListApprovedRequestsForReconcile :many
SELECT * FROM requests
WHERE status = 'approved'
ORDER BY decided_at ASC
LIMIT $1;
-- name: ApproveRequest :one
UPDATE requests
SET status = 'approved',
decided_at = now(),
decided_by = $2,
updated_at = now()
WHERE id = $1 AND status = 'pending'
RETURNING *;
-- name: RejectRequest :one
UPDATE requests
SET status = 'rejected',
notes = $2,
decided_at = now(),
decided_by = $3,
updated_at = now()
WHERE id = $1 AND status = 'pending'
RETURNING *;
-- name: CancelRequest :one
-- $2 serves as both the ownership guard (user_id = $2) and the
-- decided_by audit field. The generated parameter struct names it
-- DecidedBy — callers must pass the cancelling user's UUID, and
-- a wrong UUID will silently no-op (zero rows updated) rather than
-- erroring at the SQL layer.
UPDATE requests
SET status = 'rejected',
notes = 'cancelled by user',
decided_at = now(),
decided_by = $2,
updated_at = now()
WHERE id = $1 AND user_id = $2 AND status = 'pending'
RETURNING *;
-- name: CompleteRequest :one
-- Reconciler transitions an approved request to completed when its
-- target track/album/artist has appeared in the library. Callers
-- should set ONLY the matched_*_id corresponding to the request's
-- kind and pass pgtype.UUID{} (Valid: false) for the others —
-- per-kind one-of-three is the contract, NULL for the others
-- preserves auditability if the matched row is later evicted.
UPDATE requests
SET status = 'completed',
matched_track_id = $2,
matched_album_id = $3,
matched_artist_id = $4,
completed_at = now(),
updated_at = now()
WHERE id = $1 AND status = 'approved'
RETURNING *;
-- name: HasNonTerminalRequestForMBID :one
-- Whether something is already on its way: a pending, approved or completed
-- request at the given level, an open want for the release group, or a
-- follow of the artist. Sets the `requested` flag on catalogue search.
-- Terminal-status (rejected, failed) requests do not count. MBIDs are unique
-- per entity type, so checking one id against every column cannot
-- false-match.
SELECT (EXISTS (
SELECT 1 FROM requests r
WHERE r.status IN ('pending', 'approved', 'completed')
AND ((r.kind = 'artist' AND r.artist_mbid = @mbid)
OR (r.kind = 'album' AND r.album_mbid = @mbid)
OR (r.kind = 'track' AND r.track_mbid = @mbid))
) OR EXISTS (
SELECT 1 FROM wants w WHERE w.release_group_mbid = @mbid AND w.fulfilled_at IS NULL
) OR EXISTS (
SELECT 1 FROM follows f WHERE f.artist_mbid = @mbid
))::bool AS requested;
-- name: GetNonTerminalRequestForMBID :one
-- Returns the oldest non-terminal (pending/approved/completed) request
-- whose own-kind MBID matches @mbid, or pgx.ErrNoRows when none. Create()
-- uses this to dedupe: a re-request for something already in flight
-- returns the existing row instead of inserting a duplicate.
SELECT * FROM requests
WHERE status IN ('pending', 'approved', 'completed')
AND ((kind = 'artist' AND artist_mbid = @mbid)
OR (kind = 'album' AND album_mbid = @mbid)
OR (kind = 'track' AND track_mbid = @mbid))
ORDER BY requested_at ASC
LIMIT 1;
-- name: RepointRequestsAlbumMbid :exec
-- A want moved from a release id to its group (#5241) takes the requests
-- that name it along, so the reconciler finds it.
UPDATE requests
SET album_mbid = sqlc.arg(new_mbid),
updated_at = now()
WHERE album_mbid = sqlc.arg(old_mbid);
-13
View File
@@ -174,16 +174,3 @@ SELECT u.id, u.timezone FROM users u
WHERE pe.user_id = u.id
AND pe.started_at > now() - INTERVAL '7 days'
);
-- name: GetOldestAdmin :one
-- The account a system-initiated action is attributed to (milestone #290).
-- lidarr_requests.user_id is NOT NULL and a re-acquisition has no requesting
-- human, so the row is owned by the longest-standing admin: it keeps the
-- request auditable and puts it in the same admin queue as everything else,
-- without inventing a synthetic principal the rest of the schema would have
-- to understand. Ordered by id as a tiebreak so the choice is stable across
-- calls rather than depending on scan order.
SELECT * FROM users
WHERE is_admin = true
ORDER BY created_at, id
LIMIT 1;
+206
View File
@@ -0,0 +1,206 @@
-- The wanted list (M502 #5520): release-type profiles, follows and wants.
-- name: ListReleaseTypeProfiles :many
SELECT p.*,
(SELECT COUNT(*) FROM follows f WHERE f.profile_id = p.id)::bigint AS follow_count
FROM release_type_profiles p
ORDER BY p.is_default DESC, p.name;
-- name: GetReleaseTypeProfile :one
SELECT * FROM release_type_profiles WHERE id = $1;
-- name: GetDefaultReleaseTypeProfile :one
SELECT * FROM release_type_profiles WHERE is_default;
-- name: CreateReleaseTypeProfile :one
INSERT INTO release_type_profiles (name, primary_types, secondary_types)
VALUES ($1, $2, $3)
RETURNING *;
-- name: UpdateReleaseTypeProfile :one
UPDATE release_type_profiles
SET name = $2,
primary_types = $3,
secondary_types = $4,
updated_at = now()
WHERE id = $1
RETURNING *;
-- name: ClearDefaultReleaseTypeProfile :exec
UPDATE release_type_profiles SET is_default = false, updated_at = now() WHERE is_default;
-- name: SetDefaultReleaseTypeProfile :execrows
UPDATE release_type_profiles SET is_default = true, updated_at = now() WHERE id = $1;
-- name: DeleteReleaseTypeProfile :execrows
-- The default stays; one in use is refused by the follows foreign key.
DELETE FROM release_type_profiles WHERE id = $1 AND NOT is_default;
-- name: UpsertFollow :one
-- Following an artist already followed changes its profile. want_existing
-- only ever turns on here; the worker turns it off once applied, and turning
-- it on again re-reads the baseline so the existing releases are judged.
INSERT INTO follows (artist_mbid, artist_name, profile_id, followed_by, want_existing)
VALUES (sqlc.arg(artist_mbid), sqlc.arg(artist_name), sqlc.arg(profile_id),
sqlc.narg(followed_by), sqlc.arg(want_existing))
ON CONFLICT (artist_mbid) DO UPDATE
SET artist_name = EXCLUDED.artist_name,
profile_id = EXCLUDED.profile_id,
want_existing = follows.want_existing OR EXCLUDED.want_existing,
baseline_at = CASE WHEN EXCLUDED.want_existing THEN NULL ELSE follows.baseline_at END,
updated_at = now()
RETURNING *;
-- name: GetFollow :one
SELECT f.*, p.name AS profile_name
FROM follows f
JOIN release_type_profiles p ON p.id = f.profile_id
WHERE f.artist_mbid = $1;
-- name: DeleteFollow :execrows
DELETE FROM follows WHERE artist_mbid = $1;
-- name: ListFollows :many
SELECT f.artist_mbid, f.artist_name, f.profile_id, f.followed_at, f.baseline_at, f.checked_at,
p.name AS profile_name,
(SELECT a.id FROM artists a WHERE a.mbid = f.artist_mbid) AS artist_id,
(SELECT COUNT(*) FROM wants w
WHERE w.artist_mbid = f.artist_mbid AND w.fulfilled_at IS NULL)::bigint AS open_wants
FROM follows f
JOIN release_type_profiles p ON p.id = f.profile_id
ORDER BY lower(f.artist_name), f.artist_mbid;
-- name: ListFollowsDue :many
-- Follows whose discography has not been applied since @before, never-read
-- first, so a new follow's baseline is not stuck behind daily re-checks.
SELECT f.*
FROM follows f
WHERE f.baseline_at IS NULL OR f.checked_at IS NULL OR f.checked_at < sqlc.arg(before)
ORDER BY f.baseline_at NULLS FIRST, f.checked_at NULLS FIRST, f.artist_mbid
LIMIT sqlc.arg(max_rows);
-- name: MarkFollowChecked :exec
UPDATE follows
SET checked_at = now(),
baseline_at = COALESCE(baseline_at, now()),
want_existing = false,
updated_at = now()
WHERE artist_mbid = $1;
-- name: ListFollowGroups :many
-- An artist's release groups from the catalogue, each with whether the
-- follow has judged it before.
SELECT g.mbid, g.title, g.primary_type, g.secondary_types, g.first_release_date,
EXISTS (SELECT 1 FROM follow_seen_groups s
WHERE s.artist_mbid = sqlc.arg(artist_mbid) AND s.release_group_mbid = g.mbid) AS seen
FROM mb_artist_release_groups ag
JOIN mb_release_groups g ON g.mbid = ag.release_group_mbid
WHERE ag.artist_mbid = sqlc.arg(artist_mbid)
ORDER BY g.first_release_date, g.title;
-- name: MarkGroupSeen :exec
INSERT INTO follow_seen_groups (artist_mbid, release_group_mbid)
VALUES ($1, $2)
ON CONFLICT DO NOTHING;
-- name: LibraryHoldsReleaseGroup :one
-- Whether the library already has the group: an album tagged with it or
-- with one of its releases, or failing that an album of the artist's with
-- the same title. Only albums with a track on disk count.
SELECT EXISTS (
SELECT 1 FROM albums a
WHERE (a.release_group_mbid = sqlc.arg(group_mbid)::text
OR a.mbid = sqlc.arg(group_mbid)
OR a.mbid IN (SELECT r.mbid FROM mb_releases r WHERE r.release_group_mbid = sqlc.arg(group_mbid))
OR (lower(a.title) = lower(sqlc.arg(title)::text)
AND a.artist_id IN (SELECT ar.id FROM artists ar WHERE ar.mbid = sqlc.arg(artist_mbid)::text)))
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id AND t.missing_since IS NULL)
);
-- name: WantReleaseGroup :one
-- Wants a release group, or returns the want already recorded for it. A
-- held want is not reopened here: the library has it.
INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, primary_type,
first_release_date, reason, created_by)
VALUES (sqlc.arg(release_group_mbid), sqlc.arg(artist_mbid), sqlc.arg(artist_name), sqlc.arg(title),
sqlc.arg(primary_type), sqlc.arg(first_release_date), sqlc.arg(reason), sqlc.narg(created_by))
ON CONFLICT (release_group_mbid) DO UPDATE SET updated_at = wants.updated_at
RETURNING *;
-- name: GetWantByReleaseGroup :one
SELECT * FROM wants WHERE release_group_mbid = $1;
-- name: ListOpenWants :many
SELECT * FROM wants
WHERE fulfilled_at IS NULL
ORDER BY wanted_at, id
LIMIT $1;
-- name: WantHoldings :many
-- Each library album that may answer a want, with what it has on disk:
-- tracks present, whether one arrived since the want, and whether a track
-- missing before the want is still missing.
SELECT a.id AS album_id,
COUNT(*) FILTER (WHERE t.missing_since IS NULL)::int AS present,
COALESCE(bool_or(t.missing_since IS NULL AND t.added_at > sqlc.arg(since)::timestamptz), false)::bool AS arrived_since,
COALESCE(bool_or(t.missing_since IS NOT NULL AND t.missing_since < sqlc.arg(since)::timestamptz), false)::bool AS missing_before,
-- Missing tracks no track on disk stands in for, by position or
-- title. A missing row whose song is back at a new path (#5245) is
-- covered; a lost bonus track is not.
COUNT(*) FILTER (WHERE t.missing_since IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM tracks p
WHERE p.album_id = a.id AND p.missing_since IS NULL
AND ((p.track_number = t.track_number AND p.disc_number IS NOT DISTINCT FROM t.disc_number)
OR lower(p.title) = lower(t.title))))::int AS uncovered_missing
FROM albums a
JOIN tracks t ON t.album_id = a.id
WHERE a.release_group_mbid = sqlc.arg(group_mbid)::text
OR a.mbid = sqlc.arg(group_mbid)
OR a.id = sqlc.narg(album_id)
OR a.mbid IN (SELECT r.mbid FROM mb_releases r WHERE r.release_group_mbid = sqlc.arg(group_mbid))
GROUP BY a.id
ORDER BY present DESC, a.id;
-- name: FulfillWant :exec
UPDATE wants
SET fulfilled_at = now(),
album_id = sqlc.arg(album_id),
updated_at = now()
WHERE id = sqlc.arg(id) AND fulfilled_at IS NULL;
-- name: RepointWant :exec
-- A want recorded under a release id before #5241 is moved to its group.
UPDATE wants SET release_group_mbid = $2, updated_at = now() WHERE id = $1;
-- name: DeleteWant :execrows
DELETE FROM wants WHERE id = $1;
-- name: ListWants :many
-- The Wanted page: open wants (all but missing ones, or only missing ones),
-- or what was held most recently.
SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reason,
w.album_id, w.wanted_at, w.fulfilled_at,
-- A request names an album before the catalogue has read it: the
-- catalogue's type and date fill in once it has.
COALESCE(NULLIF(w.primary_type, ''), g.primary_type, '')::text AS primary_type,
COALESCE(g.secondary_types, '{}')::text[] AS secondary_types,
COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, '')::text AS first_release_date,
u.username AS requested_by,
(SELECT ar.id FROM artists ar WHERE ar.mbid = w.artist_mbid) AS library_artist_id
FROM wants w
LEFT JOIN users u ON u.id = w.created_by
LEFT JOIN mb_release_groups g ON g.mbid = w.release_group_mbid
WHERE CASE sqlc.arg(state)::text
WHEN 'held' THEN w.fulfilled_at IS NOT NULL
WHEN 'missing' THEN w.fulfilled_at IS NULL AND w.reason = 'missing'
ELSE w.fulfilled_at IS NULL AND w.reason <> 'missing'
END
ORDER BY COALESCE(w.fulfilled_at, w.wanted_at) DESC, w.id
LIMIT sqlc.arg(max_rows) OFFSET sqlc.arg(skip);
-- name: CountWants :one
SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::bigint AS wanted,
COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason = 'missing')::bigint AS missing,
COUNT(*) FILTER (WHERE fulfilled_at IS NOT NULL)::bigint AS held,
(SELECT COUNT(*) FROM follows)::bigint AS following
FROM wants;
+15
View File
@@ -108,6 +108,10 @@ var dataTables = []string{
"mb_artist_release_groups", // M502 #5519
"mb_release_groups", // M502 #5519
"mb_artists", // M502 #5519
"requests", // M502 #5520 (was lidarr_requests)
"wants", // M502 #5520
"follow_seen_groups", // M502 #5520
"follows", // M502 #5520
"tracks",
"albums",
"artists",
@@ -165,6 +169,17 @@ func ResetDB(t *testing.T, pool *pgxpool.Pool) {
); err != nil {
t.Fatalf("dbtest.ResetDB reset loudness settings: %v", err)
}
// Release-type profiles (M502 #5520): migration 0081 seeds "Standard" as
// the default. Tests add and edit profiles, so put back exactly the
// seeded row rather than truncating it away.
if _, err := pool.Exec(ctx, `
DELETE FROM release_type_profiles WHERE name <> 'Standard';
UPDATE release_type_profiles
SET primary_types = ARRAY['Album', 'EP', 'Single'], secondary_types = '{}', is_default = true
WHERE name = 'Standard'`,
); err != nil {
t.Fatalf("dbtest.ResetDB reset release-type profiles: %v", err)
}
// Notification email settings (M489 #5346), reset the same way.
if _, err := pool.Exec(ctx, `
UPDATE notification_email_settings
+2 -2
View File
@@ -206,8 +206,8 @@ func foldTrackInto(
if _, err := tq.MergeRepointPlaybackErrors(ctx, dbq.MergeRepointPlaybackErrorsParams{SurvivorID: survivorID, LoserID: loserID}); err != nil {
return f, fmt.Errorf("move playback errors: %w", err)
}
if _, err := tq.MergeRepointLidarrRequests(ctx, dbq.MergeRepointLidarrRequestsParams{SurvivorID: survivorID, LoserID: loserID}); err != nil {
return f, fmt.Errorf("move lidarr request matches: %w", err)
if _, err := tq.MergeRepointRequests(ctx, dbq.MergeRepointRequestsParams{SurvivorID: survivorID, LoserID: loserID}); err != nil {
return f, fmt.Errorf("move request matches: %w", err)
}
playlists, err := tq.MergeRepointPlaylistTracks(ctx, dbq.MergeRepointPlaylistTracksParams{SurvivorID: survivorID, LoserID: loserID})
-404
View File
@@ -1,404 +0,0 @@
package lidarrrequests
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
// Reconciler is a background worker that periodically scans approved
// lidarr_requests. For rows whose Lidarr add hasn't been confirmed it
// (re)sends the add idempotently until it sticks; for confirmed rows it
// transitions them to completed when their target track/album/artist has
// appeared in the local library (matched by MBID). Errors per-row are
// logged at WARN and do not abort the tick.
type Reconciler struct {
pool *pgxpool.Pool
lidarrCfg *lidarrconfig.Service
clientFn func() *lidarr.Client
logger *slog.Logger
bus *eventbus.Bus
notifier *notifications.Notifier // nil: no inbox notifications
tick time.Duration
batch int32
// releaseGroup names the MusicBrainz release group of a release id, to
// repair a request stored under a release id Lidarr does not index
// (#5241). A field so tests can stand in for MusicBrainz.
releaseGroup func(ctx context.Context, releaseMBID string) (string, error)
}
// NewReconciler constructs a Reconciler with production defaults:
// 5-minute tick, batch size 50. clientFn is the per-tick Lidarr client
// factory (same pattern as Service) so config changes apply without a
// restart; pass nil to disable the add-retry path. Pass nil for bus when
// no SSE publishing is desired (tests, or environments without the event
// stream enabled).
func NewReconciler(pool *pgxpool.Pool, cfg *lidarrconfig.Service, clientFn func() *lidarr.Client, logger *slog.Logger, bus *eventbus.Bus) *Reconciler {
if clientFn == nil {
clientFn = func() *lidarr.Client { return nil }
}
return &Reconciler{
pool: pool,
lidarrCfg: cfg,
clientFn: clientFn,
logger: logger,
bus: bus,
tick: 5 * time.Minute,
batch: 50,
releaseGroup: tags.ReleaseGroupForRelease,
}
}
// SetNotifier makes completions land in the requester's notifications inbox
// (M489). Without one, completions are only broadcast on the bus.
func (r *Reconciler) SetNotifier(n *notifications.Notifier) { r.notifier = n }
// publishCompleted tells the requester their request arrived: a
// request.status_changed event for open screens, and a notification that
// outlives the connection.
func (r *Reconciler) publishCompleted(ctx context.Context, row dbq.LidarrRequest) {
p := notifications.Payload{
RequestID: formatUUIDForBus(row.ID),
RequestKind: string(row.Kind),
Name: DisplayName(row),
Artist: row.ArtistName,
Title: requestedTitle(row),
}
if row.MatchedAlbumID.Valid {
p.AlbumID = formatUUIDForBus(row.MatchedAlbumID)
} else if row.MatchedArtistID.Valid {
p.ArtistID = formatUUIDForBus(row.MatchedArtistID)
}
r.notifier.NotifyLogged(ctx, notifications.KindRequestCompleted, notifications.ToUser(row.UserID), p.Map())
if r.bus == nil {
return
}
r.bus.Publish(eventbus.Event{
Kind: "request.status_changed",
UserID: formatUUIDForBus(row.UserID),
Data: map[string]any{
"request_id": formatUUIDForBus(row.ID),
"status": "completed",
"kind": string(row.Kind),
},
})
}
// formatUUIDForBus renders a pgtype.UUID as the canonical 8-4-4-4-12 hex
// string. Mirrors helpers in internal/api/convert.go and other packages;
// inlined here to avoid a back-edge dependency from lidarrrequests onto
// the api layer.
func formatUUIDForBus(u pgtype.UUID) string {
if !u.Valid {
return ""
}
const hex = "0123456789abcdef"
out := make([]byte, 36)
pos := 0
for i, b := range u.Bytes {
if i == 4 || i == 6 || i == 8 || i == 10 {
out[pos] = '-'
pos++
}
out[pos] = hex[b>>4]
out[pos+1] = hex[b&0xf]
pos += 2
}
return string(out)
}
// Run blocks until ctx is cancelled, ticking every r.tick. Errors from
// tickOnce are logged at WARN and never propagated.
func (r *Reconciler) Run(ctx context.Context) {
t := time.NewTicker(r.tick)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := r.tickOnce(ctx); err != nil {
r.logger.Warn("lidarrrequests: reconciler tick failed", "err", err)
}
}
}
}
// tickOnce loads approved requests (oldest first, up to r.batch) and
// transitions any whose target MBID is now present in the local library.
// It is exported-for-tests via the lowercase name (package-internal).
func (r *Reconciler) tickOnce(ctx context.Context) error {
cfg, err := r.lidarrCfg.Get(ctx)
if err != nil {
return err
}
if !cfg.Enabled {
r.logger.Debug("lidarrrequests: reconciler skipping tick — lidarr disabled")
return nil
}
q := dbq.New(r.pool)
rows, err := q.ListApprovedLidarrRequestsForReconcile(ctx, r.batch)
if err != nil {
return err
}
for _, row := range rows {
if err := r.reconcileRow(ctx, q, cfg, row); err != nil {
r.logger.Warn("lidarrrequests: reconciler row failed",
"request_id", row.ID,
"kind", row.Kind,
"err", err,
)
}
}
return nil
}
// reconcileRow drives a request forward by one step. If Lidarr hasn't
// confirmed the add yet it (re)sends it and returns — the import can't
// land before Lidarr has the add, so there's nothing to match this tick.
// Once confirmed, it checks the library for a match and, if found, calls
// CompleteLidarrRequest. Returns an error only for unexpected failures
// (the add retry's transient error included, so tickOnce logs WARN and
// it's retried next tick — "Lidarr just keeps trying"); a no-match is
// not an error.
func (r *Reconciler) reconcileRow(ctx context.Context, q *dbq.Queries, cfg lidarrconfig.Config, row dbq.LidarrRequest) error {
if !row.LidarrAddConfirmedAt.Valid {
if err := r.ensureLidarrAdd(ctx, q, cfg, row); err != nil {
return err
}
// ensureLidarrAdd either confirmed the add or no-op'd (Lidarr
// client unavailable). Fall through to import-matching so an
// item already present in the library still completes this tick.
}
switch row.Kind {
case dbq.LidarrRequestKindArtist:
return r.reconcileArtist(ctx, q, row)
case dbq.LidarrRequestKindAlbum:
return r.reconcileAlbum(ctx, q, row)
case dbq.LidarrRequestKindTrack:
return r.reconcileTrack(ctx, q, row)
default:
r.logger.Warn("lidarrrequests: reconciler unknown kind", "kind", row.Kind)
return nil
}
}
// ensureLidarrAdd (re)sends the Lidarr add for an approved request whose
// add hasn't been confirmed. On success (including ErrAlreadyExists,
// handled inside sendLidarrAdd) it stamps lidarr_add_confirmed_at so this
// stops re-sending and the next tick moves to import-matching. A failure
// is returned so tickOnce logs WARN and the row is retried next tick;
// there is intentionally no failed-state or attempt cap — per product
// decision Lidarr just keeps trying and the operator monitors Lidarr.
func (r *Reconciler) ensureLidarrAdd(ctx context.Context, q *dbq.Queries, cfg lidarrconfig.Config, row dbq.LidarrRequest) error {
client := r.clientFn()
if client == nil {
// Lidarr disabled mid-flight; tickOnce already gates on
// cfg.Enabled, so this is just defensive — nothing to do.
return nil
}
err := sendLidarrAdd(ctx, client, cfg, row)
if errors.Is(err, lidarr.ErrNotFound) {
// Lidarr's metadata has no album under this id. Requests made before
// albums carried a release group (#5241) name the album by its
// MusicBrainz release id, which Lidarr does not index: repoint the
// request at the release group and try once more.
if repaired, ok := r.repointAtReleaseGroup(ctx, q, row); ok {
row = repaired
err = sendLidarrAdd(ctx, client, cfg, row)
}
}
if err != nil {
return fmt.Errorf("lidarr add retry: %w", err)
}
return q.MarkLidarrRequestAddConfirmed(ctx, row.ID)
}
// repointAtReleaseGroup reads an album or track request's album id as a
// MusicBrainz release and, when its release group can be named, rewrites the
// request to it. The library's own albums answer first; MusicBrainz is asked
// only for a release no album row knows the group of, and its answer is cached
// onto that album. Reports false when there is nothing to repoint: not an
// album request, the group is unknown, or the id already is the group.
func (r *Reconciler) repointAtReleaseGroup(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest) (dbq.LidarrRequest, bool) {
if row.Kind == dbq.LidarrRequestKindArtist || row.LidarrAlbumMbid == nil || *row.LidarrAlbumMbid == "" {
return row, false
}
release := *row.LidarrAlbumMbid
group, err := q.GetReleaseGroupForReleaseMbid(ctx, &release)
if err != nil {
if !isNoRows(err) {
r.logger.Warn("lidarrrequests: release group lookup failed", "request_id", row.ID, "err", err)
return row, false
}
group, err = r.releaseGroup(ctx, release)
if err != nil {
if !errors.Is(err, tags.ErrNotFound) {
r.logger.Warn("lidarrrequests: MusicBrainz release group lookup failed",
"request_id", row.ID, "release_mbid", release, "err", err)
}
return row, false
}
if cerr := q.SetReleaseGroupForReleaseMbidIfNull(ctx, dbq.SetReleaseGroupForReleaseMbidIfNullParams{
ReleaseGroupMbid: group,
ReleaseMbid: release,
}); cerr != nil {
r.logger.Warn("lidarrrequests: cache release group failed", "release_mbid", release, "err", cerr)
}
}
if group == "" || group == release {
return row, false
}
if err := q.SetLidarrRequestAlbumMbid(ctx, dbq.SetLidarrRequestAlbumMbidParams{
ID: row.ID,
LidarrAlbumMbid: &group,
}); err != nil {
r.logger.Warn("lidarrrequests: repoint request failed", "request_id", row.ID, "err", err)
return row, false
}
r.logger.Info("lidarrrequests: request repointed from release to release group",
"request_id", row.ID, "release_mbid", release, "release_group_mbid", group)
row.LidarrAlbumMbid = &group
return row, true
}
// albumForRequest finds the library album a request's album id ($1) names, by
// release id or release group, once the request ($2 = requested_at) has been
// answered: the album has a track on disk AND either
//
// - a track arrived after the request (a new album, or Lidarr fetching
// another release of the group into its own row), or
// - no track that was already missing when the request was made is still
// missing (the lost files came back in place or were adopted at a new path).
//
// "Has a track on disk" alone is not enough (#5263): re-acquisition targets
// albums with ANY track missing, so the tracks that never left satisfied it
// the moment Lidarr accepted the add. added_at is the arrival clock because
// nothing rewrites it; updated_at moves on every tag re-read.
// An exact release match is preferred over another release of the group.
const albumForRequest = `
SELECT a.id
FROM albums a
WHERE (a.mbid = $1 OR a.release_group_mbid = $1)
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id AND t.missing_since IS NULL)
AND (
EXISTS (SELECT 1 FROM tracks t
WHERE t.album_id = a.id AND t.missing_since IS NULL AND t.added_at > $2)
OR NOT EXISTS (SELECT 1 FROM tracks t
WHERE t.album_id = a.id AND t.missing_since IS NOT NULL
AND t.missing_since < $2)
)
ORDER BY (a.mbid = $1) DESC, a.id
LIMIT 1`
func (r *Reconciler) reconcileArtist(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest) error {
var artistID pgtype.UUID
err := r.pool.QueryRow(ctx,
"SELECT id FROM artists WHERE mbid = $1",
row.LidarrArtistMbid,
).Scan(&artistID)
if err != nil {
if isNoRows(err) {
return nil // not in library yet
}
return err
}
completed, err := q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{
ID: row.ID,
MatchedArtistID: artistID,
MatchedAlbumID: pgtype.UUID{},
MatchedTrackID: pgtype.UUID{},
})
if err != nil {
return err
}
r.publishCompleted(ctx, completed)
return nil
}
func (r *Reconciler) reconcileAlbum(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest) error {
if row.LidarrAlbumMbid == nil {
return nil
}
var albumID pgtype.UUID
err := r.pool.QueryRow(ctx, albumForRequest, *row.LidarrAlbumMbid, row.RequestedAt).Scan(&albumID)
if err != nil {
if isNoRows(err) {
return nil
}
return err
}
completed, err := q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{
ID: row.ID,
MatchedAlbumID: albumID,
MatchedArtistID: pgtype.UUID{},
MatchedTrackID: pgtype.UUID{},
})
if err != nil {
return err
}
r.publishCompleted(ctx, completed)
return nil
}
func (r *Reconciler) reconcileTrack(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest) error {
if row.LidarrAlbumMbid == nil {
return nil
}
// Track-kind requests match via their parent album's MBID, not track.mbid.
var albumID pgtype.UUID
err := r.pool.QueryRow(ctx, albumForRequest, *row.LidarrAlbumMbid, row.RequestedAt).Scan(&albumID)
if err != nil {
if isNoRows(err) {
return nil
}
return err
}
// Load a track from that album to set matched_track_id, newest arrival first.
var trackID pgtype.UUID
err = r.pool.QueryRow(ctx,
"SELECT id FROM tracks WHERE album_id = $1 AND missing_since IS NULL ORDER BY added_at DESC, id LIMIT 1",
albumID,
).Scan(&trackID)
if err != nil {
if isNoRows(err) {
// Album is in the library but no tracks ingested yet — try again next tick.
return nil
}
return err
}
completed, err := q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{
ID: row.ID,
MatchedAlbumID: albumID,
MatchedTrackID: trackID,
MatchedArtistID: pgtype.UUID{},
})
if err != nil {
return err
}
r.publishCompleted(ctx, completed)
return nil
}
func isNoRows(err error) bool {
return err == pgx.ErrNoRows
}
@@ -1,444 +0,0 @@
package lidarrrequests
import (
"context"
"io"
"log/slog"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
)
// seedApprovedRequestDirect inserts a pending request and immediately
// approves it via ApproveLidarrRequest, bypassing the Lidarr API call.
func seedApprovedRequestDirect(t *testing.T, q *dbq.Queries, userID pgtype.UUID, p CreateParams) dbq.LidarrRequest {
t.Helper()
ctx := context.Background()
row, err := q.CreateLidarrRequest(ctx, dbq.CreateLidarrRequestParams{
UserID: userID,
Kind: dbq.LidarrRequestKind(p.Kind),
LidarrArtistMbid: p.LidarrArtistMBID,
LidarrAlbumMbid: nilableStr(p.LidarrAlbumMBID),
LidarrTrackMbid: nilableStr(p.LidarrTrackMBID),
ArtistName: p.ArtistName,
AlbumTitle: nilableStr(p.AlbumTitle),
TrackTitle: nilableStr(p.TrackTitle),
})
if err != nil {
t.Fatalf("seedApprovedRequestDirect create: %v", err)
}
approved, err := q.ApproveLidarrRequest(ctx, dbq.ApproveLidarrRequestParams{
ID: row.ID,
QualityProfileID: nil,
RootFolderPath: nil,
DecidedBy: userID,
})
if err != nil {
t.Fatalf("seedApprovedRequestDirect approve: %v", err)
}
return approved
}
func nilableStr(s string) *string {
if s == "" {
return nil
}
return &s
}
func seedArtist(t *testing.T, q *dbq.Queries, name, mbid string) dbq.Artist {
t.Helper()
a, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{
Name: name, SortName: name, Mbid: &mbid,
})
if err != nil {
t.Fatalf("seedArtist: %v", err)
}
return a
}
func seedAlbum(t *testing.T, q *dbq.Queries, artistID pgtype.UUID, title, mbid string) dbq.Album {
t.Helper()
a, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
Title: title, SortTitle: title,
ArtistID: artistID,
Mbid: &mbid,
})
if err != nil {
t.Fatalf("seedAlbum: %v", err)
}
return a
}
func seedTrack(t *testing.T, q *dbq.Queries, albumID, artistID pgtype.UUID, title, filePath string) dbq.Track {
t.Helper()
tr, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: title, AlbumID: albumID, ArtistID: artistID,
DurationMs: 180000, FilePath: filePath,
FileSize: 1024, FileFormat: "flac",
})
if err != nil {
t.Fatalf("seedTrack: %v", err)
}
return tr
}
func enableLidarrForPool(t *testing.T, pool *pgxpool.Pool) {
t.Helper()
if err := lidarrconfig.New(pool).Save(context.Background(), lidarrconfig.Config{
Enabled: true, BaseURL: "http://lidarr:8686", APIKey: "test",
}); err != nil {
t.Fatalf("enableLidarrForPool: %v", err)
}
}
func newTestLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
// TestReconciler_MatchesArtistByMBID: an approved artist-kind request whose
// MBID is in the artists table gets completed with matched_artist_id set.
func TestReconciler_MatchesArtistByMBID(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
enableLidarrForPool(t, pool)
user := seedUser(t, pool)
artistMBID := "069b64b6-7884-4f6a-94cc-e4c1d6c87a01"
artist := seedArtist(t, q, "Boards of Canada", artistMBID)
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "artist", LidarrArtistMBID: artistMBID, ArtistName: "Boards of Canada",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
got, err := q.GetLidarrRequestByID(ctx, req.ID)
if err != nil {
t.Fatalf("GetLidarrRequestByID: %v", err)
}
if got.Status != dbq.LidarrRequestStatusCompleted {
t.Errorf("status = %v, want completed", got.Status)
}
if got.MatchedArtistID != artist.ID {
t.Errorf("matched_artist_id = %v, want %v", got.MatchedArtistID, artist.ID)
}
}
// TestReconciler_MatchesAlbumByMBID: an approved album-kind request whose
// album MBID is in the albums table gets completed with matched_album_id set.
func TestReconciler_MatchesAlbumByMBID(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
enableLidarrForPool(t, pool)
user := seedUser(t, pool)
artistMBID := "a-artist-mbid-album-test"
albumMBID := "b-album-mbid-album-test"
artist := seedArtist(t, q, "Test Artist", artistMBID)
album := seedAlbum(t, q, artist.ID, "Test Album", albumMBID)
_ = seedTrack(t, q, album.ID, artist.ID, "Album Track", "/music/album-test/01.flac")
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "album", LidarrArtistMBID: artistMBID, ArtistName: "Test Artist",
LidarrAlbumMBID: albumMBID, AlbumTitle: "Test Album",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
got, err := q.GetLidarrRequestByID(ctx, req.ID)
if err != nil {
t.Fatalf("GetLidarrRequestByID: %v", err)
}
if got.Status != dbq.LidarrRequestStatusCompleted {
t.Errorf("status = %v, want completed", got.Status)
}
if got.MatchedAlbumID != album.ID {
t.Errorf("matched_album_id = %v, want %v", got.MatchedAlbumID, album.ID)
}
}
// TestReconciler_MatchesTrackViaAlbumMBID: an approved track-kind request
// matches when the parent album appears in the library. matched_track_id is
// also set to a track from that album. The track MBID on the request does
// not need to match any tracks.mbid — matching is via the parent album.
func TestReconciler_MatchesTrackViaAlbumMBID(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
enableLidarrForPool(t, pool)
user := seedUser(t, pool)
artistMBID := "a-artist-mbid-track-test"
albumMBID := "b-album-mbid-track-test"
trackMBID := "c-track-mbid-does-not-exist-in-tracks-table"
artist := seedArtist(t, q, "Track Test Artist", artistMBID)
album := seedAlbum(t, q, artist.ID, "Track Test Album", albumMBID)
track := seedTrack(t, q, album.ID, artist.ID, "Track One", "/music/track-test/01.flac")
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "track", LidarrArtistMBID: artistMBID, ArtistName: "Track Test Artist",
LidarrAlbumMBID: albumMBID, AlbumTitle: "Track Test Album",
LidarrTrackMBID: trackMBID, TrackTitle: "Track One",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
got, err := q.GetLidarrRequestByID(ctx, req.ID)
if err != nil {
t.Fatalf("GetLidarrRequestByID: %v", err)
}
if got.Status != dbq.LidarrRequestStatusCompleted {
t.Errorf("status = %v, want completed", got.Status)
}
if got.MatchedAlbumID != album.ID {
t.Errorf("matched_album_id = %v, want %v", got.MatchedAlbumID, album.ID)
}
if got.MatchedTrackID != track.ID {
t.Errorf("matched_track_id = %v, want %v", got.MatchedTrackID, track.ID)
}
}
// TestReconciler_NoMatchLeavesPending: an approved request whose MBID is not
// in any library table is left unchanged after tickOnce.
func TestReconciler_NoMatchLeavesPending(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
enableLidarrForPool(t, pool)
user := seedUser(t, pool)
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "artist", LidarrArtistMBID: "nonexistent-mbid-xyz", ArtistName: "Ghost Artist",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
got, err := q.GetLidarrRequestByID(ctx, req.ID)
if err != nil {
t.Fatalf("GetLidarrRequestByID: %v", err)
}
if got.Status != dbq.LidarrRequestStatusApproved {
t.Errorf("status = %v, want approved (unchanged)", got.Status)
}
if got.MatchedArtistID.Valid {
t.Errorf("matched_artist_id should be NULL, got %v", got.MatchedArtistID)
}
}
// TestReconciler_AlreadyCompletedRowNotReprocessed: a completed row is not
// touched by the reconciler because ListApprovedLidarrRequestsForReconcile
// filters on status='approved'.
func TestReconciler_AlreadyCompletedRowNotReprocessed(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
enableLidarrForPool(t, pool)
user := seedUser(t, pool)
artistMBID := "already-done-artist-mbid"
artist := seedArtist(t, q, "Already Done Artist", artistMBID)
// Seed approved, then complete it before running tickOnce.
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "artist", LidarrArtistMBID: artistMBID, ArtistName: "Already Done Artist",
})
completed, err := q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{
ID: req.ID,
MatchedArtistID: artist.ID,
MatchedAlbumID: pgtype.UUID{},
MatchedTrackID: pgtype.UUID{},
})
if err != nil {
t.Fatalf("CompleteLidarrRequest setup: %v", err)
}
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
got, err := q.GetLidarrRequestByID(ctx, req.ID)
if err != nil {
t.Fatalf("GetLidarrRequestByID: %v", err)
}
if got.Status != dbq.LidarrRequestStatusCompleted {
t.Errorf("status = %v, want completed (unchanged)", got.Status)
}
if !got.UpdatedAt.Time.Equal(completed.UpdatedAt.Time) {
t.Errorf("updated_at moved: was %v, now %v — reconciler should not have touched completed row",
completed.UpdatedAt.Time, got.UpdatedAt.Time)
}
}
// TestReconciler_DisabledIsNoOp: when lidarr_config.enabled=false the
// reconciler skips the entire tick, leaving approved requests untouched.
func TestReconciler_DisabledIsNoOp(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
// lidarr_config is left disabled (newPool sets it to false).
user := seedUser(t, pool)
artistMBID := "disabled-noop-mbid"
// Seed the matching artist so it WOULD be found if lidarr were enabled.
seedArtist(t, q, "No-Op Artist", artistMBID)
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "artist", LidarrArtistMBID: artistMBID, ArtistName: "No-Op Artist",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
got, err := q.GetLidarrRequestByID(ctx, req.ID)
if err != nil {
t.Fatalf("GetLidarrRequestByID: %v", err)
}
if got.Status != dbq.LidarrRequestStatusApproved {
t.Errorf("status = %v, want approved — reconciler should no-op when disabled", got.Status)
}
}
// TestReconciler_RunRunsAtLeastOneTick exercises Run's goroutine loop with a
// short tick, verifies it picks up an approved row and transitions it to
// completed before the test cancels the context.
func TestReconciler_RunRunsAtLeastOneTick(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
pool := newPool(t)
enableLidarrForPool(t, pool)
q := dbq.New(pool)
user := seedUser(t, pool)
const mbid = "run-mbid"
seedArtist(t, q, "Run Artist", mbid)
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "artist", LidarrArtistMBID: mbid, ArtistName: "Run Artist",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
rec.tick = 25 * time.Millisecond
done := make(chan struct{})
go func() {
rec.Run(ctx)
close(done)
}()
// Poll the row up to ~2s for the reconciler to flip it. Beats sleeping
// a fixed amount on slow CI.
deadline := time.Now().Add(2 * time.Second)
for {
got, err := q.GetLidarrRequestByID(ctx, req.ID)
if err == nil && got.Status == dbq.LidarrRequestStatusCompleted {
break
}
if time.Now().After(deadline) {
t.Fatalf("status not completed within 2s; last err=%v", err)
}
time.Sleep(10 * time.Millisecond)
}
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("Run did not exit within 1s of cancel")
}
}
// TestReconciler_TrackKindNoTracksInAlbumYet: the parent album exists in the
// library but no track rows have been ingested yet. Reconciler should leave
// the row approved so a subsequent tick can pick it up once tracks land.
func TestReconciler_TrackKindNoTracksInAlbumYet(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
enableLidarrForPool(t, pool)
user := seedUser(t, pool)
artistMBID := "ar-mbid-tracks-pending"
albumMBID := "al-mbid-tracks-pending"
artist := seedArtist(t, q, "Pending Tracks Artist", artistMBID)
_ = seedAlbum(t, q, artist.ID, "Album Without Tracks", albumMBID)
// No tracks seeded.
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "track", LidarrArtistMBID: artistMBID, ArtistName: "Pending Tracks Artist",
LidarrAlbumMBID: albumMBID, AlbumTitle: "Album Without Tracks",
LidarrTrackMBID: "t-mbid-not-yet", TrackTitle: "Some Track",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
got, err := q.GetLidarrRequestByID(ctx, req.ID)
if err != nil {
t.Fatalf("GetLidarrRequestByID: %v", err)
}
if got.Status != dbq.LidarrRequestStatusApproved {
t.Errorf("status = %v, want approved (album present, no tracks yet)", got.Status)
}
}
// TestReconciler_AlbumKindNoMatchInAlbumsTable exercises the album branch's
// no-rows return path when the request's album_mbid isn't in the library yet.
func TestReconciler_AlbumKindNoMatchInAlbumsTable(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
enableLidarrForPool(t, pool)
user := seedUser(t, pool)
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "album", LidarrArtistMBID: "ar-no-match", ArtistName: "Nope Artist",
LidarrAlbumMBID: "al-not-in-library", AlbumTitle: "Nope Album",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
got, err := q.GetLidarrRequestByID(ctx, req.ID)
if err != nil {
t.Fatalf("GetLidarrRequestByID: %v", err)
}
if got.Status != dbq.LidarrRequestStatusApproved {
t.Errorf("status = %v, want approved (album not yet in library)", got.Status)
}
}
@@ -1,52 +0,0 @@
package lidarrrequests
import (
"context"
"encoding/json"
"testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
)
// M489: an album request that arrives lands in the requester's inbox once,
// linked to the album it matched.
func TestReconciler_CompletionNotifiesRequester(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
enableLidarrForPool(t, pool)
user := seedUser(t, pool)
artist := seedArtist(t, q, "Notify Artist", "notify-artist-mbid")
album := seedAlbum(t, q, artist.ID, "Notify Album", "notify-album-mbid")
_ = seedTrack(t, q, album.ID, artist.ID, "Notify Track", "/music/notify/01.flac")
seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "album", LidarrArtistMBID: "notify-artist-mbid", LidarrAlbumMBID: "notify-album-mbid",
ArtistName: "Notify Artist", AlbumTitle: "Notify Album",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
rec.SetNotifier(notifications.New(pool, nil, nil))
for i := 0; i < 2; i++ { // the second tick finds nothing left to complete
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
}
rows, err := q.ListNotifications(ctx, dbq.ListNotificationsParams{UserID: user, PageLimit: 10})
if err != nil {
t.Fatalf("list notifications: %v", err)
}
if len(rows) != 1 || rows[0].Kind != string(notifications.KindRequestCompleted) {
t.Fatalf("inbox = %+v, want one request_completed", rows)
}
var p notifications.Payload
if err := json.Unmarshal(rows[0].Payload, &p); err != nil {
t.Fatal(err)
}
if p.Name != "Notify Artist – Notify Album" || p.AlbumID != formatUUIDForBus(album.ID) || p.ArtistID != "" {
t.Errorf("payload = %+v", p)
}
}
@@ -1,373 +0,0 @@
package lidarrrequests
import (
"context"
"encoding/json"
"io/fs"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"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/tags"
)
// #5241: Lidarr names albums by MusicBrainz release group. A request carrying
// a group id completes against the album row whose tags named that group,
// once a track of it is on disk.
func TestReconciler_AlbumMatchesByReleaseGroup(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
enableLidarrForPool(t, pool)
user := seedUser(t, pool)
const artistMBID, release, group = "rg-artist-match", "rg-release-match", "rg-group-match"
artist := seedArtist(t, q, "RG Artist", artistMBID)
album := seedAlbum(t, q, artist.ID, "RG Album", release)
if err := q.SetAlbumReleaseGroupMbidIfNull(ctx, dbq.SetAlbumReleaseGroupMbidIfNullParams{
ID: album.ID, ReleaseGroupMbid: nilableStr(group),
}); err != nil {
t.Fatal(err)
}
_ = seedTrack(t, q, album.ID, artist.ID, "RG One", "/music/rg-match/01.flac")
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "album", LidarrArtistMBID: artistMBID, ArtistName: "RG Artist",
LidarrAlbumMBID: group, AlbumTitle: "RG Album",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
got, err := q.GetLidarrRequestByID(ctx, req.ID)
if err != nil {
t.Fatal(err)
}
if got.Status != dbq.LidarrRequestStatusCompleted || got.MatchedAlbumID != album.ID {
t.Errorf("status %v matched %v, want completed on %v", got.Status, got.MatchedAlbumID, album.ID)
}
}
// A re-acquisition request names an album whose row never went away. The row
// alone must not complete it: nothing has come back until a track is on disk.
func TestReconciler_AlbumWithEveryTrackMissingStaysApproved(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
enableLidarrForPool(t, pool)
user := seedUser(t, pool)
const artistMBID, release = "rg-artist-missing", "rg-release-missing"
artist := seedArtist(t, q, "Missing Artist", artistMBID)
album := seedAlbum(t, q, artist.ID, "Missing Album", release)
tr := seedTrack(t, q, album.ID, artist.ID, "Gone", "/music/rg-missing/01.flac")
if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = now() WHERE id = $1", tr.ID); err != nil {
t.Fatal(err)
}
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "album", LidarrArtistMBID: artistMBID, ArtistName: "Missing Artist",
LidarrAlbumMBID: release, AlbumTitle: "Missing Album",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
got, err := q.GetLidarrRequestByID(ctx, req.ID)
if err != nil {
t.Fatal(err)
}
if got.Status != dbq.LidarrRequestStatusApproved {
t.Errorf("status = %v, want approved while every track is missing", got.Status)
}
}
// fakeAlbumLidarr answers album lookups only for knownGroup (Lidarr's metadata
// is keyed by release group) and records every add.
type fakeAlbumLidarr struct {
knownGroup string
mu sync.Mutex
added []string
}
func (f *fakeAlbumLidarr) handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/metadataprofile"):
_, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`))
case strings.HasSuffix(r.URL.Path, "/album/lookup"):
if r.URL.Query().Get("term") == "lidarr:"+f.knownGroup {
_, _ = w.Write([]byte(`[{"foreignAlbumId":"` + f.knownGroup + `","artist":{"id":42}}]`))
return
}
_, _ = w.Write([]byte(`[]`))
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/album"):
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
f.mu.Lock()
f.added = append(f.added, body["foreignAlbumId"].(string))
f.mu.Unlock()
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":9}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}
// The ~49 requests on the deploy were stored under release ids. The reconciler
// resolves the group (MusicBrainz here, since no album row knows it), rewrites
// the request, adds it, and caches the group onto the album holding the
// release.
func TestReconciler_RepointsAReleaseIdRequestAtItsReleaseGroup(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
user := seedUser(t, pool)
const artistMBID, release, group = "rg-artist-repoint", "rg-release-repoint", "rg-group-repoint"
fake := &fakeAlbumLidarr{knownGroup: group}
srv := httptest.NewServer(http.HandlerFunc(fake.handler))
t.Cleanup(srv.Close)
if err := lidarrconfig.New(pool).Save(ctx, lidarrconfig.Config{
Enabled: true, BaseURL: srv.URL, APIKey: "k",
DefaultQualityProfileID: 7, DefaultRootFolderPath: "/music",
}); err != nil {
t.Fatal(err)
}
artist := seedArtist(t, q, "Repoint Artist", artistMBID)
album := seedAlbum(t, q, artist.ID, "Repoint Album", release)
tr := seedTrack(t, q, album.ID, artist.ID, "Lost", "/music/rg-repoint/01.flac")
if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = now() WHERE id = $1", tr.ID); err != nil {
t.Fatal(err)
}
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "album", LidarrArtistMBID: artistMBID, ArtistName: "Repoint Artist",
LidarrAlbumMBID: release, AlbumTitle: "Repoint Album",
})
rec := NewReconciler(pool, lidarrconfig.New(pool),
func() *lidarr.Client { return lidarr.NewClient(srv.URL, "k") }, newTestLogger(), nil)
asked := 0
rec.releaseGroup = func(_ context.Context, id string) (string, error) {
asked++
if id == release {
return group, nil
}
return "", tags.ErrNotFound
}
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
got, err := q.GetLidarrRequestByID(ctx, req.ID)
if err != nil {
t.Fatal(err)
}
if got.LidarrAlbumMbid == nil || *got.LidarrAlbumMbid != group {
t.Errorf("request album mbid = %v, want the release group %s", got.LidarrAlbumMbid, group)
}
if !got.LidarrAddConfirmedAt.Valid {
t.Error("add not confirmed after the repoint")
}
fake.mu.Lock()
added := append([]string(nil), fake.added...)
fake.mu.Unlock()
if len(added) != 1 || added[0] != group {
t.Errorf("Lidarr adds = %v, want [%s]", added, group)
}
var cached *string
if err := pool.QueryRow(ctx, "SELECT release_group_mbid FROM albums WHERE id = $1", album.ID).Scan(&cached); err != nil {
t.Fatal(err)
}
if cached == nil || *cached != group {
t.Errorf("album release group = %v, want %s cached", cached, group)
}
if got.Status != dbq.LidarrRequestStatusApproved {
t.Errorf("status = %v, want approved until a track is back", got.Status)
}
// The next tick finds the group on the request and the album: no second
// MusicBrainz question.
before := asked
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("second tick: %v", err)
}
if asked != before {
t.Errorf("MusicBrainz asked again on the next tick (%d -> %d)", before, asked)
}
}
// markMissing stamps a track missing since well before any request a test makes.
func markMissing(t *testing.T, pool *pgxpool.Pool, trackID pgtype.UUID) {
t.Helper()
if _, err := pool.Exec(context.Background(),
"UPDATE tracks SET missing_since = now() - interval '2 days' WHERE id = $1", trackID); err != nil {
t.Fatal(err)
}
}
func requestStatus(t *testing.T, q *dbq.Queries, id pgtype.UUID) dbq.LidarrRequest {
t.Helper()
got, err := q.GetLidarrRequestByID(context.Background(), id)
if err != nil {
t.Fatal(err)
}
return got
}
// #5263: re-acquisition targets albums with SOME tracks missing. The tracks
// that never left must not complete the request; it completes once the lost
// track is back.
func TestReconciler_PartlyMissingAlbumCompletesWhenTheLostTrackReturns(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
enableLidarrForPool(t, pool)
user := seedUser(t, pool)
const artistMBID, release = "rg-artist-partial", "rg-release-partial"
artist := seedArtist(t, q, "Partial Artist", artistMBID)
album := seedAlbum(t, q, artist.ID, "Partial Album", release)
_ = seedTrack(t, q, album.ID, artist.ID, "Kept", "/music/rg-partial/01.flac")
lost := seedTrack(t, q, album.ID, artist.ID, "Lost", "/music/rg-partial/02.flac")
markMissing(t, pool, lost.ID)
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "album", LidarrArtistMBID: artistMBID, ArtistName: "Partial Artist",
LidarrAlbumMBID: release, AlbumTitle: "Partial Album",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
if got := requestStatus(t, q, req.ID); got.Status != dbq.LidarrRequestStatusApproved {
t.Fatalf("status = %v while a track is still missing, want approved", got.Status)
}
if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = NULL WHERE id = $1", lost.ID); err != nil {
t.Fatal(err)
}
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("second tick: %v", err)
}
got := requestStatus(t, q, req.ID)
if got.Status != dbq.LidarrRequestStatusCompleted || got.MatchedAlbumID != album.ID {
t.Errorf("status %v matched %v after the track returned, want completed on %v",
got.Status, got.MatchedAlbumID, album.ID)
}
}
// Lidarr may fetch a different release of the group, which the scanner files
// as its own album row. A track arriving there after the request completes it,
// even though the old row's lost tracks stay missing.
func TestReconciler_PartlyMissingAlbumCompletesOnAnotherReleaseArriving(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
enableLidarrForPool(t, pool)
user := seedUser(t, pool)
const artistMBID, oldRelease, newRelease, group = "rg-artist-edition", "rg-release-old", "rg-release-new", "rg-group-edition"
artist := seedArtist(t, q, "Edition Artist", artistMBID)
old := seedAlbum(t, q, artist.ID, "Edition Album", oldRelease)
if err := q.SetAlbumReleaseGroupMbidIfNull(ctx, dbq.SetAlbumReleaseGroupMbidIfNullParams{
ID: old.ID, ReleaseGroupMbid: nilableStr(group),
}); err != nil {
t.Fatal(err)
}
_ = seedTrack(t, q, old.ID, artist.ID, "Kept", "/music/rg-edition-old/01.flac")
lost := seedTrack(t, q, old.ID, artist.ID, "Lost", "/music/rg-edition-old/02.flac")
markMissing(t, pool, lost.ID)
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "album", LidarrArtistMBID: artistMBID, ArtistName: "Edition Artist",
LidarrAlbumMBID: group, AlbumTitle: "Edition Album",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
if got := requestStatus(t, q, req.ID); got.Status != dbq.LidarrRequestStatusApproved {
t.Fatalf("status = %v before anything arrived, want approved", got.Status)
}
arrived := seedAlbum(t, q, artist.ID, "Edition Album (Deluxe)", newRelease)
if err := q.SetAlbumReleaseGroupMbidIfNull(ctx, dbq.SetAlbumReleaseGroupMbidIfNullParams{
ID: arrived.ID, ReleaseGroupMbid: nilableStr(group),
}); err != nil {
t.Fatal(err)
}
_ = seedTrack(t, q, arrived.ID, artist.ID, "Lost", "/music/rg-edition-new/02.flac")
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("second tick: %v", err)
}
got := requestStatus(t, q, req.ID)
if got.Status != dbq.LidarrRequestStatusCompleted || got.MatchedAlbumID != arrived.ID {
t.Errorf("status %v matched %v, want completed on the arrived release %v",
got.Status, got.MatchedAlbumID, arrived.ID)
}
}
// Migration 0071 reopens requests the old check completed against a partly
// missing album, and leaves a genuine completion alone.
func TestMigration0071_ReopensFalseCompletions(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
user := seedUser(t, pool)
artist := seedArtist(t, q, "Reopen Artist", "rg-artist-reopen")
partial := seedAlbum(t, q, artist.ID, "Partial", "rg-release-reopen-partial")
_ = seedTrack(t, q, partial.ID, artist.ID, "Kept", "/music/rg-reopen-partial/01.flac")
lost := seedTrack(t, q, partial.ID, artist.ID, "Lost", "/music/rg-reopen-partial/02.flac")
markMissing(t, pool, lost.ID)
falseReq := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "album", LidarrArtistMBID: "rg-artist-reopen", ArtistName: "Reopen Artist",
LidarrAlbumMBID: "rg-release-reopen-partial", AlbumTitle: "Partial",
})
genuineReq := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "album", LidarrArtistMBID: "rg-artist-reopen", ArtistName: "Reopen Artist",
LidarrAlbumMBID: "rg-release-reopen-new", AlbumTitle: "New",
})
arrived := seedAlbum(t, q, artist.ID, "New", "rg-release-reopen-new")
_ = seedTrack(t, q, arrived.ID, artist.ID, "Fresh", "/music/rg-reopen-new/01.flac")
for _, c := range []struct {
id pgtype.UUID
album pgtype.UUID
}{{falseReq.ID, partial.ID}, {genuineReq.ID, arrived.ID}} {
if _, err := q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{
ID: c.id, MatchedAlbumID: c.album,
}); err != nil {
t.Fatal(err)
}
}
up, err := fs.ReadFile(db.MigrationsFS(), "0071_reopen_false_reacquisition_completions.up.sql")
if err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, string(up)); err != nil {
t.Fatalf("migration: %v", err)
}
got := requestStatus(t, q, falseReq.ID)
if got.Status != dbq.LidarrRequestStatusApproved || got.CompletedAt.Valid || got.MatchedAlbumID.Valid {
t.Errorf("false completion: status %v completed_at %v matched %v, want approved and cleared",
got.Status, got.CompletedAt, got.MatchedAlbumID)
}
if got := requestStatus(t, q, genuineReq.ID); got.Status != dbq.LidarrRequestStatusCompleted {
t.Errorf("genuine completion reopened: status %v", got.Status)
}
}
-382
View File
@@ -1,382 +0,0 @@
// Package lidarrrequests owns the lifecycle of user requests to add
// music via Lidarr. The synchronous Service handles Create/List/
// Approve/Reject/Cancel; the async Reconciler (separate file) closes
// approved requests once their target track lands in the library.
package lidarrrequests
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
)
// Public errors. Handlers map these to API error codes.
var (
ErrInvalidKindFields = errors.New("lidarrrequests: missing required fields for kind")
ErrNotPending = errors.New("lidarrrequests: request is not pending")
// ErrNotFound aliases apierror.ErrNotFound so handlers can errors.Is
// against the shared sentinel; existing lidarrrequests.ErrNotFound
// callsites still resolve to the same pointer.
ErrNotFound = apierror.ErrNotFound
ErrLidarrDisabled = errors.New("lidarrrequests: lidarr not configured")
// ErrDefaultsIncomplete fires when Lidarr is enabled but the operator
// hasn't picked a default quality profile or root folder. Without those,
// Lidarr's add-artist/add-album endpoints reject the payload with a
// generic 5xx — surfacing a typed error here lets the admin UI tell the
// operator exactly what to fix.
ErrDefaultsIncomplete = errors.New("lidarrrequests: default quality profile or root folder not configured")
)
// CreateParams is the input for a new request from a user.
type CreateParams struct {
Kind string // "artist", "album", or "track"
LidarrArtistMBID string
LidarrAlbumMBID string // required for kind=album/track
LidarrTrackMBID string // required for kind=track
ArtistName string
AlbumTitle string // required for kind=album/track
TrackTitle string // required for kind=track
}
// ApproveOverrides lets the admin override the snapshot defaults for one
// approval. Zero values mean "use config default."
type ApproveOverrides struct {
QualityProfileID int
RootFolderPath string
}
// Service owns the request lifecycle. clientFn is a factory called per
// Approve so that BaseURL/APIKey changes in lidarrconfig take effect
// immediately without restarting. scanFn is called after a successful
// Approve to trigger a library scan.
type Service struct {
pool *pgxpool.Pool
lidarrCfg *lidarrconfig.Service
clientFn func() *lidarr.Client // factory, called per Approve
scanFn func()
}
// NewService constructs a Service. Pass nil for clientFn to disable Lidarr
// (Approve returns ErrLidarrDisabled). Pass nil for scanFn to no-op.
func NewService(pool *pgxpool.Pool, cfg *lidarrconfig.Service, clientFn func() *lidarr.Client, scanFn func()) *Service {
if scanFn == nil {
scanFn = func() {}
}
if clientFn == nil {
clientFn = func() *lidarr.Client { return nil }
}
return &Service{pool: pool, lidarrCfg: cfg, clientFn: clientFn, scanFn: scanFn}
}
// Create validates the kind→required-fields invariant and inserts a
// pending row.
func (s *Service) Create(ctx context.Context, userID pgtype.UUID, p CreateParams) (dbq.LidarrRequest, error) {
row, _, err := s.CreateTracked(ctx, userID, p)
return row, err
}
// CreateTracked is Create, also reporting whether a row was inserted. A
// request that dedupes into one already in flight is not new, so callers
// that announce new requests (M489) say nothing for it.
func (s *Service) CreateTracked(ctx context.Context, userID pgtype.UUID, p CreateParams) (dbq.LidarrRequest, bool, error) {
if err := validateKindFields(p); err != nil {
return dbq.LidarrRequest{}, false, err
}
q := dbq.New(s.pool)
// Dedup: if a non-terminal request for this MBID already exists,
// return it instead of inserting a duplicate. The Discover/search UI
// already hides requested candidates; this backstops races and direct
// API callers so we don't accrue duplicate request rows. Match on the
// new request's own-kind MBID (mirrors HasNonTerminalRequestForMBID).
dedupMBID := p.LidarrArtistMBID
switch p.Kind {
case "album":
dedupMBID = p.LidarrAlbumMBID
case "track":
dedupMBID = p.LidarrTrackMBID
}
if existing, derr := q.GetNonTerminalRequestForMBID(ctx, dedupMBID); derr == nil {
return existing, false, nil
} else if !errors.Is(derr, pgx.ErrNoRows) {
return dbq.LidarrRequest{}, false, fmt.Errorf("create: dedup check: %w", derr)
}
row, err := q.CreateLidarrRequest(ctx, dbq.CreateLidarrRequestParams{
UserID: userID,
Kind: dbq.LidarrRequestKind(p.Kind),
LidarrArtistMbid: p.LidarrArtistMBID,
LidarrAlbumMbid: strPtr(p.LidarrAlbumMBID),
LidarrTrackMbid: strPtr(p.LidarrTrackMBID),
ArtistName: p.ArtistName,
AlbumTitle: strPtr(p.AlbumTitle),
TrackTitle: strPtr(p.TrackTitle),
})
if err != nil {
return dbq.LidarrRequest{}, false, fmt.Errorf("create: %w", err)
}
return row, true, nil
}
// DisplayName is a request as a person would say it: "Moe Shop" for an
// artist, "Moe Shop – WWW" for an album or a track.
func DisplayName(row dbq.LidarrRequest) string {
switch row.Kind {
case dbq.LidarrRequestKindTrack:
if row.TrackTitle != nil && *row.TrackTitle != "" {
return row.ArtistName + " – " + *row.TrackTitle
}
case dbq.LidarrRequestKindAlbum:
if row.AlbumTitle != nil && *row.AlbumTitle != "" {
return row.ArtistName + " – " + *row.AlbumTitle
}
}
return row.ArtistName
}
// requestedTitle is the album or track a request names, or "" for an
// artist request. DisplayName is the artist and this, joined.
func requestedTitle(row dbq.LidarrRequest) string {
switch {
case row.Kind == dbq.LidarrRequestKindTrack && row.TrackTitle != nil:
return *row.TrackTitle
case row.Kind == dbq.LidarrRequestKindAlbum && row.AlbumTitle != nil:
return *row.AlbumTitle
}
return ""
}
func validateKindFields(p CreateParams) error {
if p.LidarrArtistMBID == "" || p.ArtistName == "" {
return fmt.Errorf("%w: artist_mbid and artist_name are always required", ErrInvalidKindFields)
}
switch p.Kind {
case "artist":
// fine
case "album":
if p.LidarrAlbumMBID == "" || p.AlbumTitle == "" {
return fmt.Errorf("%w: album kind requires album_mbid and album_title", ErrInvalidKindFields)
}
case "track":
if p.LidarrAlbumMBID == "" || p.AlbumTitle == "" {
return fmt.Errorf("%w: track kind requires album_mbid and album_title (track will be promoted)", ErrInvalidKindFields)
}
if p.LidarrTrackMBID == "" || p.TrackTitle == "" {
return fmt.Errorf("%w: track kind requires track_mbid and track_title", ErrInvalidKindFields)
}
default:
return fmt.Errorf("%w: unknown kind %q", ErrInvalidKindFields, p.Kind)
}
return nil
}
// ListPending returns pending requests ordered by requested_at DESC.
func (s *Service) ListPending(ctx context.Context, limit int32) ([]dbq.LidarrRequest, error) {
return dbq.New(s.pool).ListLidarrRequestsByStatus(ctx, dbq.ListLidarrRequestsByStatusParams{
Status: dbq.LidarrRequestStatusPending, Limit: limit,
})
}
// ListByStatus returns requests with the given status, ordered by requested_at DESC.
func (s *Service) ListByStatus(ctx context.Context, status string, limit int32) ([]dbq.LidarrRequest, error) {
return dbq.New(s.pool).ListLidarrRequestsByStatus(ctx, dbq.ListLidarrRequestsByStatusParams{
Status: dbq.LidarrRequestStatus(status), Limit: limit,
})
}
// ListForUser returns all requests by a user, ordered by requested_at DESC.
func (s *Service) ListForUser(ctx context.Context, userID pgtype.UUID, limit int32) ([]dbq.LidarrRequest, error) {
return dbq.New(s.pool).ListLidarrRequestsForUser(ctx, dbq.ListLidarrRequestsForUserParams{
UserID: userID, Limit: limit,
})
}
// Approve records the admin's approval DURABLY first (snapshotting the
// chosen quality profile + root folder), then makes a best-effort Lidarr
// add and triggers a library scan. The add is no longer a gate: if Lidarr
// is unreachable the request still becomes approved and the reconciler
// idempotently (re)sends the add on each tick until it sticks (see
// sendLidarrAdd + Reconciler.ensureLidarrAdd) — "Lidarr just keeps
// trying" without the admin re-clicking. Pre-conditions (lidarr disabled,
// not pending, defaults incomplete) still fail fast before any approval.
func (s *Service) Approve(ctx context.Context, requestID pgtype.UUID, adminID pgtype.UUID, ov ApproveOverrides) (dbq.LidarrRequest, error) {
cfg, err := s.lidarrCfg.Get(ctx)
if err != nil {
return dbq.LidarrRequest{}, fmt.Errorf("approve: load config: %w", err)
}
client := s.clientFn()
if !cfg.Enabled || client == nil {
return dbq.LidarrRequest{}, ErrLidarrDisabled
}
row, err := dbq.New(s.pool).GetLidarrRequestByID(ctx, requestID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return dbq.LidarrRequest{}, ErrNotFound
}
return dbq.LidarrRequest{}, fmt.Errorf("approve: get: %w", err)
}
if row.Status != dbq.LidarrRequestStatusPending {
return dbq.LidarrRequest{}, ErrNotPending
}
qp := cfg.DefaultQualityProfileID
if ov.QualityProfileID != 0 {
qp = ov.QualityProfileID
}
rf := cfg.DefaultRootFolderPath
if ov.RootFolderPath != "" {
rf = ov.RootFolderPath
}
if qp == 0 || rf == "" {
return dbq.LidarrRequest{}, ErrDefaultsIncomplete
}
// Record the approval durably FIRST so a Lidarr outage can't lose the
// admin's decision. The snapshotted qp/rf also feed the reconciler's
// retry, so a deferred add uses exactly what the admin approved with.
approved, err := dbq.New(s.pool).ApproveLidarrRequest(ctx, dbq.ApproveLidarrRequestParams{
ID: requestID,
QualityProfileID: int32Ptr(qp),
RootFolderPath: strPtr(rf),
DecidedBy: adminID,
})
if err != nil {
return dbq.LidarrRequest{}, fmt.Errorf("approve: persist: %w", err)
}
// Best-effort immediate add for the common (Lidarr-up) path. Any
// failure is deliberately swallowed: the request is already durably
// approved and the reconciler retries the add on its next tick,
// leaving lidarr_add_confirmed_at NULL until it succeeds.
if addErr := sendLidarrAdd(ctx, client, cfg, approved); addErr == nil {
_ = dbq.New(s.pool).MarkLidarrRequestAddConfirmed(ctx, requestID)
}
s.scanFn()
return approved, nil
}
// sendLidarrAdd performs the idempotent Lidarr add for an approved
// request. Used by Service.Approve (best-effort, common path) and by the
// Reconciler (retry path). qp/rf come from the request's snapshot (set at
// Approve time) with a config-default fallback; the metadata profile is
// resolved from config, falling back to Lidarr's first profile for
// operators who upgraded from before metadata-profile support. Treats
// lidarr.ErrAlreadyExists as success — the artist/album is already in
// Lidarr, which is exactly the desired end state.
func sendLidarrAdd(ctx context.Context, client *lidarr.Client, cfg lidarrconfig.Config, row dbq.LidarrRequest) error {
qp := cfg.DefaultQualityProfileID
if row.QualityProfileID != nil && *row.QualityProfileID != 0 {
qp = int(*row.QualityProfileID)
}
rf := cfg.DefaultRootFolderPath
if row.RootFolderPath != nil && *row.RootFolderPath != "" {
rf = *row.RootFolderPath
}
if qp == 0 || rf == "" {
return ErrDefaultsIncomplete
}
mdProfileID := cfg.DefaultMetadataProfileID
if mdProfileID == 0 {
mdProfiles, mdErr := client.ListMetadataProfiles(ctx)
if mdErr != nil {
return fmt.Errorf("list metadata profiles: %w", mdErr)
}
if len(mdProfiles) == 0 {
return fmt.Errorf("no metadata profiles configured in lidarr")
}
mdProfileID = mdProfiles[0].ID
}
var err error
switch row.Kind {
case dbq.LidarrRequestKindArtist:
err = client.AddArtist(ctx, lidarr.AddArtistParams{
ForeignArtistID: row.LidarrArtistMbid,
ArtistName: row.ArtistName,
QualityProfileID: qp,
MetadataProfileID: mdProfileID,
RootFolderPath: rf,
MonitorAll: true,
})
case dbq.LidarrRequestKindAlbum, dbq.LidarrRequestKindTrack:
// Track-kind requests promote to album-add; the spec is explicit.
albumMBID := ""
if row.LidarrAlbumMbid != nil {
albumMBID = *row.LidarrAlbumMbid
}
err = client.AddAlbum(ctx, lidarr.AddAlbumParams{
ForeignAlbumID: albumMBID,
ForeignArtistID: row.LidarrArtistMbid,
ArtistName: row.ArtistName,
QualityProfileID: qp,
MetadataProfileID: mdProfileID,
RootFolderPath: rf,
})
}
if err != nil && !errors.Is(err, lidarr.ErrAlreadyExists) {
return err
}
return nil
}
// Reject transitions a pending request to rejected, recording notes and
// who decided. Returns ErrNotPending if the request is not pending,
// ErrNotFound if no such request exists.
func (s *Service) Reject(ctx context.Context, requestID pgtype.UUID, adminID pgtype.UUID, notes string) (dbq.LidarrRequest, error) {
row, err := dbq.New(s.pool).RejectLidarrRequest(ctx, dbq.RejectLidarrRequestParams{
ID: requestID, Notes: strPtr(notes), DecidedBy: adminID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// Either not found OR not pending — check which.
cur, gerr := dbq.New(s.pool).GetLidarrRequestByID(ctx, requestID)
if gerr != nil {
return dbq.LidarrRequest{}, ErrNotFound
}
if cur.Status != dbq.LidarrRequestStatusPending {
return dbq.LidarrRequest{}, ErrNotPending
}
return dbq.LidarrRequest{}, ErrNotFound
}
return dbq.LidarrRequest{}, fmt.Errorf("reject: %w", err)
}
return row, nil
}
// Cancel lets a user withdraw their own pending request. The SQL WHERE
// clause enforces both ownership (user_id = $2) and pending status
// (status = 'pending'). A zero-rows result always means ErrNotPending
// from the caller's perspective.
func (s *Service) Cancel(ctx context.Context, requestID pgtype.UUID, userID pgtype.UUID) (dbq.LidarrRequest, error) {
row, err := dbq.New(s.pool).CancelLidarrRequest(ctx, dbq.CancelLidarrRequestParams{
ID: requestID, DecidedBy: userID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return dbq.LidarrRequest{}, ErrNotPending
}
return dbq.LidarrRequest{}, fmt.Errorf("cancel: %w", err)
}
return row, nil
}
func strPtr(s string) *string {
if s == "" {
return nil
}
return &s
}
func int32Ptr(i int) *int32 {
if i == 0 {
return nil
}
v := int32(i)
return &v
}
-343
View File
@@ -1,343 +0,0 @@
package lidarrrequests
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
)
func newPool(t *testing.T) *pgxpool.Pool {
t.Helper()
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
dbtest.ResetDB(t, pool)
if _, err := pool.Exec(context.Background(),
"DELETE FROM lidarr_requests; UPDATE lidarr_config SET enabled=false, base_url=NULL, api_key=NULL WHERE id=1",
); err != nil {
t.Fatalf("reset lidarr tables: %v", err)
}
return pool
}
func seedUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID {
t.Helper()
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "rqtester", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: false,
})
if err != nil {
t.Fatalf("seed user: %v", err)
}
return u.ID
}
func TestCreate_HappyPath_Artist(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool)
svc := NewService(pool, lidarrconfig.New(pool), nil, nil)
r, err := svc.Create(context.Background(), user, CreateParams{
Kind: "artist",
LidarrArtistMBID: "069b64b6-7884-4f6a-94cc-e4c1d6c87a01",
ArtistName: "Boards of Canada",
})
if err != nil {
t.Fatalf("Create: %v", err)
}
if r.Status != dbq.LidarrRequestStatusPending {
t.Errorf("status = %v", r.Status)
}
}
func TestCreate_TrackKindRequiresAlbumMBID(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool)
svc := NewService(pool, lidarrconfig.New(pool), nil, nil)
_, err := svc.Create(context.Background(), user, CreateParams{
Kind: "track",
LidarrArtistMBID: "a-mbid", ArtistName: "X",
LidarrTrackMBID: "t-mbid", TrackTitle: "Y",
// missing album fields
})
if !errors.Is(err, ErrInvalidKindFields) {
t.Fatalf("err = %v, want ErrInvalidKindFields", err)
}
}
func TestApprove_NotConfigured(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool)
svc := NewService(pool, lidarrconfig.New(pool), nil, nil)
r, _ := svc.Create(context.Background(), user, CreateParams{
Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X",
})
_, err := svc.Approve(context.Background(), r.ID, user, ApproveOverrides{})
if !errors.Is(err, ErrLidarrDisabled) {
t.Fatalf("err = %v, want ErrLidarrDisabled", err)
}
}
func TestReject_TransitionsToRejected(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool)
svc := NewService(pool, lidarrconfig.New(pool), nil, nil)
r, _ := svc.Create(context.Background(), user, CreateParams{
Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X",
})
rejected, err := svc.Reject(context.Background(), r.ID, user, "low quality")
if err != nil {
t.Fatalf("Reject: %v", err)
}
if rejected.Status != dbq.LidarrRequestStatusRejected {
t.Errorf("status = %v", rejected.Status)
}
}
func TestReject_AlreadyRejectedReturnsErrNotPending(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool)
svc := NewService(pool, lidarrconfig.New(pool), nil, nil)
r, _ := svc.Create(context.Background(), user, CreateParams{
Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X",
})
_, _ = svc.Reject(context.Background(), r.ID, user, "first")
_, err := svc.Reject(context.Background(), r.ID, user, "second")
if !errors.Is(err, ErrNotPending) {
t.Fatalf("err = %v, want ErrNotPending", err)
}
}
func TestCancel_OwnPendingOnly(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool)
svc := NewService(pool, lidarrconfig.New(pool), nil, nil)
r, _ := svc.Create(context.Background(), user, CreateParams{
Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X",
})
if _, err := svc.Cancel(context.Background(), r.ID, user); err != nil {
t.Fatalf("Cancel: %v", err)
}
// Second cancel hits "not pending" because we just rejected it.
if _, err := svc.Cancel(context.Background(), r.ID, user); !errors.Is(err, ErrNotPending) {
t.Errorf("err = %v, want ErrNotPending", err)
}
}
func TestCreate_AlbumKindRequiresAlbumMBID(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool)
svc := NewService(pool, lidarrconfig.New(pool), nil, nil)
_, err := svc.Create(context.Background(), user, CreateParams{
Kind: "album",
LidarrArtistMBID: "a-mbid", ArtistName: "X",
// missing album_mbid + album_title
})
if !errors.Is(err, ErrInvalidKindFields) {
t.Fatalf("err = %v, want ErrInvalidKindFields", err)
}
}
func TestCreate_UnknownKindRejected(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool)
svc := NewService(pool, lidarrconfig.New(pool), nil, nil)
_, err := svc.Create(context.Background(), user, CreateParams{
Kind: "playlist", LidarrArtistMBID: "a-mbid", ArtistName: "X",
})
if !errors.Is(err, ErrInvalidKindFields) {
t.Fatalf("err = %v, want ErrInvalidKindFields", err)
}
}
func TestListPending_AndListByStatus(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool)
svc := NewService(pool, lidarrconfig.New(pool), nil, nil)
for _, name := range []string{"A", "B"} {
if _, err := svc.Create(context.Background(), user, CreateParams{
Kind: "artist", LidarrArtistMBID: name + "-mbid", ArtistName: name,
}); err != nil {
t.Fatalf("Create(%s): %v", name, err)
}
}
pending, err := svc.ListPending(context.Background(), 50)
if err != nil {
t.Fatalf("ListPending: %v", err)
}
if len(pending) != 2 {
t.Errorf("ListPending len = %d, want 2", len(pending))
}
rejected, err := svc.ListByStatus(context.Background(), "rejected", 50)
if err != nil {
t.Fatalf("ListByStatus(rejected): %v", err)
}
if len(rejected) != 0 {
t.Errorf("ListByStatus(rejected) len = %d, want 0", len(rejected))
}
}
func TestListForUser_OnlyOwnRows(t *testing.T) {
pool := newPool(t)
alice := seedUser(t, pool)
bob, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiTokenHash: "x2", IsAdmin: false,
})
if err != nil {
t.Fatalf("seed bob: %v", err)
}
svc := NewService(pool, lidarrconfig.New(pool), nil, nil)
if _, err := svc.Create(context.Background(), alice, CreateParams{
Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "Alice's request",
}); err != nil {
t.Fatalf("Create(alice): %v", err)
}
if _, err := svc.Create(context.Background(), bob.ID, CreateParams{
Kind: "artist", LidarrArtistMBID: "b-mbid", ArtistName: "Bob's request",
}); err != nil {
t.Fatalf("Create(bob): %v", err)
}
rows, err := svc.ListForUser(context.Background(), alice, 50)
if err != nil {
t.Fatalf("ListForUser: %v", err)
}
if len(rows) != 1 || rows[0].ArtistName != "Alice's request" {
t.Errorf("ListForUser = %+v, want only Alice's row", rows)
}
}
// approveTestSetup wires Service against a stub Lidarr server. Returns the
// Service, an admin user id, and the captured Lidarr request body for the
// most recent call (handler updates it in-place).
func approveTestSetup(t *testing.T) (*Service, pgtype.UUID, *pgxpool.Pool, *httptest.Server) {
t.Helper()
pool := newPool(t)
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
// Approve resolves metadata/quality profiles before the add;
// those list endpoints return JSON arrays, not the {"id":1}
// object the add endpoints return.
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":7,"name":"Lossless"}]`))
default:
_, _ = w.Write([]byte(`{"id":1}`))
}
}))
t.Cleanup(stub.Close)
cfg := lidarrconfig.New(pool)
if err := cfg.Save(context.Background(), lidarrconfig.Config{
Enabled: true,
BaseURL: stub.URL,
APIKey: "k",
DefaultQualityProfileID: 7,
DefaultRootFolderPath: "/music",
}); err != nil {
t.Fatalf("save config: %v", err)
}
clientFn := func() *lidarr.Client { return lidarr.NewClient(stub.URL, "k") }
svc := NewService(pool, cfg, clientFn, nil)
user := seedUser(t, pool)
return svc, user, pool, stub
}
func TestApprove_HappyPath_ArtistKind(t *testing.T) {
svc, user, _, _ := approveTestSetup(t)
r, err := svc.Create(context.Background(), user, CreateParams{
Kind: "artist", LidarrArtistMBID: "ar-mbid", ArtistName: "Approvee",
})
if err != nil {
t.Fatalf("Create: %v", err)
}
approved, err := svc.Approve(context.Background(), r.ID, user, ApproveOverrides{})
if err != nil {
t.Fatalf("Approve: %v", err)
}
if approved.Status != dbq.LidarrRequestStatusApproved {
t.Errorf("status = %v, want approved", approved.Status)
}
// Default snapshot from config.
if approved.QualityProfileID == nil || *approved.QualityProfileID != 7 {
t.Errorf("quality_profile_id = %v, want 7", approved.QualityProfileID)
}
if approved.RootFolderPath == nil || *approved.RootFolderPath != "/music" {
t.Errorf("root_folder_path = %v, want /music", approved.RootFolderPath)
}
}
func TestApprove_HappyPath_AlbumKindWithOverride(t *testing.T) {
svc, user, _, _ := approveTestSetup(t)
r, err := svc.Create(context.Background(), user, CreateParams{
Kind: "album",
LidarrArtistMBID: "ar-mbid", ArtistName: "X",
LidarrAlbumMBID: "al-mbid", AlbumTitle: "Y",
})
if err != nil {
t.Fatalf("Create: %v", err)
}
approved, err := svc.Approve(context.Background(), r.ID, user, ApproveOverrides{
QualityProfileID: 99,
RootFolderPath: "/elsewhere",
})
if err != nil {
t.Fatalf("Approve: %v", err)
}
if approved.QualityProfileID == nil || *approved.QualityProfileID != 99 {
t.Errorf("quality_profile_id = %v, want 99 (override)", approved.QualityProfileID)
}
if approved.RootFolderPath == nil || *approved.RootFolderPath != "/elsewhere" {
t.Errorf("root_folder_path = %v, want /elsewhere", approved.RootFolderPath)
}
}
func TestApprove_AlreadyApprovedReturnsErrNotPending(t *testing.T) {
svc, user, _, _ := approveTestSetup(t)
r, _ := svc.Create(context.Background(), user, CreateParams{
Kind: "artist", LidarrArtistMBID: "ar-mbid", ArtistName: "X",
})
if _, err := svc.Approve(context.Background(), r.ID, user, ApproveOverrides{}); err != nil {
t.Fatalf("first approve: %v", err)
}
if _, err := svc.Approve(context.Background(), r.ID, user, ApproveOverrides{}); !errors.Is(err, ErrNotPending) {
t.Errorf("second approve err = %v, want ErrNotPending", err)
}
}
func TestApprove_NotFound(t *testing.T) {
svc, user, _, _ := approveTestSetup(t)
var bogus pgtype.UUID
bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
bogus.Valid = true
if _, err := svc.Approve(context.Background(), bogus, user, ApproveOverrides{}); !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
+19 -68
View File
@@ -1,11 +1,12 @@
// Package reacquisition turns a missing file back into a Lidarr request
// without anyone pressing anything (milestone #290).
// Package reacquisition wants an album back when its files go missing,
// without anyone pressing anything (milestone #290; a want rather than a
// Lidarr request since M502 #5520).
//
// The unit of work is the ALBUM, not the track. Lidarr acquires releases;
// there is no meaningful "fetch me one track", and a track-kind request needs
// a recording MBID plenty of files lack. Grouping also does most of the
// The unit of work is the ALBUM, not the track. Music is acquired as
// releases; there is no meaningful "fetch me one track", and a recording
// MBID is something plenty of files lack. Grouping also does most of the
// safety work: the loss that produced #2523 — three reorganised albums, ~40
// missing files — becomes three requests rather than forty.
// missing files — becomes three wants rather than forty.
package reacquisition
import (
@@ -14,7 +15,6 @@ import (
"fmt"
"log/slog"
"sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
@@ -24,13 +24,9 @@ import (
// Settings is the operator-tunable policy, mirroring the columns and CHECK
// ranges in migration 0056.
type Settings struct {
Enabled bool
GraceHours int32
BackoffBaseHours int32
BackoffMaxHours int32
MaxAttempts int32
MaxPerPass int32
AutoApprove bool
Enabled bool
GraceHours int32
MaxPerPass int32
}
// Defaults mirror migration 0056's column defaults. Duplicated here so a
@@ -38,13 +34,9 @@ type Settings struct {
// zero-valued one — a zero grace window would fire on every transient
// unmount, which is the exact failure the grace period exists to prevent.
var Defaults = Settings{
Enabled: true,
GraceHours: 24,
BackoffBaseHours: 6,
BackoffMaxHours: 168,
MaxAttempts: 3,
MaxPerPass: 20,
AutoApprove: true,
Enabled: true,
GraceHours: 24,
MaxPerPass: 20,
}
// ErrOutOfRange is returned by Set for values migration 0056's CHECKs would
@@ -92,13 +84,9 @@ func (s *SettingsService) Set(ctx context.Context, in Settings) (Settings, error
return Settings{}, err
}
row, err := dbq.New(s.pool).UpdateReacquisitionSettings(ctx, dbq.UpdateReacquisitionSettingsParams{
Enabled: in.Enabled,
GraceHours: in.GraceHours,
BackoffBaseHours: in.BackoffBaseHours,
BackoffMaxHours: in.BackoffMaxHours,
MaxAttempts: in.MaxAttempts,
MaxPerPass: in.MaxPerPass,
AutoApprove: in.AutoApprove,
Enabled: in.Enabled,
GraceHours: in.GraceHours,
MaxPerPass: in.MaxPerPass,
})
if err != nil {
return Settings{}, fmt.Errorf("reacquisition: save settings: %w", err)
@@ -110,43 +98,10 @@ func (s *SettingsService) Set(ctx context.Context, in Settings) (Settings, error
return out, nil
}
// Backoff is how long to wait before the next attempt on an album that has
// already been tried [attempts] times: base * 2^(attempts-1), clamped to the
// configured maximum. Zero attempts means "never tried", which is always due.
//
// Exported and pure so the schedule is testable without a database, and so
// the admin surface can show the same number the sweeper will act on.
func (s Settings) Backoff(attempts int32) time.Duration {
if attempts <= 0 {
return 0
}
hours := s.BackoffBaseHours
for i := int32(1); i < attempts; i++ {
hours *= 2
// Clamp inside the loop as well as after: doubling from a large base
// enough times would overflow int32 before the comparison ran.
if hours >= s.BackoffMaxHours {
return time.Duration(s.BackoffMaxHours) * time.Hour
}
}
if hours > s.BackoffMaxHours {
hours = s.BackoffMaxHours
}
return time.Duration(hours) * time.Hour
}
func validate(in Settings) error {
switch {
case in.GraceHours < 1 || in.GraceHours > 720:
return fmt.Errorf("%w: grace_hours must be 1-720", ErrOutOfRange)
case in.BackoffBaseHours < 1 || in.BackoffBaseHours > 168:
return fmt.Errorf("%w: backoff_base_hours must be 1-168", ErrOutOfRange)
case in.BackoffMaxHours < 1 || in.BackoffMaxHours > 720:
return fmt.Errorf("%w: backoff_max_hours must be 1-720", ErrOutOfRange)
case in.BackoffMaxHours < in.BackoffBaseHours:
return fmt.Errorf("%w: backoff_max_hours must be >= backoff_base_hours", ErrOutOfRange)
case in.MaxAttempts < 1 || in.MaxAttempts > 10:
return fmt.Errorf("%w: max_attempts must be 1-10", ErrOutOfRange)
case in.MaxPerPass < 1 || in.MaxPerPass > 200:
return fmt.Errorf("%w: max_per_pass must be 1-200", ErrOutOfRange)
}
@@ -155,12 +110,8 @@ func validate(in Settings) error {
func fromRow(row dbq.ReacquisitionSetting) Settings {
return Settings{
Enabled: row.Enabled,
GraceHours: row.GraceHours,
BackoffBaseHours: row.BackoffBaseHours,
BackoffMaxHours: row.BackoffMaxHours,
MaxAttempts: row.MaxAttempts,
MaxPerPass: row.MaxPerPass,
AutoApprove: row.AutoApprove,
Enabled: row.Enabled,
GraceHours: row.GraceHours,
MaxPerPass: row.MaxPerPass,
}
}
-85
View File
@@ -3,69 +3,8 @@ package reacquisition
import (
"errors"
"testing"
"time"
)
func TestBackoffSchedule(t *testing.T) {
s := Defaults // 6h base, 168h (one week) cap, 3 attempts
cases := []struct {
name string
attempts int32
want time.Duration
}{
// Never tried is always due — the grace window, not the backoff, is
// what holds the first attempt back.
{"never attempted", 0, 0},
{"negative is treated as never", -1, 0},
{"after one attempt", 1, 6 * time.Hour},
{"after two", 2, 12 * time.Hour},
{"after three", 3, 24 * time.Hour},
{"after four", 4, 48 * time.Hour},
{"after five", 5, 96 * time.Hour},
// 6h * 2^5 = 192h, past the one-week cap.
{"clamped at the cap", 6, 168 * time.Hour},
{"still clamped far out", 20, 168 * time.Hour},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := s.Backoff(c.attempts); got != c.want {
t.Errorf("Backoff(%d) = %v, want %v", c.attempts, got, c.want)
}
})
}
}
// The doubling must not be able to overflow int32 before the clamp is
// consulted — a large base with a high attempt count is the case that would
// wrap negative and make a spent album look due immediately.
func TestBackoffDoesNotOverflow(t *testing.T) {
s := Settings{BackoffBaseHours: 168, BackoffMaxHours: 720}
for attempts := int32(1); attempts <= 40; attempts++ {
got := s.Backoff(attempts)
if got <= 0 {
t.Fatalf("Backoff(%d) = %v, want a positive duration", attempts, got)
}
if got > 720*time.Hour {
t.Fatalf("Backoff(%d) = %v, want <= the 720h cap", attempts, got)
}
}
}
func TestBackoffRespectsCustomSettings(t *testing.T) {
s := Settings{BackoffBaseHours: 1, BackoffMaxHours: 4}
for attempts, want := range map[int32]time.Duration{
1: 1 * time.Hour,
2: 2 * time.Hour,
3: 4 * time.Hour,
4: 4 * time.Hour, // clamped
} {
if got := s.Backoff(attempts); got != want {
t.Errorf("Backoff(%d) = %v, want %v", attempts, got, want)
}
}
}
func TestValidateRejectsWhatTheCheckWouldReject(t *testing.T) {
// Each case mirrors a CHECK in migration 0056. Validating in Go as well
// means the API answers 400 with a readable message instead of surfacing
@@ -77,15 +16,6 @@ func TestValidateRejectsWhatTheCheckWouldReject(t *testing.T) {
{"zero grace would fire on every transient unmount",
mutate(func(s *Settings) { s.GraceHours = 0 })},
{"grace beyond a month", mutate(func(s *Settings) { s.GraceHours = 721 })},
{"zero backoff base", mutate(func(s *Settings) { s.BackoffBaseHours = 0 })},
{"backoff base beyond a week", mutate(func(s *Settings) { s.BackoffBaseHours = 169 })},
{"zero backoff cap", mutate(func(s *Settings) { s.BackoffMaxHours = 0 })},
{"cap below base is incoherent", mutate(func(s *Settings) {
s.BackoffBaseHours = 48
s.BackoffMaxHours = 24
})},
{"zero attempts means never try", mutate(func(s *Settings) { s.MaxAttempts = 0 })},
{"attempts beyond ten", mutate(func(s *Settings) { s.MaxAttempts = 11 })},
{"zero per pass means never sweep", mutate(func(s *Settings) { s.MaxPerPass = 0 })},
{"per pass beyond the cap", mutate(func(s *Settings) { s.MaxPerPass = 201 })},
}
@@ -104,21 +34,6 @@ func TestValidateAcceptsDefaults(t *testing.T) {
}
}
// Equal base and cap is legal — it is how an operator asks for a flat retry
// interval rather than an escalating one.
func TestValidateAcceptsFlatBackoff(t *testing.T) {
s := mutate(func(s *Settings) {
s.BackoffBaseHours = 12
s.BackoffMaxHours = 12
})
if err := validate(s); err != nil {
t.Fatalf("flat backoff should be allowed, got %v", err)
}
if got := s.Backoff(5); got != 12*time.Hour {
t.Errorf("flat backoff gave %v, want 12h at every attempt", got)
}
}
func mutate(f func(*Settings)) Settings {
s := Defaults
f(&s)
+37 -166
View File
@@ -7,61 +7,38 @@ import (
"log/slog"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
// requestCreator is the slice of lidarrrequests.Service the sweeper needs,
// narrowed to an interface so the pass can be tested without a Lidarr client
// or an approval path that talks to one.
type requestCreator interface {
CreateTracked(ctx context.Context, userID pgtype.UUID, p lidarrrequests.CreateParams) (dbq.LidarrRequest, bool, error)
Approve(ctx context.Context, requestID, adminID pgtype.UUID, ov lidarrrequests.ApproveOverrides) (dbq.LidarrRequest, error)
}
// Sweeper periodically turns albums with long-missing files into Lidarr
// requests (milestone #290).
// Sweeper periodically wants back albums whose files have been missing past
// the grace window (milestone #290, M502 #5520). Searching for them is the
// search schedule's (#5521), which retries per want.
//
// Deliberately a periodic worker rather than a hook inside the scanner's
// reconcile pass. Reconcile runs inside a scan and has no business deciding
// to talk to a third-party service; it also re-runs often, which would make
// "attempt once, then back off" awkward to express. A worker paces itself,
// survives a restart, and retries without needing another scan.
// reconcile pass: reconcile runs inside a scan and re-runs often, and a
// filesystem lies transiently (an unmounted volume, a NAS mid-reboot) in ways
// the grace window exists to ride out.
type Sweeper struct {
pool *pgxpool.Pool
settings *SettingsService
requests requestCreator
logger *slog.Logger
// notifier tells admins about a request the sweeper filed that still
// needs their approval (M489). Nil: nobody is told.
notifier *notifications.Notifier
tick time.Duration
// releaseGroup names the MusicBrainz release group of a release id, for an
// album whose tags never carried one (#5241). Lidarr knows albums only by
// release group. A field so tests can stand in for MusicBrainz.
// album whose tags never carried one (#5241). Wants are kept by release
// group. A field so tests can stand in for MusicBrainz.
releaseGroup func(ctx context.Context, releaseMBID string) (string, error)
}
// NewSweeper constructs a Sweeper. The tick is deliberately coarse: the
// smallest meaningful backoff is measured in hours, so waking more often than
// hourly would only re-read settings and find nothing due.
func NewSweeper(
pool *pgxpool.Pool,
settings *SettingsService,
requests requestCreator,
logger *slog.Logger,
) *Sweeper {
// NewSweeper constructs a Sweeper. The tick is deliberately coarse: the grace
// window is measured in hours.
func NewSweeper(pool *pgxpool.Pool, settings *SettingsService, logger *slog.Logger) *Sweeper {
return &Sweeper{
pool: pool,
settings: settings,
requests: requests,
logger: logger,
tick: 1 * time.Hour,
releaseGroup: tags.ReleaseGroupForRelease,
@@ -86,100 +63,49 @@ func (s *Sweeper) Run(ctx context.Context) {
// PassResult reports what a single sweep did, for logging and tests.
type PassResult struct {
Cleared int64 // albums whose files came back; state dropped
Requested int // requests created this pass
Approved int // of those, sent on to Lidarr
GaveUp int // albums that spent their attempt budget
Unnameable int64 // albums with missing files but no MBID to ask for
Wanted int // albums newly wanted, or wanted again after being held
Unnameable int64 // albums with missing files but no MBID to want them by
// Unresolved: albums skipped this pass because MusicBrainz could not name
// their release group (switched off, or no such release). Lidarr cannot be
// asked for them, and no attempt is spent; the next pass tries again.
// their release group; the next pass tries again.
Unresolved int
}
// SetNotifier makes requests the sweeper files, and cannot approve itself,
// reach the admins' notifications inbox (M489).
func (s *Sweeper) SetNotifier(n *notifications.Notifier) { s.notifier = n }
// SweepOnce runs one pass. Exported so the admin surface can offer a "run
// now" without waiting out the tick, and so tests drive it directly.
// SweepOnce runs one pass. Exported so tests drive it directly.
func (s *Sweeper) SweepOnce(ctx context.Context) error {
cfg := s.settings.Get()
if !cfg.Enabled {
return nil
}
q := dbq.New(s.pool)
// Before selecting work: drop state for albums whose files came back, or
// were adopted at a new path (#2528). Doing this first means a recovered
// album cannot be picked in the same pass that would have retried it.
cleared, err := q.ClearRecoveredReacquisitions(ctx)
if err != nil {
return fmt.Errorf("clear recovered: %w", err)
}
res := PassResult{Cleared: cleared}
var res PassResult
// Counted, not acted on: an album MusicBrainz cannot name is not a
// failure to retry, it is a permanent gap the operator should see.
if n, cerr := q.CountAlbumsMissingWithoutMbid(ctx); cerr == nil {
res.Unnameable = n
}
due, err := q.ListAlbumsDueReacquisition(ctx, dbq.ListAlbumsDueReacquisitionParams{
GraceHours: cfg.GraceHours,
BackoffBaseHours: cfg.BackoffBaseHours,
BackoffMaxHours: cfg.BackoffMaxHours,
PageLimit: cfg.MaxPerPass,
GraceHours: cfg.GraceHours,
PageLimit: cfg.MaxPerPass,
})
if err != nil {
return fmt.Errorf("list due: %w", err)
}
if len(due) == 0 {
s.logSummary(res)
return nil
}
// One admin lookup per pass, not per album. A library with no admin at
// all cannot own a request, so the pass stops rather than half-working.
admin, err := q.GetOldestAdmin(ctx)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
s.logger.Warn("reacquisition: no admin account to own requests; skipping pass")
return nil
}
return fmt.Errorf("owner lookup: %w", err)
}
for _, album := range due {
if err := s.attempt(ctx, q, cfg, admin.ID, album, &res); err != nil {
// One album's failure must not abandon the rest of the pass: a
// single unmatched MBID or a transient Lidarr error says nothing
// about the next album in the list.
s.logger.Warn("reacquisition: attempt failed",
"album", album.AlbumTitle, "err", err)
if err := s.want(ctx, q, album, &res); err != nil {
// One album's failure must not abandon the rest of the pass.
s.logger.Warn("reacquisition: want failed", "album", album.AlbumTitle, "err", err)
}
}
s.logSummary(res)
return nil
}
// attempt creates (and optionally approves) the request for one album, then
// records the attempt against its backoff budget.
func (s *Sweeper) attempt(
ctx context.Context,
q *dbq.Queries,
cfg Settings,
adminID pgtype.UUID,
album dbq.ListAlbumsDueReacquisitionRow,
res *PassResult,
) error {
// The query already filters these out; belt and braces, because Create
// would reject the request and burn an attempt for no reason.
// want records one album as wanted under its release group.
func (s *Sweeper) want(ctx context.Context, q *dbq.Queries, album dbq.ListAlbumsDueReacquisitionRow, res *PassResult) error {
// The query already filters these out; belt and braces.
if album.AlbumMbid == nil || album.ArtistMbid == nil {
return nil
}
// Lidarr names albums by release group; albums.mbid is the release.
group, err := s.albumReleaseGroup(ctx, q, album)
if err != nil {
if errors.Is(err, tags.ErrNotFound) {
@@ -190,77 +116,25 @@ func (s *Sweeper) attempt(
}
return fmt.Errorf("release group: %w", err)
}
req, created, err := s.requests.CreateTracked(ctx, adminID, lidarrrequests.CreateParams{
Kind: "album",
LidarrArtistMBID: *album.ArtistMbid,
ArtistName: album.ArtistName,
LidarrAlbumMBID: group,
AlbumTitle: album.AlbumTitle,
_, err = q.WantMissingAlbum(ctx, dbq.WantMissingAlbumParams{
ReleaseGroupMbid: group, ArtistMbid: *album.ArtistMbid, ArtistName: album.ArtistName,
Title: album.AlbumTitle, AlbumID: album.AlbumID,
})
if errors.Is(err, pgx.ErrNoRows) {
// Already wanted, open: by a request, a follow, or this sweeper.
return nil
}
if err != nil {
return fmt.Errorf("create request: %w", err)
}
res.Requested++
// Record the attempt even when Create deduped into somebody else's
// existing request: the point of the counter is "how often have we gone
// looking for this album", and a manual request in flight is a reason to
// wait rather than to keep asking.
row, err := q.RecordReacquisitionAttempt(ctx, dbq.RecordReacquisitionAttemptParams{
AlbumID: album.AlbumID,
LastRequestID: req.ID,
})
if err != nil {
return fmt.Errorf("record attempt: %w", err)
}
// A request the sweeper filed and could not approve waits on an admin.
// One it deduped into was announced when it was first filed.
awaitingAdmin := created
if cfg.AutoApprove {
_, aerr := s.requests.Approve(ctx, req.ID, adminID, lidarrrequests.ApproveOverrides{})
switch {
case aerr == nil:
res.Approved++
awaitingAdmin = false
case errors.Is(aerr, lidarrrequests.ErrLidarrDisabled):
// Leave it pending rather than treating it as a failure. The
// request is still the right record of intent, and it becomes
// actionable the moment Lidarr is configured.
s.logger.Info("reacquisition: request left pending, Lidarr disabled",
"album", album.AlbumTitle)
case errors.Is(aerr, lidarrrequests.ErrNotPending):
// Deduped onto a request somebody already approved. Nothing to do
// and nothing wrong.
default:
return fmt.Errorf("approve: %w", aerr)
}
}
if awaitingAdmin {
s.notifier.NotifyLogged(ctx, notifications.KindRequestPending, notifications.ToAdmins(pgtype.UUID{}),
notifications.Payload{
RequestID: uuid.UUID(req.ID.Bytes).String(),
RequestKind: string(req.Kind),
Name: lidarrrequests.DisplayName(req),
Actor: "Re-acquisition",
}.Map())
}
if row.Attempts >= cfg.MaxAttempts {
if err := q.MarkReacquisitionGaveUp(ctx, album.AlbumID); err != nil {
return fmt.Errorf("mark gave up: %w", err)
}
res.GaveUp++
return fmt.Errorf("want: %w", err)
}
res.Wanted++
return nil
}
// albumReleaseGroup is the album's release group: the one its tags carried,
// else MusicBrainz's answer for its release id, cached onto the album so the
// next pass (and request completion) need not ask again. Returns
// tags.ErrNotFound when MusicBrainz cannot name it.
// next pass need not ask again. Returns tags.ErrNotFound when MusicBrainz
// cannot name it.
func (s *Sweeper) albumReleaseGroup(ctx context.Context, q *dbq.Queries, album dbq.ListAlbumsDueReacquisitionRow) (string, error) {
if album.AlbumReleaseGroupMbid != nil && *album.AlbumReleaseGroupMbid != "" {
return *album.AlbumReleaseGroupMbid, nil
@@ -281,14 +155,11 @@ func (s *Sweeper) albumReleaseGroup(ctx context.Context, q *dbq.Queries, album d
func (s *Sweeper) logSummary(res PassResult) {
// Silence when a pass did nothing at all — this runs hourly forever, and
// an unconditional line would bury the passes that mattered.
if res.Requested == 0 && res.Cleared == 0 && res.GaveUp == 0 && res.Unresolved == 0 {
if res.Wanted == 0 && res.Unresolved == 0 {
return
}
s.logger.Info("reacquisition: sweep",
"requested", res.Requested,
"approved", res.Approved,
"gave_up", res.GaveUp,
"cleared", res.Cleared,
"wanted", res.Wanted,
"unnameable", res.Unnameable,
"unresolved", res.Unresolved,
)
+56 -60
View File
@@ -13,9 +13,6 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
@@ -37,9 +34,6 @@ func newSweepPool(t *testing.T) *pgxpool.Pool {
}
t.Cleanup(pool.Close)
dbtest.ResetDB(t, pool)
if _, err := pool.Exec(context.Background(), "DELETE FROM lidarr_requests"); err != nil {
t.Fatalf("reset requests: %v", err)
}
return pool
}
@@ -75,39 +69,25 @@ func lostAlbum(t *testing.T, pool *pgxpool.Pool, title, release, group string) p
return al.ID
}
// #5241: re-acquisition asks Lidarr for the album's release group, never its
// release id. A tag-supplied group is used as is; otherwise MusicBrainz names
// it and the answer is cached onto the album; an album MusicBrainz cannot
// name is skipped without spending an attempt.
func TestSweep_RequestsAlbumsByReleaseGroup_Integration(t *testing.T) {
// #5241: an album is wanted by its release group, never its release id. A
// tag-supplied group is used as is; otherwise MusicBrainz names it and the
// answer is cached onto the album; an album MusicBrainz cannot name is
// skipped and tried again next pass.
func TestSweep_WantsAlbumsByReleaseGroup_Integration(t *testing.T) {
pool := newSweepPool(t)
ctx := context.Background()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
q := dbq.New(pool)
admin, err := q.CreateUser(ctx, dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "sweepadmin", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: true,
})
if err != nil {
t.Fatal(err)
}
settings, err := NewSettingsService(ctx, pool, logger)
if err != nil {
t.Fatal(err)
}
cfg := Defaults
cfg.AutoApprove = false
if _, err := settings.Set(ctx, cfg); err != nil {
t.Fatal(err)
}
tagged := lostAlbum(t, pool, "Tagged", "release-tagged", "group-tagged")
resolved := lostAlbum(t, pool, "Resolved", "release-resolved", "")
unknown := lostAlbum(t, pool, "Unknown", "release-unknown", "")
_ = lostAlbum(t, pool, "Unknown", "release-unknown", "")
requests := lidarrrequests.NewService(pool, lidarrconfig.New(pool), nil, nil)
s := NewSweeper(pool, settings, requests, logger)
s.SetNotifier(notifications.New(pool, nil, nil))
s := NewSweeper(pool, settings, logger)
asked := map[string]int{}
s.releaseGroup = func(_ context.Context, release string) (string, error) {
asked[release]++
@@ -120,28 +100,21 @@ func TestSweep_RequestsAlbumsByReleaseGroup_Integration(t *testing.T) {
t.Fatalf("sweep: %v", err)
}
requested := func(album pgtype.UUID) (string, bool) {
wantFor := func(group string, album pgtype.UUID) {
t.Helper()
var mbid *string
err := pool.QueryRow(ctx, `
SELECT lr.lidarr_album_mbid
FROM missing_reacquisitions r
JOIN lidarr_requests lr ON lr.id = r.last_request_id
WHERE r.album_id = $1`, album).Scan(&mbid)
if err != nil || mbid == nil {
return "", false
w, err := q.GetWantByReleaseGroup(ctx, group)
if err != nil {
t.Fatalf("no want for %s: %v", group, err)
}
if w.Reason != "missing" || w.AlbumID != album || w.FulfilledAt.Valid {
t.Errorf("want %s = %+v: an open missing want about the album", group, w)
}
return *mbid, true
}
if got, ok := requested(tagged); !ok || got != "group-tagged" {
t.Errorf("tagged album requested as %q (ok=%v), want group-tagged", got, ok)
}
wantFor("group-tagged", tagged)
wantFor("group-resolved", resolved)
if asked["release-tagged"] != 0 {
t.Error("MusicBrainz asked about an album whose tags named its group")
}
if got, ok := requested(resolved); !ok || got != "group-resolved" {
t.Errorf("resolved album requested as %q (ok=%v), want group-resolved", got, ok)
}
var cached *string
if err := pool.QueryRow(ctx, "SELECT release_group_mbid FROM albums WHERE id = $1", resolved).Scan(&cached); err != nil {
t.Fatal(err)
@@ -149,32 +122,55 @@ SELECT lr.lidarr_album_mbid
if cached == nil || *cached != "group-resolved" {
t.Errorf("resolved group not cached on the album: %v", cached)
}
var attempts int
if err := pool.QueryRow(ctx, "SELECT count(*) FROM missing_reacquisitions WHERE album_id = $1", unknown).Scan(&attempts); err != nil {
var n int
if err := pool.QueryRow(ctx, "SELECT count(*) FROM wants").Scan(&n); err != nil {
t.Fatal(err)
}
if attempts != 0 {
t.Errorf("unresolvable album spent an attempt (%d state rows)", attempts)
if n != 2 {
t.Errorf("%d wants, want 2: the unresolvable album is skipped", n)
}
// M489: the two requests filed without auto-approval wait on an admin,
// so each reaches the admins' inbox; the unresolvable album filed none.
inbox, err := q.ListNotifications(ctx, dbq.ListNotificationsParams{UserID: admin.ID, PageLimit: 10})
// The next pass finds both already wanted and asks nothing new.
asked = map[string]int{}
if err := s.SweepOnce(ctx); err != nil {
t.Fatal(err)
}
if asked["release-resolved"] != 0 || asked["release-unknown"] != 1 {
t.Errorf("second pass asked MusicBrainz %v: only the unresolved album again", asked)
}
}
// A want held after the files went missing means the library has a full copy
// despite the missing rows (#5245): the album is not wanted again. One held
// before they went missing is reopened.
func TestSweep_HeldWantsAreReopenedOnlyForANewLoss_Integration(t *testing.T) {
pool := newSweepPool(t)
ctx := context.Background()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
settings, err := NewSettingsService(ctx, pool, logger)
if err != nil {
t.Fatal(err)
}
if len(inbox) != 2 {
t.Errorf("admin inbox holds %d notifications, want 2", len(inbox))
}
for _, n := range inbox {
if n.Kind != string(notifications.KindRequestPending) {
t.Errorf("admin inbox holds %q, want request_pending", n.Kind)
stale := lostAlbum(t, pool, "Stale", "release-stale", "group-stale")
lost := lostAlbum(t, pool, "Lost Again", "release-lost", "group-lost")
for _, c := range []struct {
group, heldAgo string
album pgtype.UUID
}{{"group-stale", "1 hour", stale}, {"group-lost", "3 days", lost}} {
if _, err := pool.Exec(ctx, `
INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, reason, album_id, fulfilled_at)
VALUES ($1, 'a', 'A', 'T', 'request', $2, now() - $3::interval)`, c.group, c.album, c.heldAgo); err != nil {
t.Fatal(err)
}
}
var stray int
if err := pool.QueryRow(ctx, "SELECT count(*) FROM lidarr_requests WHERE lidarr_album_mbid LIKE 'release-%'").Scan(&stray); err != nil {
if err := NewSweeper(pool, settings, logger).SweepOnce(ctx); err != nil {
t.Fatal(err)
}
if stray != 0 {
t.Errorf("%d request(s) named an album by its release id", stray)
q := dbq.New(pool)
if w, _ := q.GetWantByReleaseGroup(ctx, "group-stale"); !w.FulfilledAt.Valid {
t.Error("a want held since the loss was reopened")
}
if w, _ := q.GetWantByReleaseGroup(ctx, "group-lost"); w.FulfilledAt.Valid || w.Reason != "missing" {
t.Errorf("a want held before the loss should reopen as missing: %+v", w)
}
}
@@ -270,13 +270,13 @@ func TestSuggestArtists_FiltersAlreadyRequested(t *testing.T) {
seed := seedArtist(t, pool, "Seed", "")
likeArtist(t, pool, user.ID, seed.ID)
seedUnmatched(t, pool, seed.ID, "req-mbid", "Pending Request", 0.9)
if _, err := dbq.New(pool).CreateLidarrRequest(context.Background(), dbq.CreateLidarrRequestParams{
UserID: user.ID,
Kind: dbq.LidarrRequestKindArtist,
LidarrArtistMbid: "req-mbid",
ArtistName: "Pending Request",
if _, err := dbq.New(pool).CreateRequest(context.Background(), dbq.CreateRequestParams{
UserID: user.ID,
Kind: dbq.RequestKindArtist,
ArtistMbid: "req-mbid",
ArtistName: "Pending Request",
}); err != nil {
t.Fatalf("CreateLidarrRequest: %v", err)
t.Fatalf("CreateRequest: %v", err)
}
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
@@ -294,20 +294,20 @@ func TestSuggestArtists_RejectedRequestStillShown(t *testing.T) {
seed := seedArtist(t, pool, "Seed", "")
likeArtist(t, pool, user.ID, seed.ID)
seedUnmatched(t, pool, seed.ID, "rej-mbid", "Rejected Once", 0.9)
req, err := dbq.New(pool).CreateLidarrRequest(context.Background(), dbq.CreateLidarrRequestParams{
UserID: user.ID,
Kind: dbq.LidarrRequestKindArtist,
LidarrArtistMbid: "rej-mbid",
ArtistName: "Rejected Once",
req, err := dbq.New(pool).CreateRequest(context.Background(), dbq.CreateRequestParams{
UserID: user.ID,
Kind: dbq.RequestKindArtist,
ArtistMbid: "rej-mbid",
ArtistName: "Rejected Once",
})
if err != nil {
t.Fatalf("CreateLidarrRequest: %v", err)
t.Fatalf("CreateRequest: %v", err)
}
rejNotes := "wrong artist"
if _, err := dbq.New(pool).RejectLidarrRequest(context.Background(), dbq.RejectLidarrRequestParams{
if _, err := dbq.New(pool).RejectRequest(context.Background(), dbq.RejectRequestParams{
ID: req.ID, Notes: &rejNotes, DecidedBy: user.ID,
}); err != nil {
t.Fatalf("RejectLidarrRequest: %v", err)
t.Fatalf("RejectRequest: %v", err)
}
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
+198
View File
@@ -0,0 +1,198 @@
package requests
import (
"context"
"errors"
"log/slog"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
)
// Reconciler is a background worker that completes approved requests once
// the library holds what they asked for: an artist request when the artist
// is in the library, an album or track request when the want it recorded is
// held (internal/wanted settles wants). Errors per row are logged at WARN and
// do not abort the tick.
type Reconciler struct {
pool *pgxpool.Pool
logger *slog.Logger
bus *eventbus.Bus
notifier *notifications.Notifier // nil: no inbox notifications
tick time.Duration
batch int32
}
// NewReconciler constructs a Reconciler with production defaults: 5-minute
// tick, batch size 50. Pass nil for bus when no SSE publishing is desired.
func NewReconciler(pool *pgxpool.Pool, logger *slog.Logger, bus *eventbus.Bus) *Reconciler {
return &Reconciler{pool: pool, logger: logger, bus: bus, tick: 5 * time.Minute, batch: 50}
}
// SetNotifier makes completions land in the requester's notifications inbox
// (M489). Without one, completions are only broadcast on the bus.
func (r *Reconciler) SetNotifier(n *notifications.Notifier) { r.notifier = n }
// publishCompleted tells the requester their request arrived: a
// request.status_changed event for open screens, and a notification that
// outlives the connection.
func (r *Reconciler) publishCompleted(ctx context.Context, row dbq.Request) {
p := notifications.Payload{
RequestID: formatUUIDForBus(row.ID),
RequestKind: string(row.Kind),
Name: DisplayName(row),
Artist: row.ArtistName,
Title: requestedTitle(row),
}
if row.MatchedAlbumID.Valid {
p.AlbumID = formatUUIDForBus(row.MatchedAlbumID)
} else if row.MatchedArtistID.Valid {
p.ArtistID = formatUUIDForBus(row.MatchedArtistID)
}
r.notifier.NotifyLogged(ctx, notifications.KindRequestCompleted, notifications.ToUser(row.UserID), p.Map())
if r.bus == nil {
return
}
r.bus.Publish(eventbus.Event{
Kind: "request.status_changed",
UserID: formatUUIDForBus(row.UserID),
Data: map[string]any{
"request_id": formatUUIDForBus(row.ID),
"status": "completed",
"kind": string(row.Kind),
},
})
}
// formatUUIDForBus renders a pgtype.UUID as the canonical 8-4-4-4-12 hex
// string. Mirrors helpers in internal/api/convert.go and other packages;
// inlined here to avoid a back-edge dependency from requests onto
// the api layer.
func formatUUIDForBus(u pgtype.UUID) string {
if !u.Valid {
return ""
}
const hex = "0123456789abcdef"
out := make([]byte, 36)
pos := 0
for i, b := range u.Bytes {
if i == 4 || i == 6 || i == 8 || i == 10 {
out[pos] = '-'
pos++
}
out[pos] = hex[b>>4]
out[pos+1] = hex[b&0xf]
pos += 2
}
return string(out)
}
// Run blocks until ctx is cancelled, ticking every r.tick. Errors from
// tickOnce are logged at WARN and never propagated.
func (r *Reconciler) Run(ctx context.Context) {
t := time.NewTicker(r.tick)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := r.tickOnce(ctx); err != nil {
r.logger.Warn("requests: reconciler tick failed", "err", err)
}
}
}
}
// tickOnce loads approved requests (oldest first, up to r.batch) and
// completes those the library now holds.
func (r *Reconciler) tickOnce(ctx context.Context) error {
q := dbq.New(r.pool)
rows, err := q.ListApprovedRequestsForReconcile(ctx, r.batch)
if err != nil {
return err
}
for _, row := range rows {
if err := r.reconcileRow(ctx, q, row); err != nil {
r.logger.Warn("requests: reconciler row failed",
"request_id", row.ID, "kind", row.Kind, "err", err)
}
}
return nil
}
// reconcileRow completes one request if the library holds it. A request
// not yet held is not an error.
func (r *Reconciler) reconcileRow(ctx context.Context, q *dbq.Queries, row dbq.Request) error {
if row.Kind == dbq.RequestKindArtist {
return r.reconcileArtist(ctx, q, row)
}
return r.reconcileAlbum(ctx, q, row)
}
func (r *Reconciler) reconcileArtist(ctx context.Context, q *dbq.Queries, row dbq.Request) error {
var artistID pgtype.UUID
err := r.pool.QueryRow(ctx, "SELECT id FROM artists WHERE mbid = $1", row.ArtistMbid).Scan(&artistID)
if errors.Is(err, pgx.ErrNoRows) {
return nil // not in library yet
}
if err != nil {
return err
}
completed, err := q.CompleteRequest(ctx, dbq.CompleteRequestParams{ID: row.ID, MatchedArtistID: artistID})
if err != nil {
return err
}
r.publishCompleted(ctx, completed)
return nil
}
// reconcileAlbum completes an album or track request once its want is held,
// matched to the album that answered it. A track request is matched to the
// track it named when that recording arrived, else the newest arrival.
func (r *Reconciler) reconcileAlbum(ctx context.Context, q *dbq.Queries, row dbq.Request) error {
if row.AlbumMbid == nil || *row.AlbumMbid == "" {
return nil
}
w, err := q.GetWantByReleaseGroup(ctx, *row.AlbumMbid)
if errors.Is(err, pgx.ErrNoRows) {
// Removed from the wanted list while the request waited. Wanting it
// again keeps the approval meaningful; the admin who removed it can
// reject a request instead.
_, err = wanted.WantWith(ctx, q, wantFor(row))
return err
}
if err != nil {
return err
}
if !w.FulfilledAt.Valid || !w.AlbumID.Valid {
return nil
}
params := dbq.CompleteRequestParams{ID: row.ID, MatchedAlbumID: w.AlbumID}
if row.Kind == dbq.RequestKindTrack {
var trackID pgtype.UUID
err := r.pool.QueryRow(ctx, `
SELECT id FROM tracks
WHERE album_id = $1 AND missing_since IS NULL
ORDER BY (mbid IS NOT DISTINCT FROM $2) DESC, added_at DESC, id
LIMIT 1`, w.AlbumID, row.TrackMbid).Scan(&trackID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return err
}
params.MatchedTrackID = trackID
}
completed, err := q.CompleteRequest(ctx, params)
if err != nil {
return err
}
r.publishCompleted(ctx, completed)
return nil
}
+310
View File
@@ -0,0 +1,310 @@
package requests
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"os"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
)
func newPool(t *testing.T) *pgxpool.Pool {
t.Helper()
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
dbtest.ResetDB(t, pool)
return pool
}
func seedUser(t *testing.T, pool *pgxpool.Pool, name string) pgtype.UUID {
t.Helper()
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiTokenHash: "x-" + name,
})
if err != nil {
t.Fatalf("seed user: %v", err)
}
return u.ID
}
func seedAlbum(t *testing.T, q *dbq.Queries, artistName, artistMBID, title, group string) (dbq.Artist, dbq.Album) {
t.Helper()
ctx := context.Background()
artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: artistName, SortName: artistName, Mbid: &artistMBID})
if err != nil {
t.Fatal(err)
}
release := group + "-release"
album, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: title, SortTitle: title, ArtistID: artist.ID, Mbid: &release})
if err != nil {
t.Fatal(err)
}
if err := q.SetAlbumReleaseGroupMbidIfNull(ctx, dbq.SetAlbumReleaseGroupMbidIfNullParams{ID: album.ID, ReleaseGroupMbid: &group}); err != nil {
t.Fatal(err)
}
return artist, album
}
func seedTrack(t *testing.T, q *dbq.Queries, album dbq.Album, artist dbq.Artist, title, path string) dbq.Track {
t.Helper()
tr, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: title, AlbumID: album.ID, ArtistID: artist.ID,
DurationMs: 180000, FilePath: path, FileSize: 1024, FileFormat: "flac",
})
if err != nil {
t.Fatal(err)
}
return tr
}
func quietLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
func TestCreate_ValidatesEachKind(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "creator")
svc := NewService(pool)
ctx := context.Background()
if r, err := svc.Create(ctx, user, CreateParams{Kind: "artist", ArtistMBID: "a-mbid", ArtistName: "X"}); err != nil ||
r.Status != dbq.RequestStatusPending {
t.Fatalf("artist: %+v, %v", r, err)
}
for name, p := range map[string]CreateParams{
"album without its group": {Kind: "album", ArtistMBID: "a", ArtistName: "X"},
"track without its album": {Kind: "track", ArtistMBID: "a", ArtistName: "X", TrackMBID: "t", TrackTitle: "Y"},
"unknown kind": {Kind: "playlist", ArtistMBID: "a", ArtistName: "X"},
} {
if _, err := svc.Create(ctx, user, p); !errors.Is(err, ErrInvalidKindFields) {
t.Errorf("%s: err = %v, want ErrInvalidKindFields", name, err)
}
}
again, created, err := svc.CreateTracked(ctx, user, CreateParams{Kind: "artist", ArtistMBID: "a-mbid", ArtistName: "X"})
if err != nil || created || again.Kind != dbq.RequestKindArtist {
t.Errorf("a repeat request dedupes: created=%v, %v", created, err)
}
}
func TestApprove_AlbumAndTrackRequestsWantTheirReleaseGroup(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
user := seedUser(t, pool, "wanter")
admin := seedUser(t, pool, "approver")
svc := NewService(pool)
ctx := context.Background()
album, _ := svc.Create(ctx, user, CreateParams{
Kind: "album", ArtistMBID: "artist-1", ArtistName: "Gorillaz", AlbumMBID: "group-demon-days", AlbumTitle: "Demon Days",
})
track, _ := svc.Create(ctx, user, CreateParams{
Kind: "track", ArtistMBID: "artist-1", ArtistName: "Gorillaz", AlbumMBID: "group-humanz", AlbumTitle: "Humanz",
TrackMBID: "rec-1", TrackTitle: "Andromeda",
})
for _, r := range []dbq.Request{album, track} {
got, err := svc.Approve(ctx, r.ID, admin, pgtype.UUID{})
if err != nil || got.Status != dbq.RequestStatusApproved || got.DecidedBy != admin {
t.Fatalf("approve %s: %+v, %v", r.Kind, got, err)
}
}
for group, title := range map[string]string{"group-demon-days": "Demon Days", "group-humanz": "Humanz"} {
w, err := q.GetWantByReleaseGroup(ctx, group)
if err != nil {
t.Fatalf("want %s: %v", group, err)
}
if w.Title != title || w.Reason != "request" || w.CreatedBy != user || w.FulfilledAt.Valid {
t.Errorf("want %s = %+v: the requester's open want", group, w)
}
}
if _, err := svc.Approve(ctx, album.ID, admin, pgtype.UUID{}); !errors.Is(err, ErrNotPending) {
t.Errorf("approving twice: err = %v, want ErrNotPending", err)
}
if _, err := svc.Approve(ctx, pgtype.UUID{Bytes: [16]byte{1}, Valid: true}, admin, pgtype.UUID{}); !errors.Is(err, ErrNotFound) {
t.Errorf("no such request: err = %v", err)
}
}
func TestApprove_ArtistRequestFollowsAndWantsWhatExists(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
user := seedUser(t, pool, "follower")
ctx := context.Background()
svc := NewService(pool)
albumsOnly, err := q.CreateReleaseTypeProfile(ctx, dbq.CreateReleaseTypeProfileParams{
Name: "Albums only", PrimaryTypes: []string{"Album"}, SecondaryTypes: []string{},
})
if err != nil {
t.Fatal(err)
}
r, _ := svc.Create(ctx, user, CreateParams{Kind: "artist", ArtistMBID: "artist-2", ArtistName: "Moe Shop"})
if _, err := svc.Approve(ctx, r.ID, user, albumsOnly.ID); err != nil {
t.Fatal(err)
}
f, err := q.GetFollow(ctx, "artist-2")
if err != nil {
t.Fatal(err)
}
if f.ProfileID != albumsOnly.ID || !f.WantExisting || f.BaselineAt.Valid {
t.Errorf("follow = %+v: on the chosen profile, wanting what exists, baseline still to read", f)
}
r2, _ := svc.Create(ctx, user, CreateParams{Kind: "artist", ArtistMBID: "artist-3", ArtistName: "Kero Kero Bonito"})
if _, err := svc.Approve(ctx, r2.ID, user, pgtype.UUID{}); err != nil {
t.Fatal(err)
}
if f, _ := q.GetFollow(ctx, "artist-3"); f.ProfileName != "Standard" {
t.Errorf("no profile chosen: on %q, want the default", f.ProfileName)
}
}
func TestRejectAndCancel(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "rejected")
svc := NewService(pool)
ctx := context.Background()
r, _ := svc.Create(ctx, user, CreateParams{Kind: "artist", ArtistMBID: "a-1", ArtistName: "X"})
if got, err := svc.Reject(ctx, r.ID, user, "low quality"); err != nil || got.Status != dbq.RequestStatusRejected {
t.Fatalf("reject: %+v, %v", got, err)
}
if _, err := svc.Reject(ctx, r.ID, user, "again"); !errors.Is(err, ErrNotPending) {
t.Errorf("reject twice: err = %v", err)
}
c, _ := svc.Create(ctx, user, CreateParams{Kind: "artist", ArtistMBID: "a-2", ArtistName: "Y"})
if _, err := svc.Cancel(ctx, c.ID, user); err != nil {
t.Fatal(err)
}
if _, err := svc.Cancel(ctx, c.ID, user); !errors.Is(err, ErrNotPending) {
t.Errorf("cancel twice: err = %v", err)
}
}
func TestReconciler_CompletesWhenTheWantIsHeld(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
user := seedUser(t, pool, "reconciled")
svc := NewService(pool)
ctx := context.Background()
req, _ := svc.Create(ctx, user, CreateParams{
Kind: "track", ArtistMBID: "artist-4", ArtistName: "Notify Artist", AlbumMBID: "group-notify", AlbumTitle: "Notify Album",
TrackMBID: "rec-wanted", TrackTitle: "Wanted Track",
})
if _, err := svc.Approve(ctx, req.ID, user, pgtype.UUID{}); err != nil {
t.Fatal(err)
}
rec := NewReconciler(pool, quietLogger(), nil)
rec.SetNotifier(notifications.New(pool, nil, nil))
if err := rec.tickOnce(ctx); err != nil {
t.Fatal(err)
}
if got, _ := q.GetRequestByID(ctx, req.ID); got.Status != dbq.RequestStatusApproved {
t.Fatalf("status %v before the want is held, want approved", got.Status)
}
artist, album := seedAlbum(t, q, "Notify Artist", "artist-4", "Notify Album", "group-notify")
_ = seedTrack(t, q, album, artist, "Other Track", "/music/notify/01.flac")
wantedTrack := seedTrack(t, q, album, artist, "Wanted Track", "/music/notify/02.flac")
if _, err := pool.Exec(ctx, "UPDATE tracks SET mbid = 'rec-wanted', added_at = added_at - interval '1 hour' WHERE id = $1", wantedTrack.ID); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, "UPDATE wants SET fulfilled_at = now(), album_id = $1 WHERE release_group_mbid = 'group-notify'", album.ID); err != nil {
t.Fatal(err)
}
for range 2 { // the second tick finds nothing left to complete
if err := rec.tickOnce(ctx); err != nil {
t.Fatal(err)
}
}
got, _ := q.GetRequestByID(ctx, req.ID)
if got.Status != dbq.RequestStatusCompleted || got.MatchedAlbumID != album.ID || got.MatchedTrackID != wantedTrack.ID {
t.Errorf("request = %+v: completed, matched to the album and the track it named", got)
}
inbox, err := q.ListNotifications(ctx, dbq.ListNotificationsParams{UserID: user, PageLimit: 10})
if err != nil {
t.Fatal(err)
}
if len(inbox) != 1 || inbox[0].Kind != string(notifications.KindRequestCompleted) {
t.Fatalf("inbox = %+v, want one request_completed", inbox)
}
var p notifications.Payload
if err := json.Unmarshal(inbox[0].Payload, &p); err != nil {
t.Fatal(err)
}
if p.Name != "Notify Artist – Wanted Track" || p.AlbumID != formatUUIDForBus(album.ID) {
t.Errorf("payload = %+v", p)
}
}
func TestReconciler_ArtistRequestCompletesOnceTheArtistIsHere(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
user := seedUser(t, pool, "artistreq")
svc := NewService(pool)
ctx := context.Background()
req, _ := svc.Create(ctx, user, CreateParams{Kind: "artist", ArtistMBID: "artist-5", ArtistName: "Arrives"})
if _, err := svc.Approve(ctx, req.ID, user, pgtype.UUID{}); err != nil {
t.Fatal(err)
}
rec := NewReconciler(pool, quietLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatal(err)
}
if got, _ := q.GetRequestByID(ctx, req.ID); got.Status != dbq.RequestStatusApproved {
t.Fatalf("status %v with nothing here", got.Status)
}
artist, _ := seedAlbum(t, q, "Arrives", "artist-5", "First", "group-first")
if err := rec.tickOnce(ctx); err != nil {
t.Fatal(err)
}
if got, _ := q.GetRequestByID(ctx, req.ID); got.Status != dbq.RequestStatusCompleted || got.MatchedArtistID != artist.ID {
t.Errorf("request = %+v", got)
}
}
func TestReconciler_RewantsWhatWasRemovedWhileTheRequestWaited(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
user := seedUser(t, pool, "rewant")
svc := NewService(pool)
ctx := context.Background()
req, _ := svc.Create(ctx, user, CreateParams{
Kind: "album", ArtistMBID: "artist-6", ArtistName: "Gone", AlbumMBID: "group-gone", AlbumTitle: "Gone",
})
if _, err := svc.Approve(ctx, req.ID, user, pgtype.UUID{}); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, "DELETE FROM wants"); err != nil {
t.Fatal(err)
}
if err := NewReconciler(pool, quietLogger(), nil).tickOnce(ctx); err != nil {
t.Fatal(err)
}
if _, err := q.GetWantByReleaseGroup(ctx, "group-gone"); err != nil {
t.Errorf("an approved request's want should be recorded again: %v", err)
}
}
+263
View File
@@ -0,0 +1,263 @@
// Package requests owns the lifecycle of what users ask to have added. The
// synchronous Service handles Create/List/Approve/Reject/Cancel; approving
// records the request on the wanted list (M502 #5520): an album or a track
// wants its album's release group, an artist is followed. The async
// Reconciler (separate file) completes approved requests once the library
// holds what they asked for.
package requests
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
)
// Public errors. Handlers map these to API error codes.
var (
ErrInvalidKindFields = errors.New("requests: missing required fields for kind")
ErrNotPending = errors.New("requests: request is not pending")
// ErrNotFound aliases apierror.ErrNotFound so handlers can errors.Is
// against the shared sentinel.
ErrNotFound = apierror.ErrNotFound
)
// CreateParams is the input for a new request from a user. AlbumMBID is the
// album's MusicBrainz release group.
type CreateParams struct {
Kind string // "artist", "album", or "track"
ArtistMBID string
AlbumMBID string // required for kind=album/track
TrackMBID string // required for kind=track
ArtistName string
AlbumTitle string // required for kind=album/track
TrackTitle string // required for kind=track
}
// Service owns the request lifecycle.
type Service struct {
pool *pgxpool.Pool
}
// NewService constructs a Service.
func NewService(pool *pgxpool.Pool) *Service {
return &Service{pool: pool}
}
// Create validates the kind→required-fields invariant and inserts a
// pending row.
func (s *Service) Create(ctx context.Context, userID pgtype.UUID, p CreateParams) (dbq.Request, error) {
row, _, err := s.CreateTracked(ctx, userID, p)
return row, err
}
// CreateTracked is Create, also reporting whether a row was inserted. A
// request that dedupes into one already in flight is not new, so callers
// that announce new requests (M489) say nothing for it.
func (s *Service) CreateTracked(ctx context.Context, userID pgtype.UUID, p CreateParams) (dbq.Request, bool, error) {
if err := validateKindFields(p); err != nil {
return dbq.Request{}, false, err
}
q := dbq.New(s.pool)
// Dedup: if a non-terminal request for this MBID already exists,
// return it instead of inserting a duplicate. The Discover/search UI
// already hides requested candidates; this backstops races and direct
// API callers. Match on the new request's own-kind MBID.
dedupMBID := p.ArtistMBID
switch p.Kind {
case "album":
dedupMBID = p.AlbumMBID
case "track":
dedupMBID = p.TrackMBID
}
if existing, derr := q.GetNonTerminalRequestForMBID(ctx, dedupMBID); derr == nil {
return existing, false, nil
} else if !errors.Is(derr, pgx.ErrNoRows) {
return dbq.Request{}, false, fmt.Errorf("create: dedup check: %w", derr)
}
row, err := q.CreateRequest(ctx, dbq.CreateRequestParams{
UserID: userID,
Kind: dbq.RequestKind(p.Kind),
ArtistMbid: p.ArtistMBID,
AlbumMbid: strPtr(p.AlbumMBID),
TrackMbid: strPtr(p.TrackMBID),
ArtistName: p.ArtistName,
AlbumTitle: strPtr(p.AlbumTitle),
TrackTitle: strPtr(p.TrackTitle),
})
if err != nil {
return dbq.Request{}, false, fmt.Errorf("create: %w", err)
}
return row, true, nil
}
// DisplayName is a request as a person would say it: "Moe Shop" for an
// artist, "Moe Shop – WWW" for an album or a track.
func DisplayName(row dbq.Request) string {
if t := requestedTitle(row); t != "" {
return row.ArtistName + " – " + t
}
return row.ArtistName
}
// requestedTitle is the album or track a request names, or "" for an
// artist request. DisplayName is the artist and this, joined.
func requestedTitle(row dbq.Request) string {
switch {
case row.Kind == dbq.RequestKindTrack && row.TrackTitle != nil:
return *row.TrackTitle
case row.Kind == dbq.RequestKindAlbum && row.AlbumTitle != nil:
return *row.AlbumTitle
}
return ""
}
func validateKindFields(p CreateParams) error {
if p.ArtistMBID == "" || p.ArtistName == "" {
return fmt.Errorf("%w: artist_mbid and artist_name are always required", ErrInvalidKindFields)
}
switch p.Kind {
case "artist":
// fine
case "album":
if p.AlbumMBID == "" || p.AlbumTitle == "" {
return fmt.Errorf("%w: album kind requires album_mbid and album_title", ErrInvalidKindFields)
}
case "track":
if p.AlbumMBID == "" || p.AlbumTitle == "" {
return fmt.Errorf("%w: track kind requires album_mbid and album_title (its album is what is wanted)", ErrInvalidKindFields)
}
if p.TrackMBID == "" || p.TrackTitle == "" {
return fmt.Errorf("%w: track kind requires track_mbid and track_title", ErrInvalidKindFields)
}
default:
return fmt.Errorf("%w: unknown kind %q", ErrInvalidKindFields, p.Kind)
}
return nil
}
// ListByStatus returns requests with the given status, ordered by requested_at DESC.
func (s *Service) ListByStatus(ctx context.Context, status string, limit int32) ([]dbq.Request, error) {
return dbq.New(s.pool).ListRequestsByStatus(ctx, dbq.ListRequestsByStatusParams{
Status: dbq.RequestStatus(status), Limit: limit,
})
}
// ListForUser returns all requests by a user, ordered by requested_at DESC.
func (s *Service) ListForUser(ctx context.Context, userID pgtype.UUID, limit int32) ([]dbq.Request, error) {
return dbq.New(s.pool).ListRequestsForUser(ctx, dbq.ListRequestsForUserParams{
UserID: userID, Limit: limit,
})
}
// Approve puts a pending request on the wanted list and marks it approved,
// in one transaction. An album or track request wants the album's release
// group, recorded as the requester's. An artist request follows the artist
// on profileID (the zero id means the default profile) and also wants what
// they have already released that the profile passes: someone asking for an
// artist wants their music, not only what comes next.
func (s *Service) Approve(ctx context.Context, requestID, approverID, profileID pgtype.UUID) (dbq.Request, error) {
var approved dbq.Request
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := dbq.New(tx)
row, err := q.GetRequestByID(ctx, requestID)
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("approve: get: %w", err)
}
if row.Status != dbq.RequestStatusPending {
return ErrNotPending
}
switch row.Kind {
case dbq.RequestKindArtist:
if _, err := wanted.FollowWith(ctx, q, wanted.FollowParams{
ArtistMBID: row.ArtistMbid, ArtistName: row.ArtistName,
ProfileID: profileID, By: approverID, WantExisting: true,
}); err != nil {
return err
}
default:
if _, err := wanted.WantWith(ctx, q, wantFor(row)); err != nil {
return err
}
}
approved, err = q.ApproveRequest(ctx, dbq.ApproveRequestParams{ID: requestID, DecidedBy: approverID})
if err != nil {
return fmt.Errorf("approve: persist: %w", err)
}
return nil
})
return approved, err
}
// wantFor is the want an approved album or track request records.
func wantFor(row dbq.Request) wanted.WantParams {
p := wanted.WantParams{
ArtistMBID: row.ArtistMbid, ArtistName: row.ArtistName,
Reason: wanted.ReasonRequest, CreatedBy: row.UserID,
}
if row.AlbumMbid != nil {
p.GroupMBID = *row.AlbumMbid
}
if row.AlbumTitle != nil {
p.Title = *row.AlbumTitle
}
return p
}
// Reject transitions a pending request to rejected, recording notes and
// who decided. Returns ErrNotPending if the request is not pending,
// ErrNotFound if no such request exists.
func (s *Service) Reject(ctx context.Context, requestID pgtype.UUID, adminID pgtype.UUID, notes string) (dbq.Request, error) {
row, err := dbq.New(s.pool).RejectRequest(ctx, dbq.RejectRequestParams{
ID: requestID, Notes: strPtr(notes), DecidedBy: adminID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// Either not found OR not pending — check which.
cur, gerr := dbq.New(s.pool).GetRequestByID(ctx, requestID)
if gerr != nil {
return dbq.Request{}, ErrNotFound
}
if cur.Status != dbq.RequestStatusPending {
return dbq.Request{}, ErrNotPending
}
return dbq.Request{}, ErrNotFound
}
return dbq.Request{}, fmt.Errorf("reject: %w", err)
}
return row, nil
}
// Cancel lets a user withdraw their own pending request. The SQL WHERE
// clause enforces both ownership (user_id = $2) and pending status
// (status = 'pending'). A zero-rows result always means ErrNotPending
// from the caller's perspective.
func (s *Service) Cancel(ctx context.Context, requestID pgtype.UUID, userID pgtype.UUID) (dbq.Request, error) {
row, err := dbq.New(s.pool).CancelRequest(ctx, dbq.CancelRequestParams{
ID: requestID, DecidedBy: userID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return dbq.Request{}, ErrNotPending
}
return dbq.Request{}, fmt.Errorf("cancel: %w", err)
}
return row, nil
}
func strPtr(s string) *string {
if s == "" {
return nil
}
return &s
}
+4 -4
View File
@@ -23,13 +23,13 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
@@ -171,7 +171,7 @@ func (s *Server) Router() http.Handler {
}
return lidarr.NewClient(cfg.BaseURL, cfg.APIKey)
}
lidarrReqs := lidarrrequests.NewService(s.Pool, lidarrCfg, lidarrClientFn, nil)
requestSvc := requests.NewService(s.Pool)
reacqSettings := s.ReacqSettings
if reacqSettings == nil {
// Test contexts construct Server without main.go's boot wiring.
@@ -190,7 +190,7 @@ func (s *Server) Router() http.Handler {
playlistsSvc := playlists.NewService(s.Pool, s.Logger, s.DataDir)
smtpSender := mailer.NewSMTPSender(s.Pool, s.Logger.With("component", "mailer"))
// Live-event bus for SSE subscribers (#392). Constructed per-process;
// producers in playevents / lidarrrequests / scanner publish into
// producers in playevents / requests / scanner publish into
// the same instance. Background workers (lidarr reconciler, scan
// scheduler) live in cmd/minstrel/main.go where they're constructed
// before Router() runs; they read s.Bus directly so they share this
@@ -237,7 +237,7 @@ func (s *Server) Router() http.Handler {
}
acoustIDLookup = library.NewAcoustIDLookupWorker(s.Pool, s.Logger, acoustIDSettings, nil)
}
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret, netSettings, reacqSettings, fpSettings, loudSettings, acoustIDLookup)
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, requestSvc, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret, netSettings, reacqSettings, fpSettings, loudSettings, acoustIDLookup)
// /api/admin/scan is the only admin route owned by the server package
// (it needs the Scanner). Register it as a single inline-middleware
// route — using r.Route("/api/admin", ...) here would create a second
+3 -7
View File
@@ -316,13 +316,9 @@ func TestRouter_ReacquisitionSettingsSavedThroughTheAPIReachTheSweeper(t *testin
defer ts.Close()
body, err := json.Marshal(map[string]any{
"enabled": before.Enabled,
"grace_hours": wantGrace,
"backoff_base_hours": before.BackoffBaseHours,
"backoff_max_hours": before.BackoffMaxHours,
"max_attempts": before.MaxAttempts,
"max_per_pass": before.MaxPerPass,
"auto_approve": before.AutoApprove,
"enabled": before.Enabled,
"grace_hours": wantGrace,
"max_per_pass": before.MaxPerPass,
})
if err != nil {
t.Fatalf("marshal: %v", err)
+219
View File
@@ -0,0 +1,219 @@
package wanted
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// PrimaryTypes and SecondaryTypes are MusicBrainz's release-group types, in
// the order the profile editor lists them.
var (
PrimaryTypes = []string{"Album", "EP", "Single", "Broadcast", "Other"}
SecondaryTypes = []string{
"Compilation", "Live", "Remix", "Soundtrack", "DJ-mix", "Mixtape/Street",
"Demo", "Spokenword", "Interview", "Audiobook", "Audio drama", "Field recording",
}
)
// Profile errors, which the API answers as 400 or 409.
var (
ErrProfileInvalid = errors.New("wanted: profile is not valid")
ErrProfileNotFound = errors.New("wanted: no such profile")
ErrProfileInUse = errors.New("wanted: profile is in use")
ErrProfileDefault = errors.New("wanted: the default profile cannot be deleted")
ErrProfileNameUsed = errors.New("wanted: a profile already has that name")
)
// Passes reports whether a release group of the given types is one a follow
// on profile p wants: its primary type is listed, and every secondary type
// it carries is allowed. A group with no secondary type is a studio release,
// which every profile passes on its primary type alone.
func Passes(p dbq.ReleaseTypeProfile, primary string, secondary []string) bool {
if !containsFold(p.PrimaryTypes, primary) {
return false
}
for _, s := range secondary {
if !containsFold(p.SecondaryTypes, s) {
return false
}
}
return true
}
func containsFold(list []string, s string) bool {
return slices.ContainsFunc(list, func(v string) bool { return strings.EqualFold(v, s) })
}
// ProfileInput is a profile as the editor sends it.
type ProfileInput struct {
Name string
PrimaryTypes []string
SecondaryTypes []string
IsDefault bool
}
// normalize checks the input against MusicBrainz's types and returns it with
// each type in MusicBrainz's spelling and the editor's order.
func (in ProfileInput) normalize() (ProfileInput, error) {
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" || len(in.Name) > 60 {
return in, fmt.Errorf("%w: a name of 1 to 60 characters is required", ErrProfileInvalid)
}
primary, err := pick(PrimaryTypes, in.PrimaryTypes)
if err != nil {
return in, err
}
if len(primary) == 0 {
return in, fmt.Errorf("%w: choose at least one kind of release", ErrProfileInvalid)
}
secondary, err := pick(SecondaryTypes, in.SecondaryTypes)
if err != nil {
return in, err
}
in.PrimaryTypes, in.SecondaryTypes = primary, secondary
return in, nil
}
func pick(known, chosen []string) ([]string, error) {
for _, c := range chosen {
if !containsFold(known, c) {
return nil, fmt.Errorf("%w: %q is not a MusicBrainz release type", ErrProfileInvalid, c)
}
}
out := []string{}
for _, k := range known {
if containsFold(chosen, k) {
out = append(out, k)
}
}
return out, nil
}
// CreateProfile adds a profile, making it the default when asked.
func (s *Service) CreateProfile(ctx context.Context, in ProfileInput) (dbq.ReleaseTypeProfile, error) {
in, err := in.normalize()
if err != nil {
return dbq.ReleaseTypeProfile{}, err
}
var out dbq.ReleaseTypeProfile
err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := dbq.New(tx)
p, err := q.CreateReleaseTypeProfile(ctx, dbq.CreateReleaseTypeProfileParams{
Name: in.Name, PrimaryTypes: in.PrimaryTypes, SecondaryTypes: in.SecondaryTypes,
})
if err != nil {
return nameErr(err)
}
if in.IsDefault {
if err := makeDefault(ctx, q, p.ID); err != nil {
return err
}
p.IsDefault = true
}
out = p
return nil
})
return out, err
}
// UpdateProfile replaces a profile's name and types. IsDefault only ever
// moves the default here; to unset it, make another profile the default.
func (s *Service) UpdateProfile(ctx context.Context, id pgtype.UUID, in ProfileInput) (dbq.ReleaseTypeProfile, error) {
in, err := in.normalize()
if err != nil {
return dbq.ReleaseTypeProfile{}, err
}
var out dbq.ReleaseTypeProfile
err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := dbq.New(tx)
p, err := q.UpdateReleaseTypeProfile(ctx, dbq.UpdateReleaseTypeProfileParams{
ID: id, Name: in.Name, PrimaryTypes: in.PrimaryTypes, SecondaryTypes: in.SecondaryTypes,
})
if errors.Is(err, pgx.ErrNoRows) {
return ErrProfileNotFound
}
if err != nil {
return nameErr(err)
}
if in.IsDefault && !p.IsDefault {
if err := makeDefault(ctx, q, p.ID); err != nil {
return err
}
p.IsDefault = true
}
out = p
return nil
})
return out, err
}
func makeDefault(ctx context.Context, q *dbq.Queries, id pgtype.UUID) error {
if err := q.ClearDefaultReleaseTypeProfile(ctx); err != nil {
return fmt.Errorf("wanted: clear default: %w", err)
}
if _, err := q.SetDefaultReleaseTypeProfile(ctx, id); err != nil {
return fmt.Errorf("wanted: set default: %w", err)
}
return nil
}
// DeleteProfile removes a profile no follow uses. The default stays.
func (s *Service) DeleteProfile(ctx context.Context, id pgtype.UUID) error {
q := dbq.New(s.pool)
p, err := q.GetReleaseTypeProfile(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return ErrProfileNotFound
}
if err != nil {
return fmt.Errorf("wanted: read profile: %w", err)
}
if p.IsDefault {
return ErrProfileDefault
}
if _, err := q.DeleteReleaseTypeProfile(ctx, id); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23503" {
return ErrProfileInUse
}
return fmt.Errorf("wanted: delete profile: %w", err)
}
return nil
}
// profileOrDefault reads the profile a follow names; the zero id means the
// default.
func profileOrDefault(ctx context.Context, q *dbq.Queries, id pgtype.UUID) (dbq.ReleaseTypeProfile, error) {
var (
p dbq.ReleaseTypeProfile
err error
)
if id.Valid {
p, err = q.GetReleaseTypeProfile(ctx, id)
} else {
p, err = q.GetDefaultReleaseTypeProfile(ctx)
}
if errors.Is(err, pgx.ErrNoRows) {
return p, ErrProfileNotFound
}
if err != nil {
return p, fmt.Errorf("wanted: read profile: %w", err)
}
return p, nil
}
func nameErr(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return ErrProfileNameUsed
}
return fmt.Errorf("wanted: save profile: %w", err)
}
+494
View File
@@ -0,0 +1,494 @@
// Package wanted is what Minstrel is looking for (M502 #5520): followed
// artists, each on a release-type profile, and wanted release groups. A want
// comes from an approved request, a followed artist's new release, or an
// album whose files went missing; it is held once the library has a full
// copy. Searching for open wants is #5521's.
package wanted
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
// Reasons a release group is wanted, matching wants_reason_check.
const (
ReasonRequest = "request"
ReasonFollow = "follow"
ReasonMissing = "missing"
)
// variousArtists is MusicBrainz's Various Artists. Its "discography" is every
// compilation ever catalogued; nobody follows it.
const variousArtists = "89ad4ac3-39f7-470e-963a-56509c546377"
// ErrNotFollowable: an artist nobody follows (Various Artists, or one of
// MusicBrainz's bracketed placeholders such as [unknown]).
var ErrNotFollowable = errors.New("wanted: not an artist anyone follows")
// Service owns follows, wants and the worker that keeps them current.
type Service struct {
pool *pgxpool.Pool
cat *catalogue.Service
logger *slog.Logger
now func() time.Time
// releaseGroup names the group of a MusicBrainz release id, to move a
// want recorded under a release id (#5241) to its group. A field so tests
// stand in for MusicBrainz.
releaseGroup func(ctx context.Context, releaseMBID string) (string, error)
}
// New makes a Service reading discographies and editions through cat.
func New(pool *pgxpool.Pool, cat *catalogue.Service, logger *slog.Logger) *Service {
if logger == nil {
logger = slog.Default()
}
return &Service{pool: pool, cat: cat, logger: logger, now: time.Now, releaseGroup: tags.ReleaseGroupForRelease}
}
// Followable reports whether an artist is one anyone would follow.
func Followable(mbid, name string) bool {
if mbid == "" || mbid == variousArtists {
return false
}
return len(name) < 2 || name[0] != '[' || name[len(name)-1] != ']'
}
// FollowParams is a follow as the caller asks for it. A zero ProfileID means
// the default profile. WantExisting also wants what the artist has already
// released that the profile passes and the library lacks.
type FollowParams struct {
ArtistMBID string
ArtistName string
ProfileID pgtype.UUID
By pgtype.UUID
WantExisting bool
}
// Follow starts or changes a follow. The worker reads the discography.
func (s *Service) Follow(ctx context.Context, p FollowParams) (dbq.Follow, error) {
return FollowWith(ctx, dbq.New(s.pool), p)
}
// FollowWith is Follow inside the caller's transaction (request approval).
func FollowWith(ctx context.Context, q *dbq.Queries, p FollowParams) (dbq.Follow, error) {
if !Followable(p.ArtistMBID, p.ArtistName) {
return dbq.Follow{}, ErrNotFollowable
}
profile, err := profileOrDefault(ctx, q, p.ProfileID)
if err != nil {
return dbq.Follow{}, err
}
f, err := q.UpsertFollow(ctx, dbq.UpsertFollowParams{
ArtistMbid: p.ArtistMBID, ArtistName: p.ArtistName, ProfileID: profile.ID,
FollowedBy: p.By, WantExisting: p.WantExisting,
})
if err != nil {
return dbq.Follow{}, fmt.Errorf("wanted: follow: %w", err)
}
return f, nil
}
// Unfollow stops following. Wants already recorded stay.
func (s *Service) Unfollow(ctx context.Context, artistMBID string) (bool, error) {
n, err := dbq.New(s.pool).DeleteFollow(ctx, artistMBID)
if err != nil {
return false, fmt.Errorf("wanted: unfollow: %w", err)
}
return n > 0, nil
}
// WantParams is a release group to want.
type WantParams struct {
GroupMBID string
ArtistMBID string
ArtistName string
Title string
PrimaryType string
FirstReleaseDate string
Reason string
CreatedBy pgtype.UUID
}
// WantWith records a want inside the caller's transaction, or returns the
// want already recorded for the group.
func WantWith(ctx context.Context, q *dbq.Queries, p WantParams) (dbq.Want, error) {
if p.GroupMBID == "" || p.ArtistMBID == "" {
return dbq.Want{}, errors.New("wanted: a release group and its artist are required")
}
w, err := q.WantReleaseGroup(ctx, dbq.WantReleaseGroupParams{
ReleaseGroupMbid: p.GroupMBID, ArtistMbid: p.ArtistMBID, ArtistName: p.ArtistName,
Title: p.Title, PrimaryType: p.PrimaryType, FirstReleaseDate: p.FirstReleaseDate,
Reason: p.Reason, CreatedBy: p.CreatedBy,
})
if err != nil {
return dbq.Want{}, fmt.Errorf("wanted: want: %w", err)
}
return w, nil
}
// RemoveWant drops a want, open or held.
func (s *Service) RemoveWant(ctx context.Context, id pgtype.UUID) (bool, error) {
n, err := dbq.New(s.pool).DeleteWant(ctx, id)
if err != nil {
return false, fmt.Errorf("wanted: remove want: %w", err)
}
return n > 0, nil
}
const (
// FollowRefreshAge is how often a followed artist's discography is read
// for new releases: daily, as Lidarr refreshed its artists.
FollowRefreshAge = 24 * time.Hour
// NewReleaseGrace: a group first seen after the baseline is wanted only
// if it came out no earlier than this before the follow began. MusicBrainz
// gains old releases all the time; a single from 1998 catalogued today is
// not a new release.
NewReleaseGrace = 30 * 24 * time.Hour
// Interval is how often the worker looks for work; followsPerPass and
// wantsPerPass bound one pass so neither starves the other.
Interval = 5 * time.Minute
followsPerPass = 60
wantsPerPass = 500
)
// Run applies follows and settles wants every Interval until ctx ends.
func (s *Service) Run(ctx context.Context) {
ticker := time.NewTicker(Interval)
defer ticker.Stop()
for {
res, err := s.Pass(ctx)
switch {
case err != nil && ctx.Err() == nil:
s.logger.Warn("wanted: pass stopped", "err", err, "follows", res.Follows, "wanted", res.Wanted, "held", res.Held)
case res.Wanted > 0 || res.Held > 0 || res.Baselined > 0:
s.logger.Info("wanted: pass complete", "follows", res.Follows, "baselined", res.Baselined,
"wanted", res.Wanted, "held", res.Held)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
// PassResult is what one pass did.
type PassResult struct {
Follows int // follows whose discography was applied
Baselined int // of those, first reads
Wanted int // wants recorded from follows
Held int // wants the library now answers
}
// Pass applies the follows due and settles open wants. An unreachable
// MusicBrainz stops the follows (they need it) but not the settling, which
// falls back to what is held.
func (s *Service) Pass(ctx context.Context) (PassResult, error) {
var res PassResult
ferr := s.applyFollows(ctx, &res)
if ctx.Err() != nil {
return res, ctx.Err()
}
if err := s.settleWants(ctx, &res); err != nil {
return res, err
}
return res, ferr
}
func (s *Service) applyFollows(ctx context.Context, res *PassResult) error {
q := dbq.New(s.pool)
due, err := q.ListFollowsDue(ctx, dbq.ListFollowsDueParams{
Before: pgtype.Timestamptz{Time: s.now().Add(-FollowRefreshAge), Valid: true},
MaxRows: followsPerPass,
})
if err != nil {
return fmt.Errorf("wanted: list follows due: %w", err)
}
for _, f := range due {
n, err := s.applyFollow(ctx, q, f)
if err != nil {
if errors.Is(err, musicbrainz.ErrUnreachable) || ctx.Err() != nil {
return err
}
s.logger.Warn("wanted: apply follow", "artist", f.ArtistName, "mbid", f.ArtistMbid, "err", err)
continue
}
res.Follows++
res.Wanted += n
if !f.BaselineAt.Valid {
res.Baselined++
}
}
return nil
}
// applyFollow reads the artist's discography and judges each release group
// the follow has not judged before. On the first read (the baseline) they
// are only marked seen, unless the follow asked to want what already exists.
// After it, a group is wanted when the profile passes it, it is recent, and
// the library does not hold it.
func (s *Service) applyFollow(ctx context.Context, q *dbq.Queries, f dbq.Follow) (int, error) {
d, err := s.cat.DiscographyWithin(ctx, f.ArtistMbid, FollowRefreshAge)
if err == nil && d.Stale {
// The held copy, served because MusicBrainz could not be asked:
// applying it would mark the follow checked without having looked.
return 0, musicbrainz.ErrUnreachable
}
if err != nil {
if !errors.Is(err, catalogue.ErrNotFound) {
return 0, err
}
// Merged away or removed from MusicBrainz: nothing to read. Marked
// checked so it is asked again tomorrow, not every pass.
s.logger.Info("wanted: followed artist not in MusicBrainz", "artist", f.ArtistName, "mbid", f.ArtistMbid)
return 0, q.MarkFollowChecked(ctx, f.ArtistMbid)
}
profile, err := q.GetReleaseTypeProfile(ctx, f.ProfileID)
if err != nil {
return 0, fmt.Errorf("read profile: %w", err)
}
groups, err := q.ListFollowGroups(ctx, f.ArtistMbid)
if err != nil {
return 0, fmt.Errorf("read discography: %w", err)
}
baseline := !f.BaselineAt.Valid
wanted := 0
for _, g := range groups {
judge := (baseline && f.WantExisting) || (!baseline && !g.Seen && s.recent(g.FirstReleaseDate, f.BaselineAt.Time))
if judge && Passes(profile, g.PrimaryType, g.SecondaryTypes) {
held, err := q.LibraryHoldsReleaseGroup(ctx, dbq.LibraryHoldsReleaseGroupParams{
GroupMbid: g.Mbid, Title: g.Title, ArtistMbid: f.ArtistMbid,
})
if err != nil {
return wanted, fmt.Errorf("library check: %w", err)
}
if !held {
w, err := WantWith(ctx, q, WantParams{
GroupMBID: g.Mbid, ArtistMBID: f.ArtistMbid, ArtistName: f.ArtistName, Title: g.Title,
PrimaryType: g.PrimaryType, FirstReleaseDate: g.FirstReleaseDate,
Reason: ReasonFollow, CreatedBy: f.FollowedBy,
})
if err != nil {
return wanted, err
}
if !w.FulfilledAt.Valid {
wanted++
}
}
}
if !g.Seen {
if err := q.MarkGroupSeen(ctx, dbq.MarkGroupSeenParams{ArtistMbid: f.ArtistMbid, ReleaseGroupMbid: g.Mbid}); err != nil {
return wanted, fmt.Errorf("mark seen: %w", err)
}
}
}
return wanted, q.MarkFollowChecked(ctx, f.ArtistMbid)
}
// recent reports whether a release dated date (MusicBrainz's partial date:
// "2026", "2026-10", "2026-10-09", or empty when not yet known) came out no
// earlier than NewReleaseGrace before the follow's baseline. A partial date
// is read as the last day it could mean, so a year-only date is not
// dismissed for being "January".
func (s *Service) recent(date string, baseline time.Time) bool {
t, ok := latestDate(date)
if !ok {
return true
}
return !t.Before(baseline.Add(-NewReleaseGrace))
}
func latestDate(date string) (time.Time, bool) {
if t, err := time.Parse("2006-01-02", date); err == nil {
return t, true
}
if t, err := time.Parse("2006-01", date); err == nil {
return t.AddDate(0, 1, -1), true
}
if t, err := time.Parse("2006", date); err == nil {
return t.AddDate(1, 0, -1), true
}
return time.Time{}, false
}
// settleWants marks open wants held when the library answers them. Each
// want's editions come from the catalogue, read from MusicBrainz the first
// time; once MusicBrainz is unreachable the rest use only what is held.
func (s *Service) settleWants(ctx context.Context, res *PassResult) error {
q := dbq.New(s.pool)
open, err := q.ListOpenWants(ctx, wantsPerPass)
if err != nil {
return fmt.Errorf("wanted: list open wants: %w", err)
}
mbDown := false
for _, w := range open {
if ctx.Err() != nil {
return ctx.Err()
}
shortest, moved, err := s.shortestEdition(ctx, q, w, &mbDown)
if err != nil {
s.logger.Warn("wanted: read editions", "want", w.Title, "group", w.ReleaseGroupMbid, "err", err)
}
if moved {
continue // settled next pass, under its group
}
album, ok, err := s.answeredBy(ctx, q, w, shortest)
if err != nil {
return err
}
if !ok {
continue
}
if err := q.FulfillWant(ctx, dbq.FulfillWantParams{ID: w.ID, AlbumID: album}); err != nil {
return fmt.Errorf("wanted: fulfil: %w", err)
}
res.Held++
}
return nil
}
// shortestEdition is the track count of the group's shortest official
// edition (any edition when none is official), or 0 when the catalogue
// cannot say. moved reports that the want was recorded under a release id
// and has been moved to its group.
func (s *Service) shortestEdition(ctx context.Context, q *dbq.Queries, w dbq.Want, mbDown *bool) (int, bool, error) {
var releases []dbq.MbRelease
if *mbDown {
held, err := q.ListMBReleasesForGroup(ctx, w.ReleaseGroupMbid)
if err != nil {
return 0, false, err
}
releases = held
} else {
ed, err := s.cat.Releases(ctx, w.ReleaseGroupMbid)
switch {
case err == nil:
for _, e := range ed.Editions {
releases = append(releases, e.Release)
}
case errors.Is(err, catalogue.ErrNotFound):
moved, merr := s.moveToGroup(ctx, w)
return 0, moved, merr
case errors.Is(err, musicbrainz.ErrUnreachable):
*mbDown = true
return s.shortestEdition(ctx, q, w, mbDown)
default:
return 0, false, err
}
}
return Shortest(releases), false, nil
}
// Shortest is the track count of the shortest official release with tracks,
// or of the shortest release with tracks when none is official, or 0.
func Shortest(releases []dbq.MbRelease) int {
best, bestAny := 0, 0
for _, r := range releases {
n := int(r.TrackCount)
if n <= 0 {
continue
}
if bestAny == 0 || n < bestAny {
bestAny = n
}
if r.Status == "Official" && (best == 0 || n < best) {
best = n
}
}
if best > 0 {
return best
}
return bestAny
}
// moveToGroup handles a want MusicBrainz has no release group under: one
// recorded under a release id before #5241. It is moved to the release's
// group, with the requests that name it; when the group is already wanted,
// this copy is dropped.
func (s *Service) moveToGroup(ctx context.Context, w dbq.Want) (bool, error) {
group, err := s.releaseGroup(ctx, w.ReleaseGroupMbid)
if err != nil {
if errors.Is(err, tags.ErrNotFound) {
return false, nil
}
return false, fmt.Errorf("release group of %s: %w", w.ReleaseGroupMbid, err)
}
if group == "" || group == w.ReleaseGroupMbid {
return false, nil
}
err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
tq := dbq.New(tx)
if _, gerr := tq.GetWantByReleaseGroup(ctx, group); gerr == nil {
if _, derr := tq.DeleteWant(ctx, w.ID); derr != nil {
return derr
}
} else if errors.Is(gerr, pgx.ErrNoRows) {
if rerr := tq.RepointWant(ctx, dbq.RepointWantParams{ID: w.ID, ReleaseGroupMbid: group}); rerr != nil {
return rerr
}
} else {
return gerr
}
return tq.RepointRequestsAlbumMbid(ctx, dbq.RepointRequestsAlbumMbidParams{
NewMbid: &group, OldMbid: &w.ReleaseGroupMbid,
})
})
if err != nil {
return false, fmt.Errorf("move want to its group: %w", err)
}
s.logger.Info("wanted: want moved from a release id to its group",
"want", w.Title, "release_mbid", w.ReleaseGroupMbid, "release_group_mbid", group)
return true, nil
}
// answeredBy finds the library album that answers a want, if any:
//
// - for a want about an album whose files went missing, that album once no
// missing track is left without a track on disk in its place;
// - otherwise an album of the group with at least as many tracks on disk as
// the shortest official edition;
// - and when the catalogue cannot say how long the group is, the test from
// #5263: a track arrived since the want, or nothing missing before it is
// still missing.
//
// Only albums with a track on disk count.
func (s *Service) answeredBy(ctx context.Context, q *dbq.Queries, w dbq.Want, shortest int) (pgtype.UUID, bool, error) {
holdings, err := q.WantHoldings(ctx, dbq.WantHoldingsParams{
Since: w.WantedAt, GroupMbid: w.ReleaseGroupMbid, AlbumID: w.AlbumID,
})
if err != nil {
return pgtype.UUID{}, false, fmt.Errorf("wanted: read holdings: %w", err)
}
for _, h := range holdings {
if Answers(w, h, shortest) {
return h.AlbumID, true, nil
}
}
return pgtype.UUID{}, false, nil
}
// Answers is answeredBy's verdict on one album.
func Answers(w dbq.Want, h dbq.WantHoldingsRow, shortest int) bool {
if h.Present == 0 {
return false
}
if w.Reason == ReasonMissing && w.AlbumID.Valid && h.AlbumID == w.AlbumID {
return h.UncoveredMissing == 0
}
if shortest > 0 {
return int(h.Present) >= shortest
}
return h.ArrivedSince || !h.MissingBefore
}
+457
View File
@@ -0,0 +1,457 @@
package wanted
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"sync/atomic"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
func TestPasses_PrimaryListedAndEverySecondaryAllowed(t *testing.T) {
standard := dbq.ReleaseTypeProfile{PrimaryTypes: []string{"Album", "EP", "Single"}, SecondaryTypes: []string{}}
withLive := dbq.ReleaseTypeProfile{PrimaryTypes: []string{"Album"}, SecondaryTypes: []string{"Live"}}
for _, c := range []struct {
profile dbq.ReleaseTypeProfile
primary string
secondary []string
want bool
}{
{standard, "Album", nil, true},
{standard, "single", nil, true}, // MusicBrainz's case is not relied on
{standard, "Album", []string{"Live"}, false},
{standard, "Broadcast", nil, false},
{withLive, "Album", []string{"Live"}, true},
{withLive, "Album", []string{"Live", "Compilation"}, false},
{withLive, "EP", nil, false},
} {
if got := Passes(c.profile, c.primary, c.secondary); got != c.want {
t.Errorf("Passes(%v/%v, %s %v) = %v, want %v", c.profile.PrimaryTypes, c.profile.SecondaryTypes,
c.primary, c.secondary, got, c.want)
}
}
}
func TestProfileInput_NormalizesToMusicBrainzTypes(t *testing.T) {
in, err := ProfileInput{Name: " Live too ", PrimaryTypes: []string{"single", "Album"}, SecondaryTypes: []string{"live"}}.normalize()
if err != nil {
t.Fatal(err)
}
if in.Name != "Live too" || fmt.Sprint(in.PrimaryTypes) != "[Album Single]" || fmt.Sprint(in.SecondaryTypes) != "[Live]" {
t.Errorf("normalized = %+v", in)
}
for name, bad := range map[string]ProfileInput{
"no name": {PrimaryTypes: []string{"Album"}},
"no primary type": {Name: "x"},
"unknown type": {Name: "x", PrimaryTypes: []string{"Mixtape"}},
} {
if _, err := bad.normalize(); !errors.Is(err, ErrProfileInvalid) {
t.Errorf("%s: err = %v", name, err)
}
}
}
func TestRecent_ReadsAPartialDateAsItsLastDay(t *testing.T) {
s := &Service{}
baseline := time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC)
for date, want := range map[string]bool{
"": true, // not yet known: an announced release
"2026-11-01": true,
"2026-09-15": true, // inside the grace before the follow
"2026-08-01": false,
"2026": true, // could be December
"2025": false,
"2026-09": true,
"1998-04-01": false,
} {
if got := s.recent(date, baseline); got != want {
t.Errorf("recent(%q) = %v, want %v", date, got, want)
}
}
}
func TestShortest_PrefersOfficialEditions(t *testing.T) {
rel := func(status string, n int32) dbq.MbRelease { return dbq.MbRelease{Status: status, TrackCount: n} }
for _, c := range []struct {
releases []dbq.MbRelease
want int
}{
{nil, 0},
{[]dbq.MbRelease{rel("Official", 14), rel("Official", 12), rel("Promotion", 3)}, 12},
{[]dbq.MbRelease{rel("Bootleg", 9), rel("", 11)}, 9},
{[]dbq.MbRelease{rel("Official", 0), rel("Bootleg", 9)}, 9},
} {
if got := Shortest(c.releases); got != c.want {
t.Errorf("Shortest(%v) = %d, want %d", c.releases, got, c.want)
}
}
}
func TestAnswers(t *testing.T) {
album := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
other := pgtype.UUID{Bytes: [16]byte{2}, Valid: true}
missing := dbq.Want{Reason: ReasonMissing, AlbumID: album}
request := dbq.Want{Reason: ReasonRequest}
for name, c := range map[string]struct {
w dbq.Want
h dbq.WantHoldingsRow
shortest int
want bool
}{
"nothing on disk": {request, dbq.WantHoldingsRow{AlbumID: other, ArrivedSince: true}, 10, false},
"a full edition": {request, dbq.WantHoldingsRow{AlbumID: other, Present: 10}, 10, true},
"short of every edition": {request, dbq.WantHoldingsRow{AlbumID: other, Present: 9, ArrivedSince: true}, 10, false},
"no editions known, an arrival": {request, dbq.WantHoldingsRow{AlbumID: other, Present: 1, ArrivedSince: true, MissingBefore: true}, 0, true},
"no editions known, still missing": {request, dbq.WantHoldingsRow{AlbumID: other, Present: 5, MissingBefore: true}, 0, false},
"the lost album, a gap remains": {missing, dbq.WantHoldingsRow{AlbumID: album, Present: 12, UncoveredMissing: 1}, 10, false},
"the lost album, every gap filled": {missing, dbq.WantHoldingsRow{AlbumID: album, Present: 9, UncoveredMissing: 0}, 10, true},
"another copy of the lost album": {missing, dbq.WantHoldingsRow{AlbumID: other, Present: 10}, 10, true},
} {
if got := Answers(c.w, c.h, c.shortest); got != c.want {
t.Errorf("%s: Answers = %v, want %v", name, got, c.want)
}
}
}
func TestFollowable(t *testing.T) {
for _, c := range []struct {
mbid, name string
want bool
}{
{"a", "Gorillaz", true},
{variousArtists, "Various Artists", false},
{"b", "[unknown]", false},
{"", "Nameless", false},
{"c", "[ ]Bracketed Band", true},
} {
if got := Followable(c.mbid, c.name); got != c.want {
t.Errorf("Followable(%q, %q) = %v", c.mbid, c.name, got)
}
}
}
// --- integration ---
func newPool(t *testing.T) *pgxpool.Pool {
t.Helper()
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
dbtest.ResetDB(t, pool)
return pool
}
const (
blur = "ba853904-ae25-4ebb-89d6-c44cfbd71bd2"
parklife = "11111111-0000-4000-8000-000000000001" // 1994 album, held
liveAtWemb = "11111111-0000-4000-8000-000000000002" // 2009 live album
theBallad = "11111111-0000-4000-8000-000000000003" // 2023 album, not held
newSingle = "11111111-0000-4000-8000-000000000004" // appears after the baseline
oldSingle = "11111111-0000-4000-8000-000000000005" // 1997 single, catalogued late
parkRelease = "22222222-0000-4000-8000-000000000001"
)
// fakeMB is a MusicBrainz holding Blur's discography; added groups appear in
// it after the baseline, and down makes every request a 503.
type fakeMB struct {
srv *httptest.Server
added atomic.Bool
down atomic.Bool
}
func newFakeMB(t *testing.T) *fakeMB {
t.Helper()
f := &fakeMB{}
group := func(id, title, primary, secondary, date string) string {
return fmt.Sprintf(`{"id": %q, "title": %q, "primary-type": %q, "secondary-types": [%s], "first-release-date": %q,
"artist-credit": [{"name": "Blur", "artist": {"id": %q}}]}`, id, title, primary, secondary, date, blur)
}
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if f.down.Load() {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
switch {
case r.URL.Path == "/artist/"+blur:
_, _ = fmt.Fprintf(w, `{"id": %q, "name": "Blur", "type": "Group", "country": "GB"}`, blur)
case r.URL.Path == "/release-group" && r.URL.Query().Get("artist") == blur:
groups := group(parklife, "Parklife", "Album", "", "1994-04-25") + "," +
group(liveAtWemb, "Live at Wembley Stadium", "Album", `"Live"`, "2009-07-02") + "," +
group(theBallad, "The Ballad of Darren", "Album", "", "2023-07-21")
n := 3
if f.added.Load() {
groups += "," + group(newSingle, "New Single", "Single", "", "") +
"," + group(oldSingle, "Old Single", "Single", "", "1997-02-03")
n = 5
}
_, _ = fmt.Fprintf(w, `{"release-group-count": %d, "release-groups": [%s]}`, n, groups)
case r.URL.Path == "/release-group/"+theBallad:
_, _ = fmt.Fprint(w, group(theBallad, "The Ballad of Darren", "Album", "", "2023-07-21"))
case r.URL.Path == "/release" && r.URL.Query().Get("release-group") == theBallad:
_, _ = fmt.Fprint(w, `{"release-count": 2, "releases": [
{"id": "r-ballad-1", "title": "The Ballad of Darren", "status": "Official", "media": [{"position": 1,
"tracks": [{"position": 1, "number": "1", "title": "The Ballad", "recording": {"id": "rec-1"}},
{"position": 2, "number": "2", "title": "St. Charles Square", "recording": {"id": "rec-2"}}]}]},
{"id": "r-ballad-2", "title": "The Ballad of Darren", "status": "Official", "media": [{"position": 1,
"tracks": [{"position": 1, "number": "1", "title": "The Ballad", "recording": {"id": "rec-1"}},
{"position": 2, "number": "2", "title": "St. Charles Square", "recording": {"id": "rec-2"}},
{"position": 3, "number": "3", "title": "Bonus", "recording": {"id": "rec-3"}}]}]}]}`)
case r.URL.Path == "/release":
_, _ = fmt.Fprint(w, `{"release-count": 0, "releases": []}`)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(f.srv.Close)
return f
}
func newService(t *testing.T, pool *pgxpool.Pool, f *fakeMB) *Service {
t.Helper()
mb := &musicbrainz.Client{BaseURL: f.srv.URL, Pace: &musicbrainz.Pacer{}, Retries: -1}
s := New(pool, catalogue.New(pool, mb, nil), slog.New(slog.NewTextHandler(io.Discard, nil)))
// Never the real MusicBrainz from a test.
s.releaseGroup = func(context.Context, string) (string, error) { return "", tags.ErrNotFound }
return s
}
// seedHeld puts an album of Blur's in the library with n tracks on disk.
func seedHeld(t *testing.T, q *dbq.Queries, title, release, group string, n int) dbq.Album {
t.Helper()
ctx := context.Background()
mbid := blur
artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "Blur", SortName: "Blur", Mbid: &mbid})
if err != nil {
t.Fatal(err)
}
params := dbq.UpsertAlbumParams{Title: title, SortTitle: title, ArtistID: artist.ID, Mbid: &release}
if group != "" {
params.ReleaseGroupMbid = &group
}
album, err := q.UpsertAlbum(ctx, params)
if err != nil {
t.Fatal(err)
}
for i := range n {
num := int32(i + 1)
if _, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: fmt.Sprintf("Track %d", i+1), AlbumID: album.ID, ArtistID: artist.ID, TrackNumber: &num,
DurationMs: 180000, FilePath: fmt.Sprintf("/music/%s/%02d.flac", release, i+1), FileSize: 1, FileFormat: "flac",
}); err != nil {
t.Fatal(err)
}
}
return album
}
func TestFollow_BaselineThenOnlyNewReleasesThatPass_Integration(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
f := newFakeMB(t)
s := newService(t, pool, f)
_ = seedHeld(t, q, "Parklife", parkRelease, parklife, 2)
if _, err := s.Follow(ctx, FollowParams{ArtistMBID: blur, ArtistName: "Blur"}); err != nil {
t.Fatal(err)
}
res, err := s.Pass(ctx)
if err != nil {
t.Fatal(err)
}
if res.Baselined != 1 || res.Wanted != 0 {
t.Fatalf("baseline pass = %+v: a follow wants nothing it already lists", res)
}
// MusicBrainz gains two singles: one new, one from 1997. A day later the
// follow reads the discography again.
f.added.Store(true)
if _, err := pool.Exec(ctx, `
UPDATE mb_artists SET discography_refreshed_at = now() - interval '2 days';
UPDATE follows SET checked_at = now() - interval '2 days'`); err != nil {
t.Fatal(err)
}
if res, err = s.Pass(ctx); err != nil {
t.Fatal(err)
}
if res.Wanted != 1 {
t.Errorf("second pass wanted %d, want 1 (the new single)", res.Wanted)
}
if w, err := q.GetWantByReleaseGroup(ctx, newSingle); err != nil || w.Reason != ReasonFollow || w.PrimaryType != "Single" {
t.Errorf("new single: %+v, %v", w, err)
}
if _, err := q.GetWantByReleaseGroup(ctx, oldSingle); err == nil {
t.Error("a 1997 single catalogued late is not a new release")
}
}
func TestFollow_WantExistingWantsWhatPassesAndIsNotHeld_Integration(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
f := newFakeMB(t)
s := newService(t, pool, f)
_ = seedHeld(t, q, "Parklife", parkRelease, parklife, 2)
if _, err := s.Follow(ctx, FollowParams{ArtistMBID: blur, ArtistName: "Blur", WantExisting: true}); err != nil {
t.Fatal(err)
}
if _, err := s.Pass(ctx); err != nil {
t.Fatal(err)
}
if _, err := q.GetWantByReleaseGroup(ctx, theBallad); err != nil {
t.Errorf("the studio album not held should be wanted: %v", err)
}
for group, why := range map[string]string{parklife: "held", liveAtWemb: "live, and Standard is studio only"} {
if _, err := q.GetWantByReleaseGroup(ctx, group); err == nil {
t.Errorf("%s wanted, but it is %s", group, why)
}
}
if fol, _ := q.GetFollow(ctx, blur); fol.WantExisting || !fol.BaselineAt.Valid {
t.Errorf("follow = %+v: want_existing applied once, then cleared", fol)
}
// A profile that allows live releases, applied with "want what exists".
live, err := s.CreateProfile(ctx, ProfileInput{Name: "With live", PrimaryTypes: []string{"Album"}, SecondaryTypes: []string{"Live"}})
if err != nil {
t.Fatal(err)
}
if _, err := s.Follow(ctx, FollowParams{ArtistMBID: blur, ArtistName: "Blur", ProfileID: live.ID, WantExisting: true}); err != nil {
t.Fatal(err)
}
if _, err := s.Pass(ctx); err != nil {
t.Fatal(err)
}
if _, err := q.GetWantByReleaseGroup(ctx, liveAtWemb); err != nil {
t.Errorf("with live allowed, the live album is wanted: %v", err)
}
if err := s.DeleteProfile(ctx, live.ID); !errors.Is(err, ErrProfileInUse) {
t.Errorf("deleting a profile in use: err = %v", err)
}
}
func TestSettle_HeldOnceAFullEditionIsOnDisk_Integration(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
s := newService(t, pool, newFakeMB(t))
if _, err := WantWith(ctx, q, WantParams{
GroupMBID: theBallad, ArtistMBID: blur, ArtistName: "Blur", Title: "The Ballad of Darren", Reason: ReasonRequest,
}); err != nil {
t.Fatal(err)
}
album := seedHeld(t, q, "The Ballad of Darren", "r-ballad-1", theBallad, 1)
if res, err := s.Pass(ctx); err != nil || res.Held != 0 {
t.Fatalf("one track of a two-track edition: %+v, %v", res, err)
}
_ = seedHeld(t, q, "The Ballad of Darren", "r-ballad-1", theBallad, 2)
if res, err := s.Pass(ctx); err != nil || res.Held != 1 {
t.Fatalf("the shortest edition complete: %+v, %v", res, err)
}
w, _ := q.GetWantByReleaseGroup(ctx, theBallad)
if !w.FulfilledAt.Valid || w.AlbumID != album.ID {
t.Errorf("want = %+v: held by the album", w)
}
}
func TestSettle_MovesAWantNamedByReleaseIdToItsGroup_Integration(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
s := newService(t, pool, newFakeMB(t))
s.releaseGroup = func(_ context.Context, release string) (string, error) {
if release == "r-ballad-1" {
return theBallad, nil
}
return "", tags.ErrNotFound
}
if _, err := WantWith(ctx, q, WantParams{
GroupMBID: "r-ballad-1", ArtistMBID: blur, ArtistName: "Blur", Title: "The Ballad of Darren", Reason: ReasonRequest,
}); err != nil {
t.Fatal(err)
}
group := "r-ballad-1"
if _, err := q.CreateRequest(ctx, dbq.CreateRequestParams{
UserID: seedUser(t, pool), Kind: dbq.RequestKindAlbum, ArtistMbid: blur, AlbumMbid: &group, ArtistName: "Blur",
}); err != nil {
t.Fatal(err)
}
if _, err := s.Pass(ctx); err != nil {
t.Fatal(err)
}
if _, err := q.GetWantByReleaseGroup(ctx, theBallad); err != nil {
t.Errorf("the want should be under its group now: %v", err)
}
var moved string
if err := pool.QueryRow(ctx, "SELECT album_mbid FROM requests").Scan(&moved); err != nil || moved != theBallad {
t.Errorf("the request should follow its want: %q, %v", moved, err)
}
}
func TestPass_MusicBrainzDownStopsFollowsButStillSettles_Integration(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
f := newFakeMB(t)
s := newService(t, pool, f)
if _, err := s.Follow(ctx, FollowParams{ArtistMBID: blur, ArtistName: "Blur"}); err != nil {
t.Fatal(err)
}
if _, err := WantWith(ctx, q, WantParams{
GroupMBID: "some-group", ArtistMBID: blur, ArtistName: "Blur", Title: "Some", Reason: ReasonRequest,
}); err != nil {
t.Fatal(err)
}
_ = seedHeld(t, q, "Some", "some-release", "some-group", 3)
f.down.Store(true)
res, err := s.Pass(ctx)
if !errors.Is(err, musicbrainz.ErrUnreachable) {
t.Errorf("err = %v, want ErrUnreachable", err)
}
if res.Held != 1 {
t.Errorf("held = %d: with no editions known, an album that arrived since the want answers it", res.Held)
}
if fol, _ := q.GetFollow(ctx, blur); fol.BaselineAt.Valid {
t.Error("a follow is not baselined without reading its discography")
}
}
func seedUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID {
t.Helper()
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "wanter", PasswordHash: "x", ApiTokenHash: "x",
})
if err != nil {
t.Fatal(err)
}
return u.ID
}
+8 -16
View File
@@ -32,7 +32,7 @@ import type {
LidarrConfig,
LidarrQualityProfile,
LidarrQuarantineActionRow,
LidarrRequest,
MusicRequest,
LidarrRootFolder
} from './types';
@@ -45,19 +45,17 @@ const baseConfig: LidarrConfig = {
default_root_folder_path: '/music'
};
const baseRow: LidarrRequest = {
const baseRow: MusicRequest = {
id: 'r1',
user_id: 'u1',
status: 'pending',
kind: 'album',
lidarr_artist_mbid: 'art-mbid',
lidarr_album_mbid: 'alb-mbid',
lidarr_track_mbid: null,
artist_mbid: 'art-mbid',
album_mbid: 'alb-mbid',
track_mbid: null,
artist_name: 'Aphex Twin',
album_title: 'Drukqs',
track_title: null,
quality_profile_id: null,
root_folder_path: null,
decided_at: null,
decided_by: null,
notes: null,
@@ -187,16 +185,10 @@ describe('approveRequest', () => {
expect(api.post).toHaveBeenCalledWith('/api/admin/requests/r1/approve', {});
});
test('forwards quality_profile_id + root_folder_path overrides', async () => {
test('sends the chosen release-type profile', async () => {
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(baseRow);
await approveRequest('r1', {
quality_profile_id: 2,
root_folder_path: '/music'
});
expect(api.post).toHaveBeenCalledWith('/api/admin/requests/r1/approve', {
quality_profile_id: 2,
root_folder_path: '/music'
});
await approveRequest('r1', 'p1');
expect(api.post).toHaveBeenCalledWith('/api/admin/requests/r1/approve', { profile_id: 'p1' });
});
});
+18 -20
View File
@@ -16,8 +16,8 @@ import type {
LidarrMetadataProfile,
LidarrQualityProfile,
LidarrQuarantineActionRow,
LidarrRequest,
LidarrRequestStatus,
MusicRequest,
RequestStatus,
LidarrRootFolder,
LidarrTestResult,
PlaybackErrorResolution
@@ -57,31 +57,33 @@ export async function listRootFolders(): Promise<LidarrRootFolder[]> {
// Admin request queue -----------------------------------------------------
export async function listAdminRequests(
status?: LidarrRequestStatus,
status?: RequestStatus,
limit?: number
): Promise<LidarrRequest[]> {
): Promise<MusicRequest[]> {
const params = new URLSearchParams();
if (status) params.set('status', status);
if (limit !== undefined) params.set('limit', String(limit));
const qs = params.toString();
return api.get<LidarrRequest[]>(
return api.get<MusicRequest[]>(
qs ? `/api/admin/requests?${qs}` : '/api/admin/requests'
);
}
export async function approveRequest(
id: string,
overrides: { quality_profile_id?: number; root_folder_path?: string } = {}
): Promise<LidarrRequest> {
return api.post<LidarrRequest>(`/api/admin/requests/${id}/approve`, overrides);
// Approving puts the request on the wanted list (M502 #5520). An artist
// request is followed on profileId, or the default profile when omitted.
export async function approveRequest(id: string, profileId?: string): Promise<MusicRequest> {
return api.post<MusicRequest>(
`/api/admin/requests/${id}/approve`,
profileId ? { profile_id: profileId } : {}
);
}
export async function rejectRequest(
id: string,
notes?: string
): Promise<LidarrRequest> {
): Promise<MusicRequest> {
const body = notes !== undefined ? { notes } : {};
return api.post<LidarrRequest>(`/api/admin/requests/${id}/reject`, body);
return api.post<MusicRequest>(`/api/admin/requests/${id}/reject`, body);
}
// Query factories ---------------------------------------------------------
@@ -122,7 +124,7 @@ export function createRootFoldersQuery(enabled: boolean = true) {
});
}
export function createAdminRequestsQuery(status?: LidarrRequestStatus) {
export function createAdminRequestsQuery(status?: RequestStatus) {
return createQuery({
queryKey: qk.adminRequests(status),
queryFn: () => listAdminRequests(status),
@@ -131,13 +133,13 @@ export function createAdminRequestsQuery(status?: LidarrRequestStatus) {
// until the operator acts on them.
refetchInterval: (query) => {
if (status !== 'approved') return false;
const rows = query.state.data as LidarrRequest[] | undefined;
const rows = query.state.data as MusicRequest[] | undefined;
return hasInFlightRequest(rows) ? 12_000 : false;
}
});
}
function hasInFlightRequest(rows: readonly LidarrRequest[] | undefined): boolean {
function hasInFlightRequest(rows: readonly MusicRequest[] | undefined): boolean {
return rows?.some((r) => r.status === 'approved') ?? false;
}
@@ -973,12 +975,8 @@ export async function setSuspectVerdict(trackId: string, verdict: SuspectVerdict
export type ReacquisitionSettings = {
enabled: boolean;
grace_hours: number;
backoff_base_hours: number;
backoff_max_hours: number;
max_attempts: number;
max_per_pass: number;
auto_approve: boolean;
// Albums with missing files that can never be auto-requested because
// Albums with missing files that can never be wanted automatically because
// neither they nor their artist carries an MBID. Read-only; the server
// computes it, and the card states it so the gap isn't a mystery.
unnameable_albums: number;
+3 -3
View File
@@ -1,14 +1,14 @@
import { createQuery } from '@tanstack/svelte-query';
import { api } from './client';
import { qk } from './queries';
import type { CatalogueSearchResult, LidarrRequestKind } from './types';
import type { CatalogueSearchResult, RequestKind } from './types';
// searchCatalogue asks MusicBrainz through Minstrel's release catalogue
// (M502 #5519). URLSearchParams handles encoding (spaces, quotes,
// ampersands) so callers can pass q raw.
export async function searchCatalogue(
q: string,
kind: LidarrRequestKind
kind: RequestKind
): Promise<CatalogueSearchResult[]> {
const params = new URLSearchParams({ q, kind });
return api.get<CatalogueSearchResult[]>(`/api/catalogue/search?${params.toString()}`);
@@ -16,7 +16,7 @@ export async function searchCatalogue(
// MusicBrainz allows a request a second, so a repeated search within a minute
// is served from the client's cache rather than asked again.
export function createCatalogueSearchQuery(q: string, kind: LidarrRequestKind) {
export function createCatalogueSearchQuery(q: string, kind: RequestKind) {
return createQuery({
queryKey: qk.catalogueSearch(q, kind),
queryFn: () => searchCatalogue(q, kind),
+6
View File
@@ -39,6 +39,12 @@ export const qk = {
lidarrRootFolders: () => ['lidarrRootFolders'] as const,
adminRequests: (status?: string) =>
['adminRequests', { status: status ?? 'all' }] as const,
// Wanted list (M502 #5520). Wants sit under ['adminWants'] so one prefix
// invalidation refreshes every tab and its counts.
adminWants: (state: string) => ['adminWants', { state }] as const,
adminFollows: () => ['adminFollows'] as const,
adminFollow: (artistMbid: string) => ['adminFollow', artistMbid] as const,
releaseProfiles: () => ['releaseProfiles'] as const,
myQuarantine: () => ['myQuarantine'] as const,
adminQuarantine: () => ['adminQuarantine'] as const,
adminQuarantineActions: (limit?: number) =>
+21 -23
View File
@@ -19,21 +19,19 @@ import {
} from './requests';
import { api, apiFetch } from './client';
import { qk } from './queries';
import type { LidarrRequest } from './types';
import type { MusicRequest } from './types';
const mockRow: LidarrRequest = {
const mockRow: MusicRequest = {
id: 'r1',
user_id: 'u1',
status: 'pending',
kind: 'album',
lidarr_artist_mbid: 'art-mbid',
lidarr_album_mbid: 'alb-mbid',
lidarr_track_mbid: null,
artist_mbid: 'art-mbid',
album_mbid: 'alb-mbid',
track_mbid: null,
artist_name: 'Aphex Twin',
album_title: 'Selected Ambient Works',
track_title: null,
quality_profile_id: null,
root_folder_path: null,
decided_at: null,
decided_by: null,
notes: null,
@@ -60,16 +58,16 @@ describe('createRequest', () => {
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockRow);
const out = await createRequest({
kind: 'album',
lidarr_artist_mbid: 'art-mbid',
lidarr_album_mbid: 'alb-mbid',
artist_mbid: 'art-mbid',
album_mbid: 'alb-mbid',
artist_name: 'Aphex Twin',
album_title: 'Selected Ambient Works'
});
expect(api.post).toHaveBeenCalledWith('/api/requests', {
kind: 'album',
lidarr_artist_mbid: 'art-mbid',
artist_mbid: 'art-mbid',
artist_name: 'Aphex Twin',
lidarr_album_mbid: 'alb-mbid',
album_mbid: 'alb-mbid',
album_title: 'Selected Ambient Works'
});
expect(out).toBe(mockRow);
@@ -79,10 +77,10 @@ describe('createRequest', () => {
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockRow);
await createRequest({
kind: 'artist',
lidarr_artist_mbid: 'art-mbid',
artist_mbid: 'art-mbid',
artist_name: 'Aphex Twin',
lidarr_album_mbid: '',
lidarr_track_mbid: ''
album_mbid: '',
track_mbid: ''
});
const body = (api.post as ReturnType<typeof vi.fn>).mock.calls[0][1] as Record<
string,
@@ -90,11 +88,11 @@ describe('createRequest', () => {
>;
expect(body).toEqual({
kind: 'artist',
lidarr_artist_mbid: 'art-mbid',
artist_mbid: 'art-mbid',
artist_name: 'Aphex Twin'
});
expect(body).not.toHaveProperty('lidarr_album_mbid');
expect(body).not.toHaveProperty('lidarr_track_mbid');
expect(body).not.toHaveProperty('album_mbid');
expect(body).not.toHaveProperty('track_mbid');
expect(body).not.toHaveProperty('album_title');
expect(body).not.toHaveProperty('track_title');
});
@@ -103,19 +101,19 @@ describe('createRequest', () => {
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockRow);
await createRequest({
kind: 'track',
lidarr_artist_mbid: 'art-mbid',
lidarr_album_mbid: 'alb-mbid',
lidarr_track_mbid: 'trk-mbid',
artist_mbid: 'art-mbid',
album_mbid: 'alb-mbid',
track_mbid: 'trk-mbid',
artist_name: 'Aphex Twin',
album_title: 'Drukqs',
track_title: 'Avril 14th'
});
expect(api.post).toHaveBeenCalledWith('/api/requests', {
kind: 'track',
lidarr_artist_mbid: 'art-mbid',
artist_mbid: 'art-mbid',
artist_name: 'Aphex Twin',
lidarr_album_mbid: 'alb-mbid',
lidarr_track_mbid: 'trk-mbid',
album_mbid: 'alb-mbid',
track_mbid: 'trk-mbid',
album_title: 'Drukqs',
track_title: 'Avril 14th'
});
+22 -22
View File
@@ -1,7 +1,7 @@
import { createQuery } from '@tanstack/svelte-query';
import { api, apiFetch } from './client';
import { qk } from './queries';
import type { LidarrRequest, LidarrRequestKind } from './types';
import type { MusicRequest, RequestKind } from './types';
/** Cadence for in-flight request polling. Tuned to match the operator's
"fast enough to feel live, slow enough to not hammer the server"
@@ -11,7 +11,7 @@ const POLL_INTERVAL_MS = 12_000;
/** True if any row is mid-ingest (status === 'approved'). The status flips
to 'completed' / 'rejected' when Lidarr finishes, so this predicate
lets the page poll exactly while there's something interesting to watch. */
export function hasInFlightRequest(rows: readonly LidarrRequest[] | undefined): boolean {
export function hasInFlightRequest(rows: readonly MusicRequest[] | undefined): boolean {
return rows?.some((r) => r.status === 'approved') ?? false;
}
@@ -20,21 +20,21 @@ export function hasInFlightRequest(rows: readonly LidarrRequest[] | undefined):
// strings (the backend tolerates either, but this keeps test expectations
// crisp and matches the spec §5 wire shape).
export type CreateRequestParams = {
kind: LidarrRequestKind;
lidarr_artist_mbid: string;
lidarr_album_mbid?: string;
lidarr_track_mbid?: string;
kind: RequestKind;
artist_mbid: string;
album_mbid?: string;
track_mbid?: string;
artist_name: string;
album_title?: string;
track_title?: string;
};
type CreateRequestBody = {
kind: LidarrRequestKind;
lidarr_artist_mbid: string;
kind: RequestKind;
artist_mbid: string;
artist_name: string;
lidarr_album_mbid?: string;
lidarr_track_mbid?: string;
album_mbid?: string;
track_mbid?: string;
album_title?: string;
track_title?: string;
};
@@ -42,33 +42,33 @@ type CreateRequestBody = {
function buildCreateBody(params: CreateRequestParams): CreateRequestBody {
const body: CreateRequestBody = {
kind: params.kind,
lidarr_artist_mbid: params.lidarr_artist_mbid,
artist_mbid: params.artist_mbid,
artist_name: params.artist_name
};
if (params.lidarr_album_mbid) body.lidarr_album_mbid = params.lidarr_album_mbid;
if (params.lidarr_track_mbid) body.lidarr_track_mbid = params.lidarr_track_mbid;
if (params.album_mbid) body.album_mbid = params.album_mbid;
if (params.track_mbid) body.track_mbid = params.track_mbid;
if (params.album_title) body.album_title = params.album_title;
if (params.track_title) body.track_title = params.track_title;
return body;
}
export async function createRequest(params: CreateRequestParams): Promise<LidarrRequest> {
return api.post<LidarrRequest>('/api/requests', buildCreateBody(params));
export async function createRequest(params: CreateRequestParams): Promise<MusicRequest> {
return api.post<MusicRequest>('/api/requests', buildCreateBody(params));
}
export async function listMyRequests(): Promise<LidarrRequest[]> {
return api.get<LidarrRequest[]>('/api/requests');
export async function listMyRequests(): Promise<MusicRequest[]> {
return api.get<MusicRequest[]>('/api/requests');
}
export async function getRequest(id: string): Promise<LidarrRequest> {
return api.get<LidarrRequest>(`/api/requests/${id}`);
export async function getRequest(id: string): Promise<MusicRequest> {
return api.get<MusicRequest>(`/api/requests/${id}`);
}
// Server returns the cancelled row body (not 204) so callers can patch the
// cache without a refetch. api.del's return type is fixed to null, so we
// drop down to apiFetch here to keep typing honest.
export async function cancelRequest(id: string): Promise<LidarrRequest> {
return apiFetch(`/api/requests/${id}`, { method: 'DELETE' }) as Promise<LidarrRequest>;
export async function cancelRequest(id: string): Promise<MusicRequest> {
return apiFetch(`/api/requests/${id}`, { method: 'DELETE' }) as Promise<MusicRequest>;
}
export function createMyRequestsQuery() {
@@ -81,7 +81,7 @@ export function createMyRequestsQuery() {
// refetchIntervalInBackground defaults to false → polling pauses
// while the tab is hidden and resumes on focus.
refetchInterval: (query) =>
hasInFlightRequest(query.state.data as LidarrRequest[] | undefined)
hasInFlightRequest(query.state.data as MusicRequest[] | undefined)
? POLL_INTERVAL_MS
: false
});
+82 -22
View File
@@ -51,6 +51,8 @@ export type ArtistDetail = ArtistRef & {
albums: AlbumRef[];
// Genres across this artist's tracks (#367). Server guarantees an array.
genres: string[];
// MusicBrainz id, which following is keyed by (M502 #5520); "" when none.
mbid: string;
};
export type AlbumDetail = AlbumRef & {
@@ -133,18 +135,18 @@ export type EventRequest =
export type PlayStartedResponse = { play_event_id: string; session_id: string };
export type EventOkResponse = { ok: true };
// Lidarr / requests shared enums + shapes.
// Status + Kind are used across lidarr.ts, requests.ts, and admin.ts so they
// live here. Per-module helpers (CreateRequestParams, etc.) stay in their
// Request shared enums + shapes.
// Status + Kind are used across catalogue.ts, requests.ts, and admin.ts so
// they live here. Per-module helpers (CreateRequestParams, etc.) stay in their
// respective modules.
export type LidarrRequestStatus =
export type RequestStatus =
| 'pending'
| 'approved'
| 'rejected'
| 'completed'
| 'failed';
export type LidarrRequestKind = 'artist' | 'album' | 'track';
export type RequestKind = 'artist' | 'album' | 'track';
// One row of the request search (GET /api/catalogue/search, M502 #5519).
// An album is a MusicBrainz release group; image_url is Minstrel's own art
@@ -163,19 +165,18 @@ export type CatalogueSearchResult = {
// pgtype.UUID and pgtype.Timestamptz JSON-marshal as a native string when
// Valid, and as `null` (or omitted, with omitempty) otherwise. We unify both
// "absent" cases as `string | null` so callers branch on a single check.
export type LidarrRequest = {
export type MusicRequest = {
id: string;
user_id: string;
status: LidarrRequestStatus;
kind: LidarrRequestKind;
lidarr_artist_mbid: string;
lidarr_album_mbid?: string | null;
lidarr_track_mbid?: string | null;
status: RequestStatus;
kind: RequestKind;
artist_mbid: string;
// The album's MusicBrainz release group.
album_mbid?: string | null;
track_mbid?: string | null;
artist_name: string;
album_title?: string | null;
track_title?: string | null;
quality_profile_id?: number | null;
root_folder_path?: string | null;
decided_at?: string | null;
decided_by?: string | null;
notes?: string | null;
@@ -397,16 +398,12 @@ export type AdminMissingTrack = {
last_played_at: string | null;
};
// What the re-acquisition sweeper has done about this group's album (#290).
// null when nothing has been attempted yet — the common case for a folder
// that just went missing, and distinct from attempts: 0, which cannot occur.
// What the wanted list says about this group's album (#290, M502 #5520):
// when it was wanted back, and when the library held it again. null while the
// album is inside the grace window, or when MusicBrainz can't name it.
export type AdminReacquisitionState = {
attempts: number;
last_attempt_at: string | null;
// Projected from the last attempt plus the configured backoff, so it moves
// when the operator edits the schedule. Null once the album has given up.
next_attempt_at: string | null;
gave_up_at: string | null;
wanted_at: string | null;
held_at: string | null;
};
// A directory's worth of missing tracks. The server groups because the unit an
@@ -569,3 +566,66 @@ export type ResolvedDuplicatesResponse = {
offset: number;
items: ResolvedDuplicateActivity[];
};
// Wanted list (M502 #5520) ------------------------------------------------
export type WantReason = 'request' | 'follow' | 'missing';
export type WantState = 'wanted' | 'missing' | 'held';
// One release group on the wanted list. held_at is set once the library
// holds it; artist_id and album_id link into the library when it has them.
export type Want = {
id: string;
release_group_mbid: string;
title: string;
artist_mbid: string;
artist_name: string;
artist_id?: string;
primary_type: string;
secondary_types: string[];
year: string;
reason: WantReason;
requested_by?: string;
album_id?: string;
wanted_at: string;
held_at: string | null;
image_url: string;
};
export type WantCounts = { wanted: number; missing: number; held: number; following: number };
export type WantsPage = Page<Want> & { counts: WantCounts };
export type Follow = {
artist_mbid: string;
artist_name: string;
artist_id?: string;
profile_id: string;
profile_name: string;
followed_at: string;
checked_at: string | null;
open_wants: number;
image_url: string;
};
export type FollowState = { following: boolean; profile_id?: string; profile_name?: string };
// A release-type profile: which kinds of release a follow wants. A group
// passes when its primary type is listed and every secondary type is too;
// no secondary types means studio releases only.
export type ReleaseProfile = {
id: string;
name: string;
primary_types: string[];
secondary_types: string[];
is_default: boolean;
follow_count: number;
};
export type ReleaseProfileInput = Omit<ReleaseProfile, 'id' | 'follow_count'>;
export type ReleaseProfilesResponse = {
profiles: ReleaseProfile[];
primary_types: string[];
secondary_types: string[];
};
+102
View File
@@ -0,0 +1,102 @@
import { createInfiniteQuery, createQuery } from '@tanstack/svelte-query';
import { api } from './client';
import { qk } from './queries';
import type {
Follow,
FollowState,
ReleaseProfile,
ReleaseProfileInput,
ReleaseProfilesResponse,
WantState,
WantsPage
} from './types';
// The wanted list (M502 #5520): release groups Minstrel is to get, the
// artists it follows for new releases, and the release-type profiles that
// say which kinds of release a follow wants.
export async function listWants(state: WantState, offset: number = 0): Promise<WantsPage> {
return api.get<WantsPage>(`/api/admin/wanted?state=${state}&offset=${offset}`);
}
export function wantsNextOffset(last: WantsPage): number | undefined {
const loaded = last.offset + last.items.length;
return last.items.length === 0 || loaded >= last.total ? undefined : loaded;
}
// Loads as the page scrolls (rule 172), one tab at a time.
export function createWantsQuery(state: WantState) {
return createInfiniteQuery({
queryKey: qk.adminWants(state),
queryFn: ({ pageParam }) => listWants(state, pageParam as number),
initialPageParam: 0,
getNextPageParam: wantsNextOffset,
staleTime: 30_000
});
}
export async function removeWant(id: string): Promise<void> {
await api.del(`/api/admin/wanted/${id}`);
}
export async function listFollows(): Promise<Follow[]> {
return api.get<Follow[]>('/api/admin/follows');
}
export function createFollowsQuery() {
return createQuery({
queryKey: qk.adminFollows(),
queryFn: listFollows,
staleTime: 30_000
});
}
export async function getFollow(artistMbid: string): Promise<FollowState> {
return api.get<FollowState>(`/api/admin/follows/${artistMbid}`);
}
export function createFollowQuery(artistMbid: string) {
return createQuery({
queryKey: qk.adminFollow(artistMbid),
queryFn: () => getFollow(artistMbid),
staleTime: 30_000
});
}
// Follows an artist, or moves a follow to another profile. wantExisting also
// wants what the artist has already released that the profile passes and the
// library doesn't hold. An omitted profile is the default.
export async function putFollow(
artistMbid: string,
body: { artist_name: string; profile_id?: string; want_existing?: boolean }
): Promise<FollowState> {
return api.put<FollowState>(`/api/admin/follows/${artistMbid}`, body);
}
export async function deleteFollow(artistMbid: string): Promise<void> {
await api.del(`/api/admin/follows/${artistMbid}`);
}
export async function listReleaseProfiles(): Promise<ReleaseProfilesResponse> {
return api.get<ReleaseProfilesResponse>('/api/admin/release-profiles');
}
export function createReleaseProfilesQuery() {
return createQuery({
queryKey: qk.releaseProfiles(),
queryFn: listReleaseProfiles,
staleTime: 60_000
});
}
export async function createReleaseProfile(body: ReleaseProfileInput): Promise<ReleaseProfile> {
return api.post<ReleaseProfile>('/api/admin/release-profiles', body);
}
export async function updateReleaseProfile(id: string, body: ReleaseProfileInput): Promise<ReleaseProfile> {
return api.put<ReleaseProfile>(`/api/admin/release-profiles/${id}`, body);
}
export async function deleteReleaseProfile(id: string): Promise<void> {
await api.del(`/api/admin/release-profiles/${id}`);
}
+1
View File
@@ -7,6 +7,7 @@
{ href: '/admin', label: 'Overview' },
{ href: '/admin/integrations', label: 'Integrations' },
{ href: '/admin/requests', label: 'Requests' },
{ href: '/admin/wanted', label: 'Wanted' },
{ href: '/admin/quarantine', label: 'Quarantine' },
{ href: '/admin/missing-files', label: 'Missing files' },
{ href: '/admin/duplicates', label: 'Duplicates' },
+3 -1
View File
@@ -52,7 +52,7 @@ describe('AdminTabs', () => {
);
});
test('renders all eleven tabs in order', () => {
test('renders all twelve tabs in order', () => {
state.pageUrl = new URL('http://localhost/admin');
render(AdminTabs);
const links = screen.getAllByRole('link');
@@ -60,6 +60,8 @@ describe('AdminTabs', () => {
'Overview',
'Integrations',
'Requests',
// Wanted follows Requests: an approved request goes on the wanted list.
'Wanted',
'Quarantine',
// Missing files sits with Quarantine and Playback errors: the three
// surfaces that show tracks needing an operator's attention.
+155
View File
@@ -0,0 +1,155 @@
<script lang="ts">
import { BellPlus, BellRing } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import {
createFollowQuery,
createReleaseProfilesQuery,
putFollow,
deleteFollow
} from '#lib/api/wanted.js';
import { qk } from '#lib/api/queries.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
import Modal from './Modal.svelte';
// Follow an artist for the wanted list (M502 #5520): their new releases are
// wanted, filtered by a release-type profile. Admin-only; the artist page
// shows it when the artist carries a MusicBrainz id.
let { artistMbid, artistName }: { artistMbid: string; artistName: string } = $props();
const client = useQueryClient();
const followStore = $derived(createFollowQuery(artistMbid));
const follow = $derived($followStore);
const profilesStore = createReleaseProfilesQuery();
const profiles = $derived($profilesStore.data?.profiles ?? []);
const following = $derived(follow.data?.following === true);
let open = $state(false);
let profileChoice = $state('');
let wantExisting = $state(false);
let saving = $state(false);
function openModal() {
profileChoice = follow.data?.profile_id ?? profiles.find((p) => p.is_default)?.id ?? '';
wantExisting = false;
open = true;
}
async function refresh() {
await Promise.all([
client.invalidateQueries({ queryKey: qk.adminFollow(artistMbid) }),
client.invalidateQueries({ queryKey: ['adminFollows'] }),
client.invalidateQueries({ queryKey: ['adminWants'] })
]);
}
async function save() {
saving = true;
try {
await putFollow(artistMbid, {
artist_name: artistName,
profile_id: profileChoice || undefined,
want_existing: wantExisting
});
open = false;
pushToast(
wantExisting
? `Following ${artistName}. What they've released that isn't here is wanted on the next check.`
: `Following ${artistName}. New releases will be wanted.`
);
await refresh();
} catch (e) {
pushToast(errMessage(e), 'error');
} finally {
saving = false;
}
}
async function unfollow() {
saving = true;
try {
await deleteFollow(artistMbid);
open = false;
pushToast(`No longer following ${artistName}. What was already wanted stays wanted.`);
await refresh();
} catch (e) {
pushToast(errMessage(e), 'error');
} finally {
saving = false;
}
}
</script>
<button
type="button"
onclick={openModal}
disabled={follow.isPending}
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
>
{#if following}
<BellRing size={14} strokeWidth={1.5} />
Following · {follow.data?.profile_name}
{:else}
<BellPlus size={14} strokeWidth={1.5} />
Follow
{/if}
</button>
<Modal title={following ? `Following ${artistName}` : `Follow ${artistName}`} {open} onClose={() => (open = false)}>
<p class="-mt-2 text-sm text-text-secondary">
New releases from {artistName} go on the wanted list. The profile says which kinds of
release are wanted.
</p>
<label class="mt-4 block">
<span class="block text-sm text-text-secondary">Release-type profile</span>
<select
bind:value={profileChoice}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary focus:outline-hidden focus:ring-2 focus:ring-accent"
>
{#each profiles as p (p.id)}
<option value={p.id}>{p.name}{p.is_default ? ' (default)' : ''}</option>
{/each}
</select>
</label>
<label class="mt-3 flex items-start gap-3">
<input type="checkbox" bind:checked={wantExisting} class="mt-1" />
<span>
<span class="text-sm text-text-primary">Also want what they've released that isn't here</span>
<span class="block text-xs text-text-secondary">
Off wants only releases from now on.
</span>
</span>
</label>
<div class="mt-5 flex justify-end gap-2">
{#if following}
<button
type="button"
onclick={unfollow}
disabled={saving}
class="mr-auto rounded-md px-3 py-1.5 text-sm text-text-muted hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
>
Unfollow
</button>
{/if}
<button
type="button"
onclick={() => (open = false)}
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary"
>
Cancel
</button>
<button
type="button"
onclick={save}
disabled={saving || !profileChoice}
class="rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50"
>
{following ? 'Save' : 'Follow'}
</button>
</div>
</Modal>
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import { mockQuery } from '../../test-utils/query';
import type { FollowState, ReleaseProfilesResponse } from '#lib/api/types.js';
vi.mock('#lib/api/wanted.js', () => ({
createFollowQuery: vi.fn(),
createReleaseProfilesQuery: vi.fn(),
putFollow: vi.fn().mockResolvedValue({ following: true }),
deleteFollow: vi.fn().mockResolvedValue(undefined)
}));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import FollowControl from './FollowControl.svelte';
import {
createFollowQuery,
createReleaseProfilesQuery,
deleteFollow,
putFollow
} from '#lib/api/wanted.js';
const profiles: ReleaseProfilesResponse = {
profiles: [
{ id: 'p-std', name: 'Standard', primary_types: ['Album'], secondary_types: [], is_default: true, follow_count: 0 },
{ id: 'p-live', name: 'With live', primary_types: ['Album'], secondary_types: ['Live'], is_default: false, follow_count: 0 }
],
primary_types: ['Album'],
secondary_types: ['Live']
};
afterEach(() => vi.clearAllMocks());
function setup(state: FollowState) {
vi.mocked(createFollowQuery).mockReturnValue(
mockQuery({ data: state }) as unknown as ReturnType<typeof createFollowQuery>
);
vi.mocked(createReleaseProfilesQuery).mockReturnValue(
mockQuery({ data: profiles }) as unknown as ReturnType<typeof createReleaseProfilesQuery>
);
return render(FollowControl, { artistMbid: 'mb-blur', artistName: 'Blur' });
}
describe('FollowControl', () => {
test('follows on the default profile, wanting what is already released when asked', async () => {
setup({ following: false });
await fireEvent.click(screen.getByRole('button', { name: /^follow$/i }));
expect((screen.getByLabelText(/release-type profile/i) as HTMLSelectElement).value).toBe('p-std');
await fireEvent.click(screen.getByLabelText(/also want what they've released/i));
const buttons = screen.getAllByRole('button', { name: /^follow$/i });
await fireEvent.click(buttons[buttons.length - 1]);
await waitFor(() =>
expect(putFollow).toHaveBeenCalledWith('mb-blur', {
artist_name: 'Blur',
profile_id: 'p-std',
want_existing: true
})
);
});
test('a followed artist shows its profile and can be unfollowed', async () => {
setup({ following: true, profile_id: 'p-live', profile_name: 'With live' });
await fireEvent.click(screen.getByRole('button', { name: /following · with live/i }));
expect((screen.getByLabelText(/release-type profile/i) as HTMLSelectElement).value).toBe('p-live');
await fireEvent.click(screen.getByRole('button', { name: /unfollow/i }));
await waitFor(() => expect(deleteFollow).toHaveBeenCalledWith('mb-blur'));
});
});
@@ -9,10 +9,11 @@
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
// Policy for turning a missing file back into a Lidarr request
// (milestone #290). Lives on the missing-files page rather than under
// Integrations because this is where the operator meets the problem it
// solves; Lidarr is the mechanism, not the subject.
// Policy for putting an album whose files went missing back on the wanted
// list (milestone #290, M502 #5520). Lives on the missing-files page rather
// than under Integrations because this is where the operator meets the
// problem it solves. Searching for a want, and retrying, is the search
// schedule's job, so there is no retry policy here.
let saved = $state<ReacquisitionSettings | null>(null);
let form = $state<ReacquisitionSettings | null>(null);
@@ -31,11 +32,7 @@
return {
enabled: s.enabled,
grace_hours: s.grace_hours,
backoff_base_hours: s.backoff_base_hours,
backoff_max_hours: s.backoff_max_hours,
max_attempts: s.max_attempts,
max_per_pass: s.max_per_pass,
auto_approve: s.auto_approve
max_per_pass: s.max_per_pass
};
}
@@ -70,28 +67,16 @@
}
}
// Plain-language echo of what the numbers add up to. The individual fields
// are meaningless on their own — "6 hours" says nothing until you know it
// doubles — so the card states the resulting schedule.
const schedule = $derived.by(() => {
if (!form) return '';
const steps: string[] = [];
let hours = form.backoff_base_hours;
for (let i = 0; i < form.max_attempts; i++) {
steps.push(`${hours}h`);
hours = Math.min(hours * 2, form.backoff_max_hours);
}
return steps.join(' → ');
});
</script>
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
<div>
<h3 class="font-display text-lg font-medium text-text-primary">Automatic re-acquisition</h3>
<p class="mt-1 text-sm text-text-secondary">
When a file has been missing for a while, Minstrel can ask Lidarr for the album
again by itself. Requests are made per album, not per track — a whole folder
going missing is one request, not forty.
When a file has been missing for a while, Minstrel can put its album back on the
<a href="/admin/wanted?tab=missing" class="text-accent-fg hover:underline">wanted list</a>
by itself. It's wanted per album, not per track: a whole folder going missing is one
want, not forty.
</p>
</div>
@@ -106,7 +91,7 @@
<label class="flex items-start gap-3">
<input type="checkbox" bind:checked={form.enabled} class="mt-1" />
<span>
<span class="text-sm text-text-primary">Ask Lidarr for missing albums automatically</span>
<span class="text-sm text-text-primary">Want missing albums back automatically</span>
<span class="block text-xs text-text-secondary">
Off leaves missing files listed here and does nothing about them.
</span>
@@ -115,11 +100,11 @@
<div class="grid gap-4 sm:grid-cols-2">
<label class="block">
<span class="text-sm text-text-primary">Wait before the first attempt</span>
<span class="text-sm text-text-primary">Wait before wanting it back</span>
<span class="block text-xs text-text-secondary">
Hours a file must be missing before anything is requested. A drive that
Hours a file must be missing before its album is wanted. A drive that
didn't mount, or a container that started before its media, resolves
itself well inside a day — this is what stops those becoming requests.
itself well inside a day; this is what stops those becoming wants.
</span>
<input
type="number"
@@ -132,53 +117,10 @@
/>
</label>
<label class="block">
<span class="text-sm text-text-primary">Attempts before giving up</span>
<span class="block text-xs text-text-secondary">
After this many tries the album is left alone. It's picked up again if the
file comes back and goes missing later.
</span>
<input
type="number"
min="1"
max="10"
bind:value={form.max_attempts}
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
text-text-primary focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent"
/>
</label>
<label class="block">
<span class="text-sm text-text-primary">First retry gap (hours)</span>
<input
type="number"
min="1"
max="168"
bind:value={form.backoff_base_hours}
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
text-text-primary focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent"
/>
</label>
<label class="block">
<span class="text-sm text-text-primary">Longest gap (hours)</span>
<input
type="number"
min="1"
max="720"
bind:value={form.backoff_max_hours}
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
text-text-primary focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent"
/>
</label>
<label class="block">
<span class="text-sm text-text-primary">Albums per sweep</span>
<span class="block text-xs text-text-secondary">
Ceiling on requests made each hour, so a large loss trickles.
Ceiling on albums wanted each hour, so a large loss trickles.
</span>
<input
type="number"
@@ -192,33 +134,17 @@
</label>
</div>
<p class="rounded-md bg-surface-hover px-3 py-2 text-xs text-text-secondary">
Retry schedule: <span class="font-mono text-text-primary">{schedule}</span> after the
first attempt.
</p>
<label class="flex items-start gap-3">
<input type="checkbox" bind:checked={form.auto_approve} class="mt-1" />
<span>
<span class="text-sm text-text-primary">Send requests to Lidarr without approval</span>
<span class="block text-xs text-text-secondary">
Off means requests appear in the Requests queue for you to approve, and
nothing is downloaded until you do.
</span>
</span>
</label>
{#if saved && saved.unnameable_albums > 0}
<!-- Stated rather than left to be inferred: without this, an operator
watching those rows never get a request would reasonably conclude
watching those rows never get wanted would reasonably conclude
the feature is broken. -->
<p class="flex items-start gap-2 rounded-md bg-surface-hover px-3 py-2 text-xs text-text-secondary">
<TriangleAlert size={14} class="mt-0.5 shrink-0 text-action-destructive" aria-hidden="true" />
<span>
{saved.unnameable_albums}
{saved.unnameable_albums === 1 ? 'album has' : 'albums have'} missing files but no
MusicBrainz ID, so {saved.unnameable_albums === 1 ? 'it' : 'they'} can't be requested —
Lidarr has no way to identify the release. A re-scan after tagging fixes it.
MusicBrainz ID, so {saved.unnameable_albums === 1 ? 'it' : 'they'} can't be wanted:
there's no way to identify the release. A re-scan after tagging fixes it.
</span>
</p>
{/if}
@@ -17,11 +17,7 @@ import { pushToast } from '#lib/stores/toast.svelte.js';
const base: ReacquisitionSettings = {
enabled: true,
grace_hours: 24,
backoff_base_hours: 6,
backoff_max_hours: 168,
max_attempts: 3,
max_per_pass: 20,
auto_approve: true,
unnameable_albums: 0
};
@@ -35,21 +31,14 @@ async function renderCard(over: Partial<ReacquisitionSettings> = {}) {
}
describe('ReacquisitionSettingsCard', () => {
// The individual numbers say nothing on their own — "6 hours" is meaningless
// until you know it doubles — so the card spells the schedule out.
test('states the retry schedule the numbers add up to', async () => {
// Searching and retrying belong to the wanted list's search schedule, so
// the card holds no retry policy of its own.
test('has no retry or approval settings', async () => {
await renderCard();
await waitFor(() => expect(screen.getByText('6h → 12h → 24h')).toBeTruthy());
});
test('the schedule clamps at the longest gap', async () => {
await renderCard({ backoff_base_hours: 6, backoff_max_hours: 12, max_attempts: 4 });
await waitFor(() => expect(screen.getByText('6h → 12h → 12h → 12h')).toBeTruthy());
});
test('the schedule follows the attempt count', async () => {
await renderCard({ max_attempts: 1 });
await waitFor(() => expect(screen.getByText('6h')).toBeTruthy());
await screen.findByRole('spinbutton', { name: /wait before wanting it back/i });
expect(screen.queryByText(/attempts/i)).toBeNull();
expect(screen.queryByText(/retry/i)).toBeNull();
expect(screen.queryByText(/without approval/i)).toBeNull();
});
// Saving an unchanged form would be a pointless round-trip, and a live Save
@@ -59,7 +48,7 @@ describe('ReacquisitionSettingsCard', () => {
const save = await screen.findByRole('button', { name: /save/i });
expect(save).toHaveProperty('disabled', true);
const grace = screen.getByRole('spinbutton', { name: /wait before the first attempt/i });
const grace = screen.getByRole('spinbutton', { name: /wait before wanting it back/i });
await fireEvent.input(grace, { target: { value: '48' } });
await waitFor(() => expect(save).toHaveProperty('disabled', false));
});
@@ -68,7 +57,7 @@ describe('ReacquisitionSettingsCard', () => {
vi.mocked(updateReacquisitionSettings).mockResolvedValue({ ...base, grace_hours: 48 });
await renderCard();
const grace = screen.getByRole('spinbutton', { name: /wait before the first attempt/i });
const grace = screen.getByRole('spinbutton', { name: /wait before wanting it back/i });
await fireEvent.input(grace, { target: { value: '48' } });
await fireEvent.click(await screen.findByRole('button', { name: /save/i }));
@@ -95,7 +84,7 @@ describe('ReacquisitionSettingsCard', () => {
});
await renderCard();
const grace = screen.getByRole('spinbutton', { name: /wait before the first attempt/i });
const grace = screen.getByRole('spinbutton', { name: /wait before wanting it back/i });
await fireEvent.input(grace, { target: { value: '900' } });
await fireEvent.click(await screen.findByRole('button', { name: /save/i }));
@@ -104,7 +93,7 @@ describe('ReacquisitionSettingsCard', () => {
);
});
// Without this the operator watches those albums never get a request and
// Without this the operator watches those albums never get wanted and
// reasonably concludes the feature is broken.
test('unnameable albums are called out with the reason', async () => {
await renderCard({ unnameable_albums: 3 });
@@ -114,7 +103,7 @@ describe('ReacquisitionSettingsCard', () => {
test('no warning when every missing album is identifiable', async () => {
await renderCard({ unnameable_albums: 0 });
await waitFor(() => expect(screen.getByText('6h → 12h → 24h')).toBeTruthy());
await screen.findByRole('spinbutton', { name: /wait before wanting it back/i });
expect(screen.queryByText(/MusicBrainz ID/i)).toBeNull();
});
@@ -0,0 +1,233 @@
<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import {
createReleaseProfilesQuery,
createReleaseProfile,
updateReleaseProfile,
deleteReleaseProfile
} from '#lib/api/wanted.js';
import { qk } from '#lib/api/queries.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
import type { ReleaseProfile, ReleaseProfileInput } from '#lib/api/types.js';
// Release-type profiles (M502 #5520): which kinds of release a followed
// artist's new releases must be to be wanted. A release passes when its
// primary type is ticked and so is every one of its secondary types, so a
// profile with no secondary types wants studio releases only and leaves out
// live recordings, compilations and remixes.
const client = useQueryClient();
const store = createReleaseProfilesQuery();
const query = $derived($store);
const profiles = $derived(query.data?.profiles ?? []);
const primaryTypes = $derived(query.data?.primary_types ?? []);
const secondaryTypes = $derived(query.data?.secondary_types ?? []);
// The profile being edited: its id, 'new', or null when none is.
let editing = $state<string | null>(null);
let form = $state<ReleaseProfileInput>({ name: '', primary_types: [], secondary_types: [], is_default: false });
let saving = $state(false);
function startEdit(p: ReleaseProfile) {
editing = p.id;
form = {
name: p.name,
primary_types: [...p.primary_types],
secondary_types: [...p.secondary_types],
is_default: p.is_default
};
}
function startNew() {
editing = 'new';
form = { name: '', primary_types: ['Album', 'EP'], secondary_types: [], is_default: false };
}
function toggle(list: string[], value: string): string[] {
return list.includes(value) ? list.filter((v) => v !== value) : [...list, value];
}
async function refresh() {
await client.invalidateQueries({ queryKey: qk.releaseProfiles() });
await client.invalidateQueries({ queryKey: ['adminFollows'] });
}
async function save() {
saving = true;
try {
if (editing === 'new') {
await createReleaseProfile(form);
pushToast(`${form.name} added.`);
} else if (editing) {
await updateReleaseProfile(editing, form);
pushToast(`${form.name} saved. Followed artists judge new releases by it from their next check.`);
}
editing = null;
await refresh();
} catch (e) {
pushToast(errMessage(e), 'error');
} finally {
saving = false;
}
}
async function remove(p: ReleaseProfile) {
try {
await deleteReleaseProfile(p.id);
pushToast(`${p.name} deleted.`);
if (editing === p.id) editing = null;
await refresh();
} catch (e) {
pushToast(errMessage(e), 'error');
}
}
function describe(p: ReleaseProfile): string {
const extra = p.secondary_types.length ? `, including ${p.secondary_types.join(', ')}` : ', studio only';
return p.primary_types.join(', ') + extra;
}
</script>
{#snippet editor()}
<div class="space-y-4 rounded-lg border border-border p-4" data-testid="profile-editor">
<label class="block">
<span class="text-sm text-text-primary">Name</span>
<input
type="text"
bind:value={form.name}
class="mt-1 w-full max-w-xs rounded border border-border bg-background px-2 py-1 text-sm text-text-primary
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
/>
</label>
<fieldset>
<legend class="text-sm text-text-primary">Kinds of release</legend>
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-1">
{#each primaryTypes as t (t)}
<label class="flex items-center gap-1.5 text-sm text-text-secondary">
<input
type="checkbox"
checked={form.primary_types.includes(t)}
onchange={() => (form.primary_types = toggle(form.primary_types, t))}
/>
{t}
</label>
{/each}
</div>
</fieldset>
<fieldset>
<legend class="text-sm text-text-primary">Also allow</legend>
<span class="block text-xs text-text-secondary">
Leave these unticked for studio releases only. A live album is wanted only when Live is
ticked here.
</span>
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-1">
{#each secondaryTypes as t (t)}
<label class="flex items-center gap-1.5 text-sm text-text-secondary">
<input
type="checkbox"
checked={form.secondary_types.includes(t)}
onchange={() => (form.secondary_types = toggle(form.secondary_types, t))}
/>
{t}
</label>
{/each}
</div>
</fieldset>
<label class="flex items-center gap-2 text-sm text-text-secondary">
<input type="checkbox" bind:checked={form.is_default} />
Default for new follows
</label>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={() => (editing = null)}
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary"
>
Cancel
</button>
<button
type="button"
onclick={save}
disabled={saving || !form.name.trim() || form.primary_types.length === 0}
class="rounded-md bg-action-secondary px-3 py-1.5 text-sm text-action-fg hover:opacity-90 disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save profile'}
</button>
</div>
</div>
{/snippet}
<section class="space-y-4 rounded-xl border border-border bg-surface p-5" data-testid="release-profiles-card">
<div>
<h3 class="font-display text-lg font-medium text-text-primary">Release-type profiles</h3>
<p class="mt-1 text-sm text-text-secondary">
Each followed artist is on one profile, which says which of their new releases are
<a href="/admin/wanted" class="text-accent-fg hover:underline">wanted</a>. Requests for a
specific album aren't filtered.
</p>
</div>
{#if query.isPending}
<p class="text-sm text-text-secondary">Loading…</p>
{:else if query.isError}
<p class="text-sm text-action-destructive">Couldn't load the profiles.</p>
{:else}
<ul class="divide-y divide-border rounded-lg border border-border">
{#each profiles as p (p.id)}
<li class="space-y-3 p-3" data-testid="profile-row">
<div class="flex flex-wrap items-center gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 text-sm font-medium text-text-primary">
{p.name}
{#if p.is_default}
<span class="rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent-fg">Default</span>
{/if}
</div>
<div class="text-xs text-text-secondary">
{describe(p)} · {p.follow_count === 1 ? '1 artist' : `${p.follow_count} artists`}
</div>
</div>
<button
type="button"
onclick={() => startEdit(p)}
aria-label="Edit {p.name}"
class="rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary"
>
Edit
</button>
{#if !p.is_default}
<button
type="button"
onclick={() => remove(p)}
aria-label="Delete {p.name}"
class="rounded-md px-2.5 py-1 text-xs text-text-muted hover:bg-surface-hover hover:text-text-primary"
>
Delete
</button>
{/if}
</div>
{#if editing === p.id}
{@render editor()}
{/if}
</li>
{/each}
</ul>
{#if editing === 'new'}
{@render editor()}
{:else}
<button
type="button"
onclick={startNew}
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover"
>
New profile
</button>
{/if}
{/if}
</section>
@@ -0,0 +1,91 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte';
import { mockQuery } from '../../test-utils/query';
import type { ReleaseProfilesResponse } from '#lib/api/types.js';
vi.mock('#lib/api/wanted.js', () => ({
createReleaseProfilesQuery: vi.fn(),
createReleaseProfile: vi.fn().mockResolvedValue({}),
updateReleaseProfile: vi.fn().mockResolvedValue({}),
deleteReleaseProfile: vi.fn().mockResolvedValue(undefined)
}));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import ReleaseProfilesCard from './ReleaseProfilesCard.svelte';
import {
createReleaseProfile,
createReleaseProfilesQuery,
deleteReleaseProfile,
updateReleaseProfile
} from '#lib/api/wanted.js';
const data: ReleaseProfilesResponse = {
profiles: [
{ id: 'p-std', name: 'Standard', primary_types: ['Album', 'EP', 'Single'], secondary_types: [], is_default: true, follow_count: 3 },
{ id: 'p-live', name: 'With live', primary_types: ['Album'], secondary_types: ['Live'], is_default: false, follow_count: 0 }
],
primary_types: ['Album', 'EP', 'Single', 'Broadcast', 'Other'],
secondary_types: ['Compilation', 'Live', 'Remix']
};
afterEach(() => vi.clearAllMocks());
function setup() {
vi.mocked(createReleaseProfilesQuery).mockReturnValue(
mockQuery({ data }) as unknown as ReturnType<typeof createReleaseProfilesQuery>
);
return render(ReleaseProfilesCard);
}
describe('ReleaseProfilesCard', () => {
test('says what each profile wants', () => {
setup();
const rows = screen.getAllByTestId('profile-row');
expect(rows[0].textContent).toMatch(/studio only/i);
expect(rows[0].textContent).toMatch(/3 artists/i);
expect(rows[1].textContent).toMatch(/including live/i);
});
// The default can't be deleted; the server refuses it, so the card doesn't offer it.
test('the default profile has no delete button', () => {
setup();
expect(screen.queryByRole('button', { name: /delete standard/i })).toBeNull();
expect(screen.getByRole('button', { name: /delete with live/i })).toBeTruthy();
});
test('editing ticks a secondary type and saves it', async () => {
setup();
await fireEvent.click(screen.getByRole('button', { name: /edit standard/i }));
const editor = screen.getByTestId('profile-editor');
await fireEvent.click(within(editor).getByLabelText('Live'));
await fireEvent.click(within(editor).getByRole('button', { name: /save profile/i }));
await waitFor(() =>
expect(updateReleaseProfile).toHaveBeenCalledWith('p-std', {
name: 'Standard',
primary_types: ['Album', 'EP', 'Single'],
secondary_types: ['Live'],
is_default: true
})
);
});
test('a new profile is created', async () => {
setup();
await fireEvent.click(screen.getByRole('button', { name: /new profile/i }));
const editor = screen.getByTestId('profile-editor');
await fireEvent.input(within(editor).getByLabelText(/name/i), { target: { value: 'Everything live' } });
await fireEvent.click(within(editor).getByLabelText('Live'));
await fireEvent.click(within(editor).getByRole('button', { name: /save profile/i }));
await waitFor(() =>
expect(createReleaseProfile).toHaveBeenCalledWith(
expect.objectContaining({ name: 'Everything live', secondary_types: ['Live'] })
)
);
});
test('delete removes a profile', async () => {
setup();
await fireEvent.click(screen.getByRole('button', { name: /delete with live/i }));
await waitFor(() => expect(deleteReleaseProfile).toHaveBeenCalledWith('p-live'));
});
});
+3 -3
View File
@@ -1,5 +1,5 @@
<script lang="ts" module>
import type { LidarrRequestStatus } from '#lib/api/types.js';
import type { RequestStatus } from '#lib/api/types.js';
type Tone = 'warning' | 'info' | 'success' | 'error';
@@ -8,7 +8,7 @@
// Voice-rule labels per spec §6 (and project_design_system.md). Pending requests
// are an "awaiting" moment, completed requests are "kept" (Minstrel-voice for
// success), rejected is "set aside", failed is the error register.
const STATUS_TABLE: Record<LidarrRequestStatus, StatusEntry> = {
const STATUS_TABLE: Record<RequestStatus, StatusEntry> = {
pending: { label: 'Awaiting review', tone: 'warning' },
approved: { label: 'Approved · downloading', tone: 'info' },
completed: { label: 'Kept', tone: 'success' },
@@ -18,7 +18,7 @@
</script>
<script lang="ts">
let { status }: { status: LidarrRequestStatus } = $props();
let { status }: { status: RequestStatus } = $props();
const entry = $derived(STATUS_TABLE[status]);
</script>
+1 -1
View File
@@ -94,7 +94,7 @@
try {
await createRequest({
kind: 'artist',
lidarr_artist_mbid: s.mbid,
artist_mbid: s.mbid,
artist_name: s.name
});
optimisticRequested = withMbid(optimisticRequested, s.mbid, true);
@@ -112,7 +112,7 @@ describe('SuggestionFeed', () => {
await fireEvent.click(screen.getByRole('button', { name: /request outsider/i }));
expect(createRequest).toHaveBeenCalledWith({
kind: 'artist',
lidarr_artist_mbid: 'mb1',
artist_mbid: 'mb1',
artist_name: 'Outsider'
});
expect(invalidateMock).toHaveBeenCalled();
+12 -1
View File
@@ -56,5 +56,16 @@
"playlist_not_found": "That playlist no longer exists.",
"user_not_found": "That user no longer exists.",
"service_not_found": "That connection no longer exists. Refresh the page.",
"too_many_services": "There are already twenty of these. Remove one you no longer use first."
"too_many_services": "There are already twenty of these. Remove one you no longer use first.",
"bad_state": "That isn't a tab of the wanted list.",
"bad_profile": "That isn't a release-type profile.",
"not_followable": "Various Artists and MusicBrainz placeholders can't be followed.",
"profile_not_found": "That release-type profile no longer exists. Refresh the page.",
"profile_invalid": "A profile needs a name and at least one primary type.",
"profile_name_taken": "A profile already has that name.",
"profile_in_use": "Artists are followed on this profile. Move them to another one first.",
"profile_is_default": "The default profile can't be deleted. Make another one the default first.",
"want_not_found": "That's no longer on the wanted list. Refresh the page.",
"follow_not_found": "That artist isn't followed any more. Refresh the page.",
"invalid_profile": "That isn't a release-type profile."
}
+7 -7
View File
@@ -27,8 +27,8 @@
import { runMutation } from '#lib/util/runMutation.js';
import type {
AdminQuarantineRow,
LidarrRequest,
LidarrRequestKind,
MusicRequest,
RequestKind,
LidarrQuarantineReason
} from '#lib/api/types.js';
@@ -83,17 +83,17 @@
}
// ---- Helpers ----
function fallbackIcon(kind: LidarrRequestKind) {
function fallbackIcon(kind: RequestKind) {
if (kind === 'artist') return Disc3;
if (kind === 'album') return Album;
return Music2;
}
function rowTitle(r: LidarrRequest): string {
function rowTitle(r: MusicRequest): string {
if (r.kind === 'artist') return r.artist_name;
if (r.kind === 'album') return r.album_title ?? '—';
return r.track_title ?? '—';
}
function rowSubtitle(r: LidarrRequest): string {
function rowSubtitle(r: MusicRequest): string {
if (r.kind === 'artist') return 'Artist';
if (r.kind === 'album') return `Album · ${r.artist_name}`;
return `Track · ${r.artist_name}`;
@@ -117,13 +117,13 @@
]);
}
async function onApprove(r: LidarrRequest) {
async function onApprove(r: MusicRequest) {
await runMutation(async () => {
await approveRequest(r.id);
await invalidateRequests();
});
}
async function onReject(r: LidarrRequest) {
async function onReject(r: MusicRequest) {
await runMutation(async () => {
await rejectRequest(r.id);
await invalidateRequests();
@@ -29,6 +29,7 @@
import Modal from '#lib/components/Modal.svelte';
import AcoustIDCard from '#lib/components/AcoustIDCard.svelte';
import AcquisitionCard from '#lib/components/AcquisitionCard.svelte';
import ReleaseProfilesCard from '#lib/components/ReleaseProfilesCard.svelte';
import NetworkSettingsCard from '#lib/components/NetworkSettingsCard.svelte';
import PublicAddressCard from '#lib/components/PublicAddressCard.svelte';
import NotificationEmailCard from '#lib/components/NotificationEmailCard.svelte';
@@ -423,6 +424,8 @@
<AcquisitionCard />
<ReleaseProfilesCard />
<!-- Lidarr panel -->
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
<div>
@@ -43,6 +43,27 @@ vi.mock('#lib/api/admin.js', () => ({
addAcquisitionService: vi.fn()
}));
// ReleaseProfilesCard reads its own query; settled, so it adds no "Loading…".
vi.mock('#lib/api/wanted.js', async () => {
const { mockQuery } = await import('../../../test-utils/query');
return {
createReleaseProfilesQuery: vi.fn(() =>
mockQuery({
data: {
profiles: [
{ id: 'p-std', name: 'Standard', primary_types: ['Album', 'EP', 'Single'], secondary_types: [], is_default: true, follow_count: 3 }
],
primary_types: ['Album', 'EP', 'Single'],
secondary_types: ['Live']
}
})
),
createReleaseProfile: vi.fn(),
updateReleaseProfile: vi.fn(),
deleteReleaseProfile: vi.fn()
};
});
import IntegrationsPage from './+page.svelte';
import {
createLidarrConfigQuery,
+10 -21
View File
@@ -2,7 +2,7 @@
import { pageTitle } from '#lib/branding.js';
import { FolderX, Music2 } from 'lucide-svelte';
import { createMissingFilesQuery } from '#lib/api/admin.js';
import { relativeTime, timeUntil } from '#lib/utils/relativeTime.js';
import { relativeTime } from '#lib/utils/relativeTime.js';
import { coverUrl } from '#lib/media/covers.js';
import ReacquisitionSettingsCard from '#lib/components/ReacquisitionSettingsCard.svelte';
import type { AdminMissingGroup } from '#lib/api/types.js';
@@ -26,14 +26,6 @@
const shown = $derived(groups.reduce((n, g) => n + g.tracks.length, 0));
const hasMore = $derived(offset + shown < total);
// "once" / "twice" reads far better than "1 times" in the sentence these
// land in, and the count is almost always small.
function attemptLabel(n: number): string {
if (n === 1) return 'once';
if (n === 2) return 'twice';
return `${n} times`;
}
function trackCountLabel(n: number): string {
return n === 1 ? '1 track' : `${n} tracks`;
}
@@ -103,24 +95,21 @@
</span>
</div>
<!-- What the sweeper has done about this folder. Without it the only
way to tell "not tried yet" from "asked twice, nothing came
back" is to go and read the Requests queue. -->
<!-- Where this folder's album stands on the wanted list. Without it
the only way to tell "not wanted yet" from "wanted, nothing has
come" is to go and read the wanted list. -->
{#if group.reacquisition}
{@const r = group.reacquisition}
<p
class="border-b border-border px-4 py-2 text-xs text-text-secondary"
data-testid="reacquisition-state"
>
{#if r.gave_up_at}
<span class="text-action-destructive">Gave up</span>
after {attemptLabel(r.attempts)} — last tried
{relativeTime(r.last_attempt_at ?? r.gave_up_at)}. It'll be tried again
if the files come back and go missing later.
{:else if r.last_attempt_at}
Asked Lidarr {attemptLabel(r.attempts)}, last
{relativeTime(r.last_attempt_at)}{#if r.next_attempt_at}, next
{timeUntil(r.next_attempt_at)}{/if}.
{#if r.held_at}
Held again {relativeTime(r.held_at)}. The rows here clear on the next scan.
{:else if r.wanted_at}
On the <a href="/admin/wanted?tab=missing" class="text-accent-fg hover:underline"
>wanted list</a
> since {relativeTime(r.wanted_at)}.
{/if}
</p>
{/if}
@@ -11,11 +11,7 @@ vi.mock('#lib/api/admin.js', () => ({
getReacquisitionSettings: vi.fn().mockResolvedValue({
enabled: true,
grace_hours: 24,
backoff_base_hours: 6,
backoff_max_hours: 168,
max_attempts: 3,
max_per_pass: 20,
auto_approve: true,
unnameable_albums: 0
}),
updateReacquisitionSettings: vi.fn()
@@ -125,45 +121,35 @@ describe('admin missing files', () => {
expect(screen.getByText(/couldn't load the missing-files list/i)).toBeTruthy();
});
// Nothing attempted yet is the common case for a folder that just went
// missing; a line saying so would be noise on every row.
test('no re-acquisition line before anything has been attempted', () => {
// Not wanted yet is the common case for a folder that just went missing;
// a line saying so would be noise on every row.
test('no re-acquisition line before the album is wanted', () => {
renderWith(response);
expect(screen.queryByTestId('reacquisition-state')).toBeNull();
});
test('an in-flight re-acquisition says how often and when next', () => {
test('a wanted album says since when, and links the wanted list', () => {
const withState = structuredClone(response);
withState.groups[0].reacquisition = {
attempts: 2,
last_attempt_at: new Date(Date.now() - 2 * DAY).toISOString(),
next_attempt_at: new Date(Date.now() + 4 * 3_600_000).toISOString(),
gave_up_at: null
wanted_at: new Date(Date.now() - 2 * DAY).toISOString(),
held_at: null
};
renderWith(withState);
// Collapsed: the markup wraps mid-sentence, so textContent carries the
// template's newlines and indentation between the words.
const line = text('reacquisition-state');
expect(line).toMatch(/asked lidarr twice/i);
expect(line).toMatch(/last 2d ago/i);
// Forward-looking, not relativeTime — which would say "just now" for a
// future timestamp and read as nonsense.
expect(line).toMatch(/next in 4h/i);
expect(line).toMatch(/on the wanted list since 2d ago/i);
expect(screen.getByRole('link', { name: /wanted list/i }).getAttribute('href')).toBe(
'/admin/wanted?tab=missing'
);
});
test('a given-up album says so and says it can come back', () => {
test('an album held again says so', () => {
const withState = structuredClone(response);
withState.groups[0].reacquisition = {
attempts: 3,
last_attempt_at: new Date(Date.now() - 5 * DAY).toISOString(),
next_attempt_at: null,
gave_up_at: new Date(Date.now() - 5 * DAY).toISOString()
wanted_at: new Date(Date.now() - 5 * DAY).toISOString(),
held_at: new Date(Date.now() - DAY).toISOString()
};
renderWith(withState);
const line = text('reacquisition-state');
expect(line).toMatch(/gave up/i);
expect(line).toMatch(/after 3 times/i);
expect(line).toMatch(/tried again/i);
expect(text('reacquisition-state')).toMatch(/held again 1d ago/i);
});
// Paging only appears when it can do something: a single page of results
+66 -91
View File
@@ -4,74 +4,64 @@
import { useQueryClient } from '@tanstack/svelte-query';
import RowActionsMenu, { type RowAction } from '#lib/components/RowActionsMenu.svelte';
import TabStrip from '#lib/components/TabStrip.svelte';
import {
createAdminRequestsQuery,
createQualityProfilesQuery,
createRootFoldersQuery,
approveRequest,
rejectRequest
} from '#lib/api/admin.js';
import { createAdminRequestsQuery, approveRequest, rejectRequest } from '#lib/api/admin.js';
import { createReleaseProfilesQuery } from '#lib/api/wanted.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
import StatusPill from '#lib/components/StatusPill.svelte';
import Modal from '#lib/components/Modal.svelte';
import type { LidarrRequest, LidarrRequestKind, LidarrRequestStatus } from '#lib/api/types.js';
import type { MusicRequest, RequestKind, RequestStatus } from '#lib/api/types.js';
// Approval queue. Tabs filter by status; rows expose Override/Approve/Reject
// per spec. The override modal collapses by default — most requests get the
// snapshot defaults, so we don't make the operator stare at two dropdowns
// they almost never touch.
// Approval queue. Tabs filter by status; rows expose Approve/Reject, and an
// artist request can be approved on a chosen release-type profile. Approving
// puts the request on the wanted list (M502 #5520): an album or track wants
// its album, an artist is followed.
const client = useQueryClient();
// The four tab filters. "Pending" is default — that's the actionable bucket;
// the rest are review-only views.
const tabs: { status: LidarrRequestStatus; label: string }[] = [
const tabs: { status: RequestStatus; label: string }[] = [
{ status: 'pending', label: 'Pending' },
{ status: 'approved', label: 'Approved' },
{ status: 'completed', label: 'Completed' },
{ status: 'rejected', label: 'Rejected' }
];
let activeStatus = $state<LidarrRequestStatus>('pending');
let activeStatus = $state<RequestStatus>('pending');
// The query factory is re-created on every status flip so TanStack treats
// each status as its own cache key. We don't need a single shared store.
const queryStore = $derived(createAdminRequestsQuery(activeStatus));
const query = $derived($queryStore);
const rows = $derived((query.data ?? []) as LidarrRequest[]);
const rows = $derived((query.data ?? []) as MusicRequest[]);
// Quality + root dropdowns for the override modal. Always enabled; if Lidarr
// is disabled the modal is unreachable from this page anyway (the queue
// would be empty), and on this page we'd rather see the dropdown options
// than block the modal on a separate gate.
const profilesStore = createQualityProfilesQuery(true);
const profiles = $derived($profilesStore);
const foldersStore = createRootFoldersQuery(true);
const folders = $derived($foldersStore);
// Release-type profiles for the "Approve on a profile" modal: which kinds of
// release following the artist will want.
const profilesStore = createReleaseProfilesQuery();
const profiles = $derived($profilesStore.data?.profiles ?? []);
// Modal state. `overrideOpen` and `rejectOpen` carry the request id when
// Modal state. `profileOpen` and `rejectOpen` carry the request id when
// open; null otherwise. The current row is looked up by id at render time.
let overrideOpen = $state<string | null>(null);
let qualityOverride = $state<number | ''>('');
let rootOverride = $state<string>('');
let profileOpen = $state<string | null>(null);
let profileChoice = $state<string>('');
let rejectOpen = $state<string | null>(null);
let rejectNotes = $state<string>('');
function fallbackIcon(kind: LidarrRequestKind) {
function fallbackIcon(kind: RequestKind) {
if (kind === 'artist') return Disc3;
if (kind === 'album') return Album;
return Music2;
}
function rowTitle(r: LidarrRequest): string {
function rowTitle(r: MusicRequest): string {
if (r.kind === 'artist') return r.artist_name;
if (r.kind === 'album') return r.album_title ?? '—';
return r.track_title ?? '—';
}
function rowAccessibleName(r: LidarrRequest): string {
function rowAccessibleName(r: MusicRequest): string {
if (r.kind === 'artist') return r.artist_name;
if (r.kind === 'album') return r.album_title ?? `this album by ${r.artist_name}`;
return r.track_title ?? `this track by ${r.artist_name}`;
@@ -80,7 +70,7 @@
// The id is a UUID — first 8 chars is plenty to disambiguate without a real
// username, and matches what the polish pass will replace with the real
// username surface.
function rowMeta(r: LidarrRequest): string {
function rowMeta(r: MusicRequest): string {
const when = new Date(r.requested_at).toLocaleDateString();
const userBit = `by user ${r.user_id.slice(0, 8)}`;
return `${userBit} · ${when}`;
@@ -90,45 +80,35 @@
// Invalidate every status bucket so e.g. an approve flips a row from the
// pending list to the approved list once the operator switches tabs.
await client.invalidateQueries({ queryKey: ['adminRequests'] });
await client.invalidateQueries({ queryKey: ['adminWants'] });
}
async function onApprove(
r: LidarrRequest,
overrides?: { quality_profile_id?: number; root_folder_path?: string }
) {
async function onApprove(r: MusicRequest, profileId?: string) {
try {
if (overrides && (overrides.quality_profile_id !== undefined || overrides.root_folder_path !== undefined)) {
await approveRequest(r.id, overrides);
} else {
await approveRequest(r.id);
}
await approveRequest(r.id, profileId);
await invalidate();
} catch (e) {
pushToast(errMessage(e), 'error');
}
}
function openOverride(r: LidarrRequest) {
overrideOpen = r.id;
// Reset to "Use defaults" each time — operators should consciously opt in
// to an override, not inherit the previous row's choice.
qualityOverride = '';
rootOverride = '';
function openProfile(r: MusicRequest) {
profileOpen = r.id;
// Start from the default each time, not the previous row's choice.
profileChoice = profiles.find((p) => p.is_default)?.id ?? '';
}
function cancelOverride() {
overrideOpen = null;
function cancelProfile() {
profileOpen = null;
}
async function confirmOverride(r: LidarrRequest) {
const overrides: { quality_profile_id?: number; root_folder_path?: string } = {};
if (qualityOverride !== '') overrides.quality_profile_id = Number(qualityOverride);
if (rootOverride !== '') overrides.root_folder_path = rootOverride;
overrideOpen = null;
await onApprove(r, overrides);
async function confirmProfile(r: MusicRequest) {
const choice = profileChoice;
profileOpen = null;
await onApprove(r, choice || undefined);
}
function openReject(r: LidarrRequest) {
function openReject(r: MusicRequest) {
rejectOpen = r.id;
rejectNotes = '';
}
@@ -137,7 +117,7 @@
rejectOpen = null;
}
async function confirmReject(r: LidarrRequest) {
async function confirmReject(r: MusicRequest) {
const notes = rejectNotes.trim();
rejectOpen = null;
try {
@@ -151,8 +131,8 @@
// Modal lookups: if the active tab has been refetched while a modal is
// open, the row may no longer be in `rows`. Guard against that by treating
// missing-row as closed.
const overrideRow = $derived(
overrideOpen ? rows.find((r) => r.id === overrideOpen) ?? null : null
const profileRow = $derived(
profileOpen ? rows.find((r) => r.id === profileOpen) ?? null : null
);
const rejectRow = $derived(
rejectOpen ? rows.find((r) => r.id === rejectOpen) ?? null : null
@@ -164,7 +144,11 @@
<div class="space-y-6">
<header class="space-y-1">
<h2 class="font-display text-2xl font-medium text-text-primary">Requests</h2>
<p class="text-text-secondary">Approve or set aside what users have asked for.</p>
<p class="text-text-secondary">
Approve or set aside what users have asked for. An approved album or track goes on the
<a href="/admin/wanted" class="text-accent-fg hover:underline">wanted list</a>; an approved
artist is followed, wanting what they've released and what they release next.
</p>
</header>
<TabStrip
@@ -220,7 +204,7 @@
{/if}
{#if r.kind === 'track' && r.album_title}
<div class="text-sm text-text-secondary" data-testid="track-disclosure">
Approving will add the album <em class="font-medium text-text-primary">{r.album_title}</em>.
Approving wants the album <em class="font-medium text-text-primary">{r.album_title}</em>.
</div>
{/if}
{#if r.notes}
@@ -236,12 +220,16 @@
onclick: () => onApprove(r)
}}
{@const secondary: RowAction[] = [
{
icon: SlidersHorizontal,
label: 'Override',
ariaLabel: `Override ${rowAccessibleName(r)}`,
onclick: () => openOverride(r)
},
...(r.kind === 'artist'
? [
{
icon: SlidersHorizontal,
label: 'Approve on a profile',
ariaLabel: `Approve ${rowAccessibleName(r)} on a profile`,
onclick: () => openProfile(r)
}
]
: []),
{
icon: X,
label: 'Reject',
@@ -261,36 +249,23 @@
</div>
<Modal
title="Approve with override"
open={overrideRow !== null}
onClose={cancelOverride}
title="Approve on a profile"
open={profileRow !== null}
onClose={cancelProfile}
>
<p class="-mt-2 text-sm text-text-secondary">
Leave fields blank to use the saved defaults.
The profile says which kinds of release following
{profileRow?.artist_name ?? 'this artist'} wants. Profiles are edited under Integrations.
</p>
<label class="mt-4 block">
<span class="block text-sm text-text-secondary">Quality profile</span>
<span class="block text-sm text-text-secondary">Release-type profile</span>
<select
bind:value={qualityOverride}
bind:value={profileChoice}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary focus:outline-hidden focus:ring-2 focus:ring-accent"
>
<option value="">Use default</option>
{#each profiles.data ?? [] as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
</label>
<label class="mt-3 block">
<span class="block text-sm text-text-secondary">Root folder</span>
<select
bind:value={rootOverride}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary focus:outline-hidden focus:ring-2 focus:ring-accent"
>
<option value="">Use default</option>
{#each folders.data ?? [] as f (f.path)}
<option value={f.path}>{f.path}{f.accessible ? '' : ' (not accessible)'}</option>
{#each profiles as p (p.id)}
<option value={p.id}>{p.name}{p.is_default ? ' (default)' : ''}</option>
{/each}
</select>
</label>
@@ -299,17 +274,17 @@
<button
type="button"
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary"
onclick={cancelOverride}
onclick={cancelProfile}
>
Cancel
</button>
<button
type="button"
class="inline-flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg"
onclick={() => overrideRow && confirmOverride(overrideRow)}
onclick={() => profileRow && confirmProfile(profileRow)}
>
<Check size={14} strokeWidth={2} />
Approve with override
Approve and follow
</button>
</div>
</Modal>
+42 -41
View File
@@ -1,34 +1,30 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { mockQuery } from '../../../test-utils/query';
import type { LidarrRequest } from '#lib/api/types.js';
import type { MusicRequest } from '#lib/api/types.js';
// Wrap useQueryClient so the page can call invalidateQueries() without a
// real QueryClient context. Everything else from svelte-query passes through.
vi.mock('#lib/api/admin.js', () => ({
createAdminRequestsQuery: vi.fn(),
createQualityProfilesQuery: vi.fn(),
createRootFoldersQuery: vi.fn(),
approveRequest: vi.fn(),
rejectRequest: vi.fn()
}));
vi.mock('#lib/api/wanted.js', () => ({
createReleaseProfilesQuery: vi.fn()
}));
import AdminRequestsPage from './+page.svelte';
import {
createAdminRequestsQuery,
createQualityProfilesQuery,
createRootFoldersQuery,
approveRequest,
rejectRequest
} from '#lib/api/admin.js';
import { createAdminRequestsQuery, approveRequest, rejectRequest } from '#lib/api/admin.js';
import { createReleaseProfilesQuery } from '#lib/api/wanted.js';
const baseRow: LidarrRequest = {
const baseRow: MusicRequest = {
id: 'r1',
user_id: 'u1234567-aaaa-bbbb-cccc-deadbeef0001',
status: 'pending',
kind: 'album',
lidarr_artist_mbid: 'a-mbid',
lidarr_album_mbid: 'al-mbid',
artist_mbid: 'a-mbid',
album_mbid: 'al-mbid',
artist_name: 'Boards of Canada',
album_title: 'Geogaddi',
requested_at: '2026-04-29T10:00:00Z',
@@ -39,15 +35,21 @@ const baseRow: LidarrRequest = {
afterEach(() => vi.clearAllMocks());
function setup(rows: LidarrRequest[] = [baseRow]) {
function setup(rows: MusicRequest[] = [baseRow]) {
(createAdminRequestsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: rows })
);
(createQualityProfilesQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: [{ id: 1, name: 'Standard' }] })
);
(createRootFoldersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: [{ path: '/music', accessible: true, free_space: 0 }] })
(createReleaseProfilesQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({
data: {
profiles: [
{ id: 'p-std', name: 'Standard', primary_types: ['Album'], secondary_types: [], is_default: true, follow_count: 0 },
{ id: 'p-live', name: 'With live', primary_types: ['Album'], secondary_types: ['Live'], is_default: false, follow_count: 0 }
],
primary_types: ['Album'],
secondary_types: ['Live']
}
})
);
return render(AdminRequestsPage);
}
@@ -59,30 +61,29 @@ describe('/admin/requests', () => {
expect(createAdminRequestsQuery).toHaveBeenCalledWith('approved');
});
test('Approve without override fires POST with no override body', async () => {
test('Approve sends no profile', async () => {
setup();
(approveRequest as ReturnType<typeof vi.fn>).mockResolvedValueOnce(baseRow);
await fireEvent.click(screen.getByRole('button', { name: /approve geogaddi/i }));
expect(approveRequest).toHaveBeenCalledWith('r1');
expect(approveRequest).toHaveBeenCalledWith('r1', undefined);
});
test('Override modal Confirm sends chosen values', async () => {
test('An album request has no profile choice', () => {
setup();
await fireEvent.click(screen.getByRole('button', { name: /override geogaddi/i }));
await fireEvent.change(screen.getByLabelText(/quality profile/i), {
target: { value: '1' }
});
await fireEvent.change(screen.getByLabelText(/root folder/i), {
target: { value: '/music' }
});
(approveRequest as ReturnType<typeof vi.fn>).mockResolvedValueOnce(baseRow);
expect(screen.queryByRole('button', { name: /on a profile/i })).toBeNull();
});
test('An artist request is approved on the chosen profile', async () => {
setup([{ ...baseRow, kind: 'artist', album_mbid: undefined, album_title: undefined }]);
await fireEvent.click(
screen.getByRole('button', { name: /approve with override/i })
screen.getByRole('button', { name: /approve boards of canada on a profile/i })
);
expect(approveRequest).toHaveBeenCalledWith('r1', {
quality_profile_id: 1,
root_folder_path: '/music'
});
const select = screen.getByLabelText(/release-type profile/i) as HTMLSelectElement;
expect(select.value).toBe('p-std');
await fireEvent.change(select, { target: { value: 'p-live' } });
(approveRequest as ReturnType<typeof vi.fn>).mockResolvedValueOnce(baseRow);
await fireEvent.click(screen.getByRole('button', { name: /approve and follow/i }));
expect(approveRequest).toHaveBeenCalledWith('r1', 'p-live');
});
test('Reject opens notes textarea; confirm sends notes', async () => {
@@ -100,16 +101,16 @@ describe('/admin/requests', () => {
expect(rejectRequest).toHaveBeenCalledWith('r1', 'duplicate');
});
test('Lidarr-unreachable error shows toast', async () => {
test('An approve error shows a toast', async () => {
setup();
(approveRequest as ReturnType<typeof vi.fn>).mockRejectedValueOnce({
code: 'lidarr_unreachable',
message: 'unreachable',
status: 503
code: 'request_not_pending',
message: 'not pending',
status: 409
});
await fireEvent.click(screen.getByRole('button', { name: /approve geogaddi/i }));
await waitFor(() =>
expect(screen.getByText(/lidarr is unreachable/i)).toBeInTheDocument()
expect(screen.getByText(/no longer pending/i)).toBeInTheDocument()
);
});
@@ -122,6 +123,6 @@ describe('/admin/requests', () => {
album_title: 'Geogaddi'
}
]);
expect(screen.getByText(/will add the album/i)).toBeInTheDocument();
expect(screen.getByText(/wants the album/i)).toBeInTheDocument();
});
});
+268
View File
@@ -0,0 +1,268 @@
<script lang="ts">
import { page } from '$app/state';
import { pageTitle } from '#lib/branding.js';
import { Album, Disc3, ListChecks, X } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import {
createWantsQuery,
createFollowsQuery,
createReleaseProfilesQuery,
removeWant,
putFollow,
deleteFollow
} from '#lib/api/wanted.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
import { relativeTime } from '#lib/utils/relativeTime.js';
import ListContinuation from '#lib/components/ListContinuation.svelte';
import TabStrip from '#lib/components/TabStrip.svelte';
import type { Follow, Want, WantState } from '#lib/api/types.js';
// The wanted list (M502 #5520): every release group Minstrel is to get,
// whether a user requested it, a followed artist released it, or its files
// went missing. A want is held once the library has the album; the Held tab
// is that history. Following lists the artists whose new releases are
// wanted, each on a release-type profile that says which kinds of release.
type Tab = WantState | 'following';
const fromUrl = page.url.searchParams.get('tab');
let tab = $state<Tab>(
fromUrl === 'missing' || fromUrl === 'held' || fromUrl === 'following' ? fromUrl : 'wanted'
);
// The Following tab still reads the wanted query: it carries every tab's count.
const wantState = $derived<WantState>(tab === 'following' ? 'wanted' : tab);
const client = useQueryClient();
const wantsStore = $derived(createWantsQuery(wantState));
const wants = $derived($wantsStore);
const items = $derived((wants.data?.pages ?? []).flatMap((p) => p.items) as Want[]);
const counts = $derived(wants.data?.pages?.[0]?.counts);
const followsStore = createFollowsQuery();
const follows = $derived($followsStore);
const profilesStore = createReleaseProfilesQuery();
const profiles = $derived($profilesStore.data?.profiles ?? []);
const tabs = $derived([
{ id: 'wanted' as Tab, label: 'Wanted', count: counts?.wanted },
{ id: 'missing' as Tab, label: 'Missing files', count: counts?.missing },
{ id: 'held' as Tab, label: 'Held', count: counts?.held },
{ id: 'following' as Tab, label: 'Following', count: counts?.following }
]);
let busy = $state<string | null>(null);
async function refresh() {
await Promise.all([
client.invalidateQueries({ queryKey: ['adminWants'] }),
client.invalidateQueries({ queryKey: ['adminFollows'] })
]);
}
async function onRemove(w: Want) {
busy = w.id;
try {
await removeWant(w.id);
pushToast(`${w.title} is off the wanted list.`);
await refresh();
} catch (e) {
pushToast(errMessage(e), 'error');
} finally {
busy = null;
}
}
async function onProfile(f: Follow, profileId: string) {
busy = f.artist_mbid;
try {
await putFollow(f.artist_mbid, { artist_name: f.artist_name, profile_id: profileId });
await refresh();
} catch (e) {
pushToast(errMessage(e), 'error');
} finally {
busy = null;
}
}
async function onUnfollow(f: Follow) {
busy = f.artist_mbid;
try {
await deleteFollow(f.artist_mbid);
pushToast(`No longer following ${f.artist_name}. What was already wanted stays wanted.`);
await refresh();
} catch (e) {
pushToast(errMessage(e), 'error');
} finally {
busy = null;
}
}
function why(w: Want): string {
if (w.reason === 'request') return w.requested_by ? `Requested by ${w.requested_by}` : 'Requested';
if (w.reason === 'follow') return 'New from a followed artist';
return 'Its files went missing';
}
function kind(w: Want): string {
const parts = [w.primary_type, ...w.secondary_types].filter(Boolean);
if (w.year) parts.push(w.year);
return parts.join(' · ');
}
function hideBroken(e: Event) {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}
</script>
<svelte:head><title>{pageTitle('Admin · Wanted')}</title></svelte:head>
<div class="space-y-6">
<header class="space-y-1">
<h2 class="font-display text-2xl font-medium text-text-primary">Wanted</h2>
<p class="text-text-secondary">
Albums Minstrel is to get: ones users requested, new releases from the artists it follows,
and albums whose files went missing. An album leaves the list once the library holds it.
Minstrel doesn't search indexers for these yet; that comes next.
</p>
</header>
<TabStrip ariaLabel="Wanted list" bind:value={tab} items={tabs} />
{#if tab === 'following'}
<p class="text-sm text-text-secondary">
Each artist is followed on a release-type profile, which says which kinds of release are
wanted. Profiles are edited under
<a href="/admin/integrations" class="text-accent-fg hover:underline">Integrations</a>.
</p>
{#if follows.isPending}
<p class="text-text-secondary">Reading who's followed…</p>
{:else if follows.isError}
<p class="text-error-fg">Couldn't load the followed artists.</p>
{:else if (follows.data ?? []).length === 0}
<p class="text-text-secondary">
No artists are followed. Follow one from its artist page.
</p>
{:else}
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
{#each follows.data ?? [] as f (f.artist_mbid)}
<li class="flex flex-wrap items-center gap-3 p-3" data-testid="follow-row">
<div class="relative flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-full bg-surface-hover" aria-hidden="true">
<Disc3 size={20} strokeWidth={1} class="text-text-muted" />
{#if f.image_url}
<img src={f.image_url} alt="" class="absolute inset-0 h-full w-full object-cover" loading="lazy" onerror={hideBroken} />
{/if}
</div>
<div class="min-w-0 flex-1 space-y-0.5">
<div class="truncate text-sm font-medium text-text-primary">
{#if f.artist_id}
<a href="/artists/{f.artist_id}" class="hover:underline">{f.artist_name}</a>
{:else}
{f.artist_name}
{/if}
</div>
<div class="text-xs text-text-secondary">
{f.open_wants === 1 ? '1 album wanted' : `${f.open_wants} albums wanted`} ·
{f.checked_at ? `checked ${relativeTime(f.checked_at)}` : 'not checked yet'}
</div>
</div>
<label class="flex items-center gap-2 text-xs text-text-secondary">
<span class="sr-only">Profile for {f.artist_name}</span>
<select
value={f.profile_id}
disabled={busy === f.artist_mbid}
onchange={(e) => onProfile(f, (e.currentTarget as HTMLSelectElement).value)}
class="rounded-md border border-border bg-background px-2 py-1 text-sm text-text-primary focus:outline-hidden focus:ring-2 focus:ring-accent"
>
{#each profiles as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
</label>
<button
type="button"
onclick={() => onUnfollow(f)}
disabled={busy === f.artist_mbid}
aria-label="Unfollow {f.artist_name}"
class="rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
>
Unfollow
</button>
</li>
{/each}
</ul>
{/if}
{:else if wants.isPending}
<p class="text-text-secondary">Reading the wanted list…</p>
{:else if wants.isError && !wants.isFetchNextPageError}
<p class="text-error-fg">Couldn't load the wanted list.</p>
{:else if items.length === 0}
<div class="rounded-lg border border-border bg-surface p-6 text-center">
<ListChecks size={28} strokeWidth={1} class="mx-auto text-text-muted" />
<p class="mt-3 text-text-primary">
{#if tab === 'held'}Nothing on the list has been held yet.
{:else if tab === 'missing'}No missing albums are wanted back.
{:else}Nothing is wanted.{/if}
</p>
<p class="mt-1 text-sm text-text-secondary">
{#if tab === 'missing'}
An album whose files stay missing past the grace window on the Missing files page lands here.
{:else}
A request you approve, a new release from a followed artist, or an album whose files went
missing lands here.
{/if}
</p>
</div>
{:else}
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
{#each items as w (w.id)}
<li class="flex items-center gap-3 p-3" data-testid="want-row">
<div class="relative flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-md bg-surface-hover" aria-hidden="true">
<Album size={22} strokeWidth={1} class="text-text-muted" />
{#if w.image_url}
<img src={w.image_url} alt="" class="absolute inset-0 h-full w-full object-cover" loading="lazy" onerror={hideBroken} />
{/if}
</div>
<div class="min-w-0 flex-1 space-y-0.5">
<div class="truncate text-sm font-medium text-text-primary">
{#if w.album_id && w.held_at}
<a href="/albums/{w.album_id}" class="hover:underline">{w.title}</a>
{:else}
{w.title}
{/if}
</div>
<div class="truncate text-xs text-text-secondary">
{#if w.artist_id}
<a href="/artists/{w.artist_id}" class="hover:text-text-primary hover:underline">{w.artist_name}</a>
{:else}
{w.artist_name}
{/if}
{#if kind(w)}<span class="text-text-muted"> · {kind(w)}</span>{/if}
</div>
<div class="text-xs text-text-muted" data-testid="want-why">
{why(w)} · {w.held_at ? `held ${relativeTime(w.held_at)}` : `wanted ${relativeTime(w.wanted_at)}`}
</div>
</div>
{#if !w.held_at}
<button
type="button"
onclick={() => onRemove(w)}
disabled={busy === w.id}
aria-label="Stop wanting {w.title}"
class="inline-flex shrink-0 items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
>
<X size={12} strokeWidth={2} /> Remove
</button>
{/if}
</li>
{/each}
</ul>
<ListContinuation
hasMore={!!wants.hasNextPage}
loading={wants.isFetchingNextPage}
failed={wants.isFetchNextPageError}
onMore={() => wants.fetchNextPage()}
/>
{/if}
</div>
+145
View File
@@ -0,0 +1,145 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import { mockInfiniteQuery, mockQuery } from '../../../test-utils/query';
import type { Follow, ReleaseProfilesResponse, Want, WantsPage } from '#lib/api/types.js';
vi.mock('#lib/api/wanted.js', () => ({
createWantsQuery: vi.fn(),
createFollowsQuery: vi.fn(),
createReleaseProfilesQuery: vi.fn(),
removeWant: vi.fn().mockResolvedValue(undefined),
putFollow: vi.fn().mockResolvedValue({ following: true }),
deleteFollow: vi.fn().mockResolvedValue(undefined)
}));
import AdminWantedPage from './+page.svelte';
import {
createFollowsQuery,
createReleaseProfilesQuery,
createWantsQuery,
deleteFollow,
putFollow,
removeWant
} from '#lib/api/wanted.js';
const DAY = 24 * 3_600_000;
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
function want(id: string, extra: Partial<Want> = {}): Want {
return {
id,
release_group_mbid: `rg-${id}`,
title: 'The Ballad of Darren',
artist_mbid: 'blur',
artist_name: 'Blur',
artist_id: 'art-1',
primary_type: 'Album',
secondary_types: [],
year: '2023',
reason: 'follow',
wanted_at: ago(2 * DAY),
held_at: null,
image_url: '',
...extra
};
}
function page(items: Want[]): WantsPage {
return {
items,
total: items.length,
limit: 50,
offset: 0,
counts: { wanted: 2, missing: 1, held: 4, following: 1 }
};
}
const follow: Follow = {
artist_mbid: 'blur',
artist_name: 'Blur',
artist_id: 'art-1',
profile_id: 'p-std',
profile_name: 'Standard',
followed_at: ago(10 * DAY),
checked_at: ago(3 * 3_600_000),
open_wants: 2,
image_url: ''
};
const profiles: ReleaseProfilesResponse = {
profiles: [
{ id: 'p-std', name: 'Standard', primary_types: ['Album'], secondary_types: [], is_default: true, follow_count: 1 },
{ id: 'p-live', name: 'With live', primary_types: ['Album'], secondary_types: ['Live'], is_default: false, follow_count: 0 }
],
primary_types: ['Album'],
secondary_types: ['Live']
};
afterEach(() => vi.clearAllMocks());
function setup(items: Want[] = [want('w1'), want('w2', { reason: 'request', requested_by: 'sam', title: 'Parklife' })]) {
vi.mocked(createWantsQuery).mockReturnValue(
mockInfiniteQuery({ pages: [page(items)] }) as unknown as ReturnType<typeof createWantsQuery>
);
vi.mocked(createFollowsQuery).mockReturnValue(
mockQuery({ data: [follow] }) as unknown as ReturnType<typeof createFollowsQuery>
);
vi.mocked(createReleaseProfilesQuery).mockReturnValue(
mockQuery({ data: profiles }) as unknown as ReturnType<typeof createReleaseProfilesQuery>
);
return render(AdminWantedPage);
}
describe('/admin/wanted', () => {
test('each tab shows its count', () => {
setup();
expect(screen.getByRole('tab', { name: /wanted/i }).textContent).toMatch(/2/);
expect(screen.getByRole('tab', { name: /held/i }).textContent).toMatch(/4/);
});
test('a want says why it is wanted', () => {
setup();
const why = screen.getAllByTestId('want-why').map((e) => e.textContent ?? '');
expect(why[0]).toMatch(/new from a followed artist/i);
expect(why[1]).toMatch(/requested by sam/i);
});
test('switching tab asks for that state', async () => {
setup();
await fireEvent.click(screen.getByRole('tab', { name: /missing files/i }));
expect(createWantsQuery).toHaveBeenCalledWith('missing');
});
test('remove takes a want off the list', async () => {
setup();
await fireEvent.click(screen.getByRole('button', { name: /stop wanting parklife/i }));
await waitFor(() => expect(removeWant).toHaveBeenCalledWith('w2'));
});
test('a held want has no remove button', () => {
setup([want('w1', { held_at: ago(DAY), album_id: 'al-1' })]);
expect(screen.queryByRole('button', { name: /stop wanting/i })).toBeNull();
expect(screen.getByRole('link', { name: /the ballad of darren/i }).getAttribute('href')).toBe('/albums/al-1');
});
test('following lists artists; the profile can change and the artist be unfollowed', async () => {
setup();
await fireEvent.click(screen.getByRole('tab', { name: /following/i }));
expect(screen.getAllByTestId('follow-row')).toHaveLength(1);
const select = screen.getByLabelText(/profile for blur/i) as HTMLSelectElement;
expect(select.value).toBe('p-std');
await fireEvent.change(select, { target: { value: 'p-live' } });
await waitFor(() =>
expect(putFollow).toHaveBeenCalledWith('blur', { artist_name: 'Blur', profile_id: 'p-live' })
);
await fireEvent.click(screen.getByRole('button', { name: /unfollow blur/i }));
await waitFor(() => expect(deleteFollow).toHaveBeenCalledWith('blur'));
});
test('an empty list explains what lands there', () => {
setup([]);
expect(screen.getByText(/nothing is wanted/i)).toBeTruthy();
});
});
+9 -1
View File
@@ -13,6 +13,8 @@
import CompactTrackCard from '#lib/components/CompactTrackCard.svelte';
import HorizontalScrollRow from '#lib/components/HorizontalScrollRow.svelte';
import LikeButton from '#lib/components/LikeButton.svelte';
import FollowControl from '#lib/components/FollowControl.svelte';
import { user } from '#lib/auth/store.svelte.js';
import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte';
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
import { useDelayed } from '#lib/utils/useDelayed.svelte.js';
@@ -22,6 +24,7 @@
import type { ArtistRef, TrackRef } from '#lib/api/types.js';
const id = $derived(page.params.id ?? '');
const isAdmin = $derived(user.value?.is_admin === true);
const queryStore = $derived(createArtistQuery(id));
const query = $derived($queryStore);
const showSkeleton = useDelayed(() => query.isPending);
@@ -103,13 +106,18 @@
{/each}
</ul>
{/if}
<div class="mt-3">
<div class="mt-3 flex flex-wrap items-center gap-3">
<PlayActions
name={detail.name}
disabled={isStartingPlay || detail.album_count === 0}
{onPlay}
{onShuffle}
/>
<!-- Following is keyed by MusicBrainz id, so an artist whose tags
never named one can't be followed. -->
{#if isAdmin && detail.mbid}
<FollowControl artistMbid={detail.mbid} artistName={detail.name} />
{/if}
</div>
</div>
</header>
+52 -3
View File
@@ -25,6 +25,24 @@ vi.mock('#lib/api/queries.js', () => ({
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
const userState = vi.hoisted(() => ({
current: null as { id: string; username: string; is_admin: boolean } | null
}));
vi.mock('#lib/auth/store.svelte.js', () => ({
user: { get value() { return userState.current; } }
}));
vi.mock('#lib/api/wanted.js', async () => {
const { mockQuery } = await import('../../../test-utils/query');
return {
createFollowQuery: vi.fn(() => mockQuery({ data: { following: false } })),
createReleaseProfilesQuery: vi.fn(() => mockQuery({ data: { profiles: [], primary_types: [], secondary_types: [] } })),
putFollow: vi.fn(),
deleteFollow: vi.fn()
};
});
vi.mock('#lib/api/client.js', () => apiClientMock());
vi.mock('#lib/player/store.svelte.js', () => ({
@@ -59,6 +77,7 @@ function album(id: string, title: string, year?: number): AlbumRef {
afterEach(() => {
vi.clearAllMocks();
state.pageParams = { id: 'abc' };
userState.current = null;
});
beforeEach(() => {
@@ -73,7 +92,7 @@ describe('artist detail page', () => {
const detail: ArtistDetail = {
id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 2, cover_url: '',
albums: [album('a1', 'First', 2020), album('a2', 'Second')],
genres: []
genres: [], mbid: ''
};
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
render(ArtistPage);
@@ -87,7 +106,7 @@ describe('artist detail page', () => {
const detail: ArtistDetail = {
id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 1, cover_url: '',
albums: [album('a1', 'First', 2020)],
genres: []
genres: [], mbid: ''
};
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
(createArtistTopTracksQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({
@@ -154,7 +173,7 @@ describe('artist detail page', () => {
describe('Play and Shuffle', () => {
const detail: ArtistDetail = {
id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 1, cover_url: '',
albums: [album('a1', 'First', 2020)], genres: []
albums: [album('a1', 'First', 2020)], genres: [], mbid: ''
};
const tracks = makeTracks(5);
@@ -176,4 +195,34 @@ describe('artist detail page', () => {
expect(queued.map((t: { id: string }) => t.id).sort()).toEqual(tracks.map((t) => t.id).sort());
});
});
// Following is for the wanted list (M502 #5520): an admin control, keyed by
// the artist's MusicBrainz id.
describe('Follow', () => {
const detail = (mbid: string): ArtistDetail => ({
id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 1, cover_url: '',
albums: [album('a1', 'First', 2020)], genres: [], mbid
});
test('an admin can follow an artist with a MusicBrainz id', () => {
userState.current = { id: 'u1', username: 'admin', is_admin: true };
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail('mb-alice') }));
render(ArtistPage);
expect(screen.getByRole('button', { name: /^follow$/i })).toBeInTheDocument();
});
test('no follow control without a MusicBrainz id', () => {
userState.current = { id: 'u1', username: 'admin', is_admin: true };
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail('') }));
render(ArtistPage);
expect(screen.queryByRole('button', { name: /^follow$/i })).toBeNull();
});
test('no follow control for a non-admin', () => {
userState.current = { id: 'u2', username: 'sam', is_admin: false };
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail('mb-alice') }));
render(ArtistPage);
expect(screen.queryByRole('button', { name: /^follow$/i })).toBeNull();
});
});
});
+10 -10
View File
@@ -11,7 +11,7 @@
import Modal from '#lib/components/Modal.svelte';
import type {
CatalogueSearchResult,
LidarrRequestKind
RequestKind
} from '#lib/api/types.js';
const queryClient = useQueryClient();
@@ -21,7 +21,7 @@
// pushing into `debouncedQ`, which is what the query factory observes.
let inputValue = $state('');
let debouncedQ = $state('');
let activeKind: LidarrRequestKind = $state('artist');
let activeKind: RequestKind = $state('artist');
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
$effect(() => {
@@ -56,19 +56,19 @@
// Backend validates kind→required-MBID-fields, not the human strings, so
// imperfect derivations of artist_name from secondary_text won't block the
// request. We pass the data we have and let the server take care of the rest.
function buildRequestParams(r: CatalogueSearchResult, kind: LidarrRequestKind) {
function buildRequestParams(r: CatalogueSearchResult, kind: RequestKind) {
if (kind === 'artist') {
return {
kind,
lidarr_artist_mbid: r.artist_mbid || r.mbid,
artist_mbid: r.artist_mbid || r.mbid,
artist_name: r.name
};
}
if (kind === 'album') {
return {
kind,
lidarr_artist_mbid: r.artist_mbid,
lidarr_album_mbid: r.album_mbid || r.mbid,
artist_mbid: r.artist_mbid,
album_mbid: r.album_mbid || r.mbid,
artist_name: r.secondary_text,
album_title: r.name
};
@@ -76,9 +76,9 @@
// track
return {
kind,
lidarr_artist_mbid: r.artist_mbid,
lidarr_album_mbid: r.album_mbid,
lidarr_track_mbid: r.mbid,
artist_mbid: r.artist_mbid,
album_mbid: r.album_mbid,
track_mbid: r.mbid,
artist_name: r.secondary_text,
album_title: r.secondary_text,
track_title: r.name
@@ -116,7 +116,7 @@
modalResult = null;
}
const tabs: { kind: LidarrRequestKind; label: string }[] = [
const tabs: { kind: RequestKind; label: string }[] = [
{ kind: 'artist', label: 'Artists' },
{ kind: 'album', label: 'Albums' },
{ kind: 'track', label: 'Tracks' }
+3 -3
View File
@@ -263,9 +263,9 @@ describe('Discover page', () => {
expect(mockedCreateRequest).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'track',
lidarr_track_mbid: 'tr-mbid',
lidarr_album_mbid: 'al-mbid',
lidarr_artist_mbid: 'art-mbid'
track_mbid: 'tr-mbid',
album_mbid: 'al-mbid',
artist_mbid: 'art-mbid'
})
);
});
+7 -7
View File
@@ -7,11 +7,11 @@
import StatusPill from '#lib/components/StatusPill.svelte';
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
import DiscoverTabs from '#lib/components/DiscoverTabs.svelte';
import type { LidarrRequest, LidarrRequestKind } from '#lib/api/types.js';
import type { MusicRequest, RequestKind } from '#lib/api/types.js';
const queryStore = createMyRequestsQuery();
const query = $derived($queryStore);
const requests = $derived((query.data ?? []) as LidarrRequest[]);
const requests = $derived((query.data ?? []) as MusicRequest[]);
const client = useQueryClient();
@@ -25,13 +25,13 @@
}
}
function fallbackIcon(kind: LidarrRequestKind) {
function fallbackIcon(kind: RequestKind) {
if (kind === 'artist') return Disc3;
if (kind === 'album') return Album;
return Music2;
}
function rowTitle(r: LidarrRequest): string {
function rowTitle(r: MusicRequest): string {
if (r.kind === 'artist') return r.artist_name;
if (r.kind === 'album') return r.album_title ?? '—';
return r.track_title ?? '—';
@@ -39,7 +39,7 @@
// Used in aria-labels — '—' is meaningless to screen readers, so when a
// title is missing fall back to a generic-but-intelligible phrase.
function rowAccessibleName(r: LidarrRequest): string {
function rowAccessibleName(r: MusicRequest): string {
if (r.kind === 'artist') return r.artist_name;
if (r.kind === 'album') return r.album_title ?? `this album by ${r.artist_name}`;
return r.track_title ?? `this track by ${r.artist_name}`;
@@ -48,13 +48,13 @@
// Keep the meta line short and readable. We always lead with the artist,
// then a relative-ish date — locale formatting is good enough for v1
// and keeps tests deterministic without pinning Intl.RelativeTimeFormat.
function rowMeta(r: LidarrRequest): string {
function rowMeta(r: MusicRequest): string {
const when = new Date(r.requested_at).toLocaleDateString();
if (r.kind === 'artist') return `Requested ${when}`;
return `by ${r.artist_name} · ${when}`;
}
function listenHref(r: LidarrRequest): string | null {
function listenHref(r: MusicRequest): string | null {
if (r.matched_track_id) return `/tracks/${r.matched_track_id}`;
if (r.matched_album_id) return `/albums/${r.matched_album_id}`;
if (r.matched_artist_id) return `/artists/${r.matched_artist_id}`;
+16 -18
View File
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { mockQuery } from '../../test-utils/query';
import { apiClientMock } from '../../test-utils/mocks/client';
import type { LidarrRequest } from '#lib/api/types.js';
import type { MusicRequest } from '#lib/api/types.js';
import { pageUrlModule } from '../../test-utils/mocks/appState';
// $app/state / $app/navigation must be mocked or rendering the page
@@ -43,20 +43,18 @@ import { qk } from '#lib/api/queries.js';
const mockedCreateMyRequestsQuery = createMyRequestsQuery as ReturnType<typeof vi.fn>;
const mockedCancelRequest = cancelRequest as ReturnType<typeof vi.fn>;
function req(over: Partial<LidarrRequest> = {}): LidarrRequest {
function req(over: Partial<MusicRequest> = {}): MusicRequest {
return {
id: 'r1',
user_id: 'u1',
status: 'pending',
kind: 'album',
lidarr_artist_mbid: 'art-mbid',
lidarr_album_mbid: 'al-mbid',
lidarr_track_mbid: null,
artist_mbid: 'art-mbid',
album_mbid: 'al-mbid',
track_mbid: null,
artist_name: 'Boards of Canada',
album_title: 'Music Has The Right To Children',
track_title: null,
quality_profile_id: null,
root_folder_path: null,
decided_at: null,
decided_by: null,
notes: null,
@@ -78,12 +76,12 @@ afterEach(() => {
describe('Requests page', () => {
test('renders one row per request from the API', () => {
const rows: LidarrRequest[] = [
const rows: MusicRequest[] = [
req({ id: 'r1', kind: 'artist', artist_name: 'Boards of Canada' }),
req({ id: 'r2', kind: 'album', album_title: 'Geogaddi', status: 'completed' }),
req({ id: 'r3', kind: 'track', track_title: 'Roygbiv', status: 'rejected', notes: 'Already in library' })
];
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<LidarrRequest[]>({ data: rows }));
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<MusicRequest[]>({ data: rows }));
render(RequestsPage);
expect(screen.getAllByTestId('request-row')).toHaveLength(3);
expect(screen.getByText('Boards of Canada')).toBeInTheDocument();
@@ -92,10 +90,10 @@ describe('Requests page', () => {
});
test('pending row exposes Cancel; clicking calls cancelRequest and invalidates myRequests', async () => {
const rows: LidarrRequest[] = [
const rows: MusicRequest[] = [
req({ id: 'r-pending', status: 'pending', kind: 'album', album_title: 'Geogaddi' })
];
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<LidarrRequest[]>({ data: rows }));
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<MusicRequest[]>({ data: rows }));
render(RequestsPage);
const cancelBtn = screen.getByRole('button', { name: /cancel request for geogaddi/i });
await fireEvent.click(cancelBtn);
@@ -106,7 +104,7 @@ describe('Requests page', () => {
});
test('completed row with matched_track_id renders Listen link to /tracks/<id>', () => {
const rows: LidarrRequest[] = [
const rows: MusicRequest[] = [
req({
id: 'r-track',
status: 'completed',
@@ -117,14 +115,14 @@ describe('Requests page', () => {
matched_artist_id: 'art-9'
})
];
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<LidarrRequest[]>({ data: rows }));
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<MusicRequest[]>({ data: rows }));
render(RequestsPage);
const link = screen.getByRole('link', { name: /listen to roygbiv/i });
expect(link).toHaveAttribute('href', '/tracks/tr-9');
});
test('completed row with only matched_album_id renders Listen link to /albums/<id>', () => {
const rows: LidarrRequest[] = [
const rows: MusicRequest[] = [
req({
id: 'r-album',
status: 'completed',
@@ -135,14 +133,14 @@ describe('Requests page', () => {
matched_artist_id: 'art-7'
})
];
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<LidarrRequest[]>({ data: rows }));
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<MusicRequest[]>({ data: rows }));
render(RequestsPage);
const link = screen.getByRole('link', { name: /listen to geogaddi/i });
expect(link).toHaveAttribute('href', '/albums/al-7');
});
test('rejected row renders notes and hides Cancel and Listen', () => {
const rows: LidarrRequest[] = [
const rows: MusicRequest[] = [
req({
id: 'r-rej',
status: 'rejected',
@@ -151,7 +149,7 @@ describe('Requests page', () => {
notes: 'Already kept in the library.'
})
];
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<LidarrRequest[]>({ data: rows }));
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<MusicRequest[]>({ data: rows }));
render(RequestsPage);
expect(screen.getByTestId('rejection-notes')).toHaveTextContent('Already kept in the library.');
expect(screen.queryByRole('button', { name: /cancel request/i })).not.toBeInTheDocument();
@@ -159,7 +157,7 @@ describe('Requests page', () => {
});
test('empty list shows the voice-rule "Nothing requested yet." copy', () => {
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<LidarrRequest[]>({ data: [] }));
mockedCreateMyRequestsQuery.mockReturnValue(mockQuery<MusicRequest[]>({ data: [] }));
render(RequestsPage);
expect(screen.getByText(/nothing requested yet\./i)).toBeInTheDocument();
expect(screen.queryAllByTestId('request-row')).toHaveLength(0);