diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt index 360ba82b..60fe379f 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt @@ -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.", diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/DiscoverWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/DiscoverWire.kt index b37043e9..1897092c 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/DiscoverWire.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/DiscoverWire.kt @@ -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, ) diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go index 9a218e51..49afe10c 100644 --- a/cmd/minstrel/main.go +++ b/cmd/minstrel/main.go @@ -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 diff --git a/internal/api/admin_library_missing.go b/internal/api/admin_library_missing.go index abbafb0b..d29f3df4 100644 --- a/internal/api/admin_library_missing.go +++ b/internal/api/admin_library_missing.go @@ -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 } diff --git a/internal/api/admin_reacquisition.go b/internal/api/admin_reacquisition.go index b4f3b30a..7fe1cc57 100644 --- a/internal/api/admin_reacquisition.go +++ b/internal/api/admin_reacquisition.go @@ -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. diff --git a/internal/api/admin_requests.go b/internal/api/admin_requests.go index 1d63e487..0b3f87cb 100644 --- a/internal/api/admin_requests.go +++ b/internal/api/admin_requests.go @@ -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) diff --git a/internal/api/admin_requests_test.go b/internal/api/admin_requests_test.go index 2106b5ed..1fb001d9 100644 --- a/internal/api/admin_requests_test.go +++ b/internal/api/admin_requests_test.go @@ -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) + } +} diff --git a/internal/api/admin_wanted.go b/internal/api/admin_wanted.go new file mode 100644 index 00000000..ab4a3eb2 --- /dev/null +++ b/internal/api/admin_wanted.go @@ -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)) + } +} diff --git a/internal/api/api.go b/internal/api/api.go index 94518874..9d3e2721 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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 diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index feb137ae..eaef533c 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -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 } diff --git a/internal/api/catalogue_test.go b/internal/api/catalogue_test.go index de879d2e..c019b29d 100644 --- a/internal/api/catalogue_test.go +++ b/internal/api/catalogue_test.go @@ -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) } diff --git a/internal/api/events_publish.go b/internal/api/events_publish.go index 7b77edb4..2178606d 100644 --- a/internal/api/events_publish.go +++ b/internal/api/events_publish.go @@ -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 } diff --git a/internal/api/library.go b/internal/api/library.go index a4e2c99c..b2641af6 100644 --- a/internal/api/library.go +++ b/internal/api/library.go @@ -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) } diff --git a/internal/api/library_test.go b/internal/api/library_test.go index 7bb8dc83..e3bd8f6c 100644 --- a/internal/api/library_test.go +++ b/internal/api/library_test.go @@ -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", diff --git a/internal/api/lidarr_test.go b/internal/api/lidarr_test.go index 3d18771f..5f0a71dc 100644 --- a/internal/api/lidarr_test.go +++ b/internal/api/lidarr_test.go @@ -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) } diff --git a/internal/api/notify_producers.go b/internal/api/notify_producers.go index e690f581..aba5f769 100644 --- a/internal/api/notify_producers.go +++ b/internal/api/notify_producers.go @@ -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 } diff --git a/internal/api/requests.go b/internal/api/requests.go index 0c1edd47..826a886b 100644 --- a/internal/api/requests.go +++ b/internal/api/requests.go @@ -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) diff --git a/internal/api/requests_test.go b/internal/api/requests_test.go index c3f95a44..112f5d9c 100644 --- a/internal/api/requests_test.go +++ b/internal/api/requests_test.go @@ -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 { diff --git a/internal/api/types.go b/internal/api/types.go index bcab22cf..31af1d17 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -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}. diff --git a/internal/catalogue/catalogue.go b/internal/catalogue/catalogue.go index 9084f9dd..92af3b3d 100644 --- a/internal/catalogue/catalogue.go +++ b/internal/catalogue/catalogue.go @@ -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 diff --git a/internal/db/dbq/lidarr_requests.sql.go b/internal/db/dbq/lidarr_requests.sql.go deleted file mode 100644 index ecaac112..00000000 --- a/internal/db/dbq/lidarr_requests.sql.go +++ /dev/null @@ -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 -} diff --git a/internal/db/dbq/merge.sql.go b/internal/db/dbq/merge.sql.go index 542eb77b..c73176f4 100644 --- a/internal/db/dbq/merge.sql.go +++ b/internal/db/dbq/merge.sql.go @@ -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 ` diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index e34dc4bb..75d88561 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -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 diff --git a/internal/db/dbq/reacquisition.sql.go b/internal/db/dbq/reacquisition.sql.go index 0a4e9da0..c549005b 100644 --- a/internal/db/dbq/reacquisition.sql.go +++ b/internal/db/dbq/reacquisition.sql.go @@ -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 } diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go index 442fafee..9e1687b6 100644 --- a/internal/db/dbq/recommendation.sql.go +++ b/internal/db/dbq/recommendation.sql.go @@ -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 diff --git a/internal/db/dbq/requests.sql.go b/internal/db/dbq/requests.sql.go new file mode 100644 index 00000000..d34248aa --- /dev/null +++ b/internal/db/dbq/requests.sql.go @@ -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 +} diff --git a/internal/db/dbq/users.sql.go b/internal/db/dbq/users.sql.go index 0c1173c5..21b6bcac 100644 --- a/internal/db/dbq/users.sql.go +++ b/internal/db/dbq/users.sql.go @@ -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 ` diff --git a/internal/db/dbq/wanted.sql.go b/internal/db/dbq/wanted.sql.go new file mode 100644 index 00000000..38414108 --- /dev/null +++ b/internal/db/dbq/wanted.sql.go @@ -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 +} diff --git a/internal/db/migrations/0081_wanted.down.sql b/internal/db/migrations/0081_wanted.down.sql new file mode 100644 index 00000000..4087ae4d --- /dev/null +++ b/internal/db/migrations/0081_wanted.down.sql @@ -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; diff --git a/internal/db/migrations/0081_wanted.up.sql b/internal/db/migrations/0081_wanted.up.sql new file mode 100644 index 00000000..0c52f6ab --- /dev/null +++ b/internal/db/migrations/0081_wanted.up.sql @@ -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; diff --git a/internal/db/queries/lidarr_requests.sql b/internal/db/queries/lidarr_requests.sql deleted file mode 100644 index ec96568c..00000000 --- a/internal/db/queries/lidarr_requests.sql +++ /dev/null @@ -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; diff --git a/internal/db/queries/merge.sql b/internal/db/queries/merge.sql index 024e5d58..4ea902c6 100644 --- a/internal/db/queries/merge.sql +++ b/internal/db/queries/merge.sql @@ -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 diff --git a/internal/db/queries/reacquisition.sql b/internal/db/queries/reacquisition.sql index 85f3de86..e03f7169 100644 --- a/internal/db/queries/reacquisition.sql +++ b/internal/db/queries/reacquisition.sql @@ -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; diff --git a/internal/db/queries/recommendation.sql b/internal/db/queries/recommendation.sql index cb164e5f..f4a6e63c 100644 --- a/internal/db/queries/recommendation.sql +++ b/internal/db/queries/recommendation.sql @@ -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 diff --git a/internal/db/queries/requests.sql b/internal/db/queries/requests.sql new file mode 100644 index 00000000..27fa6c09 --- /dev/null +++ b/internal/db/queries/requests.sql @@ -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); diff --git a/internal/db/queries/users.sql b/internal/db/queries/users.sql index 22f0ef88..534fb0d1 100644 --- a/internal/db/queries/users.sql +++ b/internal/db/queries/users.sql @@ -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; diff --git a/internal/db/queries/wanted.sql b/internal/db/queries/wanted.sql new file mode 100644 index 00000000..e28463d9 --- /dev/null +++ b/internal/db/queries/wanted.sql @@ -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; diff --git a/internal/dbtest/reset.go b/internal/dbtest/reset.go index b22504a1..540276b8 100644 --- a/internal/dbtest/reset.go +++ b/internal/dbtest/reset.go @@ -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 diff --git a/internal/library/duplicate_merge.go b/internal/library/duplicate_merge.go index 1856b514..fc016620 100644 --- a/internal/library/duplicate_merge.go +++ b/internal/library/duplicate_merge.go @@ -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}) diff --git a/internal/lidarrrequests/reconciler.go b/internal/lidarrrequests/reconciler.go deleted file mode 100644 index eac3c103..00000000 --- a/internal/lidarrrequests/reconciler.go +++ /dev/null @@ -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 -} diff --git a/internal/lidarrrequests/reconciler_integration_test.go b/internal/lidarrrequests/reconciler_integration_test.go deleted file mode 100644 index 6458f83f..00000000 --- a/internal/lidarrrequests/reconciler_integration_test.go +++ /dev/null @@ -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) - } -} diff --git a/internal/lidarrrequests/reconciler_notify_test.go b/internal/lidarrrequests/reconciler_notify_test.go deleted file mode 100644 index 53e71751..00000000 --- a/internal/lidarrrequests/reconciler_notify_test.go +++ /dev/null @@ -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) - } -} diff --git a/internal/lidarrrequests/reconciler_release_group_test.go b/internal/lidarrrequests/reconciler_release_group_test.go deleted file mode 100644 index 696e2a4d..00000000 --- a/internal/lidarrrequests/reconciler_release_group_test.go +++ /dev/null @@ -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) - } -} diff --git a/internal/lidarrrequests/service.go b/internal/lidarrrequests/service.go deleted file mode 100644 index b177ae33..00000000 --- a/internal/lidarrrequests/service.go +++ /dev/null @@ -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 -} diff --git a/internal/lidarrrequests/service_test.go b/internal/lidarrrequests/service_test.go deleted file mode 100644 index c1c90149..00000000 --- a/internal/lidarrrequests/service_test.go +++ /dev/null @@ -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) - } -} diff --git a/internal/reacquisition/settings.go b/internal/reacquisition/settings.go index 455f9f36..f465ddd4 100644 --- a/internal/reacquisition/settings.go +++ b/internal/reacquisition/settings.go @@ -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, } } diff --git a/internal/reacquisition/settings_test.go b/internal/reacquisition/settings_test.go index 6f7ed3e2..60b599d9 100644 --- a/internal/reacquisition/settings_test.go +++ b/internal/reacquisition/settings_test.go @@ -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) diff --git a/internal/reacquisition/sweeper.go b/internal/reacquisition/sweeper.go index fba2a6e3..5d67d5e2 100644 --- a/internal/reacquisition/sweeper.go +++ b/internal/reacquisition/sweeper.go @@ -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, ) diff --git a/internal/reacquisition/sweeper_test.go b/internal/reacquisition/sweeper_test.go index 0bb7c732..bb8adaaa 100644 --- a/internal/reacquisition/sweeper_test.go +++ b/internal/reacquisition/sweeper_test.go @@ -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) } } diff --git a/internal/recommendation/suggestions_integration_test.go b/internal/recommendation/suggestions_integration_test.go index 9c86e76c..e5ca0126 100644 --- a/internal/recommendation/suggestions_integration_test.go +++ b/internal/recommendation/suggestions_integration_test.go @@ -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) diff --git a/internal/requests/reconciler.go b/internal/requests/reconciler.go new file mode 100644 index 00000000..72fed3b8 --- /dev/null +++ b/internal/requests/reconciler.go @@ -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 +} diff --git a/internal/requests/requests_test.go b/internal/requests/requests_test.go new file mode 100644 index 00000000..a0389a92 --- /dev/null +++ b/internal/requests/requests_test.go @@ -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) + } +} diff --git a/internal/requests/service.go b/internal/requests/service.go new file mode 100644 index 00000000..747b922b --- /dev/null +++ b/internal/requests/service.go @@ -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 +} diff --git a/internal/server/server.go b/internal/server/server.go index bb77f503..7d325e78 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 diff --git a/internal/server/server_test.go b/internal/server/server_test.go index bc037214..c949ed42 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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) diff --git a/internal/wanted/profiles.go b/internal/wanted/profiles.go new file mode 100644 index 00000000..d0a466b1 --- /dev/null +++ b/internal/wanted/profiles.go @@ -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) +} diff --git a/internal/wanted/wanted.go b/internal/wanted/wanted.go new file mode 100644 index 00000000..0d430b52 --- /dev/null +++ b/internal/wanted/wanted.go @@ -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 +} diff --git a/internal/wanted/wanted_test.go b/internal/wanted/wanted_test.go new file mode 100644 index 00000000..7ede0c7c --- /dev/null +++ b/internal/wanted/wanted_test.go @@ -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 +} diff --git a/web/src/lib/api/admin.test.ts b/web/src/lib/api/admin.test.ts index 3c919af1..2a15a3eb 100644 --- a/web/src/lib/api/admin.test.ts +++ b/web/src/lib/api/admin.test.ts @@ -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).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' }); }); }); diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index b56a8ad8..966dd94b 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -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 { // Admin request queue ----------------------------------------------------- export async function listAdminRequests( - status?: LidarrRequestStatus, + status?: RequestStatus, limit?: number -): Promise { +): Promise { 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( + return api.get( qs ? `/api/admin/requests?${qs}` : '/api/admin/requests' ); } -export async function approveRequest( - id: string, - overrides: { quality_profile_id?: number; root_folder_path?: string } = {} -): Promise { - return api.post(`/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 { + return api.post( + `/api/admin/requests/${id}/approve`, + profileId ? { profile_id: profileId } : {} + ); } export async function rejectRequest( id: string, notes?: string -): Promise { +): Promise { const body = notes !== undefined ? { notes } : {}; - return api.post(`/api/admin/requests/${id}/reject`, body); + return api.post(`/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; diff --git a/web/src/lib/api/catalogue.ts b/web/src/lib/api/catalogue.ts index 093a8183..8729a85a 100644 --- a/web/src/lib/api/catalogue.ts +++ b/web/src/lib/api/catalogue.ts @@ -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 { const params = new URLSearchParams({ q, kind }); return api.get(`/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), diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index 058318c7..f8c99725 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -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) => diff --git a/web/src/lib/api/requests.test.ts b/web/src/lib/api/requests.test.ts index 38877fd6..fb646533 100644 --- a/web/src/lib/api/requests.test.ts +++ b/web/src/lib/api/requests.test.ts @@ -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).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).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).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).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' }); diff --git a/web/src/lib/api/requests.ts b/web/src/lib/api/requests.ts index be716af2..f4f64de8 100644 --- a/web/src/lib/api/requests.ts +++ b/web/src/lib/api/requests.ts @@ -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 { - return api.post('/api/requests', buildCreateBody(params)); +export async function createRequest(params: CreateRequestParams): Promise { + return api.post('/api/requests', buildCreateBody(params)); } -export async function listMyRequests(): Promise { - return api.get('/api/requests'); +export async function listMyRequests(): Promise { + return api.get('/api/requests'); } -export async function getRequest(id: string): Promise { - return api.get(`/api/requests/${id}`); +export async function getRequest(id: string): Promise { + return api.get(`/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 { - return apiFetch(`/api/requests/${id}`, { method: 'DELETE' }) as Promise; +export async function cancelRequest(id: string): Promise { + return apiFetch(`/api/requests/${id}`, { method: 'DELETE' }) as Promise; } 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 }); diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 83279c2f..995f350c 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -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 & { 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; + +export type ReleaseProfilesResponse = { + profiles: ReleaseProfile[]; + primary_types: string[]; + secondary_types: string[]; +}; diff --git a/web/src/lib/api/wanted.ts b/web/src/lib/api/wanted.ts new file mode 100644 index 00000000..39f95cec --- /dev/null +++ b/web/src/lib/api/wanted.ts @@ -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 { + return api.get(`/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 { + await api.del(`/api/admin/wanted/${id}`); +} + +export async function listFollows(): Promise { + return api.get('/api/admin/follows'); +} + +export function createFollowsQuery() { + return createQuery({ + queryKey: qk.adminFollows(), + queryFn: listFollows, + staleTime: 30_000 + }); +} + +export async function getFollow(artistMbid: string): Promise { + return api.get(`/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 { + return api.put(`/api/admin/follows/${artistMbid}`, body); +} + +export async function deleteFollow(artistMbid: string): Promise { + await api.del(`/api/admin/follows/${artistMbid}`); +} + +export async function listReleaseProfiles(): Promise { + return api.get('/api/admin/release-profiles'); +} + +export function createReleaseProfilesQuery() { + return createQuery({ + queryKey: qk.releaseProfiles(), + queryFn: listReleaseProfiles, + staleTime: 60_000 + }); +} + +export async function createReleaseProfile(body: ReleaseProfileInput): Promise { + return api.post('/api/admin/release-profiles', body); +} + +export async function updateReleaseProfile(id: string, body: ReleaseProfileInput): Promise { + return api.put(`/api/admin/release-profiles/${id}`, body); +} + +export async function deleteReleaseProfile(id: string): Promise { + await api.del(`/api/admin/release-profiles/${id}`); +} diff --git a/web/src/lib/components/AdminTabs.svelte b/web/src/lib/components/AdminTabs.svelte index e75b3e54..8c51bc8e 100644 --- a/web/src/lib/components/AdminTabs.svelte +++ b/web/src/lib/components/AdminTabs.svelte @@ -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' }, diff --git a/web/src/lib/components/AdminTabs.test.ts b/web/src/lib/components/AdminTabs.test.ts index 205a257a..8f180ea5 100644 --- a/web/src/lib/components/AdminTabs.test.ts +++ b/web/src/lib/components/AdminTabs.test.ts @@ -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. diff --git a/web/src/lib/components/FollowControl.svelte b/web/src/lib/components/FollowControl.svelte new file mode 100644 index 00000000..0153042b --- /dev/null +++ b/web/src/lib/components/FollowControl.svelte @@ -0,0 +1,155 @@ + + + + + (open = false)}> +

+ New releases from {artistName} go on the wanted list. The profile says which kinds of + release are wanted. +

+ + + + + +
+ {#if following} + + {/if} + + +
+
diff --git a/web/src/lib/components/FollowControl.test.ts b/web/src/lib/components/FollowControl.test.ts new file mode 100644 index 00000000..df9f2de5 --- /dev/null +++ b/web/src/lib/components/FollowControl.test.ts @@ -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 + ); + vi.mocked(createReleaseProfilesQuery).mockReturnValue( + mockQuery({ data: profiles }) as unknown as ReturnType + ); + 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')); + }); +}); diff --git a/web/src/lib/components/ReacquisitionSettingsCard.svelte b/web/src/lib/components/ReacquisitionSettingsCard.svelte index c9e16643..3c210dbc 100644 --- a/web/src/lib/components/ReacquisitionSettingsCard.svelte +++ b/web/src/lib/components/ReacquisitionSettingsCard.svelte @@ -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(null); let form = $state(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(' → '); - });

Automatic re-acquisition

- 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 + wanted list + by itself. It's wanted per album, not per track: a whole folder going missing is one + want, not forty.

@@ -106,7 +91,7 @@