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