feat: a wanted list — follow artists on release-type profiles; requests and re-acquisition become wants (M502 #5520)
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 1m21s
release / go (push) Successful in 1m35s
release / integration (push) Failing after 4m57s
release / android (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m28s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped

Minstrel keeps its own list of what it is to get, keyed by MusicBrainz
release group. A want comes from an approved request, a new release by a
followed artist, or an album whose files went missing, and is held once the
library has a full edition of it.

- Migration 0081: release_type_profiles (Standard seeded: Album, EP,
  Single, studio only), follows, follow_seen_groups and wants.
  lidarr_requests becomes requests with plain *_mbid columns; approved
  album and track requests move onto the wanted list, approved artist
  requests become follows, and the library's album artists start out
  followed. missing_reacquisitions and the retry settings go.
- internal/wanted: follows are checked against MusicBrainz discographies
  daily. The first check sets a baseline, and only releases after it are
  wanted unless the follow asked for what already exists. Profiles filter
  by primary and secondary type, so live recordings stay out unless a
  profile ticks Live.
- internal/requests (was lidarrrequests): approving puts the request on
  the wanted list, and an artist request can be approved on a chosen
  profile. A request completes when its want is held.
- The re-acquisition sweeper wants a missing album back instead of asking
  Lidarr; searching for it is the search schedule's (#5521).
- Web: an admin Wanted page (Wanted, Missing files, Held, Following), a
  follow control on artist pages, a release-type profiles card on
  Integrations, "Approve on a profile" on Requests, and want state on
  Missing files. The artist detail API returns the artist's MBID.
- Android: the request body sends artist_mbid, album_mbid and track_mbid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 00:46:22 -04:00
co-authored by Claude Opus 5.5
parent 867f52358c
commit 04515a977c
93 changed files with 6522 additions and 4484 deletions
+25 -60
View File
@@ -2,12 +2,10 @@ package api
import (
"net/http"
"time"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
)
// missingTrackView is one track whose file the scan could not find.
@@ -30,19 +28,13 @@ type missingTrackView struct {
LastPlayedAt *string `json:"last_played_at"`
}
// reacquisitionStateView is what the sweeper has done about one album
// (milestone #290), attached to the group so the operator can tell "nothing
// has happened yet" from "asked twice, still nothing" without cross-checking
// the Requests queue.
//
// NextAttemptAt is computed rather than stored: the schedule is a function of
// the attempt count and the current settings, so persisting it would go stale
// the moment an operator edited the backoff.
// reacquisitionStateView is what the wanted list says about one album
// (milestone #290, M502 #5520): when it was wanted back, and when the library
// held it again. A missing album with no want yet is still inside the grace
// window, or MusicBrainz cannot name it.
type reacquisitionStateView struct {
Attempts int `json:"attempts"`
LastAttemptAt *string `json:"last_attempt_at"`
NextAttemptAt *string `json:"next_attempt_at"`
GaveUpAt *string `json:"gave_up_at"`
WantedAt *string `json:"wanted_at"`
HeldAt *string `json:"held_at"`
}
// missingGroupView is a directory's worth of missing tracks.
@@ -159,15 +151,14 @@ func groupMissingByDirectory(rows []dbq.ListMissingTracksRow) []missingGroupView
return groups
}
// attachReacquisitionState decorates each group with what the sweeper has
// done about its album (milestone #290).
// attachReacquisitionState decorates each group with its album's want.
//
// Best-effort: this is context on a list whose primary job is showing what is
// missing, so a failure here leaves the groups bare rather than failing the
// page. One batched query for the whole page, not one per group.
//
// A group is keyed by directory while re-acquisition is keyed by album, and
// those line up in practice (an album's files live in one folder) but are not
// A group is keyed by directory while a want is keyed by album, and those
// line up in practice (an album's files live in one folder) but are not
// guaranteed to — a directory holding two albums takes the first album's
// state, which is the same album its first track belongs to.
func (h *handlers) attachReacquisitionState(
@@ -191,60 +182,34 @@ func (h *handlers) attachReacquisitionState(
ids = append(ids, row.AlbumID)
}
states, err := q.GetReacquisitionForAlbums(r.Context(), ids)
wants, err := q.GetWantsForAlbums(r.Context(), ids)
if err != nil {
h.logger.Warn("admin missing: reacquisition state lookup failed", "err", err)
h.logger.Warn("admin missing: want lookup failed", "err", err)
return
}
byAlbum := make(map[string]dbq.MissingReacquisition, len(states))
for _, s := range states {
byAlbum[uuidToString(s.AlbumID)] = s
}
// The settings service is optional in contexts that only wire routing
// (tests), and the backoff projection is decoration on a list whose real
// job is elsewhere — so fall back to the shipped defaults rather than
// making this a nil-pointer waiting to happen (rule #48).
cfg := reacquisition.Defaults
if h.reacqSettings != nil {
cfg = h.reacqSettings.Get()
// Newest want first, so the first seen per album is its current one.
byAlbum := make(map[string]dbq.GetWantsForAlbumsRow, len(wants))
for _, w := range wants {
if _, seen := byAlbum[uuidToString(w.AlbumID)]; !seen {
byAlbum[uuidToString(w.AlbumID)] = w
}
}
for i := range groups {
albumID, ok := dirAlbum[groups[i].Directory]
if !ok {
continue
}
state, ok := byAlbum[uuidToString(albumID)]
w, ok := byAlbum[uuidToString(albumID)]
if !ok {
continue
}
groups[i].Reacquisition = buildReacquisitionState(state, cfg.Backoff(state.Attempts))
}
}
// buildReacquisitionState renders one album's attempt record, projecting the
// next attempt from the last one plus the backoff the current settings imply.
func buildReacquisitionState(
state dbq.MissingReacquisition,
backoff time.Duration,
) *reacquisitionStateView {
out := &reacquisitionStateView{Attempts: int(state.Attempts)}
if state.LastAttemptAt.Valid {
s := formatTimestamp(state.LastAttemptAt)
out.LastAttemptAt = &s
// Only meaningful while more attempts remain; an album that has given
// up has no next attempt to promise.
if !state.GaveUpAt.Valid {
next := formatTimestamp(pgtype.Timestamptz{
Time: state.LastAttemptAt.Time.Add(backoff),
Valid: true,
})
out.NextAttemptAt = &next
view := &reacquisitionStateView{}
wantedAt := formatTimestamp(w.WantedAt)
view.WantedAt = &wantedAt
if w.FulfilledAt.Valid {
heldAt := formatTimestamp(w.FulfilledAt)
view.HeldAt = &heldAt
}
groups[i].Reacquisition = view
}
if state.GaveUpAt.Valid {
s := formatTimestamp(state.GaveUpAt)
out.GaveUpAt = &s
}
return out
}
+13 -25
View File
@@ -13,18 +13,14 @@ import (
// reacquisitionSettingsResp is the admin card's payload (milestone #290).
//
// Carries more than the stored settings: [UnnameableAlbums] is the count of
// albums with missing files that can never be auto-requested because neither
// they nor their artist has an MBID. The card states that number rather than
// leaving the operator to wonder why some rows never get a request — a
// feature that silently does nothing for part of its input reads as broken.
// albums with missing files that can never be wanted automatically because
// neither they nor their artist has an MBID. The card states that number
// rather than leaving the operator to wonder why some rows never get wanted —
// a feature that silently does nothing for part of its input reads as broken.
type reacquisitionSettingsResp struct {
Enabled bool `json:"enabled"`
GraceHours int32 `json:"grace_hours"`
BackoffBaseHours int32 `json:"backoff_base_hours"`
BackoffMaxHours int32 `json:"backoff_max_hours"`
MaxAttempts int32 `json:"max_attempts"`
MaxPerPass int32 `json:"max_per_pass"`
AutoApprove bool `json:"auto_approve"`
Enabled bool `json:"enabled"`
GraceHours int32 `json:"grace_hours"`
MaxPerPass int32 `json:"max_per_pass"`
UnnameableAlbums int64 `json:"unnameable_albums"`
}
@@ -42,13 +38,9 @@ func (h *handlers) handleUpdateReacquisitionSettings(w http.ResponseWriter, r *h
return
}
_, err := h.reacqSettings.Set(r.Context(), reacquisition.Settings{
Enabled: req.Enabled,
GraceHours: req.GraceHours,
BackoffBaseHours: req.BackoffBaseHours,
BackoffMaxHours: req.BackoffMaxHours,
MaxAttempts: req.MaxAttempts,
MaxPerPass: req.MaxPerPass,
AutoApprove: req.AutoApprove,
Enabled: req.Enabled,
GraceHours: req.GraceHours,
MaxPerPass: req.MaxPerPass,
})
if err != nil {
// The Go-side validation mirrors migration 0056's CHECKs so the
@@ -69,13 +61,9 @@ func (h *handlers) handleUpdateReacquisitionSettings(w http.ResponseWriter, r *h
func (h *handlers) reacquisitionPayload(r *http.Request) reacquisitionSettingsResp {
cur := h.reacqSettings.Get()
out := reacquisitionSettingsResp{
Enabled: cur.Enabled,
GraceHours: cur.GraceHours,
BackoffBaseHours: cur.BackoffBaseHours,
BackoffMaxHours: cur.BackoffMaxHours,
MaxAttempts: cur.MaxAttempts,
MaxPerPass: cur.MaxPerPass,
AutoApprove: cur.AutoApprove,
Enabled: cur.Enabled,
GraceHours: cur.GraceHours,
MaxPerPass: cur.MaxPerPass,
}
// Best-effort: the settings are the point of this endpoint, and failing
// the whole card because a count query hiccuped would be the wrong trade.
+27 -33
View File
@@ -7,12 +7,13 @@ import (
"strconv"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
)
// validRequestStatuses is the set of allowed values for the ?status= param.
@@ -50,7 +51,7 @@ func (h *handlers) handleListAdminRequests(w http.ResponseWriter, r *http.Reques
limit = v
}
rows, err := h.lidarrRequests.ListByStatus(r.Context(), status, int32(limit))
rows, err := h.requests.ListByStatus(r.Context(), status, int32(limit))
if err != nil {
h.logger.Error("admin: list requests", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
@@ -68,12 +69,15 @@ func (h *handlers) handleListAdminRequests(w http.ResponseWriter, r *http.Reques
}
// approveRequestBody is the optional JSON body for POST /api/admin/requests/:id/approve.
// profile_id picks the release-type profile an artist request is followed
// on; omitted, the default profile.
type approveRequestBody struct {
QualityProfileID int `json:"quality_profile_id"`
RootFolderPath string `json:"root_folder_path"`
ProfileID string `json:"profile_id"`
}
// handleApproveRequest implements POST /api/admin/requests/:id/approve.
// handleApproveRequest implements POST /api/admin/requests/:id/approve. The
// request goes on the wanted list (M502 #5520): an album or track request
// wants its album, an artist request follows the artist.
func (h *handlers) handleApproveRequest(w http.ResponseWriter, r *http.Request) {
admin, ok := auth.UserFromContext(r.Context())
if !ok {
@@ -90,35 +94,25 @@ func (h *handlers) handleApproveRequest(w http.ResponseWriter, r *http.Request)
var body approveRequestBody
// Body is entirely optional; ignore decode errors.
_ = json.NewDecoder(r.Body).Decode(&body)
var profile pgtype.UUID
if body.ProfileID != "" {
if profile, ok = parseUUID(body.ProfileID); !ok {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_profile")
return
}
}
row, err := h.lidarrRequests.Approve(r.Context(), id, admin.ID, lidarrrequests.ApproveOverrides{
QualityProfileID: body.QualityProfileID,
RootFolderPath: body.RootFolderPath,
})
row, err := h.requests.Approve(r.Context(), id, admin.ID, profile)
if err != nil {
switch {
case errors.Is(err, lidarrrequests.ErrLidarrDisabled):
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_disabled")
case errors.Is(err, lidarrrequests.ErrDefaultsIncomplete):
writeAdminJSONErr(w, http.StatusBadRequest, "lidarr_defaults_incomplete")
case errors.Is(err, lidarrrequests.ErrNotFound):
case errors.Is(err, requests.ErrNotFound):
writeAdminJSONErr(w, http.StatusNotFound, "request_not_found")
case errors.Is(err, lidarrrequests.ErrNotPending):
case errors.Is(err, requests.ErrNotPending):
writeAdminJSONErr(w, http.StatusConflict, "request_not_pending")
case errors.Is(err, lidarr.ErrUnreachable):
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_unreachable")
case errors.Is(err, lidarr.ErrAuthFailed):
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_auth_failed")
case errors.Is(err, lidarr.ErrServerError):
// Lidarr itself 5xx'd — its response body is in err's wrapped
// message (see lidarr.readBodySnippet). Logged below for ops.
h.logger.Error("admin: approve request: lidarr 5xx", "err", err)
writeAdminJSONErr(w, http.StatusBadGateway, "lidarr_server_error")
case errors.Is(err, lidarr.ErrLookupFailed):
// Lidarr returned 4xx — usually "artist already exists" or
// "no metadata found." Log so ops can see the actual reason.
h.logger.Warn("admin: approve request: lidarr 4xx", "err", err)
writeAdminJSONErr(w, http.StatusBadGateway, "lidarr_rejected")
case errors.Is(err, wanted.ErrProfileNotFound):
writeAdminJSONErr(w, http.StatusBadRequest, "profile_not_found")
case errors.Is(err, wanted.ErrNotFollowable):
writeAdminJSONErr(w, http.StatusBadRequest, "not_followable")
default:
h.logger.Error("admin: approve request", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
@@ -153,12 +147,12 @@ func (h *handlers) handleRejectRequest(w http.ResponseWriter, r *http.Request) {
var body rejectRequestBody
_ = json.NewDecoder(r.Body).Decode(&body)
row, err := h.lidarrRequests.Reject(r.Context(), id, admin.ID, body.Notes)
row, err := h.requests.Reject(r.Context(), id, admin.ID, body.Notes)
if err != nil {
switch {
case errors.Is(err, lidarrrequests.ErrNotFound):
case errors.Is(err, requests.ErrNotFound):
writeAdminJSONErr(w, http.StatusNotFound, "request_not_found")
case errors.Is(err, lidarrrequests.ErrNotPending):
case errors.Is(err, requests.ErrNotPending):
writeAdminJSONErr(w, http.StatusConflict, "request_not_pending")
default:
h.logger.Error("admin: reject request", "err", err)
+66 -200
View File
@@ -6,16 +6,13 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
)
// newAdminRequestsRouter builds a test chi router with the three admin/requests
@@ -52,21 +49,10 @@ func doAdminRequestReq(t *testing.T, h *handlers, method, path string, body []by
return w
}
// testHandlersWithClientFn creates a handlers instance with a real clientFn
// so Service.Approve actually calls Lidarr during tests.
func testHandlersWithClientFn(t *testing.T) (*handlers, *lidarrconfig.Service) {
// testHandlersWithClientFn is the request handlers' test fixture.
func testHandlersWithClientFn(t *testing.T) (*handlers, *pgxpool.Pool) {
t.Helper()
h, _ := testHandlers(t)
lidarrCfg := lidarrconfig.New(h.pool)
clientFn := func() *lidarr.Client {
cfg, err := lidarrCfg.Get(context.Background())
if err != nil || !cfg.Enabled || cfg.BaseURL == "" {
return nil
}
return lidarr.NewClient(cfg.BaseURL, cfg.APIKey)
}
h.lidarrRequests = lidarrrequests.NewService(h.pool, lidarrCfg, clientFn, nil)
return h, lidarrCfg
return testHandlers(t)
}
// seedPendingArtistRequest creates a pending artist-kind lidarr request row
@@ -90,7 +76,7 @@ func TestHandleListAdminRequests_DefaultStatus_Pending(t *testing.T) {
// Reject one request directly via service.
rv3 := createArtistRequest(t, h, alice, "mbid-reject-1", "Reject Me")
_, err := h.lidarrRequests.Reject(context.Background(), rv3.ID, admin.ID, "test")
_, err := h.requests.Reject(context.Background(), rv3.ID, admin.ID, "test")
if err != nil {
t.Fatalf("reject seed: %v", err)
}
@@ -126,7 +112,7 @@ func TestHandleListAdminRequests_StatusFilter(t *testing.T) {
createArtistRequest(t, h, alice, "mbid-pending-f1", "Pending Filter 1")
rv2 := createArtistRequest(t, h, alice, "mbid-rejected-f1", "Rejected Filter 1")
_, err := h.lidarrRequests.Reject(context.Background(), rv2.ID, admin.ID, "")
_, err := h.requests.Reject(context.Background(), rv2.ID, admin.ID, "")
if err != nil {
t.Fatalf("reject seed: %v", err)
}
@@ -169,198 +155,19 @@ func TestHandleListAdminRequests_BadStatus_400(t *testing.T) {
}
}
// TestHandleApproveRequest_HappyPath seeds a Lidarr stub, seeds a pending
// artist request, and verifies POST /approve → 200 with row status=approved.
func TestHandleApproveRequest_HappyPath(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
switch {
case strings.Contains(r.URL.Path, "/metadataprofile"):
_, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`))
case strings.Contains(r.URL.Path, "/qualityprofile"):
_, _ = w.Write([]byte(`[{"id":1,"name":"Lossless"}]`))
default:
_, _ = w.Write([]byte(`{"id":1}`))
}
}))
t.Cleanup(stub.Close)
saveLidarrConfig(t, h, stub.URL, true)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-approve", "Approve Me")
w := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/approve", nil, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var got requestView
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if got.Status != "approved" {
t.Errorf("status = %q, want approved", got.Status)
}
if got.ID != rv.ID {
t.Errorf("id mismatch")
}
}
// TestHandleApproveRequest_OverrideUsed seeds config with default qp=1/root="/m",
// approves with override qp=5/root="/other", verifies the stub received those
// values and the row reflects the overrides.
func TestHandleApproveRequest_OverrideUsed(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
var capturedBody []byte
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
buf := make([]byte, 4096)
n, _ := r.Body.Read(buf)
capturedBody = buf[:n]
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
switch {
case strings.Contains(r.URL.Path, "/metadataprofile"):
_, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`))
case strings.Contains(r.URL.Path, "/qualityprofile"):
_, _ = w.Write([]byte(`[{"id":1,"name":"Lossless"}]`))
default:
_, _ = w.Write([]byte(`{"id":1}`))
}
}))
t.Cleanup(stub.Close)
// Save config with default qp=1, root="/m".
if err := lidarrconfig.New(h.pool).Save(context.Background(), lidarrconfig.Config{
Enabled: true,
BaseURL: stub.URL,
APIKey: "test-key",
DefaultQualityProfileID: 1,
DefaultRootFolderPath: "/m",
}); err != nil {
t.Fatalf("save config: %v", err)
}
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-override", "Override Me")
body := []byte(`{"quality_profile_id":5,"root_folder_path":"/other"}`)
w := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/approve", body, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
// Verify stub received the override values.
var stubPayload map[string]any
if err := json.Unmarshal(capturedBody, &stubPayload); err != nil {
t.Fatalf("decode stub payload: %v; body = %s", err, capturedBody)
}
if qp, _ := stubPayload["qualityProfileId"].(float64); int(qp) != 5 {
t.Errorf("stub qualityProfileId = %v, want 5", stubPayload["qualityProfileId"])
}
if rf, _ := stubPayload["rootFolderPath"].(string); rf != "/other" {
t.Errorf("stub rootFolderPath = %q, want /other", rf)
}
// Verify the returned row reflects the overrides.
var got requestView
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode response: %v; body = %s", err, w.Body.String())
}
if got.QualityProfileID == nil || *got.QualityProfileID != 5 {
t.Errorf("quality_profile_id = %v, want 5", got.QualityProfileID)
}
if got.RootFolderPath == nil || *got.RootFolderPath != "/other" {
t.Errorf("root_folder_path = %v, want /other", got.RootFolderPath)
}
}
// TestHandleApproveRequest_LidarrUnreachable_ApprovesDeferred points config
// at an unreachable port and verifies the durable-approve contract: POST
// /approve still succeeds (200) and the request becomes approved with the
// Lidarr add deferred (lidarr_add_confirmed_at NULL) for the reconciler to
// retry — the admin's decision is never lost to a transient Lidarr outage.
func TestHandleApproveRequest_LidarrUnreachable_ApprovesDeferred(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
saveLidarrConfig(t, h, "http://127.0.0.1:1", true)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-unreachable", "Unreachable")
w := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/approve", nil, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (durable approve despite Lidarr down); body = %s", w.Code, w.Body.String())
}
// Must no longer be pending.
pending, err := h.lidarrRequests.ListByStatus(context.Background(), "pending", 10)
if err != nil {
t.Fatalf("list pending: %v", err)
}
for _, r := range pending {
if r.ID == rv.ID {
t.Error("row still pending after durable approve")
}
}
// Must be approved with the add deferred (confirmed_at NULL) so the
// reconciler retries it — not failed, not lost.
approved, err := h.lidarrRequests.ListByStatus(context.Background(), "approved", 10)
if err != nil {
t.Fatalf("list approved: %v", err)
}
var got *dbq.LidarrRequest
for i := range approved {
if approved[i].ID == rv.ID {
got = &approved[i]
break
}
}
if got == nil {
t.Fatalf("row %s not in approved after approve with Lidarr down", uuidToString(rv.ID))
}
if got.LidarrAddConfirmedAt.Valid {
t.Error("lidarr_add_confirmed_at set despite Lidarr unreachable; want NULL (deferred to reconciler)")
}
}
// TestHandleApproveRequest_NotPending_409 pre-rejects a row and verifies
// POST /approve → 409 request_not_pending.
func TestHandleApproveRequest_NotPending_409(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
// Need an enabled Lidarr config so Approve reaches the not-pending check.
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id":1}`))
}))
t.Cleanup(stub.Close)
saveLidarrConfig(t, h, stub.URL, true)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-notpending", "Not Pending")
// Pre-reject the row.
_, err := h.lidarrRequests.Reject(context.Background(), rv.ID, admin.ID, "pre-rejected")
_, err := h.requests.Reject(context.Background(), rv.ID, admin.ID, "pre-rejected")
if err != nil {
t.Fatalf("pre-reject: %v", err)
}
@@ -470,3 +277,62 @@ func TestAdminRequestsEndpoints_NonAdmin403(t *testing.T) {
})
}
}
// TestHandleApproveRequest_AlbumBecomesAWant: approving an album request puts
// its release group on the wanted list, as the requester's.
func TestHandleApproveRequest_AlbumBecomesAWant(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
w := doCreateRequest(h, alice, `{"kind":"album","artist_mbid":"artist-x","artist_name":"Gorillaz","album_mbid":"group-x","album_title":"Demon Days"}`)
if w.Code != http.StatusCreated {
t.Fatalf("create: %d %s", w.Code, w.Body.String())
}
var rv requestView
_ = json.Unmarshal(w.Body.Bytes(), &rv)
resp := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/approve", nil, admin)
if resp.Code != http.StatusOK {
t.Fatalf("approve: %d %s", resp.Code, resp.Body.String())
}
want, err := dbq.New(h.pool).GetWantByReleaseGroup(context.Background(), "group-x")
if err != nil {
t.Fatalf("no want recorded: %v", err)
}
if want.Title != "Demon Days" || want.CreatedBy != alice.ID || want.Reason != "request" {
t.Errorf("want = %+v", want)
}
}
// TestHandleApproveRequest_ArtistOnTheChosenProfile: an artist request is
// followed on the profile the admin picked; an unknown profile is a 400.
func TestHandleApproveRequest_ArtistOnTheChosenProfile(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
ctx := context.Background()
profile, err := dbq.New(h.pool).CreateReleaseTypeProfile(ctx, dbq.CreateReleaseTypeProfileParams{
Name: "Albums only", PrimaryTypes: []string{"Album"}, SecondaryTypes: []string{},
})
if err != nil {
t.Fatal(err)
}
rv := seedPendingArtistRequest(t, h, alice, "artist-profiled", "Profiled")
path := "/api/admin/requests/" + uuidToString(rv.ID) + "/approve"
if resp := doAdminRequestReq(t, h, http.MethodPost, path, []byte(`{"profile_id":"00000000-0000-4000-8000-000000000000"}`), admin); resp.Code != http.StatusBadRequest {
t.Fatalf("unknown profile: %d %s", resp.Code, resp.Body.String())
}
body := []byte(`{"profile_id":"` + uuidToString(profile.ID) + `"}`)
if resp := doAdminRequestReq(t, h, http.MethodPost, path, body, admin); resp.Code != http.StatusOK {
t.Fatalf("approve: %d %s", resp.Code, resp.Body.String())
}
f, err := dbq.New(h.pool).GetFollow(ctx, "artist-profiled")
if err != nil || f.ProfileID != profile.ID || !f.WantExisting {
t.Errorf("follow = %+v, %v: on Albums only, wanting what exists", f, err)
}
}
+392
View File
@@ -0,0 +1,392 @@
package api
import (
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
)
// The wanted list's admin surface (M502 #5520): wants, follows and
// release-type profiles.
// wantView is one row of GET /api/admin/wanted.
type wantView struct {
ID string `json:"id"`
ReleaseGroupMBID string `json:"release_group_mbid"`
Title string `json:"title"`
ArtistMBID string `json:"artist_mbid"`
ArtistName string `json:"artist_name"`
ArtistID string `json:"artist_id,omitempty"`
PrimaryType string `json:"primary_type"`
SecondaryTypes []string `json:"secondary_types"`
Year string `json:"year"`
Reason string `json:"reason"`
RequestedBy string `json:"requested_by,omitempty"`
AlbumID string `json:"album_id,omitempty"`
WantedAt string `json:"wanted_at"`
HeldAt *string `json:"held_at"`
ImageURL string `json:"image_url"`
}
// wantCounts sizes each tab of the Wanted page.
type wantCounts struct {
Wanted int64 `json:"wanted"`
Missing int64 `json:"missing"`
Held int64 `json:"held"`
Following int64 `json:"following"`
}
type wantsPage struct {
Items []wantView `json:"items"`
Total int64 `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Counts wantCounts `json:"counts"`
}
const wantsPageSize = 50
// handleListWants implements GET /api/admin/wanted?state=wanted|missing|held&offset=.
// wanted is everything open but missing files; held is newest first.
func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
state := r.URL.Query().Get("state")
if state == "" {
state = "wanted"
}
if state != "wanted" && state != "missing" && state != "held" {
writeErr(w, apierror.BadRequest("bad_state", "state must be wanted, missing or held"))
return
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if offset < 0 {
offset = 0
}
q := dbq.New(h.pool)
rows, err := q.ListWants(r.Context(), dbq.ListWantsParams{State: state, Skip: int32(offset), MaxRows: wantsPageSize})
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: list", apierror.Internal(err))
return
}
counts, err := q.CountWants(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: count", apierror.Internal(err))
return
}
out := wantsPage{
Items: make([]wantView, 0, len(rows)), Limit: wantsPageSize, Offset: offset,
Counts: wantCounts{Wanted: counts.Wanted, Missing: counts.Missing, Held: counts.Held, Following: counts.Following},
}
switch state {
case "missing":
out.Total = counts.Missing
case "held":
out.Total = counts.Held
default:
out.Total = counts.Wanted
}
for _, row := range rows {
v := wantView{
ID: uuidToString(row.ID), ReleaseGroupMBID: row.ReleaseGroupMbid, Title: row.Title,
ArtistMBID: row.ArtistMbid, ArtistName: row.ArtistName,
PrimaryType: row.PrimaryType, SecondaryTypes: row.SecondaryTypes,
Year: first4(row.FirstReleaseDate), Reason: row.Reason,
WantedAt: formatTimestamp(row.WantedAt),
ImageURL: groupArtURL(musicbrainz.ReleaseGroup{MBID: row.ReleaseGroupMbid, Title: row.Title, ArtistCredit: row.ArtistName}),
}
if row.LibraryArtistID.Valid {
v.ArtistID = uuidToString(row.LibraryArtistID)
}
if row.AlbumID.Valid {
v.AlbumID = uuidToString(row.AlbumID)
}
if row.RequestedBy != nil {
v.RequestedBy = *row.RequestedBy
}
if row.FulfilledAt.Valid {
held := formatTimestamp(row.FulfilledAt)
v.HeldAt = &held
}
if v.SecondaryTypes == nil {
v.SecondaryTypes = []string{}
}
out.Items = append(out.Items, v)
}
writeJSON(w, http.StatusOK, out)
}
func first4(date string) string {
if len(date) < 4 {
return ""
}
return date[:4]
}
// handleRemoveWant implements DELETE /api/admin/wanted/{id}.
func (h *handlers) handleRemoveWant(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
removed, err := h.wanted.RemoveWant(r.Context(), id)
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: remove", apierror.Internal(err))
return
}
if !removed {
writeErr(w, apierror.NotFound("want"))
return
}
w.WriteHeader(http.StatusNoContent)
}
// followView is a followed artist.
type followView struct {
ArtistMBID string `json:"artist_mbid"`
ArtistName string `json:"artist_name"`
ArtistID string `json:"artist_id,omitempty"`
ProfileID string `json:"profile_id"`
ProfileName string `json:"profile_name"`
FollowedAt string `json:"followed_at"`
CheckedAt *string `json:"checked_at"`
OpenWants int64 `json:"open_wants"`
ImageURL string `json:"image_url"`
}
// handleListFollows implements GET /api/admin/follows.
func (h *handlers) handleListFollows(w http.ResponseWriter, r *http.Request) {
rows, err := dbq.New(h.pool).ListFollows(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "admin follows: list", apierror.Internal(err))
return
}
out := make([]followView, 0, len(rows))
for _, f := range rows {
v := followView{
ArtistMBID: f.ArtistMbid, ArtistName: f.ArtistName,
ProfileID: uuidToString(f.ProfileID), ProfileName: f.ProfileName,
FollowedAt: formatTimestamp(f.FollowedAt), OpenWants: f.OpenWants,
ImageURL: artistArtURL(f.ArtistMbid, f.ArtistName),
}
if f.ArtistID.Valid {
v.ArtistID = uuidToString(f.ArtistID)
}
if f.CheckedAt.Valid {
checked := formatTimestamp(f.CheckedAt)
v.CheckedAt = &checked
}
out = append(out, v)
}
writeJSON(w, http.StatusOK, out)
}
// followStateView answers whether one artist is followed, for the control on
// an artist page.
type followStateView struct {
Following bool `json:"following"`
ProfileID string `json:"profile_id,omitempty"`
ProfileName string `json:"profile_name,omitempty"`
}
// handleGetFollow implements GET /api/admin/follows/{mbid}.
func (h *handlers) handleGetFollow(w http.ResponseWriter, r *http.Request) {
f, err := dbq.New(h.pool).GetFollow(r.Context(), chi.URLParam(r, "mbid"))
if errors.Is(err, pgx.ErrNoRows) {
writeJSON(w, http.StatusOK, followStateView{})
return
}
if err != nil {
writeErrWithLog(w, h.logger, "admin follows: get", apierror.Internal(err))
return
}
writeJSON(w, http.StatusOK, followStateView{
Following: true, ProfileID: uuidToString(f.ProfileID), ProfileName: f.ProfileName,
})
}
type putFollowBody struct {
ArtistName string `json:"artist_name"`
ProfileID string `json:"profile_id"`
WantExisting bool `json:"want_existing"`
}
// handlePutFollow implements PUT /api/admin/follows/{mbid}: follow an artist,
// or change the profile it is followed on. want_existing also wants what the
// artist has already released that the profile passes and isn't held.
func (h *handlers) handlePutFollow(w http.ResponseWriter, r *http.Request) {
admin, ok := requireUser(w, r)
if !ok {
return
}
var body putFollowBody
if !decodeBody(w, r, &body) {
return
}
var profile pgtype.UUID
if body.ProfileID != "" {
if profile, ok = parseUUID(body.ProfileID); !ok {
writeErr(w, apierror.BadRequest("bad_profile", "profile_id is not an id"))
return
}
}
mbid := chi.URLParam(r, "mbid")
if _, err := h.wanted.Follow(r.Context(), wanted.FollowParams{
ArtistMBID: mbid, ArtistName: body.ArtistName, ProfileID: profile,
By: admin.ID, WantExisting: body.WantExisting,
}); err != nil {
switch {
case errors.Is(err, wanted.ErrNotFollowable):
writeErr(w, apierror.BadRequest("not_followable", "Various Artists and MusicBrainz placeholders can't be followed"))
case errors.Is(err, wanted.ErrProfileNotFound):
writeErr(w, apierror.BadRequest("profile_not_found", "no such profile"))
default:
writeErrWithLog(w, h.logger, "admin follows: put", apierror.Internal(err))
}
return
}
h.handleGetFollow(w, r)
}
// handleDeleteFollow implements DELETE /api/admin/follows/{mbid}. What the
// follow already wanted stays on the list.
func (h *handlers) handleDeleteFollow(w http.ResponseWriter, r *http.Request) {
removed, err := h.wanted.Unfollow(r.Context(), chi.URLParam(r, "mbid"))
if err != nil {
writeErrWithLog(w, h.logger, "admin follows: delete", apierror.Internal(err))
return
}
if !removed {
writeErr(w, apierror.NotFound("follow"))
return
}
w.WriteHeader(http.StatusNoContent)
}
// releaseProfileView is a release-type profile.
type releaseProfileView struct {
ID string `json:"id"`
Name string `json:"name"`
PrimaryTypes []string `json:"primary_types"`
SecondaryTypes []string `json:"secondary_types"`
IsDefault bool `json:"is_default"`
FollowCount int64 `json:"follow_count"`
}
// releaseProfilesResponse also carries MusicBrainz's types, so the editor
// offers exactly what a profile may hold.
type releaseProfilesResponse struct {
Profiles []releaseProfileView `json:"profiles"`
PrimaryTypes []string `json:"primary_types"`
SecondaryTypes []string `json:"secondary_types"`
}
// handleListReleaseProfiles implements GET /api/admin/release-profiles.
func (h *handlers) handleListReleaseProfiles(w http.ResponseWriter, r *http.Request) {
rows, err := dbq.New(h.pool).ListReleaseTypeProfiles(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "admin release profiles: list", apierror.Internal(err))
return
}
out := releaseProfilesResponse{
Profiles: make([]releaseProfileView, 0, len(rows)),
PrimaryTypes: wanted.PrimaryTypes, SecondaryTypes: wanted.SecondaryTypes,
}
for _, p := range rows {
out.Profiles = append(out.Profiles, releaseProfileView{
ID: uuidToString(p.ID), Name: p.Name, PrimaryTypes: p.PrimaryTypes,
SecondaryTypes: p.SecondaryTypes, IsDefault: p.IsDefault, FollowCount: p.FollowCount,
})
}
writeJSON(w, http.StatusOK, out)
}
type releaseProfileBody struct {
Name string `json:"name"`
PrimaryTypes []string `json:"primary_types"`
SecondaryTypes []string `json:"secondary_types"`
IsDefault bool `json:"is_default"`
}
func (b releaseProfileBody) input() wanted.ProfileInput {
return wanted.ProfileInput{
Name: b.Name, PrimaryTypes: b.PrimaryTypes, SecondaryTypes: b.SecondaryTypes, IsDefault: b.IsDefault,
}
}
// handleCreateReleaseProfile implements POST /api/admin/release-profiles.
func (h *handlers) handleCreateReleaseProfile(w http.ResponseWriter, r *http.Request) {
var body releaseProfileBody
if !decodeBody(w, r, &body) {
return
}
p, err := h.wanted.CreateProfile(r.Context(), body.input())
if err != nil {
h.writeProfileErr(w, err)
return
}
writeJSON(w, http.StatusCreated, releaseProfileView{
ID: uuidToString(p.ID), Name: p.Name, PrimaryTypes: p.PrimaryTypes,
SecondaryTypes: p.SecondaryTypes, IsDefault: p.IsDefault,
})
}
// handleUpdateReleaseProfile implements PUT /api/admin/release-profiles/{id}.
// A follow on the profile judges new releases by the new types from its next
// check; releases it has already judged are not judged again.
func (h *handlers) handleUpdateReleaseProfile(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
var body releaseProfileBody
if !decodeBody(w, r, &body) {
return
}
p, err := h.wanted.UpdateProfile(r.Context(), id, body.input())
if err != nil {
h.writeProfileErr(w, err)
return
}
writeJSON(w, http.StatusOK, releaseProfileView{
ID: uuidToString(p.ID), Name: p.Name, PrimaryTypes: p.PrimaryTypes,
SecondaryTypes: p.SecondaryTypes, IsDefault: p.IsDefault,
})
}
// handleDeleteReleaseProfile implements DELETE /api/admin/release-profiles/{id}.
func (h *handlers) handleDeleteReleaseProfile(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
if err := h.wanted.DeleteProfile(r.Context(), id); err != nil {
h.writeProfileErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *handlers) writeProfileErr(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, wanted.ErrProfileInvalid):
writeErr(w, apierror.BadRequest("profile_invalid", err.Error()))
case errors.Is(err, wanted.ErrProfileNotFound):
writeErr(w, apierror.NotFound("profile"))
case errors.Is(err, wanted.ErrProfileNameUsed):
writeErr(w, apierror.Conflict("profile_name_taken", "a profile already has that name"))
case errors.Is(err, wanted.ErrProfileInUse):
writeErr(w, apierror.Conflict("profile_in_use", "artists are followed on this profile; move them first"))
case errors.Is(err, wanted.ErrProfileDefault):
writeErr(w, apierror.Conflict("profile_is_default", "the default profile can't be deleted"))
default:
writeErrWithLog(w, h.logger, "admin release profiles", apierror.Internal(err))
}
}
+28 -9
View File
@@ -22,7 +22,6 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
@@ -31,14 +30,16 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
)
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
// RequireUser; everything else is gated by the middleware. The events writer
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService, loudSettings *library.LoudnessSettingsService, acoustIDLookup *library.AcoustIDLookupWorker) {
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, requestSvc *requests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService, loudSettings *library.LoudnessSettingsService, acoustIDLookup *library.AcoustIDLookupWorker) {
rng := rand.New(rand.NewSource(rand.Int63()))
setupToken, err := auth.NewSetupToken()
if err != nil {
@@ -47,12 +48,13 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
panic("api: mint setup token: " + err.Error())
}
logSetupTokenIfNeeded(pool, logger, setupToken)
cat := catalogue.New(pool, nil, logger)
h := &handlers{
pool: pool, logger: logger, events: events, recCfg: recCfg,
recSettings: recSettings,
rng: rng.Float64,
lidarrCfg: lidarrCfg,
lidarrRequests: lidarrReqs,
requests: requestSvc,
lidarrQuarantine: lidarrQuar,
tracks: tracksSvc,
playlists: playlistsSvc,
@@ -73,7 +75,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
loudnessSettings: loudSettings,
acoustIDLookup: acoustIDLookup,
acquisition: acquire.NewStore(pool),
catalogue: catalogue.New(pool, nil, logger),
catalogue: cat,
wanted: wanted.New(pool, cat, logger),
catalogueSearches: newSearchCache(),
catalogueArt: catalogue.NewArt(dataDir, artSources(coverSettings)),
leveled: newLeveledRenderer(dataDir, loudSettings, logger),
@@ -240,6 +243,20 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Post("/requests/{id}/approve", h.handleApproveRequest)
admin.Post("/requests/{id}/reject", h.handleRejectRequest)
// The wanted list (M502 #5520): what Minstrel is looking
// for, the artists it follows, and the release-type
// profiles that decide which of their releases count.
admin.Get("/wanted", h.handleListWants)
admin.Delete("/wanted/{id}", h.handleRemoveWant)
admin.Get("/follows", h.handleListFollows)
admin.Get("/follows/{mbid}", h.handleGetFollow)
admin.Put("/follows/{mbid}", h.handlePutFollow)
admin.Delete("/follows/{mbid}", h.handleDeleteFollow)
admin.Get("/release-profiles", h.handleListReleaseProfiles)
admin.Post("/release-profiles", h.handleCreateReleaseProfile)
admin.Put("/release-profiles/{id}", h.handleUpdateReleaseProfile)
admin.Delete("/release-profiles/{id}", h.handleDeleteReleaseProfile)
admin.Get("/quarantine", h.handleListAdminQuarantine)
admin.Post("/quarantine/{track_id}/resolve", h.handleResolveQuarantine)
@@ -257,10 +274,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Get("/network-settings", h.handleGetNetworkSettings)
admin.Put("/network-settings", h.handleUpdateNetworkSettings)
// Policy for turning a missing file back into a Lidarr
// request (#290). Sits beside the missing-files list it
// governs rather than under /lidarr, because the operator
// meets it on the missing-files surface.
// Policy for wanting back an album whose files went missing
// (#290). Sits beside the missing-files list it governs,
// because the operator meets it on that surface.
admin.Get("/library/reacquisition", h.handleGetReacquisitionSettings)
admin.Put("/library/reacquisition", h.handleUpdateReacquisitionSettings)
@@ -361,7 +377,7 @@ type handlers struct {
// full table scan, and library size only moves when a scan runs.
librarySize *recommendation.LibrarySize
lidarrCfg *lidarrconfig.Service
lidarrRequests *lidarrrequests.Service
requests *requests.Service
lidarrQuarantine *lidarrquarantine.Service
tracks *tracks.Service
playlists *playlists.Service
@@ -397,6 +413,9 @@ type handlers struct {
// catalogueArt the images for what isn't held (M502 #5519).
catalogue *catalogue.Service
catalogueArt *catalogue.Art
// wanted is the wanted list: follows, release-type profiles and wants
// (M502 #5520). Its worker runs in main; the API reads and edits.
wanted *wanted.Service
// catalogueSearches reuses recent MusicBrainz answers; nil asks every
// time, which is what handler tests get.
catalogueSearches *searchCache
+6 -3
View File
@@ -18,6 +18,7 @@ import (
"golang.org/x/crypto/bcrypt"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
@@ -25,13 +26,14 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
)
// testHandlers spins up a handlers instance against MINSTREL_TEST_DATABASE_URL.
@@ -65,7 +67,8 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
t.Fatalf("recsettings: %v", err)
}
lidarrCfg := lidarrconfig.New(pool)
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
requestSvc := requests.NewService(pool)
cat := catalogue.New(pool, nil, logger)
lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil, "")
// tracks.Service has no Lidarr unmonitorer in tests by default; the
// admin-tracks tests below override h.tracks via installTracksLidarrStub
@@ -73,7 +76,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
dataDir := t.TempDir()
tracksSvc := tracks.NewService(pool, logger, nil, dataDir)
playlistsSvc := playlists.NewService(pool, logger, dataDir)
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, recSettings: recSettings, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}, notifier: notifications.New(pool, nil, nil)}
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, recSettings: recSettings, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, requests: requestSvc, catalogue: cat, wanted: wanted.New(pool, cat, logger), lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}, notifier: notifications.New(pool, nil, nil)}
return h, pool
}
+3 -3
View File
@@ -154,9 +154,9 @@ func TestCatalogueSearch_MarksWhatIsHeldAndWhatIsRequested(t *testing.T) {
user := seedUser(t, h.pool, "requester", "pw", false)
track := searchRecMBID
if _, err := q.CreateLidarrRequest(ctx, dbq.CreateLidarrRequestParams{
UserID: user.ID, Kind: dbq.LidarrRequestKindTrack, LidarrArtistMbid: searchArtistMBID,
LidarrTrackMbid: &track, ArtistName: "Radiohead",
if _, err := q.CreateRequest(ctx, dbq.CreateRequestParams{
UserID: user.ID, Kind: dbq.RequestKindTrack, ArtistMbid: searchArtistMBID,
TrackMbid: &track, ArtistName: "Radiohead",
}); err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -108,7 +108,7 @@ func (h *handlers) publishSystemRebuilt(userID pgtype.UUID) {
// new state without manual refresh. Admin actors (approve / reject) still
// route to the requester's user_id, not the admin's — the original user
// is who needs to see the update.
func (h *handlers) publishRequestStatusChanged(row dbq.LidarrRequest) {
func (h *handlers) publishRequestStatusChanged(row dbq.Request) {
if h.eventbus == nil {
return
}
+3
View File
@@ -132,6 +132,9 @@ func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) {
Albums: refs,
Genres: nonNilStrings(genres),
}
if artist.Mbid != nil {
detail.MBID = *artist.Mbid
}
writeJSON(w, http.StatusOK, detail)
}
+1 -1
View File
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
r := chi.NewRouter()
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
30*time.Minute, 0.5, 30000)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil, h.netSettings, nil, nil, nil, nil)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.requests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil, h.netSettings, nil, nil, nil, nil)
paths := []string{
"/api/artists",
+2 -2
View File
@@ -13,13 +13,13 @@ import (
// Lidarr search they once sat beside is now the catalogue search
// (catalogue_test.go, M502 #5519).
// resetLidarrState clears lidarr_requests rows and resets lidarr_config
// resetLidarrState clears requests rows and resets lidarr_config
// to the factory-default state (enabled=false, nulls) between tests.
func resetLidarrState(t *testing.T, h *handlers) {
t.Helper()
ctx := context.Background()
if _, err := h.pool.Exec(ctx,
"DELETE FROM lidarr_requests; UPDATE lidarr_config SET enabled=false, base_url=NULL, api_key=NULL WHERE id=1",
"DELETE FROM requests; UPDATE lidarr_config SET enabled=false, base_url=NULL, api_key=NULL WHERE id=1",
); err != nil {
t.Fatalf("resetLidarrState: %v", err)
}
+4 -4
View File
@@ -6,8 +6,8 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
)
// Helpers for the handlers that produce notifications (M489). Every call is
@@ -22,17 +22,17 @@ func userLabel(u dbq.User) string {
return u.Username
}
func requestPayload(row dbq.LidarrRequest) notifications.Payload {
func requestPayload(row dbq.Request) notifications.Payload {
return notifications.Payload{
RequestID: uuidToString(row.ID),
RequestKind: string(row.Kind),
Name: lidarrrequests.DisplayName(row),
Name: requests.DisplayName(row),
}
}
// notifyRequestDecided tells the requester an admin approved or declined
// their request. An admin deciding their own request needs no notice.
func (h *handlers) notifyRequestDecided(ctx context.Context, kind notifications.Kind, admin dbq.User, row dbq.LidarrRequest, reason string) {
func (h *handlers) notifyRequestDecided(ctx context.Context, kind notifications.Kind, admin dbq.User, row dbq.Request, reason string) {
if row.UserID == admin.ID {
return
}
+75 -86
View File
@@ -9,34 +9,32 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
)
// requestView is the JSON shape returned by all /api/requests handlers.
// Field names follow the lowercase snake_case convention for /api/* responses.
type requestView struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
Status string `json:"status"`
Kind string `json:"kind"`
LidarrArtistMBID string `json:"lidarr_artist_mbid"`
LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"`
LidarrTrackMBID *string `json:"lidarr_track_mbid,omitempty"`
ArtistName string `json:"artist_name"`
AlbumTitle *string `json:"album_title,omitempty"`
TrackTitle *string `json:"track_title,omitempty"`
QualityProfileID *int32 `json:"quality_profile_id,omitempty"`
RootFolderPath *string `json:"root_folder_path,omitempty"`
DecidedAt pgtype.Timestamptz `json:"decided_at,omitempty"`
DecidedBy pgtype.UUID `json:"decided_by,omitempty"`
Notes *string `json:"notes,omitempty"`
CompletedAt pgtype.Timestamptz `json:"completed_at,omitempty"`
MatchedTrackID pgtype.UUID `json:"matched_track_id,omitempty"`
MatchedAlbumID pgtype.UUID `json:"matched_album_id,omitempty"`
MatchedArtistID pgtype.UUID `json:"matched_artist_id,omitempty"`
RequestedAt pgtype.Timestamptz `json:"requested_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
Status string `json:"status"`
Kind string `json:"kind"`
ArtistMBID string `json:"artist_mbid"`
AlbumMBID *string `json:"album_mbid,omitempty"`
TrackMBID *string `json:"track_mbid,omitempty"`
ArtistName string `json:"artist_name"`
AlbumTitle *string `json:"album_title,omitempty"`
TrackTitle *string `json:"track_title,omitempty"`
DecidedAt pgtype.Timestamptz `json:"decided_at,omitempty"`
DecidedBy pgtype.UUID `json:"decided_by,omitempty"`
Notes *string `json:"notes,omitempty"`
CompletedAt pgtype.Timestamptz `json:"completed_at,omitempty"`
MatchedTrackID pgtype.UUID `json:"matched_track_id,omitempty"`
MatchedAlbumID pgtype.UUID `json:"matched_album_id,omitempty"`
MatchedArtistID pgtype.UUID `json:"matched_artist_id,omitempty"`
RequestedAt pgtype.Timestamptz `json:"requested_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
// Progress counters, computed at response time from the matched
// entity's children. 0 when nothing has been ingested yet (or when
// the request has no matched entity yet). For artist requests both
@@ -45,29 +43,27 @@ type requestView struct {
ImportedTrackCount int `json:"imported_track_count"`
}
func requestViewFrom(row dbq.LidarrRequest) requestView {
func requestViewFrom(row dbq.Request) requestView {
return requestView{
ID: row.ID,
UserID: row.UserID,
Status: string(row.Status),
Kind: string(row.Kind),
LidarrArtistMBID: row.LidarrArtistMbid,
LidarrAlbumMBID: row.LidarrAlbumMbid,
LidarrTrackMBID: row.LidarrTrackMbid,
ArtistName: row.ArtistName,
AlbumTitle: row.AlbumTitle,
TrackTitle: row.TrackTitle,
QualityProfileID: row.QualityProfileID,
RootFolderPath: row.RootFolderPath,
DecidedAt: row.DecidedAt,
DecidedBy: row.DecidedBy,
Notes: row.Notes,
CompletedAt: row.CompletedAt,
MatchedTrackID: row.MatchedTrackID,
MatchedAlbumID: row.MatchedAlbumID,
MatchedArtistID: row.MatchedArtistID,
RequestedAt: row.RequestedAt,
UpdatedAt: row.UpdatedAt,
ID: row.ID,
UserID: row.UserID,
Status: string(row.Status),
Kind: string(row.Kind),
ArtistMBID: row.ArtistMbid,
AlbumMBID: row.AlbumMbid,
TrackMBID: row.TrackMbid,
ArtistName: row.ArtistName,
AlbumTitle: row.AlbumTitle,
TrackTitle: row.TrackTitle,
DecidedAt: row.DecidedAt,
DecidedBy: row.DecidedBy,
Notes: row.Notes,
CompletedAt: row.CompletedAt,
MatchedTrackID: row.MatchedTrackID,
MatchedAlbumID: row.MatchedAlbumID,
MatchedArtistID: row.MatchedArtistID,
RequestedAt: row.RequestedAt,
UpdatedAt: row.UpdatedAt,
}
}
@@ -81,9 +77,9 @@ func requestViewFrom(row dbq.LidarrRequest) requestView {
// N+1 against the request list: acceptable at admin scale (dozens of
// in-flight requests at most). Errors are swallowed — progress is a
// reporting nicety, not a correctness gate.
func fillProgress(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest, view *requestView) {
func fillProgress(ctx context.Context, q *dbq.Queries, row dbq.Request, view *requestView) {
switch row.Kind {
case dbq.LidarrRequestKindArtist:
case dbq.RequestKindArtist:
if !row.MatchedArtistID.Valid {
return
}
@@ -93,7 +89,7 @@ func fillProgress(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest, vi
if n, err := q.CountTracksByArtist(ctx, row.MatchedArtistID); err == nil {
view.ImportedTrackCount = int(n)
}
case dbq.LidarrRequestKindAlbum:
case dbq.RequestKindAlbum:
if !row.MatchedAlbumID.Valid {
return
}
@@ -101,22 +97,23 @@ func fillProgress(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest, vi
if n, err := q.CountTracksByAlbum(ctx, row.MatchedAlbumID); err == nil {
view.ImportedTrackCount = int(n)
}
case dbq.LidarrRequestKindTrack:
case dbq.RequestKindTrack:
if row.MatchedTrackID.Valid {
view.ImportedTrackCount = 1
}
}
}
// createRequestBody is the decoded JSON for POST /api/requests.
// createRequestBody is the decoded JSON for POST /api/requests. album_mbid
// is the album's MusicBrainz release group, as catalogue search returns it.
type createRequestBody struct {
Kind string `json:"kind"`
LidarrArtistMBID string `json:"lidarr_artist_mbid"`
LidarrAlbumMBID string `json:"lidarr_album_mbid"`
LidarrTrackMBID string `json:"lidarr_track_mbid"`
ArtistName string `json:"artist_name"`
AlbumTitle string `json:"album_title"`
TrackTitle string `json:"track_title"`
Kind string `json:"kind"`
ArtistMBID string `json:"artist_mbid"`
AlbumMBID string `json:"album_mbid"`
TrackMBID string `json:"track_mbid"`
ArtistName string `json:"artist_name"`
AlbumTitle string `json:"album_title"`
TrackTitle string `json:"track_title"`
}
// handleCreateRequest implements POST /api/requests.
@@ -131,17 +128,17 @@ func (h *handlers) handleCreateRequest(w http.ResponseWriter, r *http.Request) {
return
}
row, created, err := h.lidarrRequests.CreateTracked(r.Context(), user.ID, lidarrrequests.CreateParams{
Kind: body.Kind,
LidarrArtistMBID: body.LidarrArtistMBID,
LidarrAlbumMBID: body.LidarrAlbumMBID,
LidarrTrackMBID: body.LidarrTrackMBID,
ArtistName: body.ArtistName,
AlbumTitle: body.AlbumTitle,
TrackTitle: body.TrackTitle,
row, created, err := h.requests.CreateTracked(r.Context(), user.ID, requests.CreateParams{
Kind: body.Kind,
ArtistMBID: body.ArtistMBID,
AlbumMBID: body.AlbumMBID,
TrackMBID: body.TrackMBID,
ArtistName: body.ArtistName,
AlbumTitle: body.AlbumTitle,
TrackTitle: body.TrackTitle,
})
if err != nil {
if errors.Is(err, lidarrrequests.ErrInvalidKindFields) {
if errors.Is(err, requests.ErrInvalidKindFields) {
writeErr(w, apierror.BadRequest("mbid_required", err.Error()))
return
}
@@ -150,22 +147,14 @@ func (h *handlers) handleCreateRequest(w http.ResponseWriter, r *http.Request) {
return
}
// Auto-approve flow (#355 / #376 U2.5): when the user has the
// auto_approve_requests flag set by an admin, transition the
// just-created pending row through Approve so it dispatches to
// Lidarr immediately. Failures here (Lidarr disabled, defaults
// missing, network error) leave the row pending — same fallback
// as a manual admin approve that hits the same error path. The
// user gets a 201 with whatever status Approve produced; admin
// can still resolve the row in /admin/requests if it stayed
// pending.
//
// The "actor" id passed to Approve is the user themselves — the
// auto-approval is the user acting under the privilege the admin
// granted them via the toggle. The trail of "admin set the flag"
// already lives in audit_log (ActionAutoApproveToggle from U2).
// Auto-approve flow (#355 / #376 U2.5): when an admin has set the user's
// auto_approve_requests flag, the new request goes straight onto the
// wanted list as if approved. A failure leaves it pending for an admin,
// the same as a manual approval hitting the same error. The approver is
// the user themselves, acting under the privilege the admin granted; the
// grant is in audit_log (ActionAutoApproveToggle).
if user.AutoApproveRequests {
approved, aerr := h.lidarrRequests.Approve(r.Context(), row.ID, user.ID, lidarrrequests.ApproveOverrides{})
approved, aerr := h.requests.Approve(r.Context(), row.ID, user.ID, pgtype.UUID{})
if aerr == nil {
row = approved
} else {
@@ -180,7 +169,7 @@ func (h *handlers) handleCreateRequest(w http.ResponseWriter, r *http.Request) {
// (M489). One that deduped into a request already in flight was
// announced when it was first made, and the requester, if they are an
// admin themselves, needs no notice of their own request.
if created && row.Status == dbq.LidarrRequestStatusPending {
if created && row.Status == dbq.RequestStatusPending {
p := requestPayload(row)
p.Actor = userLabel(user)
h.notifier.NotifyLogged(r.Context(), notifications.KindRequestPending, notifications.ToAdmins(user.ID), p.Map())
@@ -197,7 +186,7 @@ func (h *handlers) handleListRequests(w http.ResponseWriter, r *http.Request) {
return
}
rows, err := h.lidarrRequests.ListForUser(r.Context(), user.ID, 100)
rows, err := h.requests.ListForUser(r.Context(), user.ID, 100)
if err != nil {
h.logger.Error("api: list requests", "err", err)
writeErr(w, apierror.InternalMsg("list failed", err))
@@ -222,7 +211,7 @@ func (h *handlers) handleGetRequest(w http.ResponseWriter, r *http.Request) {
return
}
row, apiErr := resolveByID(r, "id", dbq.New(h.pool).GetLidarrRequestByID, "request")
row, apiErr := resolveByID(r, "id", dbq.New(h.pool).GetRequestByID, "request")
if apiErr != nil {
writeErr(w, apiErr)
return
@@ -252,12 +241,12 @@ func (h *handlers) handleCancelRequest(w http.ResponseWriter, r *http.Request) {
return
}
row, err := h.lidarrRequests.Cancel(r.Context(), id, user.ID)
row, err := h.requests.Cancel(r.Context(), id, user.ID)
if err != nil {
switch {
case errors.Is(err, lidarrrequests.ErrNotPending):
case errors.Is(err, requests.ErrNotPending):
writeErr(w, apierror.Conflict("request_not_pending", "request is not pending"))
case errors.Is(err, lidarrrequests.ErrNotFound):
case errors.Is(err, requests.ErrNotFound):
writeErr(w, apierror.NotFound("request"))
default:
h.logger.Error("api: cancel request", "err", err)
+18 -22
View File
@@ -73,7 +73,7 @@ func doCancelRequest(h *handlers, user dbq.User, id pgtype.UUID) *httptest.Respo
// via the handler and returns the parsed response.
func createArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMBID, artistName string) requestView {
t.Helper()
body := fmt.Sprintf(`{"kind":"artist","lidarr_artist_mbid":%q,"artist_name":%q}`, artistMBID, artistName)
body := fmt.Sprintf(`{"kind":"artist","artist_mbid":%q,"artist_name":%q}`, artistMBID, artistName)
w := doCreateRequest(h, user, body)
if w.Code != http.StatusCreated {
t.Fatalf("createArtistRequest: status = %d, want 201; body = %s", w.Code, w.Body.String())
@@ -85,13 +85,10 @@ func createArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMBID, a
return rv
}
// TestHandleCreateRequest_AutoApprove_LidarrDisabled_StaysPending verifies
// that a user with auto_approve_requests=true still receives a 201 Created
// when Lidarr is disabled in the test environment — the auto-approve attempt
// fails inside Service.Approve with ErrLidarrDisabled, which the handler
// catches and logs, leaving the row in pending state. This is the U2.5
// fallback contract: a misconfigured Lidarr must not break request creation.
func TestHandleCreateRequest_AutoApprove_LidarrDisabled_StaysPending(t *testing.T) {
// TestHandleCreateRequest_AutoApprove_GoesStraightOnTheWantedList verifies
// that a user with auto_approve_requests=true gets their request approved on
// creation (#355 / #376 U2.5): an artist request follows the artist at once.
func TestHandleCreateRequest_AutoApprove_GoesStraightOnTheWantedList(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
@@ -105,13 +102,9 @@ func TestHandleCreateRequest_AutoApprove_LidarrDisabled_StaysPending(t *testing.
if err != nil {
t.Fatalf("reload user: %v", err)
}
if !refreshed.AutoApproveRequests {
t.Fatalf("auto_approve flag did not persist — bailing")
}
body := `{"kind":"artist","lidarr_artist_mbid":"auto-mbid-1","artist_name":"Autoband"}`
body := `{"kind":"artist","artist_mbid":"auto-mbid-1","artist_name":"Autoband"}`
w := doCreateRequest(h, refreshed, body)
if w.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body = %s", w.Code, w.Body.String())
}
@@ -119,8 +112,11 @@ func TestHandleCreateRequest_AutoApprove_LidarrDisabled_StaysPending(t *testing.
if err := json.Unmarshal(w.Body.Bytes(), &rv); err != nil {
t.Fatalf("decode: %v", err)
}
if rv.Status != "pending" {
t.Errorf("status = %q, want pending (auto-approve falls back when Lidarr disabled)", rv.Status)
if rv.Status != "approved" {
t.Errorf("status = %q, want approved", rv.Status)
}
if _, err := dbq.New(pool).GetFollow(context.Background(), "auto-mbid-1"); err != nil {
t.Errorf("the artist should be followed: %v", err)
}
}
@@ -131,7 +127,7 @@ func TestHandleCreateRequest_Artist_HappyPath(t *testing.T) {
resetLidarrState(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
body := `{"kind":"artist","lidarr_artist_mbid":"artist-mbid-1","artist_name":"The Beatles"}`
body := `{"kind":"artist","artist_mbid":"artist-mbid-1","artist_name":"The Beatles"}`
w := doCreateRequest(h, alice, body)
if w.Code != http.StatusCreated {
@@ -147,8 +143,8 @@ func TestHandleCreateRequest_Artist_HappyPath(t *testing.T) {
if rv.Status != "pending" {
t.Errorf("status = %q, want pending", rv.Status)
}
if rv.LidarrArtistMBID != "artist-mbid-1" {
t.Errorf("lidarr_artist_mbid = %q", rv.LidarrArtistMBID)
if rv.ArtistMBID != "artist-mbid-1" {
t.Errorf("artist_mbid = %q", rv.ArtistMBID)
}
if rv.ArtistName != "The Beatles" {
t.Errorf("artist_name = %q", rv.ArtistName)
@@ -159,14 +155,14 @@ func TestHandleCreateRequest_Artist_HappyPath(t *testing.T) {
}
// TestHandleCreateRequest_AlbumWithoutAlbumMBID_400 verifies that sending
// kind=album without lidarr_album_mbid returns 400 with mbid_required.
// kind=album without album_mbid returns 400 with mbid_required.
func TestHandleCreateRequest_AlbumWithoutAlbumMBID_400(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
// Missing lidarr_album_mbid and album_title.
body := `{"kind":"album","lidarr_artist_mbid":"artist-mbid-1","artist_name":"The Beatles"}`
// Missing album_mbid and album_title.
body := `{"kind":"album","artist_mbid":"artist-mbid-1","artist_name":"The Beatles"}`
w := doCreateRequest(h, alice, body)
if w.Code != http.StatusBadRequest {
@@ -193,7 +189,7 @@ func TestHandleCreateRequest_TrackWithoutTrackFields_400(t *testing.T) {
alice := seedUser(t, pool, "alice", "pw", false)
// Provides album fields but misses track_mbid and track_title.
body := `{"kind":"track","lidarr_artist_mbid":"artist-mbid-1","artist_name":"Beatles","lidarr_album_mbid":"album-mbid-1","album_title":"Abbey Road"}`
body := `{"kind":"track","artist_mbid":"artist-mbid-1","artist_name":"Beatles","album_mbid":"album-mbid-1","album_title":"Abbey Road"}`
w := doCreateRequest(h, alice, body)
if w.Code != http.StatusBadRequest {
+3
View File
@@ -102,6 +102,9 @@ type ArtistDetail struct {
// Genres carried by this artist's tracks, for quick-jump chips (#367).
// Always non-nil at JSON so the client can iterate without a null check.
Genres []string `json:"genres"`
// MBID is the artist's MusicBrainz id, which following is keyed by
// (M502 #5520). Empty when the tags never named one.
MBID string `json:"mbid"`
}
// AlbumDetail is the response body of GET /api/albums/{id}.
+8 -1
View File
@@ -66,6 +66,13 @@ type Discography struct {
// DiscographyMaxAge. When MusicBrainz is unreachable, a copy already held is
// returned marked Stale; with nothing held, the error is returned.
func (s *Service) Discography(ctx context.Context, artistMBID string) (Discography, error) {
return s.DiscographyWithin(ctx, artistMBID, DiscographyMaxAge)
}
// DiscographyWithin is Discography with the age past which the held copy is
// read again: a followed artist is checked for new releases more often than
// one the catalogue merely knows.
func (s *Service) DiscographyWithin(ctx context.Context, artistMBID string, maxAge time.Duration) (Discography, error) {
q := dbq.New(s.pool)
artist, err := q.GetMBArtist(ctx, artistMBID)
held := err == nil && artist.DiscographyRefreshedAt.Valid
@@ -73,7 +80,7 @@ func (s *Service) Discography(ctx context.Context, artistMBID string) (Discograp
return Discography{}, fmt.Errorf("catalogue: read artist: %w", err)
}
stale := false
if !held || s.now().Sub(artist.DiscographyRefreshedAt.Time) > DiscographyMaxAge {
if !held || s.now().Sub(artist.DiscographyRefreshedAt.Time) > maxAge {
if rerr := s.RefreshArtist(ctx, artistMBID); rerr != nil {
if !held || !errors.Is(rerr, musicbrainz.ErrUnreachable) {
return Discography{}, rerr
-582
View File
@@ -1,582 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: lidarr_requests.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const approveLidarrRequest = `-- name: ApproveLidarrRequest :one
UPDATE lidarr_requests
SET status = 'approved',
quality_profile_id = $2,
root_folder_path = $3,
decided_at = now(),
decided_by = $4,
updated_at = now()
WHERE id = $1 AND status = 'pending'
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at
`
type ApproveLidarrRequestParams struct {
ID pgtype.UUID
QualityProfileID *int32
RootFolderPath *string
DecidedBy pgtype.UUID
}
func (q *Queries) ApproveLidarrRequest(ctx context.Context, arg ApproveLidarrRequestParams) (LidarrRequest, error) {
row := q.db.QueryRow(ctx, approveLidarrRequest,
arg.ID,
arg.QualityProfileID,
arg.RootFolderPath,
arg.DecidedBy,
)
var i LidarrRequest
err := row.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.LidarrArtistMbid,
&i.LidarrAlbumMbid,
&i.LidarrTrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.QualityProfileID,
&i.RootFolderPath,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
)
return i, err
}
const cancelLidarrRequest = `-- name: CancelLidarrRequest :one
UPDATE lidarr_requests
SET status = 'rejected',
notes = 'cancelled by user',
decided_at = now(),
decided_by = $2,
updated_at = now()
WHERE id = $1 AND user_id = $2 AND status = 'pending'
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at
`
type CancelLidarrRequestParams struct {
ID pgtype.UUID
DecidedBy pgtype.UUID
}
// $2 serves as both the ownership guard (user_id = $2) and the
// decided_by audit field. The generated parameter struct names it
// DecidedBy — callers must pass the cancelling user's UUID, and
// a wrong UUID will silently no-op (zero rows updated) rather than
// erroring at the SQL layer.
func (q *Queries) CancelLidarrRequest(ctx context.Context, arg CancelLidarrRequestParams) (LidarrRequest, error) {
row := q.db.QueryRow(ctx, cancelLidarrRequest, arg.ID, arg.DecidedBy)
var i LidarrRequest
err := row.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.LidarrArtistMbid,
&i.LidarrAlbumMbid,
&i.LidarrTrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.QualityProfileID,
&i.RootFolderPath,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
)
return i, err
}
const completeLidarrRequest = `-- name: CompleteLidarrRequest :one
UPDATE lidarr_requests
SET status = 'completed',
matched_track_id = $2,
matched_album_id = $3,
matched_artist_id = $4,
completed_at = now(),
updated_at = now()
WHERE id = $1 AND status = 'approved'
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at
`
type CompleteLidarrRequestParams struct {
ID pgtype.UUID
MatchedTrackID pgtype.UUID
MatchedAlbumID pgtype.UUID
MatchedArtistID pgtype.UUID
}
// Reconciler transitions an approved request to completed when its
// target track/album/artist has appeared in the library. Callers
// should set ONLY the matched_*_id corresponding to the request's
// kind and pass pgtype.UUID{} (Valid: false) for the others —
// per-kind one-of-three is the contract, NULL for the others
// preserves auditability if the matched row is later evicted.
func (q *Queries) CompleteLidarrRequest(ctx context.Context, arg CompleteLidarrRequestParams) (LidarrRequest, error) {
row := q.db.QueryRow(ctx, completeLidarrRequest,
arg.ID,
arg.MatchedTrackID,
arg.MatchedAlbumID,
arg.MatchedArtistID,
)
var i LidarrRequest
err := row.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.LidarrArtistMbid,
&i.LidarrAlbumMbid,
&i.LidarrTrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.QualityProfileID,
&i.RootFolderPath,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
)
return i, err
}
const createLidarrRequest = `-- name: CreateLidarrRequest :one
INSERT INTO lidarr_requests (
user_id, kind,
lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid,
artist_name, album_title, track_title
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at
`
type CreateLidarrRequestParams struct {
UserID pgtype.UUID
Kind LidarrRequestKind
LidarrArtistMbid string
LidarrAlbumMbid *string
LidarrTrackMbid *string
ArtistName string
AlbumTitle *string
TrackTitle *string
}
func (q *Queries) CreateLidarrRequest(ctx context.Context, arg CreateLidarrRequestParams) (LidarrRequest, error) {
row := q.db.QueryRow(ctx, createLidarrRequest,
arg.UserID,
arg.Kind,
arg.LidarrArtistMbid,
arg.LidarrAlbumMbid,
arg.LidarrTrackMbid,
arg.ArtistName,
arg.AlbumTitle,
arg.TrackTitle,
)
var i LidarrRequest
err := row.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.LidarrArtistMbid,
&i.LidarrAlbumMbid,
&i.LidarrTrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.QualityProfileID,
&i.RootFolderPath,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
)
return i, err
}
const getLidarrRequestByID = `-- name: GetLidarrRequestByID :one
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at FROM lidarr_requests WHERE id = $1
`
func (q *Queries) GetLidarrRequestByID(ctx context.Context, id pgtype.UUID) (LidarrRequest, error) {
row := q.db.QueryRow(ctx, getLidarrRequestByID, id)
var i LidarrRequest
err := row.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.LidarrArtistMbid,
&i.LidarrAlbumMbid,
&i.LidarrTrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.QualityProfileID,
&i.RootFolderPath,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
)
return i, err
}
const getNonTerminalRequestForMBID = `-- name: GetNonTerminalRequestForMBID :one
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at FROM lidarr_requests
WHERE status IN ('pending', 'approved', 'completed')
AND ((kind = 'artist' AND lidarr_artist_mbid = $1)
OR (kind = 'album' AND lidarr_album_mbid = $1)
OR (kind = 'track' AND lidarr_track_mbid = $1))
ORDER BY requested_at ASC
LIMIT 1
`
// Returns the oldest non-terminal (pending/approved/completed) request
// whose own-kind MBID matches @mbid, or pgx.ErrNoRows when none. Create()
// uses this to dedupe: a re-request for something already in flight
// returns the existing row instead of inserting a duplicate. MBIDs are
// globally unique per entity type, so the cross-column OR cannot
// false-match (an artist MBID never collides with an album/track MBID).
func (q *Queries) GetNonTerminalRequestForMBID(ctx context.Context, mbid string) (LidarrRequest, error) {
row := q.db.QueryRow(ctx, getNonTerminalRequestForMBID, mbid)
var i LidarrRequest
err := row.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.LidarrArtistMbid,
&i.LidarrAlbumMbid,
&i.LidarrTrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.QualityProfileID,
&i.RootFolderPath,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
)
return i, err
}
const hasNonTerminalRequestForMBID = `-- name: HasNonTerminalRequestForMBID :one
SELECT EXISTS (
SELECT 1 FROM lidarr_requests
WHERE status IN ('pending', 'approved', 'completed')
AND ((kind = 'artist' AND lidarr_artist_mbid = $1)
OR (kind = 'album' AND lidarr_album_mbid = $1)
OR (kind = 'track' AND lidarr_track_mbid = $1))
)
`
// Returns true if any user has a pending/approved/completed request
// whose MBID matches at the given level. Used to set the `requested`
// flag on /api/lidarr/search responses. Terminal-status (rejected,
// failed) rows do not count. The same MBID parameter is checked
// against artist/album/track columns based on kind.
func (q *Queries) HasNonTerminalRequestForMBID(ctx context.Context, mbid string) (bool, error) {
row := q.db.QueryRow(ctx, hasNonTerminalRequestForMBID, mbid)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const listApprovedLidarrRequestsForReconcile = `-- name: ListApprovedLidarrRequestsForReconcile :many
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at FROM lidarr_requests
WHERE status = 'approved'
ORDER BY decided_at ASC
LIMIT $1
`
func (q *Queries) ListApprovedLidarrRequestsForReconcile(ctx context.Context, limit int32) ([]LidarrRequest, error) {
rows, err := q.db.Query(ctx, listApprovedLidarrRequestsForReconcile, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []LidarrRequest
for rows.Next() {
var i LidarrRequest
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.LidarrArtistMbid,
&i.LidarrAlbumMbid,
&i.LidarrTrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.QualityProfileID,
&i.RootFolderPath,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listLidarrRequestsByStatus = `-- name: ListLidarrRequestsByStatus :many
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at FROM lidarr_requests
WHERE status = $1
ORDER BY requested_at DESC
LIMIT $2
`
type ListLidarrRequestsByStatusParams struct {
Status LidarrRequestStatus
Limit int32
}
func (q *Queries) ListLidarrRequestsByStatus(ctx context.Context, arg ListLidarrRequestsByStatusParams) ([]LidarrRequest, error) {
rows, err := q.db.Query(ctx, listLidarrRequestsByStatus, arg.Status, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []LidarrRequest
for rows.Next() {
var i LidarrRequest
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.LidarrArtistMbid,
&i.LidarrAlbumMbid,
&i.LidarrTrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.QualityProfileID,
&i.RootFolderPath,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listLidarrRequestsForUser = `-- name: ListLidarrRequestsForUser :many
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at FROM lidarr_requests
WHERE user_id = $1
ORDER BY requested_at DESC
LIMIT $2
`
type ListLidarrRequestsForUserParams struct {
UserID pgtype.UUID
Limit int32
}
func (q *Queries) ListLidarrRequestsForUser(ctx context.Context, arg ListLidarrRequestsForUserParams) ([]LidarrRequest, error) {
rows, err := q.db.Query(ctx, listLidarrRequestsForUser, arg.UserID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []LidarrRequest
for rows.Next() {
var i LidarrRequest
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.LidarrArtistMbid,
&i.LidarrAlbumMbid,
&i.LidarrTrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.QualityProfileID,
&i.RootFolderPath,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markLidarrRequestAddConfirmed = `-- name: MarkLidarrRequestAddConfirmed :exec
UPDATE lidarr_requests
SET lidarr_add_confirmed_at = now(),
updated_at = now()
WHERE id = $1
AND status = 'approved'
AND lidarr_add_confirmed_at IS NULL
`
// Idempotently records that Lidarr accepted the add for an approved
// request. The lidarr_add_confirmed_at IS NULL guard makes a redundant
// reconciler retry a no-op rather than bumping updated_at every tick.
func (q *Queries) MarkLidarrRequestAddConfirmed(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, markLidarrRequestAddConfirmed, id)
return err
}
const rejectLidarrRequest = `-- name: RejectLidarrRequest :one
UPDATE lidarr_requests
SET status = 'rejected',
notes = $2,
decided_at = now(),
decided_by = $3,
updated_at = now()
WHERE id = $1 AND status = 'pending'
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at
`
type RejectLidarrRequestParams struct {
ID pgtype.UUID
Notes *string
DecidedBy pgtype.UUID
}
func (q *Queries) RejectLidarrRequest(ctx context.Context, arg RejectLidarrRequestParams) (LidarrRequest, error) {
row := q.db.QueryRow(ctx, rejectLidarrRequest, arg.ID, arg.Notes, arg.DecidedBy)
var i LidarrRequest
err := row.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.LidarrArtistMbid,
&i.LidarrAlbumMbid,
&i.LidarrTrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.QualityProfileID,
&i.RootFolderPath,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
)
return i, err
}
const setLidarrRequestAlbumMbid = `-- name: SetLidarrRequestAlbumMbid :exec
UPDATE lidarr_requests
SET lidarr_album_mbid = $2,
updated_at = now()
WHERE id = $1
`
type SetLidarrRequestAlbumMbidParams struct {
ID pgtype.UUID
LidarrAlbumMbid *string
}
// #5241: repoint a request at the album's release group. Requests made before
// the release-group read named albums by MusicBrainz release id, which Lidarr
// does not index; the reconciler rewrites them once it has resolved the group.
func (q *Queries) SetLidarrRequestAlbumMbid(ctx context.Context, arg SetLidarrRequestAlbumMbidParams) error {
_, err := q.db.Exec(ctx, setLidarrRequestAlbumMbid, arg.ID, arg.LidarrAlbumMbid)
return err
}
+18 -18
View File
@@ -233,24 +233,6 @@ func (q *Queries) MergeRepointContextualLikes(ctx context.Context, arg MergeRepo
return result.RowsAffected(), nil
}
const mergeRepointLidarrRequests = `-- name: MergeRepointLidarrRequests :execrows
UPDATE lidarr_requests SET matched_track_id = $1::uuid
WHERE matched_track_id = $2::uuid
`
type MergeRepointLidarrRequestsParams struct {
SurvivorID pgtype.UUID
LoserID pgtype.UUID
}
func (q *Queries) MergeRepointLidarrRequests(ctx context.Context, arg MergeRepointLidarrRequestsParams) (int64, error) {
result, err := q.db.Exec(ctx, mergeRepointLidarrRequests, arg.SurvivorID, arg.LoserID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const mergeRepointPlayEvents = `-- name: MergeRepointPlayEvents :execrows
UPDATE play_events SET track_id = $1::uuid WHERE track_id = $2::uuid
@@ -321,6 +303,24 @@ func (q *Queries) MergeRepointPlaylistTracks(ctx context.Context, arg MergeRepoi
return items, nil
}
const mergeRepointRequests = `-- name: MergeRepointRequests :execrows
UPDATE requests SET matched_track_id = $1::uuid
WHERE matched_track_id = $2::uuid
`
type MergeRepointRequestsParams struct {
SurvivorID pgtype.UUID
LoserID pgtype.UUID
}
func (q *Queries) MergeRepointRequests(ctx context.Context, arg MergeRepointRequestsParams) (int64, error) {
result, err := q.db.Exec(ctx, mergeRepointRequests, arg.SurvivorID, arg.LoserID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const mergeRepointSkipEvents = `-- name: MergeRepointSkipEvents :execrows
UPDATE skip_events SET track_id = $1::uuid WHERE track_id = $2::uuid
`
+105 -77
View File
@@ -99,92 +99,92 @@ func (ns NullLidarrQuarantineReason) Value() (driver.Value, error) {
return string(ns.LidarrQuarantineReason), nil
}
type LidarrRequestKind string
type RequestKind string
const (
LidarrRequestKindArtist LidarrRequestKind = "artist"
LidarrRequestKindAlbum LidarrRequestKind = "album"
LidarrRequestKindTrack LidarrRequestKind = "track"
RequestKindArtist RequestKind = "artist"
RequestKindAlbum RequestKind = "album"
RequestKindTrack RequestKind = "track"
)
func (e *LidarrRequestKind) Scan(src interface{}) error {
func (e *RequestKind) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = LidarrRequestKind(s)
*e = RequestKind(s)
case string:
*e = LidarrRequestKind(s)
*e = RequestKind(s)
default:
return fmt.Errorf("unsupported scan type for LidarrRequestKind: %T", src)
return fmt.Errorf("unsupported scan type for RequestKind: %T", src)
}
return nil
}
type NullLidarrRequestKind struct {
LidarrRequestKind LidarrRequestKind
Valid bool // Valid is true if LidarrRequestKind is not NULL
type NullRequestKind struct {
RequestKind RequestKind
Valid bool // Valid is true if RequestKind is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullLidarrRequestKind) Scan(value interface{}) error {
func (ns *NullRequestKind) Scan(value interface{}) error {
if value == nil {
ns.LidarrRequestKind, ns.Valid = "", false
ns.RequestKind, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.LidarrRequestKind.Scan(value)
return ns.RequestKind.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullLidarrRequestKind) Value() (driver.Value, error) {
func (ns NullRequestKind) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.LidarrRequestKind), nil
return string(ns.RequestKind), nil
}
type LidarrRequestStatus string
type RequestStatus string
const (
LidarrRequestStatusPending LidarrRequestStatus = "pending"
LidarrRequestStatusApproved LidarrRequestStatus = "approved"
LidarrRequestStatusRejected LidarrRequestStatus = "rejected"
LidarrRequestStatusCompleted LidarrRequestStatus = "completed"
LidarrRequestStatusFailed LidarrRequestStatus = "failed"
RequestStatusPending RequestStatus = "pending"
RequestStatusApproved RequestStatus = "approved"
RequestStatusRejected RequestStatus = "rejected"
RequestStatusCompleted RequestStatus = "completed"
RequestStatusFailed RequestStatus = "failed"
)
func (e *LidarrRequestStatus) Scan(src interface{}) error {
func (e *RequestStatus) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = LidarrRequestStatus(s)
*e = RequestStatus(s)
case string:
*e = LidarrRequestStatus(s)
*e = RequestStatus(s)
default:
return fmt.Errorf("unsupported scan type for LidarrRequestStatus: %T", src)
return fmt.Errorf("unsupported scan type for RequestStatus: %T", src)
}
return nil
}
type NullLidarrRequestStatus struct {
LidarrRequestStatus LidarrRequestStatus
Valid bool // Valid is true if LidarrRequestStatus is not NULL
type NullRequestStatus struct {
RequestStatus RequestStatus
Valid bool // Valid is true if RequestStatus is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullLidarrRequestStatus) Scan(value interface{}) error {
func (ns *NullRequestStatus) Scan(value interface{}) error {
if value == nil {
ns.LidarrRequestStatus, ns.Valid = "", false
ns.RequestStatus, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.LidarrRequestStatus.Scan(value)
return ns.RequestStatus.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullLidarrRequestStatus) Value() (driver.Value, error) {
func (ns NullRequestStatus) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.LidarrRequestStatus), nil
return string(ns.RequestStatus), nil
}
type AcoustidSetting struct {
@@ -384,6 +384,24 @@ type FingerprintSetting struct {
AutoResolve bool
}
type Follow struct {
ArtistMbid string
ArtistName string
ProfileID pgtype.UUID
FollowedBy pgtype.UUID
FollowedAt pgtype.Timestamptz
BaselineAt pgtype.Timestamptz
WantExisting bool
CheckedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type FollowSeenGroup struct {
ArtistMbid string
ReleaseGroupMbid string
SeenAt pgtype.Timestamptz
}
type GeneralLike struct {
UserID pgtype.UUID
TrackID pgtype.UUID
@@ -443,31 +461,6 @@ type LidarrQuarantineAction struct {
CreatedAt pgtype.Timestamptz
}
type LidarrRequest struct {
ID pgtype.UUID
UserID pgtype.UUID
Status LidarrRequestStatus
Kind LidarrRequestKind
LidarrArtistMbid string
LidarrAlbumMbid *string
LidarrTrackMbid *string
ArtistName string
AlbumTitle *string
TrackTitle *string
QualityProfileID *int32
RootFolderPath *string
DecidedAt pgtype.Timestamptz
DecidedBy pgtype.UUID
Notes *string
CompletedAt pgtype.Timestamptz
MatchedTrackID pgtype.UUID
MatchedAlbumID pgtype.UUID
MatchedArtistID pgtype.UUID
RequestedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
LidarrAddConfirmedAt pgtype.Timestamptz
}
type ListenbrainzSimilarRecording struct {
SeedTrackID pgtype.UUID
RecordingMbid string
@@ -535,16 +528,6 @@ type MbReleaseTrack struct {
RecordingMbid string
}
type MissingReacquisition struct {
AlbumID pgtype.UUID
Attempts int32
LastAttemptAt pgtype.Timestamptz
LastRequestID pgtype.UUID
GaveUpAt pgtype.Timestamptz
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type NetworkSetting struct {
ID bool
TrustedProxyHops int32
@@ -636,14 +619,10 @@ type PlaylistTrack struct {
}
type ReacquisitionSetting struct {
ID bool
Enabled bool
GraceHours int32
BackoffBaseHours int32
BackoffMaxHours int32
MaxAttempts int32
MaxPerPass int32
AutoApprove bool
ID bool
Enabled bool
GraceHours int32
MaxPerPass int32
}
type RecommendationTuningAudit struct {
@@ -673,6 +652,38 @@ type RegistrationSetting struct {
Mode string
}
type ReleaseTypeProfile struct {
ID pgtype.UUID
Name string
PrimaryTypes []string
SecondaryTypes []string
IsDefault bool
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type Request struct {
ID pgtype.UUID
UserID pgtype.UUID
Status RequestStatus
Kind RequestKind
ArtistMbid string
AlbumMbid *string
TrackMbid *string
ArtistName string
AlbumTitle *string
TrackTitle *string
DecidedAt pgtype.Timestamptz
DecidedBy pgtype.UUID
Notes *string
CompletedAt pgtype.Timestamptz
MatchedTrackID pgtype.UUID
MatchedAlbumID pgtype.UUID
MatchedArtistID pgtype.UUID
RequestedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type ScanRun struct {
ID pgtype.UUID
StartedAt pgtype.Timestamptz
@@ -950,6 +961,23 @@ type UserNotificationPref struct {
UpdatedAt pgtype.Timestamptz
}
type Want struct {
ID pgtype.UUID
ReleaseGroupMbid string
ArtistMbid string
ArtistName string
Title string
PrimaryType string
FirstReleaseDate string
Reason string
AlbumID pgtype.UUID
CreatedBy pgtype.UUID
WantedAt pgtype.Timestamptz
FulfilledAt pgtype.Timestamptz
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type YouMightLikeAlbum struct {
UserID pgtype.UUID
AlbumID pgtype.UUID
+129 -188
View File
@@ -11,27 +11,6 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const clearRecoveredReacquisitions = `-- name: ClearRecoveredReacquisitions :execrows
DELETE FROM missing_reacquisitions r
WHERE NOT EXISTS (
SELECT 1 FROM tracks
WHERE tracks.album_id = r.album_id
AND tracks.missing_since IS NOT NULL
)
`
// Drops state for albums that no longer have any missing track — the files
// came back, or the scanner adopted them at a new path (#2528). Deleting
// rather than resetting counters means a future loss starts from a clean
// budget, which is right: it is a new problem, not a continuation.
func (q *Queries) ClearRecoveredReacquisitions(ctx context.Context) (int64, error) {
result, err := q.db.Exec(ctx, clearRecoveredReacquisitions)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const countAlbumsMissingWithoutMbid = `-- name: CountAlbumsMissingWithoutMbid :one
SELECT COUNT(DISTINCT albums.id)::bigint
FROM albums
@@ -41,10 +20,9 @@ SELECT COUNT(DISTINCT albums.id)::bigint
AND (albums.mbid IS NULL OR artists.mbid IS NULL)
`
// Albums with missing files that can never be auto-requested because nothing
// identifies them to MusicBrainz. Surfaced on the admin card so the gap is
// visible: silently doing nothing for these would read as the feature being
// broken.
// Albums with missing files that can never be wanted automatically because
// nothing identifies them to MusicBrainz. Surfaced on the admin card so the
// gap is visible: silently doing nothing for these would read as broken.
func (q *Queries) CountAlbumsMissingWithoutMbid(ctx context.Context) (int64, error) {
row := q.db.QueryRow(ctx, countAlbumsMissingWithoutMbid)
var column_1 int64
@@ -52,29 +30,56 @@ func (q *Queries) CountAlbumsMissingWithoutMbid(ctx context.Context) (int64, err
return column_1, err
}
const getReacquisitionForAlbums = `-- name: GetReacquisitionForAlbums :many
SELECT album_id, attempts, last_attempt_at, last_request_id, gave_up_at, created_at, updated_at FROM missing_reacquisitions WHERE album_id = ANY($1::uuid[])
const getReacquisitionSettings = `-- name: GetReacquisitionSettings :one
SELECT id, enabled, grace_hours, max_per_pass FROM reacquisition_settings WHERE id = true
`
// State for the admin missing-files surface, so each directory group can say
// whether a re-acquisition is in flight, waiting, or given up.
func (q *Queries) GetReacquisitionForAlbums(ctx context.Context, albumIds []pgtype.UUID) ([]MissingReacquisition, error) {
rows, err := q.db.Query(ctx, getReacquisitionForAlbums, albumIds)
// Re-acquisition of missing files (milestone #290, reworked for M502 #5520).
// The unit is the album: an album whose files went missing becomes a want for
// its release group, and the search schedule (#5521) looks for it.
func (q *Queries) GetReacquisitionSettings(ctx context.Context) (ReacquisitionSetting, error) {
row := q.db.QueryRow(ctx, getReacquisitionSettings)
var i ReacquisitionSetting
err := row.Scan(
&i.ID,
&i.Enabled,
&i.GraceHours,
&i.MaxPerPass,
)
return i, err
}
const getWantsForAlbums = `-- name: GetWantsForAlbums :many
SELECT a.id AS album_id, w.id AS want_id, w.wanted_at, w.fulfilled_at
FROM albums a
JOIN wants w ON w.album_id = a.id OR w.release_group_mbid = a.release_group_mbid
WHERE a.id = ANY($1::uuid[])
ORDER BY w.wanted_at DESC
`
type GetWantsForAlbumsRow struct {
AlbumID pgtype.UUID
WantID pgtype.UUID
WantedAt pgtype.Timestamptz
FulfilledAt pgtype.Timestamptz
}
// What the wanted list says about each album on the missing-files page.
func (q *Queries) GetWantsForAlbums(ctx context.Context, albumIds []pgtype.UUID) ([]GetWantsForAlbumsRow, error) {
rows, err := q.db.Query(ctx, getWantsForAlbums, albumIds)
if err != nil {
return nil, err
}
defer rows.Close()
var items []MissingReacquisition
var items []GetWantsForAlbumsRow
for rows.Next() {
var i MissingReacquisition
var i GetWantsForAlbumsRow
if err := rows.Scan(
&i.AlbumID,
&i.Attempts,
&i.LastAttemptAt,
&i.LastRequestID,
&i.GaveUpAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.WantID,
&i.WantedAt,
&i.FulfilledAt,
); err != nil {
return nil, err
}
@@ -86,67 +91,35 @@ func (q *Queries) GetReacquisitionForAlbums(ctx context.Context, albumIds []pgty
return items, nil
}
const getReacquisitionSettings = `-- name: GetReacquisitionSettings :one
SELECT id, enabled, grace_hours, backoff_base_hours, backoff_max_hours, max_attempts, max_per_pass, auto_approve FROM reacquisition_settings WHERE id = true
`
// Auto re-acquisition of missing files (milestone #290). The unit is the
// album: Lidarr acquires releases, and grouping collapses "40 missing files"
// into "3 albums to ask for".
func (q *Queries) GetReacquisitionSettings(ctx context.Context) (ReacquisitionSetting, error) {
row := q.db.QueryRow(ctx, getReacquisitionSettings)
var i ReacquisitionSetting
err := row.Scan(
&i.ID,
&i.Enabled,
&i.GraceHours,
&i.BackoffBaseHours,
&i.BackoffMaxHours,
&i.MaxAttempts,
&i.MaxPerPass,
&i.AutoApprove,
)
return i, err
}
const listAlbumsDueReacquisition = `-- name: ListAlbumsDueReacquisition :many
SELECT albums.id AS album_id,
albums.title AS album_title,
albums.mbid AS album_mbid,
SELECT albums.id AS album_id,
albums.title AS album_title,
albums.mbid AS album_mbid,
albums.release_group_mbid AS album_release_group_mbid,
artists.id AS artist_id,
artists.name AS artist_name,
artists.mbid AS artist_mbid,
COUNT(tracks.id)::bigint AS missing_track_count,
COALESCE(r.attempts, 0)::int AS attempts
artists.name AS artist_name,
artists.mbid AS artist_mbid,
COUNT(tracks.id)::bigint AS missing_track_count,
MAX(tracks.missing_since)::timestamptz AS last_missing_since
FROM albums
JOIN artists ON artists.id = albums.artist_id
JOIN tracks ON tracks.album_id = albums.id
LEFT JOIN missing_reacquisitions r ON r.album_id = albums.id
WHERE tracks.missing_since IS NOT NULL
AND tracks.missing_since <= now() - make_interval(hours => $1::int)
AND albums.mbid IS NOT NULL
AND artists.mbid IS NOT NULL
AND (r.gave_up_at IS NULL)
AND (
r.last_attempt_at IS NULL
OR r.last_attempt_at <= now() - make_interval(hours => LEAST(
($2::int
* POWER(2, GREATEST(COALESCE(r.attempts, 0) - 1, 0)))::int,
$3::int))
)
GROUP BY albums.id, albums.title, albums.mbid, albums.release_group_mbid,
artists.id, artists.name, artists.mbid, r.attempts, r.last_attempt_at
ORDER BY r.last_attempt_at NULLS FIRST, albums.sort_title
LIMIT $4
artists.name, artists.mbid
HAVING NOT EXISTS (
SELECT 1 FROM wants w
WHERE (w.album_id = albums.id OR w.release_group_mbid = albums.release_group_mbid)
AND (w.fulfilled_at IS NULL OR w.fulfilled_at > MAX(tracks.missing_since)))
ORDER BY MIN(tracks.missing_since), albums.sort_title
LIMIT $2
`
type ListAlbumsDueReacquisitionParams struct {
GraceHours int32
BackoffBaseHours int32
BackoffMaxHours int32
PageLimit int32
GraceHours int32
PageLimit int32
}
type ListAlbumsDueReacquisitionRow struct {
@@ -154,37 +127,24 @@ type ListAlbumsDueReacquisitionRow struct {
AlbumTitle string
AlbumMbid *string
AlbumReleaseGroupMbid *string
ArtistID pgtype.UUID
ArtistName string
ArtistMbid *string
MissingTrackCount int64
Attempts int32
LastMissingSince pgtype.Timestamptz
}
// The sweeper's selection. An album qualifies when:
//
// - it still has at least one track whose file has been missing longer than
// the grace window. Measured on missing_since, which reconcile never
// re-stamps (#2523), so it is a genuine "gone since" clock rather than
// "when we last noticed";
// - MusicBrainz can name it. Both the album and its artist MBID are
// required — Create rejects an album-kind request without them, and there
// is nothing to ask Lidarr for anyway. Albums failing this are counted
// separately (CountAlbumsMissingWithoutMbid) rather than vanishing;
// - it has not spent its attempt budget (gave_up_at IS NULL);
// - its backoff has elapsed: base * 2^(attempts-1) hours since the last
// attempt, clamped to the configured maximum. First attempt (no row, or
// last_attempt_at NULL) is always due.
//
// Oldest attempt first, never-attempted first, so a large loss drains in a
// stable order across passes instead of re-picking the same head each time.
// re-stamps (#2523), so it is a genuine "gone since" clock;
// - MusicBrainz can name it: the album and its artist have MBIDs. Albums
// failing this are counted separately (CountAlbumsMissingWithoutMbid);
// - no want already covers it: none open, and none held since its files
// went missing. A want held after the loss means the library has a full
// copy despite the missing rows (#5245), so asking again would loop.
func (q *Queries) ListAlbumsDueReacquisition(ctx context.Context, arg ListAlbumsDueReacquisitionParams) ([]ListAlbumsDueReacquisitionRow, error) {
rows, err := q.db.Query(ctx, listAlbumsDueReacquisition,
arg.GraceHours,
arg.BackoffBaseHours,
arg.BackoffMaxHours,
arg.PageLimit,
)
rows, err := q.db.Query(ctx, listAlbumsDueReacquisition, arg.GraceHours, arg.PageLimit)
if err != nil {
return nil, err
}
@@ -197,11 +157,10 @@ func (q *Queries) ListAlbumsDueReacquisition(ctx context.Context, arg ListAlbums
&i.AlbumTitle,
&i.AlbumMbid,
&i.AlbumReleaseGroupMbid,
&i.ArtistID,
&i.ArtistName,
&i.ArtistMbid,
&i.MissingTrackCount,
&i.Attempts,
&i.LastMissingSince,
); err != nil {
return nil, err
}
@@ -213,101 +172,83 @@ func (q *Queries) ListAlbumsDueReacquisition(ctx context.Context, arg ListAlbums
return items, nil
}
const markReacquisitionGaveUp = `-- name: MarkReacquisitionGaveUp :exec
UPDATE missing_reacquisitions
SET gave_up_at = now(),
updated_at = now()
WHERE album_id = $1
AND gave_up_at IS NULL
`
// Stamped when the attempt budget is spent. Stored as a timestamp rather than
// inferred from `attempts >= max_attempts` so the verdict survives an operator
// later raising the maximum, and so the admin surface can say when.
func (q *Queries) MarkReacquisitionGaveUp(ctx context.Context, albumID pgtype.UUID) error {
_, err := q.db.Exec(ctx, markReacquisitionGaveUp, albumID)
return err
}
const recordReacquisitionAttempt = `-- name: RecordReacquisitionAttempt :one
INSERT INTO missing_reacquisitions (album_id, attempts, last_attempt_at, last_request_id)
VALUES ($1, 1, now(), $2)
ON CONFLICT (album_id) DO UPDATE
SET attempts = missing_reacquisitions.attempts + 1,
last_attempt_at = now(),
last_request_id = COALESCE(EXCLUDED.last_request_id,
missing_reacquisitions.last_request_id),
updated_at = now()
RETURNING album_id, attempts, last_attempt_at, last_request_id, gave_up_at, created_at, updated_at
`
type RecordReacquisitionAttemptParams struct {
AlbumID pgtype.UUID
LastRequestID pgtype.UUID
}
// Bumps the attempt counter and stamps the clock the backoff measures from.
// Upsert because the first attempt has no row yet.
func (q *Queries) RecordReacquisitionAttempt(ctx context.Context, arg RecordReacquisitionAttemptParams) (MissingReacquisition, error) {
row := q.db.QueryRow(ctx, recordReacquisitionAttempt, arg.AlbumID, arg.LastRequestID)
var i MissingReacquisition
err := row.Scan(
&i.AlbumID,
&i.Attempts,
&i.LastAttemptAt,
&i.LastRequestID,
&i.GaveUpAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const updateReacquisitionSettings = `-- name: UpdateReacquisitionSettings :one
UPDATE reacquisition_settings
SET enabled = $1,
grace_hours = $2,
backoff_base_hours = $3,
backoff_max_hours = $4,
max_attempts = $5,
max_per_pass = $6,
auto_approve = $7
SET enabled = $1,
grace_hours = $2,
max_per_pass = $3
WHERE id = true
RETURNING id, enabled, grace_hours, backoff_base_hours, backoff_max_hours, max_attempts, max_per_pass, auto_approve
RETURNING id, enabled, grace_hours, max_per_pass
`
type UpdateReacquisitionSettingsParams struct {
Enabled bool
GraceHours int32
BackoffBaseHours int32
BackoffMaxHours int32
MaxAttempts int32
MaxPerPass int32
AutoApprove bool
Enabled bool
GraceHours int32
MaxPerPass int32
}
// Whole-row write from the admin card; the CHECKs in migration 0056 are the
// validation, so a bad value fails loudly rather than being clamped silently.
func (q *Queries) UpdateReacquisitionSettings(ctx context.Context, arg UpdateReacquisitionSettingsParams) (ReacquisitionSetting, error) {
row := q.db.QueryRow(ctx, updateReacquisitionSettings,
arg.Enabled,
arg.GraceHours,
arg.BackoffBaseHours,
arg.BackoffMaxHours,
arg.MaxAttempts,
arg.MaxPerPass,
arg.AutoApprove,
)
row := q.db.QueryRow(ctx, updateReacquisitionSettings, arg.Enabled, arg.GraceHours, arg.MaxPerPass)
var i ReacquisitionSetting
err := row.Scan(
&i.ID,
&i.Enabled,
&i.GraceHours,
&i.BackoffBaseHours,
&i.BackoffMaxHours,
&i.MaxAttempts,
&i.MaxPerPass,
&i.AutoApprove,
)
return i, err
}
const wantMissingAlbum = `-- name: WantMissingAlbum :one
INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, reason, album_id)
VALUES ($1, $2, $3,
$4, 'missing', $5)
ON CONFLICT (release_group_mbid) DO UPDATE
SET reason = 'missing',
album_id = EXCLUDED.album_id,
wanted_at = now(),
fulfilled_at = NULL,
updated_at = now()
WHERE wants.fulfilled_at IS NOT NULL
RETURNING id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at
`
type WantMissingAlbumParams struct {
ReleaseGroupMbid string
ArtistMbid string
ArtistName string
Title string
AlbumID pgtype.UUID
}
// Records an album with missing files as wanted. A held want for the same
// group is reopened; an open one is left as it is, and no row comes back.
func (q *Queries) WantMissingAlbum(ctx context.Context, arg WantMissingAlbumParams) (Want, error) {
row := q.db.QueryRow(ctx, wantMissingAlbum,
arg.ReleaseGroupMbid,
arg.ArtistMbid,
arg.ArtistName,
arg.Title,
arg.AlbumID,
)
var i Want
err := row.Scan(
&i.ID,
&i.ReleaseGroupMbid,
&i.ArtistMbid,
&i.ArtistName,
&i.Title,
&i.PrimaryType,
&i.FirstReleaseDate,
&i.Reason,
&i.AlbumID,
&i.CreatedBy,
&i.WantedAt,
&i.FulfilledAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+2 -2
View File
@@ -1142,9 +1142,9 @@ contributions AS (
JOIN seeds ON seeds.artist_id = u.seed_artist_id
WHERE NOT EXISTS (SELECT 1 FROM artists WHERE mbid = u.candidate_mbid)
AND NOT EXISTS (
SELECT 1 FROM lidarr_requests r
SELECT 1 FROM requests r
WHERE r.user_id = $1
AND r.lidarr_artist_mbid = u.candidate_mbid
AND r.artist_mbid = u.candidate_mbid
AND r.status NOT IN ('rejected', 'failed')
)
-- Snoozed by this user and not yet expired (#2374). Time-boxed and
+528
View File
@@ -0,0 +1,528 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: requests.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const approveRequest = `-- name: ApproveRequest :one
UPDATE requests
SET status = 'approved',
decided_at = now(),
decided_by = $2,
updated_at = now()
WHERE id = $1 AND status = 'pending'
RETURNING id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at
`
type ApproveRequestParams struct {
ID pgtype.UUID
DecidedBy pgtype.UUID
}
func (q *Queries) ApproveRequest(ctx context.Context, arg ApproveRequestParams) (Request, error) {
row := q.db.QueryRow(ctx, approveRequest, arg.ID, arg.DecidedBy)
var i Request
err := row.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.ArtistMbid,
&i.AlbumMbid,
&i.TrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
)
return i, err
}
const cancelRequest = `-- name: CancelRequest :one
UPDATE requests
SET status = 'rejected',
notes = 'cancelled by user',
decided_at = now(),
decided_by = $2,
updated_at = now()
WHERE id = $1 AND user_id = $2 AND status = 'pending'
RETURNING id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at
`
type CancelRequestParams struct {
ID pgtype.UUID
DecidedBy pgtype.UUID
}
// $2 serves as both the ownership guard (user_id = $2) and the
// decided_by audit field. The generated parameter struct names it
// DecidedBy — callers must pass the cancelling user's UUID, and
// a wrong UUID will silently no-op (zero rows updated) rather than
// erroring at the SQL layer.
func (q *Queries) CancelRequest(ctx context.Context, arg CancelRequestParams) (Request, error) {
row := q.db.QueryRow(ctx, cancelRequest, arg.ID, arg.DecidedBy)
var i Request
err := row.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.ArtistMbid,
&i.AlbumMbid,
&i.TrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
)
return i, err
}
const completeRequest = `-- name: CompleteRequest :one
UPDATE requests
SET status = 'completed',
matched_track_id = $2,
matched_album_id = $3,
matched_artist_id = $4,
completed_at = now(),
updated_at = now()
WHERE id = $1 AND status = 'approved'
RETURNING id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at
`
type CompleteRequestParams struct {
ID pgtype.UUID
MatchedTrackID pgtype.UUID
MatchedAlbumID pgtype.UUID
MatchedArtistID pgtype.UUID
}
// Reconciler transitions an approved request to completed when its
// target track/album/artist has appeared in the library. Callers
// should set ONLY the matched_*_id corresponding to the request's
// kind and pass pgtype.UUID{} (Valid: false) for the others —
// per-kind one-of-three is the contract, NULL for the others
// preserves auditability if the matched row is later evicted.
func (q *Queries) CompleteRequest(ctx context.Context, arg CompleteRequestParams) (Request, error) {
row := q.db.QueryRow(ctx, completeRequest,
arg.ID,
arg.MatchedTrackID,
arg.MatchedAlbumID,
arg.MatchedArtistID,
)
var i Request
err := row.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.ArtistMbid,
&i.AlbumMbid,
&i.TrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
)
return i, err
}
const createRequest = `-- name: CreateRequest :one
INSERT INTO requests (
user_id, kind,
artist_mbid, album_mbid, track_mbid,
artist_name, album_title, track_title
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at
`
type CreateRequestParams struct {
UserID pgtype.UUID
Kind RequestKind
ArtistMbid string
AlbumMbid *string
TrackMbid *string
ArtistName string
AlbumTitle *string
TrackTitle *string
}
func (q *Queries) CreateRequest(ctx context.Context, arg CreateRequestParams) (Request, error) {
row := q.db.QueryRow(ctx, createRequest,
arg.UserID,
arg.Kind,
arg.ArtistMbid,
arg.AlbumMbid,
arg.TrackMbid,
arg.ArtistName,
arg.AlbumTitle,
arg.TrackTitle,
)
var i Request
err := row.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.ArtistMbid,
&i.AlbumMbid,
&i.TrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
)
return i, err
}
const getNonTerminalRequestForMBID = `-- name: GetNonTerminalRequestForMBID :one
SELECT id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at FROM requests
WHERE status IN ('pending', 'approved', 'completed')
AND ((kind = 'artist' AND artist_mbid = $1)
OR (kind = 'album' AND album_mbid = $1)
OR (kind = 'track' AND track_mbid = $1))
ORDER BY requested_at ASC
LIMIT 1
`
// Returns the oldest non-terminal (pending/approved/completed) request
// whose own-kind MBID matches @mbid, or pgx.ErrNoRows when none. Create()
// uses this to dedupe: a re-request for something already in flight
// returns the existing row instead of inserting a duplicate.
func (q *Queries) GetNonTerminalRequestForMBID(ctx context.Context, mbid string) (Request, error) {
row := q.db.QueryRow(ctx, getNonTerminalRequestForMBID, mbid)
var i Request
err := row.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.ArtistMbid,
&i.AlbumMbid,
&i.TrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
)
return i, err
}
const getRequestByID = `-- name: GetRequestByID :one
SELECT id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at FROM requests WHERE id = $1
`
func (q *Queries) GetRequestByID(ctx context.Context, id pgtype.UUID) (Request, error) {
row := q.db.QueryRow(ctx, getRequestByID, id)
var i Request
err := row.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.ArtistMbid,
&i.AlbumMbid,
&i.TrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
)
return i, err
}
const hasNonTerminalRequestForMBID = `-- name: HasNonTerminalRequestForMBID :one
SELECT (EXISTS (
SELECT 1 FROM requests r
WHERE r.status IN ('pending', 'approved', 'completed')
AND ((r.kind = 'artist' AND r.artist_mbid = $1)
OR (r.kind = 'album' AND r.album_mbid = $1)
OR (r.kind = 'track' AND r.track_mbid = $1))
) OR EXISTS (
SELECT 1 FROM wants w WHERE w.release_group_mbid = $1 AND w.fulfilled_at IS NULL
) OR EXISTS (
SELECT 1 FROM follows f WHERE f.artist_mbid = $1
))::bool AS requested
`
// Whether something is already on its way: a pending, approved or completed
// request at the given level, an open want for the release group, or a
// follow of the artist. Sets the `requested` flag on catalogue search.
// Terminal-status (rejected, failed) requests do not count. MBIDs are unique
// per entity type, so checking one id against every column cannot
// false-match.
func (q *Queries) HasNonTerminalRequestForMBID(ctx context.Context, mbid string) (bool, error) {
row := q.db.QueryRow(ctx, hasNonTerminalRequestForMBID, mbid)
var requested bool
err := row.Scan(&requested)
return requested, err
}
const listApprovedRequestsForReconcile = `-- name: ListApprovedRequestsForReconcile :many
SELECT id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at FROM requests
WHERE status = 'approved'
ORDER BY decided_at ASC
LIMIT $1
`
func (q *Queries) ListApprovedRequestsForReconcile(ctx context.Context, limit int32) ([]Request, error) {
rows, err := q.db.Query(ctx, listApprovedRequestsForReconcile, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Request
for rows.Next() {
var i Request
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.ArtistMbid,
&i.AlbumMbid,
&i.TrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRequestsByStatus = `-- name: ListRequestsByStatus :many
SELECT id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at FROM requests
WHERE status = $1
ORDER BY requested_at DESC
LIMIT $2
`
type ListRequestsByStatusParams struct {
Status RequestStatus
Limit int32
}
func (q *Queries) ListRequestsByStatus(ctx context.Context, arg ListRequestsByStatusParams) ([]Request, error) {
rows, err := q.db.Query(ctx, listRequestsByStatus, arg.Status, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Request
for rows.Next() {
var i Request
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.ArtistMbid,
&i.AlbumMbid,
&i.TrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRequestsForUser = `-- name: ListRequestsForUser :many
SELECT id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at FROM requests
WHERE user_id = $1
ORDER BY requested_at DESC
LIMIT $2
`
type ListRequestsForUserParams struct {
UserID pgtype.UUID
Limit int32
}
func (q *Queries) ListRequestsForUser(ctx context.Context, arg ListRequestsForUserParams) ([]Request, error) {
rows, err := q.db.Query(ctx, listRequestsForUser, arg.UserID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Request
for rows.Next() {
var i Request
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.ArtistMbid,
&i.AlbumMbid,
&i.TrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const rejectRequest = `-- name: RejectRequest :one
UPDATE requests
SET status = 'rejected',
notes = $2,
decided_at = now(),
decided_by = $3,
updated_at = now()
WHERE id = $1 AND status = 'pending'
RETURNING id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at
`
type RejectRequestParams struct {
ID pgtype.UUID
Notes *string
DecidedBy pgtype.UUID
}
func (q *Queries) RejectRequest(ctx context.Context, arg RejectRequestParams) (Request, error) {
row := q.db.QueryRow(ctx, rejectRequest, arg.ID, arg.Notes, arg.DecidedBy)
var i Request
err := row.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.ArtistMbid,
&i.AlbumMbid,
&i.TrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
)
return i, err
}
const repointRequestsAlbumMbid = `-- name: RepointRequestsAlbumMbid :exec
UPDATE requests
SET album_mbid = $1,
updated_at = now()
WHERE album_mbid = $2
`
type RepointRequestsAlbumMbidParams struct {
NewMbid *string
OldMbid *string
}
// A want moved from a release id to its group (#5241) takes the requests
// that name it along, so the reconciler finds it.
func (q *Queries) RepointRequestsAlbumMbid(ctx context.Context, arg RepointRequestsAlbumMbidParams) error {
_, err := q.db.Exec(ctx, repointRequestsAlbumMbid, arg.NewMbid, arg.OldMbid)
return err
}
-37
View File
@@ -239,43 +239,6 @@ func (q *Queries) GetListenBrainzConfig(ctx context.Context, id pgtype.UUID) (Ge
return i, err
}
const getOldestAdmin = `-- name: GetOldestAdmin :one
SELECT id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash FROM users
WHERE is_admin = true
ORDER BY created_at, id
LIMIT 1
`
// The account a system-initiated action is attributed to (milestone #290).
// lidarr_requests.user_id is NOT NULL and a re-acquisition has no requesting
// human, so the row is owned by the longest-standing admin: it keeps the
// request auditable and puts it in the same admin queue as everything else,
// without inventing a synthetic principal the rest of the schema would have
// to understand. Ordered by id as a tiebreak so the choice is stable across
// calls rather than depending on scan order.
func (q *Queries) GetOldestAdmin(ctx context.Context) (User, error) {
row := q.db.QueryRow(ctx, getOldestAdmin)
var i User
err := row.Scan(
&i.ID,
&i.Username,
&i.PasswordHash,
&i.IsAdmin,
&i.CreatedAt,
&i.SubsonicPassword,
&i.ListenbrainzToken,
&i.ListenbrainzEnabled,
&i.DisplayName,
&i.AutoApproveRequests,
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
&i.ApiTokenHash,
)
return i, err
}
const getUserByAPITokenHash = `-- name: GetUserByAPITokenHash :one
SELECT id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash FROM users WHERE api_token_hash = $1
`
+839
View File
@@ -0,0 +1,839 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: wanted.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const clearDefaultReleaseTypeProfile = `-- name: ClearDefaultReleaseTypeProfile :exec
UPDATE release_type_profiles SET is_default = false, updated_at = now() WHERE is_default
`
func (q *Queries) ClearDefaultReleaseTypeProfile(ctx context.Context) error {
_, err := q.db.Exec(ctx, clearDefaultReleaseTypeProfile)
return err
}
const countWants = `-- name: CountWants :one
SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::bigint AS wanted,
COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason = 'missing')::bigint AS missing,
COUNT(*) FILTER (WHERE fulfilled_at IS NOT NULL)::bigint AS held,
(SELECT COUNT(*) FROM follows)::bigint AS following
FROM wants
`
type CountWantsRow struct {
Wanted int64
Missing int64
Held int64
Following int64
}
func (q *Queries) CountWants(ctx context.Context) (CountWantsRow, error) {
row := q.db.QueryRow(ctx, countWants)
var i CountWantsRow
err := row.Scan(
&i.Wanted,
&i.Missing,
&i.Held,
&i.Following,
)
return i, err
}
const createReleaseTypeProfile = `-- name: CreateReleaseTypeProfile :one
INSERT INTO release_type_profiles (name, primary_types, secondary_types)
VALUES ($1, $2, $3)
RETURNING id, name, primary_types, secondary_types, is_default, created_at, updated_at
`
type CreateReleaseTypeProfileParams struct {
Name string
PrimaryTypes []string
SecondaryTypes []string
}
func (q *Queries) CreateReleaseTypeProfile(ctx context.Context, arg CreateReleaseTypeProfileParams) (ReleaseTypeProfile, error) {
row := q.db.QueryRow(ctx, createReleaseTypeProfile, arg.Name, arg.PrimaryTypes, arg.SecondaryTypes)
var i ReleaseTypeProfile
err := row.Scan(
&i.ID,
&i.Name,
&i.PrimaryTypes,
&i.SecondaryTypes,
&i.IsDefault,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteFollow = `-- name: DeleteFollow :execrows
DELETE FROM follows WHERE artist_mbid = $1
`
func (q *Queries) DeleteFollow(ctx context.Context, artistMbid string) (int64, error) {
result, err := q.db.Exec(ctx, deleteFollow, artistMbid)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const deleteReleaseTypeProfile = `-- name: DeleteReleaseTypeProfile :execrows
DELETE FROM release_type_profiles WHERE id = $1 AND NOT is_default
`
// The default stays; one in use is refused by the follows foreign key.
func (q *Queries) DeleteReleaseTypeProfile(ctx context.Context, id pgtype.UUID) (int64, error) {
result, err := q.db.Exec(ctx, deleteReleaseTypeProfile, id)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const deleteWant = `-- name: DeleteWant :execrows
DELETE FROM wants WHERE id = $1
`
func (q *Queries) DeleteWant(ctx context.Context, id pgtype.UUID) (int64, error) {
result, err := q.db.Exec(ctx, deleteWant, id)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const fulfillWant = `-- name: FulfillWant :exec
UPDATE wants
SET fulfilled_at = now(),
album_id = $1,
updated_at = now()
WHERE id = $2 AND fulfilled_at IS NULL
`
type FulfillWantParams struct {
AlbumID pgtype.UUID
ID pgtype.UUID
}
func (q *Queries) FulfillWant(ctx context.Context, arg FulfillWantParams) error {
_, err := q.db.Exec(ctx, fulfillWant, arg.AlbumID, arg.ID)
return err
}
const getDefaultReleaseTypeProfile = `-- name: GetDefaultReleaseTypeProfile :one
SELECT id, name, primary_types, secondary_types, is_default, created_at, updated_at FROM release_type_profiles WHERE is_default
`
func (q *Queries) GetDefaultReleaseTypeProfile(ctx context.Context) (ReleaseTypeProfile, error) {
row := q.db.QueryRow(ctx, getDefaultReleaseTypeProfile)
var i ReleaseTypeProfile
err := row.Scan(
&i.ID,
&i.Name,
&i.PrimaryTypes,
&i.SecondaryTypes,
&i.IsDefault,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getFollow = `-- name: GetFollow :one
SELECT f.artist_mbid, f.artist_name, f.profile_id, f.followed_by, f.followed_at, f.baseline_at, f.want_existing, f.checked_at, f.updated_at, p.name AS profile_name
FROM follows f
JOIN release_type_profiles p ON p.id = f.profile_id
WHERE f.artist_mbid = $1
`
type GetFollowRow struct {
ArtistMbid string
ArtistName string
ProfileID pgtype.UUID
FollowedBy pgtype.UUID
FollowedAt pgtype.Timestamptz
BaselineAt pgtype.Timestamptz
WantExisting bool
CheckedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
ProfileName string
}
func (q *Queries) GetFollow(ctx context.Context, artistMbid string) (GetFollowRow, error) {
row := q.db.QueryRow(ctx, getFollow, artistMbid)
var i GetFollowRow
err := row.Scan(
&i.ArtistMbid,
&i.ArtistName,
&i.ProfileID,
&i.FollowedBy,
&i.FollowedAt,
&i.BaselineAt,
&i.WantExisting,
&i.CheckedAt,
&i.UpdatedAt,
&i.ProfileName,
)
return i, err
}
const getReleaseTypeProfile = `-- name: GetReleaseTypeProfile :one
SELECT id, name, primary_types, secondary_types, is_default, created_at, updated_at FROM release_type_profiles WHERE id = $1
`
func (q *Queries) GetReleaseTypeProfile(ctx context.Context, id pgtype.UUID) (ReleaseTypeProfile, error) {
row := q.db.QueryRow(ctx, getReleaseTypeProfile, id)
var i ReleaseTypeProfile
err := row.Scan(
&i.ID,
&i.Name,
&i.PrimaryTypes,
&i.SecondaryTypes,
&i.IsDefault,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getWantByReleaseGroup = `-- name: GetWantByReleaseGroup :one
SELECT id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at FROM wants WHERE release_group_mbid = $1
`
func (q *Queries) GetWantByReleaseGroup(ctx context.Context, releaseGroupMbid string) (Want, error) {
row := q.db.QueryRow(ctx, getWantByReleaseGroup, releaseGroupMbid)
var i Want
err := row.Scan(
&i.ID,
&i.ReleaseGroupMbid,
&i.ArtistMbid,
&i.ArtistName,
&i.Title,
&i.PrimaryType,
&i.FirstReleaseDate,
&i.Reason,
&i.AlbumID,
&i.CreatedBy,
&i.WantedAt,
&i.FulfilledAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const libraryHoldsReleaseGroup = `-- name: LibraryHoldsReleaseGroup :one
SELECT EXISTS (
SELECT 1 FROM albums a
WHERE (a.release_group_mbid = $1::text
OR a.mbid = $1
OR a.mbid IN (SELECT r.mbid FROM mb_releases r WHERE r.release_group_mbid = $1)
OR (lower(a.title) = lower($2::text)
AND a.artist_id IN (SELECT ar.id FROM artists ar WHERE ar.mbid = $3::text)))
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id AND t.missing_since IS NULL)
)
`
type LibraryHoldsReleaseGroupParams struct {
GroupMbid string
Title string
ArtistMbid string
}
// Whether the library already has the group: an album tagged with it or
// with one of its releases, or failing that an album of the artist's with
// the same title. Only albums with a track on disk count.
func (q *Queries) LibraryHoldsReleaseGroup(ctx context.Context, arg LibraryHoldsReleaseGroupParams) (bool, error) {
row := q.db.QueryRow(ctx, libraryHoldsReleaseGroup, arg.GroupMbid, arg.Title, arg.ArtistMbid)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const listFollowGroups = `-- name: ListFollowGroups :many
SELECT g.mbid, g.title, g.primary_type, g.secondary_types, g.first_release_date,
EXISTS (SELECT 1 FROM follow_seen_groups s
WHERE s.artist_mbid = $1 AND s.release_group_mbid = g.mbid) AS seen
FROM mb_artist_release_groups ag
JOIN mb_release_groups g ON g.mbid = ag.release_group_mbid
WHERE ag.artist_mbid = $1
ORDER BY g.first_release_date, g.title
`
type ListFollowGroupsRow struct {
Mbid string
Title string
PrimaryType string
SecondaryTypes []string
FirstReleaseDate string
Seen bool
}
// An artist's release groups from the catalogue, each with whether the
// follow has judged it before.
func (q *Queries) ListFollowGroups(ctx context.Context, artistMbid string) ([]ListFollowGroupsRow, error) {
rows, err := q.db.Query(ctx, listFollowGroups, artistMbid)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListFollowGroupsRow
for rows.Next() {
var i ListFollowGroupsRow
if err := rows.Scan(
&i.Mbid,
&i.Title,
&i.PrimaryType,
&i.SecondaryTypes,
&i.FirstReleaseDate,
&i.Seen,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listFollows = `-- name: ListFollows :many
SELECT f.artist_mbid, f.artist_name, f.profile_id, f.followed_at, f.baseline_at, f.checked_at,
p.name AS profile_name,
(SELECT a.id FROM artists a WHERE a.mbid = f.artist_mbid) AS artist_id,
(SELECT COUNT(*) FROM wants w
WHERE w.artist_mbid = f.artist_mbid AND w.fulfilled_at IS NULL)::bigint AS open_wants
FROM follows f
JOIN release_type_profiles p ON p.id = f.profile_id
ORDER BY lower(f.artist_name), f.artist_mbid
`
type ListFollowsRow struct {
ArtistMbid string
ArtistName string
ProfileID pgtype.UUID
FollowedAt pgtype.Timestamptz
BaselineAt pgtype.Timestamptz
CheckedAt pgtype.Timestamptz
ProfileName string
ArtistID pgtype.UUID
OpenWants int64
}
func (q *Queries) ListFollows(ctx context.Context) ([]ListFollowsRow, error) {
rows, err := q.db.Query(ctx, listFollows)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListFollowsRow
for rows.Next() {
var i ListFollowsRow
if err := rows.Scan(
&i.ArtistMbid,
&i.ArtistName,
&i.ProfileID,
&i.FollowedAt,
&i.BaselineAt,
&i.CheckedAt,
&i.ProfileName,
&i.ArtistID,
&i.OpenWants,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listFollowsDue = `-- name: ListFollowsDue :many
SELECT f.artist_mbid, f.artist_name, f.profile_id, f.followed_by, f.followed_at, f.baseline_at, f.want_existing, f.checked_at, f.updated_at
FROM follows f
WHERE f.baseline_at IS NULL OR f.checked_at IS NULL OR f.checked_at < $1
ORDER BY f.baseline_at NULLS FIRST, f.checked_at NULLS FIRST, f.artist_mbid
LIMIT $2
`
type ListFollowsDueParams struct {
Before pgtype.Timestamptz
MaxRows int32
}
// Follows whose discography has not been applied since @before, never-read
// first, so a new follow's baseline is not stuck behind daily re-checks.
func (q *Queries) ListFollowsDue(ctx context.Context, arg ListFollowsDueParams) ([]Follow, error) {
rows, err := q.db.Query(ctx, listFollowsDue, arg.Before, arg.MaxRows)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Follow
for rows.Next() {
var i Follow
if err := rows.Scan(
&i.ArtistMbid,
&i.ArtistName,
&i.ProfileID,
&i.FollowedBy,
&i.FollowedAt,
&i.BaselineAt,
&i.WantExisting,
&i.CheckedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listOpenWants = `-- name: ListOpenWants :many
SELECT id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at FROM wants
WHERE fulfilled_at IS NULL
ORDER BY wanted_at, id
LIMIT $1
`
func (q *Queries) ListOpenWants(ctx context.Context, limit int32) ([]Want, error) {
rows, err := q.db.Query(ctx, listOpenWants, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Want
for rows.Next() {
var i Want
if err := rows.Scan(
&i.ID,
&i.ReleaseGroupMbid,
&i.ArtistMbid,
&i.ArtistName,
&i.Title,
&i.PrimaryType,
&i.FirstReleaseDate,
&i.Reason,
&i.AlbumID,
&i.CreatedBy,
&i.WantedAt,
&i.FulfilledAt,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listReleaseTypeProfiles = `-- name: ListReleaseTypeProfiles :many
SELECT p.id, p.name, p.primary_types, p.secondary_types, p.is_default, p.created_at, p.updated_at,
(SELECT COUNT(*) FROM follows f WHERE f.profile_id = p.id)::bigint AS follow_count
FROM release_type_profiles p
ORDER BY p.is_default DESC, p.name
`
type ListReleaseTypeProfilesRow struct {
ID pgtype.UUID
Name string
PrimaryTypes []string
SecondaryTypes []string
IsDefault bool
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
FollowCount int64
}
// The wanted list (M502 #5520): release-type profiles, follows and wants.
func (q *Queries) ListReleaseTypeProfiles(ctx context.Context) ([]ListReleaseTypeProfilesRow, error) {
rows, err := q.db.Query(ctx, listReleaseTypeProfiles)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListReleaseTypeProfilesRow
for rows.Next() {
var i ListReleaseTypeProfilesRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.PrimaryTypes,
&i.SecondaryTypes,
&i.IsDefault,
&i.CreatedAt,
&i.UpdatedAt,
&i.FollowCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listWants = `-- name: ListWants :many
SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reason,
w.album_id, w.wanted_at, w.fulfilled_at,
-- A request names an album before the catalogue has read it: the
-- catalogue's type and date fill in once it has.
COALESCE(NULLIF(w.primary_type, ''), g.primary_type, '')::text AS primary_type,
COALESCE(g.secondary_types, '{}')::text[] AS secondary_types,
COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, '')::text AS first_release_date,
u.username AS requested_by,
(SELECT ar.id FROM artists ar WHERE ar.mbid = w.artist_mbid) AS library_artist_id
FROM wants w
LEFT JOIN users u ON u.id = w.created_by
LEFT JOIN mb_release_groups g ON g.mbid = w.release_group_mbid
WHERE CASE $1::text
WHEN 'held' THEN w.fulfilled_at IS NOT NULL
WHEN 'missing' THEN w.fulfilled_at IS NULL AND w.reason = 'missing'
ELSE w.fulfilled_at IS NULL AND w.reason <> 'missing'
END
ORDER BY COALESCE(w.fulfilled_at, w.wanted_at) DESC, w.id
LIMIT $3 OFFSET $2
`
type ListWantsParams struct {
State string
Skip int32
MaxRows int32
}
type ListWantsRow struct {
ID pgtype.UUID
ReleaseGroupMbid string
ArtistMbid string
ArtistName string
Title string
Reason string
AlbumID pgtype.UUID
WantedAt pgtype.Timestamptz
FulfilledAt pgtype.Timestamptz
PrimaryType string
SecondaryTypes []string
FirstReleaseDate string
RequestedBy *string
LibraryArtistID pgtype.UUID
}
// The Wanted page: open wants (all but missing ones, or only missing ones),
// or what was held most recently.
func (q *Queries) ListWants(ctx context.Context, arg ListWantsParams) ([]ListWantsRow, error) {
rows, err := q.db.Query(ctx, listWants, arg.State, arg.Skip, arg.MaxRows)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListWantsRow
for rows.Next() {
var i ListWantsRow
if err := rows.Scan(
&i.ID,
&i.ReleaseGroupMbid,
&i.ArtistMbid,
&i.ArtistName,
&i.Title,
&i.Reason,
&i.AlbumID,
&i.WantedAt,
&i.FulfilledAt,
&i.PrimaryType,
&i.SecondaryTypes,
&i.FirstReleaseDate,
&i.RequestedBy,
&i.LibraryArtistID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markFollowChecked = `-- name: MarkFollowChecked :exec
UPDATE follows
SET checked_at = now(),
baseline_at = COALESCE(baseline_at, now()),
want_existing = false,
updated_at = now()
WHERE artist_mbid = $1
`
func (q *Queries) MarkFollowChecked(ctx context.Context, artistMbid string) error {
_, err := q.db.Exec(ctx, markFollowChecked, artistMbid)
return err
}
const markGroupSeen = `-- name: MarkGroupSeen :exec
INSERT INTO follow_seen_groups (artist_mbid, release_group_mbid)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
`
type MarkGroupSeenParams struct {
ArtistMbid string
ReleaseGroupMbid string
}
func (q *Queries) MarkGroupSeen(ctx context.Context, arg MarkGroupSeenParams) error {
_, err := q.db.Exec(ctx, markGroupSeen, arg.ArtistMbid, arg.ReleaseGroupMbid)
return err
}
const repointWant = `-- name: RepointWant :exec
UPDATE wants SET release_group_mbid = $2, updated_at = now() WHERE id = $1
`
type RepointWantParams struct {
ID pgtype.UUID
ReleaseGroupMbid string
}
// A want recorded under a release id before #5241 is moved to its group.
func (q *Queries) RepointWant(ctx context.Context, arg RepointWantParams) error {
_, err := q.db.Exec(ctx, repointWant, arg.ID, arg.ReleaseGroupMbid)
return err
}
const setDefaultReleaseTypeProfile = `-- name: SetDefaultReleaseTypeProfile :execrows
UPDATE release_type_profiles SET is_default = true, updated_at = now() WHERE id = $1
`
func (q *Queries) SetDefaultReleaseTypeProfile(ctx context.Context, id pgtype.UUID) (int64, error) {
result, err := q.db.Exec(ctx, setDefaultReleaseTypeProfile, id)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const updateReleaseTypeProfile = `-- name: UpdateReleaseTypeProfile :one
UPDATE release_type_profiles
SET name = $2,
primary_types = $3,
secondary_types = $4,
updated_at = now()
WHERE id = $1
RETURNING id, name, primary_types, secondary_types, is_default, created_at, updated_at
`
type UpdateReleaseTypeProfileParams struct {
ID pgtype.UUID
Name string
PrimaryTypes []string
SecondaryTypes []string
}
func (q *Queries) UpdateReleaseTypeProfile(ctx context.Context, arg UpdateReleaseTypeProfileParams) (ReleaseTypeProfile, error) {
row := q.db.QueryRow(ctx, updateReleaseTypeProfile,
arg.ID,
arg.Name,
arg.PrimaryTypes,
arg.SecondaryTypes,
)
var i ReleaseTypeProfile
err := row.Scan(
&i.ID,
&i.Name,
&i.PrimaryTypes,
&i.SecondaryTypes,
&i.IsDefault,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const upsertFollow = `-- name: UpsertFollow :one
INSERT INTO follows (artist_mbid, artist_name, profile_id, followed_by, want_existing)
VALUES ($1, $2, $3,
$4, $5)
ON CONFLICT (artist_mbid) DO UPDATE
SET artist_name = EXCLUDED.artist_name,
profile_id = EXCLUDED.profile_id,
want_existing = follows.want_existing OR EXCLUDED.want_existing,
baseline_at = CASE WHEN EXCLUDED.want_existing THEN NULL ELSE follows.baseline_at END,
updated_at = now()
RETURNING artist_mbid, artist_name, profile_id, followed_by, followed_at, baseline_at, want_existing, checked_at, updated_at
`
type UpsertFollowParams struct {
ArtistMbid string
ArtistName string
ProfileID pgtype.UUID
FollowedBy pgtype.UUID
WantExisting bool
}
// Following an artist already followed changes its profile. want_existing
// only ever turns on here; the worker turns it off once applied, and turning
// it on again re-reads the baseline so the existing releases are judged.
func (q *Queries) UpsertFollow(ctx context.Context, arg UpsertFollowParams) (Follow, error) {
row := q.db.QueryRow(ctx, upsertFollow,
arg.ArtistMbid,
arg.ArtistName,
arg.ProfileID,
arg.FollowedBy,
arg.WantExisting,
)
var i Follow
err := row.Scan(
&i.ArtistMbid,
&i.ArtistName,
&i.ProfileID,
&i.FollowedBy,
&i.FollowedAt,
&i.BaselineAt,
&i.WantExisting,
&i.CheckedAt,
&i.UpdatedAt,
)
return i, err
}
const wantHoldings = `-- name: WantHoldings :many
SELECT a.id AS album_id,
COUNT(*) FILTER (WHERE t.missing_since IS NULL)::int AS present,
COALESCE(bool_or(t.missing_since IS NULL AND t.added_at > $1::timestamptz), false)::bool AS arrived_since,
COALESCE(bool_or(t.missing_since IS NOT NULL AND t.missing_since < $1::timestamptz), false)::bool AS missing_before,
-- Missing tracks no track on disk stands in for, by position or
-- title. A missing row whose song is back at a new path (#5245) is
-- covered; a lost bonus track is not.
COUNT(*) FILTER (WHERE t.missing_since IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM tracks p
WHERE p.album_id = a.id AND p.missing_since IS NULL
AND ((p.track_number = t.track_number AND p.disc_number IS NOT DISTINCT FROM t.disc_number)
OR lower(p.title) = lower(t.title))))::int AS uncovered_missing
FROM albums a
JOIN tracks t ON t.album_id = a.id
WHERE a.release_group_mbid = $2::text
OR a.mbid = $2
OR a.id = $3
OR a.mbid IN (SELECT r.mbid FROM mb_releases r WHERE r.release_group_mbid = $2)
GROUP BY a.id
ORDER BY present DESC, a.id
`
type WantHoldingsParams struct {
Since pgtype.Timestamptz
GroupMbid string
AlbumID pgtype.UUID
}
type WantHoldingsRow struct {
AlbumID pgtype.UUID
Present int32
ArrivedSince bool
MissingBefore bool
UncoveredMissing int32
}
// Each library album that may answer a want, with what it has on disk:
// tracks present, whether one arrived since the want, and whether a track
// missing before the want is still missing.
func (q *Queries) WantHoldings(ctx context.Context, arg WantHoldingsParams) ([]WantHoldingsRow, error) {
rows, err := q.db.Query(ctx, wantHoldings, arg.Since, arg.GroupMbid, arg.AlbumID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []WantHoldingsRow
for rows.Next() {
var i WantHoldingsRow
if err := rows.Scan(
&i.AlbumID,
&i.Present,
&i.ArrivedSince,
&i.MissingBefore,
&i.UncoveredMissing,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const wantReleaseGroup = `-- name: WantReleaseGroup :one
INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, primary_type,
first_release_date, reason, created_by)
VALUES ($1, $2, $3, $4,
$5, $6, $7, $8)
ON CONFLICT (release_group_mbid) DO UPDATE SET updated_at = wants.updated_at
RETURNING id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at
`
type WantReleaseGroupParams struct {
ReleaseGroupMbid string
ArtistMbid string
ArtistName string
Title string
PrimaryType string
FirstReleaseDate string
Reason string
CreatedBy pgtype.UUID
}
// Wants a release group, or returns the want already recorded for it. A
// held want is not reopened here: the library has it.
func (q *Queries) WantReleaseGroup(ctx context.Context, arg WantReleaseGroupParams) (Want, error) {
row := q.db.QueryRow(ctx, wantReleaseGroup,
arg.ReleaseGroupMbid,
arg.ArtistMbid,
arg.ArtistName,
arg.Title,
arg.PrimaryType,
arg.FirstReleaseDate,
arg.Reason,
arg.CreatedBy,
)
var i Want
err := row.Scan(
&i.ID,
&i.ReleaseGroupMbid,
&i.ArtistMbid,
&i.ArtistName,
&i.Title,
&i.PrimaryType,
&i.FirstReleaseDate,
&i.Reason,
&i.AlbumID,
&i.CreatedBy,
&i.WantedAt,
&i.FulfilledAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
@@ -0,0 +1,38 @@
ALTER TABLE reacquisition_settings
ADD COLUMN backoff_base_hours int NOT NULL DEFAULT 6,
ADD COLUMN backoff_max_hours int NOT NULL DEFAULT 168,
ADD COLUMN max_attempts int NOT NULL DEFAULT 3,
ADD COLUMN auto_approve boolean NOT NULL DEFAULT true;
ALTER INDEX requests_user_id_idx RENAME TO lidarr_requests_user_id_idx;
ALTER INDEX requests_status_idx RENAME TO lidarr_requests_status_idx;
ALTER INDEX requests_artist_mbid_idx RENAME TO lidarr_requests_artist_mbid_idx;
ALTER INDEX requests_album_mbid_idx RENAME TO lidarr_requests_album_mbid_idx;
ALTER TABLE requests ADD COLUMN lidarr_add_confirmed_at timestamptz;
ALTER TABLE requests ADD COLUMN root_folder_path text;
ALTER TABLE requests ADD COLUMN quality_profile_id int;
ALTER TABLE requests RENAME COLUMN track_mbid TO lidarr_track_mbid;
ALTER TABLE requests RENAME COLUMN album_mbid TO lidarr_album_mbid;
ALTER TABLE requests RENAME COLUMN artist_mbid TO lidarr_artist_mbid;
ALTER TYPE request_kind RENAME TO lidarr_request_kind;
ALTER TYPE request_status RENAME TO lidarr_request_status;
ALTER TABLE requests RENAME TO lidarr_requests;
CREATE TABLE missing_reacquisitions (
album_id uuid PRIMARY KEY REFERENCES albums (id) ON DELETE CASCADE,
attempts int NOT NULL DEFAULT 0,
last_attempt_at timestamptz,
last_request_id uuid REFERENCES lidarr_requests (id) ON DELETE SET NULL,
gave_up_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT missing_reacquisitions_attempts_nonneg CHECK (attempts >= 0)
);
CREATE INDEX missing_reacquisitions_due_idx
ON missing_reacquisitions (last_attempt_at NULLS FIRST)
WHERE gave_up_at IS NULL;
DROP TABLE wants;
DROP TABLE follow_seen_groups;
DROP TABLE follows;
DROP TABLE release_type_profiles;
+165
View File
@@ -0,0 +1,165 @@
-- 0081_wanted.up.sql — the wanted list (Scribe milestone #502, #5520).
--
-- With Lidarr gone, Minstrel keeps its own record of what it is looking for:
--
-- release_type_profiles which kinds of release a follow wants: primary
-- types (Album, EP, Single…) and the secondary types
-- allowed (Live, Compilation, Remix…). An empty
-- secondary list means studio releases only, which
-- is how live recordings are left out.
-- follows artists whose new releases are wanted, each on a
-- profile.
-- follow_seen_groups release groups a follow has already judged, so
-- each is judged once.
-- wants release groups wanted: a request, a followed
-- artist's new release, or an album whose files went
-- missing. A want is held once the library has a
-- full copy (internal/wanted).
--
-- lidarr_requests becomes requests with no Lidarr columns: approving a request
-- now records a want or a follow instead of sending an add to Lidarr. The
-- missing-file sweeper's attempt bookkeeping goes too; retrying belongs to the
-- search schedule (#5521), which works per want.
CREATE TABLE release_type_profiles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL UNIQUE,
primary_types text[] NOT NULL,
secondary_types text[] NOT NULL DEFAULT '{}',
is_default boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT release_type_profiles_primary_nonempty CHECK (cardinality(primary_types) > 0)
);
-- At most one default; the service keeps exactly one by moving it.
CREATE UNIQUE INDEX release_type_profiles_one_default
ON release_type_profiles (is_default) WHERE is_default;
-- Lidarr's "Standard" metadata profile, which every followed artist used
-- (note #5527): albums, EPs and singles, studio releases only.
INSERT INTO release_type_profiles (name, primary_types, secondary_types, is_default)
VALUES ('Standard', ARRAY['Album', 'EP', 'Single'], '{}', true);
CREATE TABLE follows (
artist_mbid text PRIMARY KEY,
artist_name text NOT NULL,
-- RESTRICT: a profile in use cannot be deleted out from under a follow.
profile_id uuid NOT NULL REFERENCES release_type_profiles (id) ON DELETE RESTRICT,
followed_by uuid REFERENCES users (id) ON DELETE SET NULL,
followed_at timestamptz NOT NULL DEFAULT now(),
-- When the first discography read marked everything then listed as seen.
-- NULL until then: a follow wants only what appears after it.
baseline_at timestamptz,
-- Whether the baseline also wants what the artist had already released
-- that the profile passes and the library lacks: an approved artist
-- request, or the operator ticking "also want…". Cleared once applied.
want_existing boolean NOT NULL DEFAULT false,
-- The discography read this follow last applied.
checked_at timestamptz,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX follows_profile_idx ON follows (profile_id);
CREATE TABLE follow_seen_groups (
artist_mbid text NOT NULL REFERENCES follows (artist_mbid) ON DELETE CASCADE,
release_group_mbid text NOT NULL,
seen_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (artist_mbid, release_group_mbid)
);
CREATE TABLE wants (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
release_group_mbid text NOT NULL UNIQUE,
artist_mbid text NOT NULL,
artist_name text NOT NULL,
title text NOT NULL,
primary_type text NOT NULL DEFAULT '',
first_release_date text NOT NULL DEFAULT '',
reason text NOT NULL
CONSTRAINT wants_reason_check CHECK (reason IN ('request', 'follow', 'missing')),
-- The library album this want is about (a missing want), or the one that
-- answered it.
album_id uuid REFERENCES albums (id) ON DELETE SET NULL,
created_by uuid REFERENCES users (id) ON DELETE SET NULL,
-- When it was last wanted: set again when a held want is reopened, so
-- "arrived since it was wanted" measures from the reopening.
wanted_at timestamptz NOT NULL DEFAULT now(),
fulfilled_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX wants_open_idx ON wants (wanted_at) WHERE fulfilled_at IS NULL;
CREATE INDEX wants_artist_idx ON wants (artist_mbid);
-- Requests lose Lidarr.
DROP TABLE missing_reacquisitions;
ALTER TABLE lidarr_requests RENAME TO requests;
ALTER TYPE lidarr_request_status RENAME TO request_status;
ALTER TYPE lidarr_request_kind RENAME TO request_kind;
ALTER TABLE requests RENAME COLUMN lidarr_artist_mbid TO artist_mbid;
ALTER TABLE requests RENAME COLUMN lidarr_album_mbid TO album_mbid;
ALTER TABLE requests RENAME COLUMN lidarr_track_mbid TO track_mbid;
ALTER TABLE requests DROP COLUMN quality_profile_id;
ALTER TABLE requests DROP COLUMN root_folder_path;
ALTER TABLE requests DROP COLUMN lidarr_add_confirmed_at;
ALTER INDEX lidarr_requests_user_id_idx RENAME TO requests_user_id_idx;
ALTER INDEX lidarr_requests_status_idx RENAME TO requests_status_idx;
ALTER INDEX lidarr_requests_artist_mbid_idx RENAME TO requests_artist_mbid_idx;
ALTER INDEX lidarr_requests_album_mbid_idx RENAME TO requests_album_mbid_idx;
-- Requests made before #5241 name albums by release id. A want is keyed by
-- release group, so repoint every request the library can translate.
UPDATE requests r
SET album_mbid = a.release_group_mbid,
updated_at = now()
FROM albums a
WHERE a.mbid = r.album_mbid
AND a.release_group_mbid IS NOT NULL
AND a.release_group_mbid <> r.album_mbid;
-- Approved album and track requests still waiting become wants, judged by
-- the same test as any other (internal/wanted).
INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, reason, created_by, wanted_at)
SELECT DISTINCT ON (r.album_mbid)
r.album_mbid, r.artist_mbid, r.artist_name, COALESCE(r.album_title, ''), 'request', r.user_id, r.requested_at
FROM requests r
WHERE r.status = 'approved'
AND r.kind IN ('album', 'track')
AND r.album_mbid IS NOT NULL
ORDER BY r.album_mbid, r.requested_at
ON CONFLICT (release_group_mbid) DO NOTHING;
-- The library's album artists start out followed on the default profile
-- (operator, 2026-10-09: "we already have artists in minstrel those start as
-- the wanted artists"). Various Artists and MusicBrainz's bracketed
-- placeholders ([unknown], [anonymous]…) are not artists anyone follows.
INSERT INTO follows (artist_mbid, artist_name, profile_id)
SELECT ar.mbid, ar.name, p.id
FROM artists ar
CROSS JOIN (SELECT id FROM release_type_profiles WHERE is_default) p
WHERE ar.mbid IS NOT NULL AND ar.mbid <> ''
AND ar.mbid <> '89ad4ac3-39f7-470e-963a-56509c546377'
AND ar.name NOT LIKE '[%]'
AND EXISTS (SELECT 1 FROM albums al WHERE al.artist_id = ar.id)
ON CONFLICT (artist_mbid) DO NOTHING;
-- An approved artist request was an artist added to Lidarr: it is followed.
INSERT INTO follows (artist_mbid, artist_name, profile_id, followed_by)
SELECT DISTINCT ON (r.artist_mbid) r.artist_mbid, r.artist_name, p.id, r.decided_by
FROM requests r
CROSS JOIN (SELECT id FROM release_type_profiles WHERE is_default) p
WHERE r.status = 'approved' AND r.kind = 'artist'
ORDER BY r.artist_mbid, r.requested_at
ON CONFLICT (artist_mbid) DO NOTHING;
-- The sweeper now only records a want; how often to look again is the
-- search schedule's business.
ALTER TABLE reacquisition_settings
DROP COLUMN backoff_base_hours,
DROP COLUMN backoff_max_hours,
DROP COLUMN max_attempts,
DROP COLUMN auto_approve;
-130
View File
@@ -1,130 +0,0 @@
-- name: CreateLidarrRequest :one
INSERT INTO lidarr_requests (
user_id, kind,
lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid,
artist_name, album_title, track_title
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *;
-- name: GetLidarrRequestByID :one
SELECT * FROM lidarr_requests WHERE id = $1;
-- name: ListLidarrRequestsForUser :many
SELECT * FROM lidarr_requests
WHERE user_id = $1
ORDER BY requested_at DESC
LIMIT $2;
-- name: ListLidarrRequestsByStatus :many
SELECT * FROM lidarr_requests
WHERE status = $1
ORDER BY requested_at DESC
LIMIT $2;
-- name: ListApprovedLidarrRequestsForReconcile :many
SELECT * FROM lidarr_requests
WHERE status = 'approved'
ORDER BY decided_at ASC
LIMIT $1;
-- name: ApproveLidarrRequest :one
UPDATE lidarr_requests
SET status = 'approved',
quality_profile_id = $2,
root_folder_path = $3,
decided_at = now(),
decided_by = $4,
updated_at = now()
WHERE id = $1 AND status = 'pending'
RETURNING *;
-- name: RejectLidarrRequest :one
UPDATE lidarr_requests
SET status = 'rejected',
notes = $2,
decided_at = now(),
decided_by = $3,
updated_at = now()
WHERE id = $1 AND status = 'pending'
RETURNING *;
-- name: CancelLidarrRequest :one
-- $2 serves as both the ownership guard (user_id = $2) and the
-- decided_by audit field. The generated parameter struct names it
-- DecidedBy — callers must pass the cancelling user's UUID, and
-- a wrong UUID will silently no-op (zero rows updated) rather than
-- erroring at the SQL layer.
UPDATE lidarr_requests
SET status = 'rejected',
notes = 'cancelled by user',
decided_at = now(),
decided_by = $2,
updated_at = now()
WHERE id = $1 AND user_id = $2 AND status = 'pending'
RETURNING *;
-- name: CompleteLidarrRequest :one
-- Reconciler transitions an approved request to completed when its
-- target track/album/artist has appeared in the library. Callers
-- should set ONLY the matched_*_id corresponding to the request's
-- kind and pass pgtype.UUID{} (Valid: false) for the others —
-- per-kind one-of-three is the contract, NULL for the others
-- preserves auditability if the matched row is later evicted.
UPDATE lidarr_requests
SET status = 'completed',
matched_track_id = $2,
matched_album_id = $3,
matched_artist_id = $4,
completed_at = now(),
updated_at = now()
WHERE id = $1 AND status = 'approved'
RETURNING *;
-- name: HasNonTerminalRequestForMBID :one
-- Returns true if any user has a pending/approved/completed request
-- whose MBID matches at the given level. Used to set the `requested`
-- flag on /api/lidarr/search responses. Terminal-status (rejected,
-- failed) rows do not count. The same MBID parameter is checked
-- against artist/album/track columns based on kind.
SELECT EXISTS (
SELECT 1 FROM lidarr_requests
WHERE status IN ('pending', 'approved', 'completed')
AND ((kind = 'artist' AND lidarr_artist_mbid = @mbid)
OR (kind = 'album' AND lidarr_album_mbid = @mbid)
OR (kind = 'track' AND lidarr_track_mbid = @mbid))
);
-- name: GetNonTerminalRequestForMBID :one
-- Returns the oldest non-terminal (pending/approved/completed) request
-- whose own-kind MBID matches @mbid, or pgx.ErrNoRows when none. Create()
-- uses this to dedupe: a re-request for something already in flight
-- returns the existing row instead of inserting a duplicate. MBIDs are
-- globally unique per entity type, so the cross-column OR cannot
-- false-match (an artist MBID never collides with an album/track MBID).
SELECT * FROM lidarr_requests
WHERE status IN ('pending', 'approved', 'completed')
AND ((kind = 'artist' AND lidarr_artist_mbid = @mbid)
OR (kind = 'album' AND lidarr_album_mbid = @mbid)
OR (kind = 'track' AND lidarr_track_mbid = @mbid))
ORDER BY requested_at ASC
LIMIT 1;
-- name: MarkLidarrRequestAddConfirmed :exec
-- Idempotently records that Lidarr accepted the add for an approved
-- request. The lidarr_add_confirmed_at IS NULL guard makes a redundant
-- reconciler retry a no-op rather than bumping updated_at every tick.
UPDATE lidarr_requests
SET lidarr_add_confirmed_at = now(),
updated_at = now()
WHERE id = $1
AND status = 'approved'
AND lidarr_add_confirmed_at IS NULL;
-- name: SetLidarrRequestAlbumMbid :exec
-- #5241: repoint a request at the album's release group. Requests made before
-- the release-group read named albums by MusicBrainz release id, which Lidarr
-- does not index; the reconciler rewrites them once it has resolved the group.
UPDATE lidarr_requests
SET lidarr_album_mbid = $2,
updated_at = now()
WHERE id = $1;
+2 -2
View File
@@ -35,8 +35,8 @@ UPDATE contextual_likes SET track_id = sqlc.arg(survivor_id)::uuid WHERE track_i
-- name: MergeRepointPlaybackErrors :execrows
UPDATE playback_errors SET track_id = sqlc.arg(survivor_id)::uuid WHERE track_id = sqlc.arg(loser_id)::uuid;
-- name: MergeRepointLidarrRequests :execrows
UPDATE lidarr_requests SET matched_track_id = sqlc.arg(survivor_id)::uuid
-- name: MergeRepointRequests :execrows
UPDATE requests SET matched_track_id = sqlc.arg(survivor_id)::uuid
WHERE matched_track_id = sqlc.arg(loser_id)::uuid;
-- name: MergeRepointPlaylistTracks :many
+48 -83
View File
@@ -1,6 +1,6 @@
-- Auto re-acquisition of missing files (milestone #290). The unit is the
-- album: Lidarr acquires releases, and grouping collapses "40 missing files"
-- into "3 albums to ask for".
-- Re-acquisition of missing files (milestone #290, reworked for M502 #5520).
-- The unit is the album: an album whose files went missing becomes a want for
-- its release group, and the search schedule (#5521) looks for it.
-- name: GetReacquisitionSettings :one
SELECT * FROM reacquisition_settings WHERE id = true;
@@ -9,13 +9,9 @@ SELECT * FROM reacquisition_settings WHERE id = true;
-- Whole-row write from the admin card; the CHECKs in migration 0056 are the
-- validation, so a bad value fails loudly rather than being clamped silently.
UPDATE reacquisition_settings
SET enabled = sqlc.arg(enabled),
grace_hours = sqlc.arg(grace_hours),
backoff_base_hours = sqlc.arg(backoff_base_hours),
backoff_max_hours = sqlc.arg(backoff_max_hours),
max_attempts = sqlc.arg(max_attempts),
max_per_pass = sqlc.arg(max_per_pass),
auto_approve = sqlc.arg(auto_approve)
SET enabled = sqlc.arg(enabled),
grace_hours = sqlc.arg(grace_hours),
max_per_pass = sqlc.arg(max_per_pass)
WHERE id = true
RETURNING *;
@@ -24,54 +20,40 @@ RETURNING *;
--
-- * it still has at least one track whose file has been missing longer than
-- the grace window. Measured on missing_since, which reconcile never
-- re-stamps (#2523), so it is a genuine "gone since" clock rather than
-- "when we last noticed";
-- * MusicBrainz can name it. Both the album and its artist MBID are
-- required — Create rejects an album-kind request without them, and there
-- is nothing to ask Lidarr for anyway. Albums failing this are counted
-- separately (CountAlbumsMissingWithoutMbid) rather than vanishing;
-- * it has not spent its attempt budget (gave_up_at IS NULL);
-- * its backoff has elapsed: base * 2^(attempts-1) hours since the last
-- attempt, clamped to the configured maximum. First attempt (no row, or
-- last_attempt_at NULL) is always due.
--
-- Oldest attempt first, never-attempted first, so a large loss drains in a
-- stable order across passes instead of re-picking the same head each time.
SELECT albums.id AS album_id,
albums.title AS album_title,
albums.mbid AS album_mbid,
-- re-stamps (#2523), so it is a genuine "gone since" clock;
-- * MusicBrainz can name it: the album and its artist have MBIDs. Albums
-- failing this are counted separately (CountAlbumsMissingWithoutMbid);
-- * no want already covers it: none open, and none held since its files
-- went missing. A want held after the loss means the library has a full
-- copy despite the missing rows (#5245), so asking again would loop.
SELECT albums.id AS album_id,
albums.title AS album_title,
albums.mbid AS album_mbid,
albums.release_group_mbid AS album_release_group_mbid,
artists.id AS artist_id,
artists.name AS artist_name,
artists.mbid AS artist_mbid,
COUNT(tracks.id)::bigint AS missing_track_count,
COALESCE(r.attempts, 0)::int AS attempts
artists.name AS artist_name,
artists.mbid AS artist_mbid,
COUNT(tracks.id)::bigint AS missing_track_count,
MAX(tracks.missing_since)::timestamptz AS last_missing_since
FROM albums
JOIN artists ON artists.id = albums.artist_id
JOIN tracks ON tracks.album_id = albums.id
LEFT JOIN missing_reacquisitions r ON r.album_id = albums.id
WHERE tracks.missing_since IS NOT NULL
AND tracks.missing_since <= now() - make_interval(hours => sqlc.arg(grace_hours)::int)
AND albums.mbid IS NOT NULL
AND artists.mbid IS NOT NULL
AND (r.gave_up_at IS NULL)
AND (
r.last_attempt_at IS NULL
OR r.last_attempt_at <= now() - make_interval(hours => LEAST(
(sqlc.arg(backoff_base_hours)::int
* POWER(2, GREATEST(COALESCE(r.attempts, 0) - 1, 0)))::int,
sqlc.arg(backoff_max_hours)::int))
)
GROUP BY albums.id, albums.title, albums.mbid, albums.release_group_mbid,
artists.id, artists.name, artists.mbid, r.attempts, r.last_attempt_at
ORDER BY r.last_attempt_at NULLS FIRST, albums.sort_title
artists.name, artists.mbid
HAVING NOT EXISTS (
SELECT 1 FROM wants w
WHERE (w.album_id = albums.id OR w.release_group_mbid = albums.release_group_mbid)
AND (w.fulfilled_at IS NULL OR w.fulfilled_at > MAX(tracks.missing_since)))
ORDER BY MIN(tracks.missing_since), albums.sort_title
LIMIT sqlc.arg(page_limit);
-- name: CountAlbumsMissingWithoutMbid :one
-- Albums with missing files that can never be auto-requested because nothing
-- identifies them to MusicBrainz. Surfaced on the admin card so the gap is
-- visible: silently doing nothing for these would read as the feature being
-- broken.
-- Albums with missing files that can never be wanted automatically because
-- nothing identifies them to MusicBrainz. Surfaced on the admin card so the
-- gap is visible: silently doing nothing for these would read as broken.
SELECT COUNT(DISTINCT albums.id)::bigint
FROM albums
JOIN artists ON artists.id = albums.artist_id
@@ -79,42 +61,25 @@ SELECT COUNT(DISTINCT albums.id)::bigint
WHERE tracks.missing_since IS NOT NULL
AND (albums.mbid IS NULL OR artists.mbid IS NULL);
-- name: RecordReacquisitionAttempt :one
-- Bumps the attempt counter and stamps the clock the backoff measures from.
-- Upsert because the first attempt has no row yet.
INSERT INTO missing_reacquisitions (album_id, attempts, last_attempt_at, last_request_id)
VALUES (sqlc.arg(album_id), 1, now(), sqlc.narg(last_request_id))
ON CONFLICT (album_id) DO UPDATE
SET attempts = missing_reacquisitions.attempts + 1,
last_attempt_at = now(),
last_request_id = COALESCE(EXCLUDED.last_request_id,
missing_reacquisitions.last_request_id),
updated_at = now()
-- name: WantMissingAlbum :one
-- Records an album with missing files as wanted. A held want for the same
-- group is reopened; an open one is left as it is, and no row comes back.
INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, reason, album_id)
VALUES (sqlc.arg(release_group_mbid), sqlc.arg(artist_mbid), sqlc.arg(artist_name),
sqlc.arg(title), 'missing', sqlc.arg(album_id))
ON CONFLICT (release_group_mbid) DO UPDATE
SET reason = 'missing',
album_id = EXCLUDED.album_id,
wanted_at = now(),
fulfilled_at = NULL,
updated_at = now()
WHERE wants.fulfilled_at IS NOT NULL
RETURNING *;
-- name: MarkReacquisitionGaveUp :exec
-- Stamped when the attempt budget is spent. Stored as a timestamp rather than
-- inferred from `attempts >= max_attempts` so the verdict survives an operator
-- later raising the maximum, and so the admin surface can say when.
UPDATE missing_reacquisitions
SET gave_up_at = now(),
updated_at = now()
WHERE album_id = sqlc.arg(album_id)
AND gave_up_at IS NULL;
-- name: ClearRecoveredReacquisitions :execrows
-- Drops state for albums that no longer have any missing track — the files
-- came back, or the scanner adopted them at a new path (#2528). Deleting
-- rather than resetting counters means a future loss starts from a clean
-- budget, which is right: it is a new problem, not a continuation.
DELETE FROM missing_reacquisitions r
WHERE NOT EXISTS (
SELECT 1 FROM tracks
WHERE tracks.album_id = r.album_id
AND tracks.missing_since IS NOT NULL
);
-- name: GetReacquisitionForAlbums :many
-- State for the admin missing-files surface, so each directory group can say
-- whether a re-acquisition is in flight, waiting, or given up.
SELECT * FROM missing_reacquisitions WHERE album_id = ANY(sqlc.arg(album_ids)::uuid[]);
-- name: GetWantsForAlbums :many
-- What the wanted list says about each album on the missing-files page.
SELECT a.id AS album_id, w.id AS want_id, w.wanted_at, w.fulfilled_at
FROM albums a
JOIN wants w ON w.album_id = a.id OR w.release_group_mbid = a.release_group_mbid
WHERE a.id = ANY(sqlc.arg(album_ids)::uuid[])
ORDER BY w.wanted_at DESC;
+2 -2
View File
@@ -362,9 +362,9 @@ contributions AS (
JOIN seeds ON seeds.artist_id = u.seed_artist_id
WHERE NOT EXISTS (SELECT 1 FROM artists WHERE mbid = u.candidate_mbid)
AND NOT EXISTS (
SELECT 1 FROM lidarr_requests r
SELECT 1 FROM requests r
WHERE r.user_id = $1
AND r.lidarr_artist_mbid = u.candidate_mbid
AND r.artist_mbid = u.candidate_mbid
AND r.status NOT IN ('rejected', 'failed')
)
-- Snoozed by this user and not yet expired (#2374). Time-boxed and
+119
View File
@@ -0,0 +1,119 @@
-- name: CreateRequest :one
INSERT INTO requests (
user_id, kind,
artist_mbid, album_mbid, track_mbid,
artist_name, album_title, track_title
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *;
-- name: GetRequestByID :one
SELECT * FROM requests WHERE id = $1;
-- name: ListRequestsForUser :many
SELECT * FROM requests
WHERE user_id = $1
ORDER BY requested_at DESC
LIMIT $2;
-- name: ListRequestsByStatus :many
SELECT * FROM requests
WHERE status = $1
ORDER BY requested_at DESC
LIMIT $2;
-- name: ListApprovedRequestsForReconcile :many
SELECT * FROM requests
WHERE status = 'approved'
ORDER BY decided_at ASC
LIMIT $1;
-- name: ApproveRequest :one
UPDATE requests
SET status = 'approved',
decided_at = now(),
decided_by = $2,
updated_at = now()
WHERE id = $1 AND status = 'pending'
RETURNING *;
-- name: RejectRequest :one
UPDATE requests
SET status = 'rejected',
notes = $2,
decided_at = now(),
decided_by = $3,
updated_at = now()
WHERE id = $1 AND status = 'pending'
RETURNING *;
-- name: CancelRequest :one
-- $2 serves as both the ownership guard (user_id = $2) and the
-- decided_by audit field. The generated parameter struct names it
-- DecidedBy — callers must pass the cancelling user's UUID, and
-- a wrong UUID will silently no-op (zero rows updated) rather than
-- erroring at the SQL layer.
UPDATE requests
SET status = 'rejected',
notes = 'cancelled by user',
decided_at = now(),
decided_by = $2,
updated_at = now()
WHERE id = $1 AND user_id = $2 AND status = 'pending'
RETURNING *;
-- name: CompleteRequest :one
-- Reconciler transitions an approved request to completed when its
-- target track/album/artist has appeared in the library. Callers
-- should set ONLY the matched_*_id corresponding to the request's
-- kind and pass pgtype.UUID{} (Valid: false) for the others —
-- per-kind one-of-three is the contract, NULL for the others
-- preserves auditability if the matched row is later evicted.
UPDATE requests
SET status = 'completed',
matched_track_id = $2,
matched_album_id = $3,
matched_artist_id = $4,
completed_at = now(),
updated_at = now()
WHERE id = $1 AND status = 'approved'
RETURNING *;
-- name: HasNonTerminalRequestForMBID :one
-- Whether something is already on its way: a pending, approved or completed
-- request at the given level, an open want for the release group, or a
-- follow of the artist. Sets the `requested` flag on catalogue search.
-- Terminal-status (rejected, failed) requests do not count. MBIDs are unique
-- per entity type, so checking one id against every column cannot
-- false-match.
SELECT (EXISTS (
SELECT 1 FROM requests r
WHERE r.status IN ('pending', 'approved', 'completed')
AND ((r.kind = 'artist' AND r.artist_mbid = @mbid)
OR (r.kind = 'album' AND r.album_mbid = @mbid)
OR (r.kind = 'track' AND r.track_mbid = @mbid))
) OR EXISTS (
SELECT 1 FROM wants w WHERE w.release_group_mbid = @mbid AND w.fulfilled_at IS NULL
) OR EXISTS (
SELECT 1 FROM follows f WHERE f.artist_mbid = @mbid
))::bool AS requested;
-- name: GetNonTerminalRequestForMBID :one
-- Returns the oldest non-terminal (pending/approved/completed) request
-- whose own-kind MBID matches @mbid, or pgx.ErrNoRows when none. Create()
-- uses this to dedupe: a re-request for something already in flight
-- returns the existing row instead of inserting a duplicate.
SELECT * FROM requests
WHERE status IN ('pending', 'approved', 'completed')
AND ((kind = 'artist' AND artist_mbid = @mbid)
OR (kind = 'album' AND album_mbid = @mbid)
OR (kind = 'track' AND track_mbid = @mbid))
ORDER BY requested_at ASC
LIMIT 1;
-- name: RepointRequestsAlbumMbid :exec
-- A want moved from a release id to its group (#5241) takes the requests
-- that name it along, so the reconciler finds it.
UPDATE requests
SET album_mbid = sqlc.arg(new_mbid),
updated_at = now()
WHERE album_mbid = sqlc.arg(old_mbid);
-13
View File
@@ -174,16 +174,3 @@ SELECT u.id, u.timezone FROM users u
WHERE pe.user_id = u.id
AND pe.started_at > now() - INTERVAL '7 days'
);
-- name: GetOldestAdmin :one
-- The account a system-initiated action is attributed to (milestone #290).
-- lidarr_requests.user_id is NOT NULL and a re-acquisition has no requesting
-- human, so the row is owned by the longest-standing admin: it keeps the
-- request auditable and puts it in the same admin queue as everything else,
-- without inventing a synthetic principal the rest of the schema would have
-- to understand. Ordered by id as a tiebreak so the choice is stable across
-- calls rather than depending on scan order.
SELECT * FROM users
WHERE is_admin = true
ORDER BY created_at, id
LIMIT 1;
+206
View File
@@ -0,0 +1,206 @@
-- The wanted list (M502 #5520): release-type profiles, follows and wants.
-- name: ListReleaseTypeProfiles :many
SELECT p.*,
(SELECT COUNT(*) FROM follows f WHERE f.profile_id = p.id)::bigint AS follow_count
FROM release_type_profiles p
ORDER BY p.is_default DESC, p.name;
-- name: GetReleaseTypeProfile :one
SELECT * FROM release_type_profiles WHERE id = $1;
-- name: GetDefaultReleaseTypeProfile :one
SELECT * FROM release_type_profiles WHERE is_default;
-- name: CreateReleaseTypeProfile :one
INSERT INTO release_type_profiles (name, primary_types, secondary_types)
VALUES ($1, $2, $3)
RETURNING *;
-- name: UpdateReleaseTypeProfile :one
UPDATE release_type_profiles
SET name = $2,
primary_types = $3,
secondary_types = $4,
updated_at = now()
WHERE id = $1
RETURNING *;
-- name: ClearDefaultReleaseTypeProfile :exec
UPDATE release_type_profiles SET is_default = false, updated_at = now() WHERE is_default;
-- name: SetDefaultReleaseTypeProfile :execrows
UPDATE release_type_profiles SET is_default = true, updated_at = now() WHERE id = $1;
-- name: DeleteReleaseTypeProfile :execrows
-- The default stays; one in use is refused by the follows foreign key.
DELETE FROM release_type_profiles WHERE id = $1 AND NOT is_default;
-- name: UpsertFollow :one
-- Following an artist already followed changes its profile. want_existing
-- only ever turns on here; the worker turns it off once applied, and turning
-- it on again re-reads the baseline so the existing releases are judged.
INSERT INTO follows (artist_mbid, artist_name, profile_id, followed_by, want_existing)
VALUES (sqlc.arg(artist_mbid), sqlc.arg(artist_name), sqlc.arg(profile_id),
sqlc.narg(followed_by), sqlc.arg(want_existing))
ON CONFLICT (artist_mbid) DO UPDATE
SET artist_name = EXCLUDED.artist_name,
profile_id = EXCLUDED.profile_id,
want_existing = follows.want_existing OR EXCLUDED.want_existing,
baseline_at = CASE WHEN EXCLUDED.want_existing THEN NULL ELSE follows.baseline_at END,
updated_at = now()
RETURNING *;
-- name: GetFollow :one
SELECT f.*, p.name AS profile_name
FROM follows f
JOIN release_type_profiles p ON p.id = f.profile_id
WHERE f.artist_mbid = $1;
-- name: DeleteFollow :execrows
DELETE FROM follows WHERE artist_mbid = $1;
-- name: ListFollows :many
SELECT f.artist_mbid, f.artist_name, f.profile_id, f.followed_at, f.baseline_at, f.checked_at,
p.name AS profile_name,
(SELECT a.id FROM artists a WHERE a.mbid = f.artist_mbid) AS artist_id,
(SELECT COUNT(*) FROM wants w
WHERE w.artist_mbid = f.artist_mbid AND w.fulfilled_at IS NULL)::bigint AS open_wants
FROM follows f
JOIN release_type_profiles p ON p.id = f.profile_id
ORDER BY lower(f.artist_name), f.artist_mbid;
-- name: ListFollowsDue :many
-- Follows whose discography has not been applied since @before, never-read
-- first, so a new follow's baseline is not stuck behind daily re-checks.
SELECT f.*
FROM follows f
WHERE f.baseline_at IS NULL OR f.checked_at IS NULL OR f.checked_at < sqlc.arg(before)
ORDER BY f.baseline_at NULLS FIRST, f.checked_at NULLS FIRST, f.artist_mbid
LIMIT sqlc.arg(max_rows);
-- name: MarkFollowChecked :exec
UPDATE follows
SET checked_at = now(),
baseline_at = COALESCE(baseline_at, now()),
want_existing = false,
updated_at = now()
WHERE artist_mbid = $1;
-- name: ListFollowGroups :many
-- An artist's release groups from the catalogue, each with whether the
-- follow has judged it before.
SELECT g.mbid, g.title, g.primary_type, g.secondary_types, g.first_release_date,
EXISTS (SELECT 1 FROM follow_seen_groups s
WHERE s.artist_mbid = sqlc.arg(artist_mbid) AND s.release_group_mbid = g.mbid) AS seen
FROM mb_artist_release_groups ag
JOIN mb_release_groups g ON g.mbid = ag.release_group_mbid
WHERE ag.artist_mbid = sqlc.arg(artist_mbid)
ORDER BY g.first_release_date, g.title;
-- name: MarkGroupSeen :exec
INSERT INTO follow_seen_groups (artist_mbid, release_group_mbid)
VALUES ($1, $2)
ON CONFLICT DO NOTHING;
-- name: LibraryHoldsReleaseGroup :one
-- Whether the library already has the group: an album tagged with it or
-- with one of its releases, or failing that an album of the artist's with
-- the same title. Only albums with a track on disk count.
SELECT EXISTS (
SELECT 1 FROM albums a
WHERE (a.release_group_mbid = sqlc.arg(group_mbid)::text
OR a.mbid = sqlc.arg(group_mbid)
OR a.mbid IN (SELECT r.mbid FROM mb_releases r WHERE r.release_group_mbid = sqlc.arg(group_mbid))
OR (lower(a.title) = lower(sqlc.arg(title)::text)
AND a.artist_id IN (SELECT ar.id FROM artists ar WHERE ar.mbid = sqlc.arg(artist_mbid)::text)))
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id AND t.missing_since IS NULL)
);
-- name: WantReleaseGroup :one
-- Wants a release group, or returns the want already recorded for it. A
-- held want is not reopened here: the library has it.
INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, primary_type,
first_release_date, reason, created_by)
VALUES (sqlc.arg(release_group_mbid), sqlc.arg(artist_mbid), sqlc.arg(artist_name), sqlc.arg(title),
sqlc.arg(primary_type), sqlc.arg(first_release_date), sqlc.arg(reason), sqlc.narg(created_by))
ON CONFLICT (release_group_mbid) DO UPDATE SET updated_at = wants.updated_at
RETURNING *;
-- name: GetWantByReleaseGroup :one
SELECT * FROM wants WHERE release_group_mbid = $1;
-- name: ListOpenWants :many
SELECT * FROM wants
WHERE fulfilled_at IS NULL
ORDER BY wanted_at, id
LIMIT $1;
-- name: WantHoldings :many
-- Each library album that may answer a want, with what it has on disk:
-- tracks present, whether one arrived since the want, and whether a track
-- missing before the want is still missing.
SELECT a.id AS album_id,
COUNT(*) FILTER (WHERE t.missing_since IS NULL)::int AS present,
COALESCE(bool_or(t.missing_since IS NULL AND t.added_at > sqlc.arg(since)::timestamptz), false)::bool AS arrived_since,
COALESCE(bool_or(t.missing_since IS NOT NULL AND t.missing_since < sqlc.arg(since)::timestamptz), false)::bool AS missing_before,
-- Missing tracks no track on disk stands in for, by position or
-- title. A missing row whose song is back at a new path (#5245) is
-- covered; a lost bonus track is not.
COUNT(*) FILTER (WHERE t.missing_since IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM tracks p
WHERE p.album_id = a.id AND p.missing_since IS NULL
AND ((p.track_number = t.track_number AND p.disc_number IS NOT DISTINCT FROM t.disc_number)
OR lower(p.title) = lower(t.title))))::int AS uncovered_missing
FROM albums a
JOIN tracks t ON t.album_id = a.id
WHERE a.release_group_mbid = sqlc.arg(group_mbid)::text
OR a.mbid = sqlc.arg(group_mbid)
OR a.id = sqlc.narg(album_id)
OR a.mbid IN (SELECT r.mbid FROM mb_releases r WHERE r.release_group_mbid = sqlc.arg(group_mbid))
GROUP BY a.id
ORDER BY present DESC, a.id;
-- name: FulfillWant :exec
UPDATE wants
SET fulfilled_at = now(),
album_id = sqlc.arg(album_id),
updated_at = now()
WHERE id = sqlc.arg(id) AND fulfilled_at IS NULL;
-- name: RepointWant :exec
-- A want recorded under a release id before #5241 is moved to its group.
UPDATE wants SET release_group_mbid = $2, updated_at = now() WHERE id = $1;
-- name: DeleteWant :execrows
DELETE FROM wants WHERE id = $1;
-- name: ListWants :many
-- The Wanted page: open wants (all but missing ones, or only missing ones),
-- or what was held most recently.
SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reason,
w.album_id, w.wanted_at, w.fulfilled_at,
-- A request names an album before the catalogue has read it: the
-- catalogue's type and date fill in once it has.
COALESCE(NULLIF(w.primary_type, ''), g.primary_type, '')::text AS primary_type,
COALESCE(g.secondary_types, '{}')::text[] AS secondary_types,
COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, '')::text AS first_release_date,
u.username AS requested_by,
(SELECT ar.id FROM artists ar WHERE ar.mbid = w.artist_mbid) AS library_artist_id
FROM wants w
LEFT JOIN users u ON u.id = w.created_by
LEFT JOIN mb_release_groups g ON g.mbid = w.release_group_mbid
WHERE CASE sqlc.arg(state)::text
WHEN 'held' THEN w.fulfilled_at IS NOT NULL
WHEN 'missing' THEN w.fulfilled_at IS NULL AND w.reason = 'missing'
ELSE w.fulfilled_at IS NULL AND w.reason <> 'missing'
END
ORDER BY COALESCE(w.fulfilled_at, w.wanted_at) DESC, w.id
LIMIT sqlc.arg(max_rows) OFFSET sqlc.arg(skip);
-- name: CountWants :one
SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::bigint AS wanted,
COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason = 'missing')::bigint AS missing,
COUNT(*) FILTER (WHERE fulfilled_at IS NOT NULL)::bigint AS held,
(SELECT COUNT(*) FROM follows)::bigint AS following
FROM wants;
+15
View File
@@ -108,6 +108,10 @@ var dataTables = []string{
"mb_artist_release_groups", // M502 #5519
"mb_release_groups", // M502 #5519
"mb_artists", // M502 #5519
"requests", // M502 #5520 (was lidarr_requests)
"wants", // M502 #5520
"follow_seen_groups", // M502 #5520
"follows", // M502 #5520
"tracks",
"albums",
"artists",
@@ -165,6 +169,17 @@ func ResetDB(t *testing.T, pool *pgxpool.Pool) {
); err != nil {
t.Fatalf("dbtest.ResetDB reset loudness settings: %v", err)
}
// Release-type profiles (M502 #5520): migration 0081 seeds "Standard" as
// the default. Tests add and edit profiles, so put back exactly the
// seeded row rather than truncating it away.
if _, err := pool.Exec(ctx, `
DELETE FROM release_type_profiles WHERE name <> 'Standard';
UPDATE release_type_profiles
SET primary_types = ARRAY['Album', 'EP', 'Single'], secondary_types = '{}', is_default = true
WHERE name = 'Standard'`,
); err != nil {
t.Fatalf("dbtest.ResetDB reset release-type profiles: %v", err)
}
// Notification email settings (M489 #5346), reset the same way.
if _, err := pool.Exec(ctx, `
UPDATE notification_email_settings
+2 -2
View File
@@ -206,8 +206,8 @@ func foldTrackInto(
if _, err := tq.MergeRepointPlaybackErrors(ctx, dbq.MergeRepointPlaybackErrorsParams{SurvivorID: survivorID, LoserID: loserID}); err != nil {
return f, fmt.Errorf("move playback errors: %w", err)
}
if _, err := tq.MergeRepointLidarrRequests(ctx, dbq.MergeRepointLidarrRequestsParams{SurvivorID: survivorID, LoserID: loserID}); err != nil {
return f, fmt.Errorf("move lidarr request matches: %w", err)
if _, err := tq.MergeRepointRequests(ctx, dbq.MergeRepointRequestsParams{SurvivorID: survivorID, LoserID: loserID}); err != nil {
return f, fmt.Errorf("move request matches: %w", err)
}
playlists, err := tq.MergeRepointPlaylistTracks(ctx, dbq.MergeRepointPlaylistTracksParams{SurvivorID: survivorID, LoserID: loserID})
-404
View File
@@ -1,404 +0,0 @@
package lidarrrequests
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
// Reconciler is a background worker that periodically scans approved
// lidarr_requests. For rows whose Lidarr add hasn't been confirmed it
// (re)sends the add idempotently until it sticks; for confirmed rows it
// transitions them to completed when their target track/album/artist has
// appeared in the local library (matched by MBID). Errors per-row are
// logged at WARN and do not abort the tick.
type Reconciler struct {
pool *pgxpool.Pool
lidarrCfg *lidarrconfig.Service
clientFn func() *lidarr.Client
logger *slog.Logger
bus *eventbus.Bus
notifier *notifications.Notifier // nil: no inbox notifications
tick time.Duration
batch int32
// releaseGroup names the MusicBrainz release group of a release id, to
// repair a request stored under a release id Lidarr does not index
// (#5241). A field so tests can stand in for MusicBrainz.
releaseGroup func(ctx context.Context, releaseMBID string) (string, error)
}
// NewReconciler constructs a Reconciler with production defaults:
// 5-minute tick, batch size 50. clientFn is the per-tick Lidarr client
// factory (same pattern as Service) so config changes apply without a
// restart; pass nil to disable the add-retry path. Pass nil for bus when
// no SSE publishing is desired (tests, or environments without the event
// stream enabled).
func NewReconciler(pool *pgxpool.Pool, cfg *lidarrconfig.Service, clientFn func() *lidarr.Client, logger *slog.Logger, bus *eventbus.Bus) *Reconciler {
if clientFn == nil {
clientFn = func() *lidarr.Client { return nil }
}
return &Reconciler{
pool: pool,
lidarrCfg: cfg,
clientFn: clientFn,
logger: logger,
bus: bus,
tick: 5 * time.Minute,
batch: 50,
releaseGroup: tags.ReleaseGroupForRelease,
}
}
// SetNotifier makes completions land in the requester's notifications inbox
// (M489). Without one, completions are only broadcast on the bus.
func (r *Reconciler) SetNotifier(n *notifications.Notifier) { r.notifier = n }
// publishCompleted tells the requester their request arrived: a
// request.status_changed event for open screens, and a notification that
// outlives the connection.
func (r *Reconciler) publishCompleted(ctx context.Context, row dbq.LidarrRequest) {
p := notifications.Payload{
RequestID: formatUUIDForBus(row.ID),
RequestKind: string(row.Kind),
Name: DisplayName(row),
Artist: row.ArtistName,
Title: requestedTitle(row),
}
if row.MatchedAlbumID.Valid {
p.AlbumID = formatUUIDForBus(row.MatchedAlbumID)
} else if row.MatchedArtistID.Valid {
p.ArtistID = formatUUIDForBus(row.MatchedArtistID)
}
r.notifier.NotifyLogged(ctx, notifications.KindRequestCompleted, notifications.ToUser(row.UserID), p.Map())
if r.bus == nil {
return
}
r.bus.Publish(eventbus.Event{
Kind: "request.status_changed",
UserID: formatUUIDForBus(row.UserID),
Data: map[string]any{
"request_id": formatUUIDForBus(row.ID),
"status": "completed",
"kind": string(row.Kind),
},
})
}
// formatUUIDForBus renders a pgtype.UUID as the canonical 8-4-4-4-12 hex
// string. Mirrors helpers in internal/api/convert.go and other packages;
// inlined here to avoid a back-edge dependency from lidarrrequests onto
// the api layer.
func formatUUIDForBus(u pgtype.UUID) string {
if !u.Valid {
return ""
}
const hex = "0123456789abcdef"
out := make([]byte, 36)
pos := 0
for i, b := range u.Bytes {
if i == 4 || i == 6 || i == 8 || i == 10 {
out[pos] = '-'
pos++
}
out[pos] = hex[b>>4]
out[pos+1] = hex[b&0xf]
pos += 2
}
return string(out)
}
// Run blocks until ctx is cancelled, ticking every r.tick. Errors from
// tickOnce are logged at WARN and never propagated.
func (r *Reconciler) Run(ctx context.Context) {
t := time.NewTicker(r.tick)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := r.tickOnce(ctx); err != nil {
r.logger.Warn("lidarrrequests: reconciler tick failed", "err", err)
}
}
}
}
// tickOnce loads approved requests (oldest first, up to r.batch) and
// transitions any whose target MBID is now present in the local library.
// It is exported-for-tests via the lowercase name (package-internal).
func (r *Reconciler) tickOnce(ctx context.Context) error {
cfg, err := r.lidarrCfg.Get(ctx)
if err != nil {
return err
}
if !cfg.Enabled {
r.logger.Debug("lidarrrequests: reconciler skipping tick — lidarr disabled")
return nil
}
q := dbq.New(r.pool)
rows, err := q.ListApprovedLidarrRequestsForReconcile(ctx, r.batch)
if err != nil {
return err
}
for _, row := range rows {
if err := r.reconcileRow(ctx, q, cfg, row); err != nil {
r.logger.Warn("lidarrrequests: reconciler row failed",
"request_id", row.ID,
"kind", row.Kind,
"err", err,
)
}
}
return nil
}
// reconcileRow drives a request forward by one step. If Lidarr hasn't
// confirmed the add yet it (re)sends it and returns — the import can't
// land before Lidarr has the add, so there's nothing to match this tick.
// Once confirmed, it checks the library for a match and, if found, calls
// CompleteLidarrRequest. Returns an error only for unexpected failures
// (the add retry's transient error included, so tickOnce logs WARN and
// it's retried next tick — "Lidarr just keeps trying"); a no-match is
// not an error.
func (r *Reconciler) reconcileRow(ctx context.Context, q *dbq.Queries, cfg lidarrconfig.Config, row dbq.LidarrRequest) error {
if !row.LidarrAddConfirmedAt.Valid {
if err := r.ensureLidarrAdd(ctx, q, cfg, row); err != nil {
return err
}
// ensureLidarrAdd either confirmed the add or no-op'd (Lidarr
// client unavailable). Fall through to import-matching so an
// item already present in the library still completes this tick.
}
switch row.Kind {
case dbq.LidarrRequestKindArtist:
return r.reconcileArtist(ctx, q, row)
case dbq.LidarrRequestKindAlbum:
return r.reconcileAlbum(ctx, q, row)
case dbq.LidarrRequestKindTrack:
return r.reconcileTrack(ctx, q, row)
default:
r.logger.Warn("lidarrrequests: reconciler unknown kind", "kind", row.Kind)
return nil
}
}
// ensureLidarrAdd (re)sends the Lidarr add for an approved request whose
// add hasn't been confirmed. On success (including ErrAlreadyExists,
// handled inside sendLidarrAdd) it stamps lidarr_add_confirmed_at so this
// stops re-sending and the next tick moves to import-matching. A failure
// is returned so tickOnce logs WARN and the row is retried next tick;
// there is intentionally no failed-state or attempt cap — per product
// decision Lidarr just keeps trying and the operator monitors Lidarr.
func (r *Reconciler) ensureLidarrAdd(ctx context.Context, q *dbq.Queries, cfg lidarrconfig.Config, row dbq.LidarrRequest) error {
client := r.clientFn()
if client == nil {
// Lidarr disabled mid-flight; tickOnce already gates on
// cfg.Enabled, so this is just defensive — nothing to do.
return nil
}
err := sendLidarrAdd(ctx, client, cfg, row)
if errors.Is(err, lidarr.ErrNotFound) {
// Lidarr's metadata has no album under this id. Requests made before
// albums carried a release group (#5241) name the album by its
// MusicBrainz release id, which Lidarr does not index: repoint the
// request at the release group and try once more.
if repaired, ok := r.repointAtReleaseGroup(ctx, q, row); ok {
row = repaired
err = sendLidarrAdd(ctx, client, cfg, row)
}
}
if err != nil {
return fmt.Errorf("lidarr add retry: %w", err)
}
return q.MarkLidarrRequestAddConfirmed(ctx, row.ID)
}
// repointAtReleaseGroup reads an album or track request's album id as a
// MusicBrainz release and, when its release group can be named, rewrites the
// request to it. The library's own albums answer first; MusicBrainz is asked
// only for a release no album row knows the group of, and its answer is cached
// onto that album. Reports false when there is nothing to repoint: not an
// album request, the group is unknown, or the id already is the group.
func (r *Reconciler) repointAtReleaseGroup(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest) (dbq.LidarrRequest, bool) {
if row.Kind == dbq.LidarrRequestKindArtist || row.LidarrAlbumMbid == nil || *row.LidarrAlbumMbid == "" {
return row, false
}
release := *row.LidarrAlbumMbid
group, err := q.GetReleaseGroupForReleaseMbid(ctx, &release)
if err != nil {
if !isNoRows(err) {
r.logger.Warn("lidarrrequests: release group lookup failed", "request_id", row.ID, "err", err)
return row, false
}
group, err = r.releaseGroup(ctx, release)
if err != nil {
if !errors.Is(err, tags.ErrNotFound) {
r.logger.Warn("lidarrrequests: MusicBrainz release group lookup failed",
"request_id", row.ID, "release_mbid", release, "err", err)
}
return row, false
}
if cerr := q.SetReleaseGroupForReleaseMbidIfNull(ctx, dbq.SetReleaseGroupForReleaseMbidIfNullParams{
ReleaseGroupMbid: group,
ReleaseMbid: release,
}); cerr != nil {
r.logger.Warn("lidarrrequests: cache release group failed", "release_mbid", release, "err", cerr)
}
}
if group == "" || group == release {
return row, false
}
if err := q.SetLidarrRequestAlbumMbid(ctx, dbq.SetLidarrRequestAlbumMbidParams{
ID: row.ID,
LidarrAlbumMbid: &group,
}); err != nil {
r.logger.Warn("lidarrrequests: repoint request failed", "request_id", row.ID, "err", err)
return row, false
}
r.logger.Info("lidarrrequests: request repointed from release to release group",
"request_id", row.ID, "release_mbid", release, "release_group_mbid", group)
row.LidarrAlbumMbid = &group
return row, true
}
// albumForRequest finds the library album a request's album id ($1) names, by
// release id or release group, once the request ($2 = requested_at) has been
// answered: the album has a track on disk AND either
//
// - a track arrived after the request (a new album, or Lidarr fetching
// another release of the group into its own row), or
// - no track that was already missing when the request was made is still
// missing (the lost files came back in place or were adopted at a new path).
//
// "Has a track on disk" alone is not enough (#5263): re-acquisition targets
// albums with ANY track missing, so the tracks that never left satisfied it
// the moment Lidarr accepted the add. added_at is the arrival clock because
// nothing rewrites it; updated_at moves on every tag re-read.
// An exact release match is preferred over another release of the group.
const albumForRequest = `
SELECT a.id
FROM albums a
WHERE (a.mbid = $1 OR a.release_group_mbid = $1)
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id AND t.missing_since IS NULL)
AND (
EXISTS (SELECT 1 FROM tracks t
WHERE t.album_id = a.id AND t.missing_since IS NULL AND t.added_at > $2)
OR NOT EXISTS (SELECT 1 FROM tracks t
WHERE t.album_id = a.id AND t.missing_since IS NOT NULL
AND t.missing_since < $2)
)
ORDER BY (a.mbid = $1) DESC, a.id
LIMIT 1`
func (r *Reconciler) reconcileArtist(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest) error {
var artistID pgtype.UUID
err := r.pool.QueryRow(ctx,
"SELECT id FROM artists WHERE mbid = $1",
row.LidarrArtistMbid,
).Scan(&artistID)
if err != nil {
if isNoRows(err) {
return nil // not in library yet
}
return err
}
completed, err := q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{
ID: row.ID,
MatchedArtistID: artistID,
MatchedAlbumID: pgtype.UUID{},
MatchedTrackID: pgtype.UUID{},
})
if err != nil {
return err
}
r.publishCompleted(ctx, completed)
return nil
}
func (r *Reconciler) reconcileAlbum(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest) error {
if row.LidarrAlbumMbid == nil {
return nil
}
var albumID pgtype.UUID
err := r.pool.QueryRow(ctx, albumForRequest, *row.LidarrAlbumMbid, row.RequestedAt).Scan(&albumID)
if err != nil {
if isNoRows(err) {
return nil
}
return err
}
completed, err := q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{
ID: row.ID,
MatchedAlbumID: albumID,
MatchedArtistID: pgtype.UUID{},
MatchedTrackID: pgtype.UUID{},
})
if err != nil {
return err
}
r.publishCompleted(ctx, completed)
return nil
}
func (r *Reconciler) reconcileTrack(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest) error {
if row.LidarrAlbumMbid == nil {
return nil
}
// Track-kind requests match via their parent album's MBID, not track.mbid.
var albumID pgtype.UUID
err := r.pool.QueryRow(ctx, albumForRequest, *row.LidarrAlbumMbid, row.RequestedAt).Scan(&albumID)
if err != nil {
if isNoRows(err) {
return nil
}
return err
}
// Load a track from that album to set matched_track_id, newest arrival first.
var trackID pgtype.UUID
err = r.pool.QueryRow(ctx,
"SELECT id FROM tracks WHERE album_id = $1 AND missing_since IS NULL ORDER BY added_at DESC, id LIMIT 1",
albumID,
).Scan(&trackID)
if err != nil {
if isNoRows(err) {
// Album is in the library but no tracks ingested yet — try again next tick.
return nil
}
return err
}
completed, err := q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{
ID: row.ID,
MatchedAlbumID: albumID,
MatchedTrackID: trackID,
MatchedArtistID: pgtype.UUID{},
})
if err != nil {
return err
}
r.publishCompleted(ctx, completed)
return nil
}
func isNoRows(err error) bool {
return err == pgx.ErrNoRows
}
@@ -1,444 +0,0 @@
package lidarrrequests
import (
"context"
"io"
"log/slog"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
)
// seedApprovedRequestDirect inserts a pending request and immediately
// approves it via ApproveLidarrRequest, bypassing the Lidarr API call.
func seedApprovedRequestDirect(t *testing.T, q *dbq.Queries, userID pgtype.UUID, p CreateParams) dbq.LidarrRequest {
t.Helper()
ctx := context.Background()
row, err := q.CreateLidarrRequest(ctx, dbq.CreateLidarrRequestParams{
UserID: userID,
Kind: dbq.LidarrRequestKind(p.Kind),
LidarrArtistMbid: p.LidarrArtistMBID,
LidarrAlbumMbid: nilableStr(p.LidarrAlbumMBID),
LidarrTrackMbid: nilableStr(p.LidarrTrackMBID),
ArtistName: p.ArtistName,
AlbumTitle: nilableStr(p.AlbumTitle),
TrackTitle: nilableStr(p.TrackTitle),
})
if err != nil {
t.Fatalf("seedApprovedRequestDirect create: %v", err)
}
approved, err := q.ApproveLidarrRequest(ctx, dbq.ApproveLidarrRequestParams{
ID: row.ID,
QualityProfileID: nil,
RootFolderPath: nil,
DecidedBy: userID,
})
if err != nil {
t.Fatalf("seedApprovedRequestDirect approve: %v", err)
}
return approved
}
func nilableStr(s string) *string {
if s == "" {
return nil
}
return &s
}
func seedArtist(t *testing.T, q *dbq.Queries, name, mbid string) dbq.Artist {
t.Helper()
a, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{
Name: name, SortName: name, Mbid: &mbid,
})
if err != nil {
t.Fatalf("seedArtist: %v", err)
}
return a
}
func seedAlbum(t *testing.T, q *dbq.Queries, artistID pgtype.UUID, title, mbid string) dbq.Album {
t.Helper()
a, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
Title: title, SortTitle: title,
ArtistID: artistID,
Mbid: &mbid,
})
if err != nil {
t.Fatalf("seedAlbum: %v", err)
}
return a
}
func seedTrack(t *testing.T, q *dbq.Queries, albumID, artistID pgtype.UUID, title, filePath string) dbq.Track {
t.Helper()
tr, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: title, AlbumID: albumID, ArtistID: artistID,
DurationMs: 180000, FilePath: filePath,
FileSize: 1024, FileFormat: "flac",
})
if err != nil {
t.Fatalf("seedTrack: %v", err)
}
return tr
}
func enableLidarrForPool(t *testing.T, pool *pgxpool.Pool) {
t.Helper()
if err := lidarrconfig.New(pool).Save(context.Background(), lidarrconfig.Config{
Enabled: true, BaseURL: "http://lidarr:8686", APIKey: "test",
}); err != nil {
t.Fatalf("enableLidarrForPool: %v", err)
}
}
func newTestLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
// TestReconciler_MatchesArtistByMBID: an approved artist-kind request whose
// MBID is in the artists table gets completed with matched_artist_id set.
func TestReconciler_MatchesArtistByMBID(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
enableLidarrForPool(t, pool)
user := seedUser(t, pool)
artistMBID := "069b64b6-7884-4f6a-94cc-e4c1d6c87a01"
artist := seedArtist(t, q, "Boards of Canada", artistMBID)
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "artist", LidarrArtistMBID: artistMBID, ArtistName: "Boards of Canada",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
got, err := q.GetLidarrRequestByID(ctx, req.ID)
if err != nil {
t.Fatalf("GetLidarrRequestByID: %v", err)
}
if got.Status != dbq.LidarrRequestStatusCompleted {
t.Errorf("status = %v, want completed", got.Status)
}
if got.MatchedArtistID != artist.ID {
t.Errorf("matched_artist_id = %v, want %v", got.MatchedArtistID, artist.ID)
}
}
// TestReconciler_MatchesAlbumByMBID: an approved album-kind request whose
// album MBID is in the albums table gets completed with matched_album_id set.
func TestReconciler_MatchesAlbumByMBID(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
enableLidarrForPool(t, pool)
user := seedUser(t, pool)
artistMBID := "a-artist-mbid-album-test"
albumMBID := "b-album-mbid-album-test"
artist := seedArtist(t, q, "Test Artist", artistMBID)
album := seedAlbum(t, q, artist.ID, "Test Album", albumMBID)
_ = seedTrack(t, q, album.ID, artist.ID, "Album Track", "/music/album-test/01.flac")
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "album", LidarrArtistMBID: artistMBID, ArtistName: "Test Artist",
LidarrAlbumMBID: albumMBID, AlbumTitle: "Test Album",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
got, err := q.GetLidarrRequestByID(ctx, req.ID)
if err != nil {
t.Fatalf("GetLidarrRequestByID: %v", err)
}
if got.Status != dbq.LidarrRequestStatusCompleted {
t.Errorf("status = %v, want completed", got.Status)
}
if got.MatchedAlbumID != album.ID {
t.Errorf("matched_album_id = %v, want %v", got.MatchedAlbumID, album.ID)
}
}
// TestReconciler_MatchesTrackViaAlbumMBID: an approved track-kind request
// matches when the parent album appears in the library. matched_track_id is
// also set to a track from that album. The track MBID on the request does
// not need to match any tracks.mbid — matching is via the parent album.
func TestReconciler_MatchesTrackViaAlbumMBID(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
enableLidarrForPool(t, pool)
user := seedUser(t, pool)
artistMBID := "a-artist-mbid-track-test"
albumMBID := "b-album-mbid-track-test"
trackMBID := "c-track-mbid-does-not-exist-in-tracks-table"
artist := seedArtist(t, q, "Track Test Artist", artistMBID)
album := seedAlbum(t, q, artist.ID, "Track Test Album", albumMBID)
track := seedTrack(t, q, album.ID, artist.ID, "Track One", "/music/track-test/01.flac")
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "track", LidarrArtistMBID: artistMBID, ArtistName: "Track Test Artist",
LidarrAlbumMBID: albumMBID, AlbumTitle: "Track Test Album",
LidarrTrackMBID: trackMBID, TrackTitle: "Track One",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
got, err := q.GetLidarrRequestByID(ctx, req.ID)
if err != nil {
t.Fatalf("GetLidarrRequestByID: %v", err)
}
if got.Status != dbq.LidarrRequestStatusCompleted {
t.Errorf("status = %v, want completed", got.Status)
}
if got.MatchedAlbumID != album.ID {
t.Errorf("matched_album_id = %v, want %v", got.MatchedAlbumID, album.ID)
}
if got.MatchedTrackID != track.ID {
t.Errorf("matched_track_id = %v, want %v", got.MatchedTrackID, track.ID)
}
}
// TestReconciler_NoMatchLeavesPending: an approved request whose MBID is not
// in any library table is left unchanged after tickOnce.
func TestReconciler_NoMatchLeavesPending(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
enableLidarrForPool(t, pool)
user := seedUser(t, pool)
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "artist", LidarrArtistMBID: "nonexistent-mbid-xyz", ArtistName: "Ghost Artist",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
got, err := q.GetLidarrRequestByID(ctx, req.ID)
if err != nil {
t.Fatalf("GetLidarrRequestByID: %v", err)
}
if got.Status != dbq.LidarrRequestStatusApproved {
t.Errorf("status = %v, want approved (unchanged)", got.Status)
}
if got.MatchedArtistID.Valid {
t.Errorf("matched_artist_id should be NULL, got %v", got.MatchedArtistID)
}
}
// TestReconciler_AlreadyCompletedRowNotReprocessed: a completed row is not
// touched by the reconciler because ListApprovedLidarrRequestsForReconcile
// filters on status='approved'.
func TestReconciler_AlreadyCompletedRowNotReprocessed(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
enableLidarrForPool(t, pool)
user := seedUser(t, pool)
artistMBID := "already-done-artist-mbid"
artist := seedArtist(t, q, "Already Done Artist", artistMBID)
// Seed approved, then complete it before running tickOnce.
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "artist", LidarrArtistMBID: artistMBID, ArtistName: "Already Done Artist",
})
completed, err := q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{
ID: req.ID,
MatchedArtistID: artist.ID,
MatchedAlbumID: pgtype.UUID{},
MatchedTrackID: pgtype.UUID{},
})
if err != nil {
t.Fatalf("CompleteLidarrRequest setup: %v", err)
}
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
got, err := q.GetLidarrRequestByID(ctx, req.ID)
if err != nil {
t.Fatalf("GetLidarrRequestByID: %v", err)
}
if got.Status != dbq.LidarrRequestStatusCompleted {
t.Errorf("status = %v, want completed (unchanged)", got.Status)
}
if !got.UpdatedAt.Time.Equal(completed.UpdatedAt.Time) {
t.Errorf("updated_at moved: was %v, now %v — reconciler should not have touched completed row",
completed.UpdatedAt.Time, got.UpdatedAt.Time)
}
}
// TestReconciler_DisabledIsNoOp: when lidarr_config.enabled=false the
// reconciler skips the entire tick, leaving approved requests untouched.
func TestReconciler_DisabledIsNoOp(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
// lidarr_config is left disabled (newPool sets it to false).
user := seedUser(t, pool)
artistMBID := "disabled-noop-mbid"
// Seed the matching artist so it WOULD be found if lidarr were enabled.
seedArtist(t, q, "No-Op Artist", artistMBID)
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "artist", LidarrArtistMBID: artistMBID, ArtistName: "No-Op Artist",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
got, err := q.GetLidarrRequestByID(ctx, req.ID)
if err != nil {
t.Fatalf("GetLidarrRequestByID: %v", err)
}
if got.Status != dbq.LidarrRequestStatusApproved {
t.Errorf("status = %v, want approved — reconciler should no-op when disabled", got.Status)
}
}
// TestReconciler_RunRunsAtLeastOneTick exercises Run's goroutine loop with a
// short tick, verifies it picks up an approved row and transitions it to
// completed before the test cancels the context.
func TestReconciler_RunRunsAtLeastOneTick(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
pool := newPool(t)
enableLidarrForPool(t, pool)
q := dbq.New(pool)
user := seedUser(t, pool)
const mbid = "run-mbid"
seedArtist(t, q, "Run Artist", mbid)
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "artist", LidarrArtistMBID: mbid, ArtistName: "Run Artist",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
rec.tick = 25 * time.Millisecond
done := make(chan struct{})
go func() {
rec.Run(ctx)
close(done)
}()
// Poll the row up to ~2s for the reconciler to flip it. Beats sleeping
// a fixed amount on slow CI.
deadline := time.Now().Add(2 * time.Second)
for {
got, err := q.GetLidarrRequestByID(ctx, req.ID)
if err == nil && got.Status == dbq.LidarrRequestStatusCompleted {
break
}
if time.Now().After(deadline) {
t.Fatalf("status not completed within 2s; last err=%v", err)
}
time.Sleep(10 * time.Millisecond)
}
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("Run did not exit within 1s of cancel")
}
}
// TestReconciler_TrackKindNoTracksInAlbumYet: the parent album exists in the
// library but no track rows have been ingested yet. Reconciler should leave
// the row approved so a subsequent tick can pick it up once tracks land.
func TestReconciler_TrackKindNoTracksInAlbumYet(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
enableLidarrForPool(t, pool)
user := seedUser(t, pool)
artistMBID := "ar-mbid-tracks-pending"
albumMBID := "al-mbid-tracks-pending"
artist := seedArtist(t, q, "Pending Tracks Artist", artistMBID)
_ = seedAlbum(t, q, artist.ID, "Album Without Tracks", albumMBID)
// No tracks seeded.
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "track", LidarrArtistMBID: artistMBID, ArtistName: "Pending Tracks Artist",
LidarrAlbumMBID: albumMBID, AlbumTitle: "Album Without Tracks",
LidarrTrackMBID: "t-mbid-not-yet", TrackTitle: "Some Track",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
got, err := q.GetLidarrRequestByID(ctx, req.ID)
if err != nil {
t.Fatalf("GetLidarrRequestByID: %v", err)
}
if got.Status != dbq.LidarrRequestStatusApproved {
t.Errorf("status = %v, want approved (album present, no tracks yet)", got.Status)
}
}
// TestReconciler_AlbumKindNoMatchInAlbumsTable exercises the album branch's
// no-rows return path when the request's album_mbid isn't in the library yet.
func TestReconciler_AlbumKindNoMatchInAlbumsTable(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
enableLidarrForPool(t, pool)
user := seedUser(t, pool)
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "album", LidarrArtistMBID: "ar-no-match", ArtistName: "Nope Artist",
LidarrAlbumMBID: "al-not-in-library", AlbumTitle: "Nope Album",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
got, err := q.GetLidarrRequestByID(ctx, req.ID)
if err != nil {
t.Fatalf("GetLidarrRequestByID: %v", err)
}
if got.Status != dbq.LidarrRequestStatusApproved {
t.Errorf("status = %v, want approved (album not yet in library)", got.Status)
}
}
@@ -1,52 +0,0 @@
package lidarrrequests
import (
"context"
"encoding/json"
"testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
)
// M489: an album request that arrives lands in the requester's inbox once,
// linked to the album it matched.
func TestReconciler_CompletionNotifiesRequester(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
enableLidarrForPool(t, pool)
user := seedUser(t, pool)
artist := seedArtist(t, q, "Notify Artist", "notify-artist-mbid")
album := seedAlbum(t, q, artist.ID, "Notify Album", "notify-album-mbid")
_ = seedTrack(t, q, album.ID, artist.ID, "Notify Track", "/music/notify/01.flac")
seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "album", LidarrArtistMBID: "notify-artist-mbid", LidarrAlbumMBID: "notify-album-mbid",
ArtistName: "Notify Artist", AlbumTitle: "Notify Album",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
rec.SetNotifier(notifications.New(pool, nil, nil))
for i := 0; i < 2; i++ { // the second tick finds nothing left to complete
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
}
rows, err := q.ListNotifications(ctx, dbq.ListNotificationsParams{UserID: user, PageLimit: 10})
if err != nil {
t.Fatalf("list notifications: %v", err)
}
if len(rows) != 1 || rows[0].Kind != string(notifications.KindRequestCompleted) {
t.Fatalf("inbox = %+v, want one request_completed", rows)
}
var p notifications.Payload
if err := json.Unmarshal(rows[0].Payload, &p); err != nil {
t.Fatal(err)
}
if p.Name != "Notify Artist – Notify Album" || p.AlbumID != formatUUIDForBus(album.ID) || p.ArtistID != "" {
t.Errorf("payload = %+v", p)
}
}
@@ -1,373 +0,0 @@
package lidarrrequests
import (
"context"
"encoding/json"
"io/fs"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
// #5241: Lidarr names albums by MusicBrainz release group. A request carrying
// a group id completes against the album row whose tags named that group,
// once a track of it is on disk.
func TestReconciler_AlbumMatchesByReleaseGroup(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
enableLidarrForPool(t, pool)
user := seedUser(t, pool)
const artistMBID, release, group = "rg-artist-match", "rg-release-match", "rg-group-match"
artist := seedArtist(t, q, "RG Artist", artistMBID)
album := seedAlbum(t, q, artist.ID, "RG Album", release)
if err := q.SetAlbumReleaseGroupMbidIfNull(ctx, dbq.SetAlbumReleaseGroupMbidIfNullParams{
ID: album.ID, ReleaseGroupMbid: nilableStr(group),
}); err != nil {
t.Fatal(err)
}
_ = seedTrack(t, q, album.ID, artist.ID, "RG One", "/music/rg-match/01.flac")
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "album", LidarrArtistMBID: artistMBID, ArtistName: "RG Artist",
LidarrAlbumMBID: group, AlbumTitle: "RG Album",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
got, err := q.GetLidarrRequestByID(ctx, req.ID)
if err != nil {
t.Fatal(err)
}
if got.Status != dbq.LidarrRequestStatusCompleted || got.MatchedAlbumID != album.ID {
t.Errorf("status %v matched %v, want completed on %v", got.Status, got.MatchedAlbumID, album.ID)
}
}
// A re-acquisition request names an album whose row never went away. The row
// alone must not complete it: nothing has come back until a track is on disk.
func TestReconciler_AlbumWithEveryTrackMissingStaysApproved(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
enableLidarrForPool(t, pool)
user := seedUser(t, pool)
const artistMBID, release = "rg-artist-missing", "rg-release-missing"
artist := seedArtist(t, q, "Missing Artist", artistMBID)
album := seedAlbum(t, q, artist.ID, "Missing Album", release)
tr := seedTrack(t, q, album.ID, artist.ID, "Gone", "/music/rg-missing/01.flac")
if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = now() WHERE id = $1", tr.ID); err != nil {
t.Fatal(err)
}
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "album", LidarrArtistMBID: artistMBID, ArtistName: "Missing Artist",
LidarrAlbumMBID: release, AlbumTitle: "Missing Album",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
got, err := q.GetLidarrRequestByID(ctx, req.ID)
if err != nil {
t.Fatal(err)
}
if got.Status != dbq.LidarrRequestStatusApproved {
t.Errorf("status = %v, want approved while every track is missing", got.Status)
}
}
// fakeAlbumLidarr answers album lookups only for knownGroup (Lidarr's metadata
// is keyed by release group) and records every add.
type fakeAlbumLidarr struct {
knownGroup string
mu sync.Mutex
added []string
}
func (f *fakeAlbumLidarr) handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/metadataprofile"):
_, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`))
case strings.HasSuffix(r.URL.Path, "/album/lookup"):
if r.URL.Query().Get("term") == "lidarr:"+f.knownGroup {
_, _ = w.Write([]byte(`[{"foreignAlbumId":"` + f.knownGroup + `","artist":{"id":42}}]`))
return
}
_, _ = w.Write([]byte(`[]`))
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/album"):
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
f.mu.Lock()
f.added = append(f.added, body["foreignAlbumId"].(string))
f.mu.Unlock()
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":9}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}
// The ~49 requests on the deploy were stored under release ids. The reconciler
// resolves the group (MusicBrainz here, since no album row knows it), rewrites
// the request, adds it, and caches the group onto the album holding the
// release.
func TestReconciler_RepointsAReleaseIdRequestAtItsReleaseGroup(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
user := seedUser(t, pool)
const artistMBID, release, group = "rg-artist-repoint", "rg-release-repoint", "rg-group-repoint"
fake := &fakeAlbumLidarr{knownGroup: group}
srv := httptest.NewServer(http.HandlerFunc(fake.handler))
t.Cleanup(srv.Close)
if err := lidarrconfig.New(pool).Save(ctx, lidarrconfig.Config{
Enabled: true, BaseURL: srv.URL, APIKey: "k",
DefaultQualityProfileID: 7, DefaultRootFolderPath: "/music",
}); err != nil {
t.Fatal(err)
}
artist := seedArtist(t, q, "Repoint Artist", artistMBID)
album := seedAlbum(t, q, artist.ID, "Repoint Album", release)
tr := seedTrack(t, q, album.ID, artist.ID, "Lost", "/music/rg-repoint/01.flac")
if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = now() WHERE id = $1", tr.ID); err != nil {
t.Fatal(err)
}
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "album", LidarrArtistMBID: artistMBID, ArtistName: "Repoint Artist",
LidarrAlbumMBID: release, AlbumTitle: "Repoint Album",
})
rec := NewReconciler(pool, lidarrconfig.New(pool),
func() *lidarr.Client { return lidarr.NewClient(srv.URL, "k") }, newTestLogger(), nil)
asked := 0
rec.releaseGroup = func(_ context.Context, id string) (string, error) {
asked++
if id == release {
return group, nil
}
return "", tags.ErrNotFound
}
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
got, err := q.GetLidarrRequestByID(ctx, req.ID)
if err != nil {
t.Fatal(err)
}
if got.LidarrAlbumMbid == nil || *got.LidarrAlbumMbid != group {
t.Errorf("request album mbid = %v, want the release group %s", got.LidarrAlbumMbid, group)
}
if !got.LidarrAddConfirmedAt.Valid {
t.Error("add not confirmed after the repoint")
}
fake.mu.Lock()
added := append([]string(nil), fake.added...)
fake.mu.Unlock()
if len(added) != 1 || added[0] != group {
t.Errorf("Lidarr adds = %v, want [%s]", added, group)
}
var cached *string
if err := pool.QueryRow(ctx, "SELECT release_group_mbid FROM albums WHERE id = $1", album.ID).Scan(&cached); err != nil {
t.Fatal(err)
}
if cached == nil || *cached != group {
t.Errorf("album release group = %v, want %s cached", cached, group)
}
if got.Status != dbq.LidarrRequestStatusApproved {
t.Errorf("status = %v, want approved until a track is back", got.Status)
}
// The next tick finds the group on the request and the album: no second
// MusicBrainz question.
before := asked
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("second tick: %v", err)
}
if asked != before {
t.Errorf("MusicBrainz asked again on the next tick (%d -> %d)", before, asked)
}
}
// markMissing stamps a track missing since well before any request a test makes.
func markMissing(t *testing.T, pool *pgxpool.Pool, trackID pgtype.UUID) {
t.Helper()
if _, err := pool.Exec(context.Background(),
"UPDATE tracks SET missing_since = now() - interval '2 days' WHERE id = $1", trackID); err != nil {
t.Fatal(err)
}
}
func requestStatus(t *testing.T, q *dbq.Queries, id pgtype.UUID) dbq.LidarrRequest {
t.Helper()
got, err := q.GetLidarrRequestByID(context.Background(), id)
if err != nil {
t.Fatal(err)
}
return got
}
// #5263: re-acquisition targets albums with SOME tracks missing. The tracks
// that never left must not complete the request; it completes once the lost
// track is back.
func TestReconciler_PartlyMissingAlbumCompletesWhenTheLostTrackReturns(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
enableLidarrForPool(t, pool)
user := seedUser(t, pool)
const artistMBID, release = "rg-artist-partial", "rg-release-partial"
artist := seedArtist(t, q, "Partial Artist", artistMBID)
album := seedAlbum(t, q, artist.ID, "Partial Album", release)
_ = seedTrack(t, q, album.ID, artist.ID, "Kept", "/music/rg-partial/01.flac")
lost := seedTrack(t, q, album.ID, artist.ID, "Lost", "/music/rg-partial/02.flac")
markMissing(t, pool, lost.ID)
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "album", LidarrArtistMBID: artistMBID, ArtistName: "Partial Artist",
LidarrAlbumMBID: release, AlbumTitle: "Partial Album",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
if got := requestStatus(t, q, req.ID); got.Status != dbq.LidarrRequestStatusApproved {
t.Fatalf("status = %v while a track is still missing, want approved", got.Status)
}
if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = NULL WHERE id = $1", lost.ID); err != nil {
t.Fatal(err)
}
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("second tick: %v", err)
}
got := requestStatus(t, q, req.ID)
if got.Status != dbq.LidarrRequestStatusCompleted || got.MatchedAlbumID != album.ID {
t.Errorf("status %v matched %v after the track returned, want completed on %v",
got.Status, got.MatchedAlbumID, album.ID)
}
}
// Lidarr may fetch a different release of the group, which the scanner files
// as its own album row. A track arriving there after the request completes it,
// even though the old row's lost tracks stay missing.
func TestReconciler_PartlyMissingAlbumCompletesOnAnotherReleaseArriving(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
enableLidarrForPool(t, pool)
user := seedUser(t, pool)
const artistMBID, oldRelease, newRelease, group = "rg-artist-edition", "rg-release-old", "rg-release-new", "rg-group-edition"
artist := seedArtist(t, q, "Edition Artist", artistMBID)
old := seedAlbum(t, q, artist.ID, "Edition Album", oldRelease)
if err := q.SetAlbumReleaseGroupMbidIfNull(ctx, dbq.SetAlbumReleaseGroupMbidIfNullParams{
ID: old.ID, ReleaseGroupMbid: nilableStr(group),
}); err != nil {
t.Fatal(err)
}
_ = seedTrack(t, q, old.ID, artist.ID, "Kept", "/music/rg-edition-old/01.flac")
lost := seedTrack(t, q, old.ID, artist.ID, "Lost", "/music/rg-edition-old/02.flac")
markMissing(t, pool, lost.ID)
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "album", LidarrArtistMBID: artistMBID, ArtistName: "Edition Artist",
LidarrAlbumMBID: group, AlbumTitle: "Edition Album",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
if got := requestStatus(t, q, req.ID); got.Status != dbq.LidarrRequestStatusApproved {
t.Fatalf("status = %v before anything arrived, want approved", got.Status)
}
arrived := seedAlbum(t, q, artist.ID, "Edition Album (Deluxe)", newRelease)
if err := q.SetAlbumReleaseGroupMbidIfNull(ctx, dbq.SetAlbumReleaseGroupMbidIfNullParams{
ID: arrived.ID, ReleaseGroupMbid: nilableStr(group),
}); err != nil {
t.Fatal(err)
}
_ = seedTrack(t, q, arrived.ID, artist.ID, "Lost", "/music/rg-edition-new/02.flac")
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("second tick: %v", err)
}
got := requestStatus(t, q, req.ID)
if got.Status != dbq.LidarrRequestStatusCompleted || got.MatchedAlbumID != arrived.ID {
t.Errorf("status %v matched %v, want completed on the arrived release %v",
got.Status, got.MatchedAlbumID, arrived.ID)
}
}
// Migration 0071 reopens requests the old check completed against a partly
// missing album, and leaves a genuine completion alone.
func TestMigration0071_ReopensFalseCompletions(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
user := seedUser(t, pool)
artist := seedArtist(t, q, "Reopen Artist", "rg-artist-reopen")
partial := seedAlbum(t, q, artist.ID, "Partial", "rg-release-reopen-partial")
_ = seedTrack(t, q, partial.ID, artist.ID, "Kept", "/music/rg-reopen-partial/01.flac")
lost := seedTrack(t, q, partial.ID, artist.ID, "Lost", "/music/rg-reopen-partial/02.flac")
markMissing(t, pool, lost.ID)
falseReq := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "album", LidarrArtistMBID: "rg-artist-reopen", ArtistName: "Reopen Artist",
LidarrAlbumMBID: "rg-release-reopen-partial", AlbumTitle: "Partial",
})
genuineReq := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "album", LidarrArtistMBID: "rg-artist-reopen", ArtistName: "Reopen Artist",
LidarrAlbumMBID: "rg-release-reopen-new", AlbumTitle: "New",
})
arrived := seedAlbum(t, q, artist.ID, "New", "rg-release-reopen-new")
_ = seedTrack(t, q, arrived.ID, artist.ID, "Fresh", "/music/rg-reopen-new/01.flac")
for _, c := range []struct {
id pgtype.UUID
album pgtype.UUID
}{{falseReq.ID, partial.ID}, {genuineReq.ID, arrived.ID}} {
if _, err := q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{
ID: c.id, MatchedAlbumID: c.album,
}); err != nil {
t.Fatal(err)
}
}
up, err := fs.ReadFile(db.MigrationsFS(), "0071_reopen_false_reacquisition_completions.up.sql")
if err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, string(up)); err != nil {
t.Fatalf("migration: %v", err)
}
got := requestStatus(t, q, falseReq.ID)
if got.Status != dbq.LidarrRequestStatusApproved || got.CompletedAt.Valid || got.MatchedAlbumID.Valid {
t.Errorf("false completion: status %v completed_at %v matched %v, want approved and cleared",
got.Status, got.CompletedAt, got.MatchedAlbumID)
}
if got := requestStatus(t, q, genuineReq.ID); got.Status != dbq.LidarrRequestStatusCompleted {
t.Errorf("genuine completion reopened: status %v", got.Status)
}
}
-382
View File
@@ -1,382 +0,0 @@
// Package lidarrrequests owns the lifecycle of user requests to add
// music via Lidarr. The synchronous Service handles Create/List/
// Approve/Reject/Cancel; the async Reconciler (separate file) closes
// approved requests once their target track lands in the library.
package lidarrrequests
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
)
// Public errors. Handlers map these to API error codes.
var (
ErrInvalidKindFields = errors.New("lidarrrequests: missing required fields for kind")
ErrNotPending = errors.New("lidarrrequests: request is not pending")
// ErrNotFound aliases apierror.ErrNotFound so handlers can errors.Is
// against the shared sentinel; existing lidarrrequests.ErrNotFound
// callsites still resolve to the same pointer.
ErrNotFound = apierror.ErrNotFound
ErrLidarrDisabled = errors.New("lidarrrequests: lidarr not configured")
// ErrDefaultsIncomplete fires when Lidarr is enabled but the operator
// hasn't picked a default quality profile or root folder. Without those,
// Lidarr's add-artist/add-album endpoints reject the payload with a
// generic 5xx — surfacing a typed error here lets the admin UI tell the
// operator exactly what to fix.
ErrDefaultsIncomplete = errors.New("lidarrrequests: default quality profile or root folder not configured")
)
// CreateParams is the input for a new request from a user.
type CreateParams struct {
Kind string // "artist", "album", or "track"
LidarrArtistMBID string
LidarrAlbumMBID string // required for kind=album/track
LidarrTrackMBID string // required for kind=track
ArtistName string
AlbumTitle string // required for kind=album/track
TrackTitle string // required for kind=track
}
// ApproveOverrides lets the admin override the snapshot defaults for one
// approval. Zero values mean "use config default."
type ApproveOverrides struct {
QualityProfileID int
RootFolderPath string
}
// Service owns the request lifecycle. clientFn is a factory called per
// Approve so that BaseURL/APIKey changes in lidarrconfig take effect
// immediately without restarting. scanFn is called after a successful
// Approve to trigger a library scan.
type Service struct {
pool *pgxpool.Pool
lidarrCfg *lidarrconfig.Service
clientFn func() *lidarr.Client // factory, called per Approve
scanFn func()
}
// NewService constructs a Service. Pass nil for clientFn to disable Lidarr
// (Approve returns ErrLidarrDisabled). Pass nil for scanFn to no-op.
func NewService(pool *pgxpool.Pool, cfg *lidarrconfig.Service, clientFn func() *lidarr.Client, scanFn func()) *Service {
if scanFn == nil {
scanFn = func() {}
}
if clientFn == nil {
clientFn = func() *lidarr.Client { return nil }
}
return &Service{pool: pool, lidarrCfg: cfg, clientFn: clientFn, scanFn: scanFn}
}
// Create validates the kind→required-fields invariant and inserts a
// pending row.
func (s *Service) Create(ctx context.Context, userID pgtype.UUID, p CreateParams) (dbq.LidarrRequest, error) {
row, _, err := s.CreateTracked(ctx, userID, p)
return row, err
}
// CreateTracked is Create, also reporting whether a row was inserted. A
// request that dedupes into one already in flight is not new, so callers
// that announce new requests (M489) say nothing for it.
func (s *Service) CreateTracked(ctx context.Context, userID pgtype.UUID, p CreateParams) (dbq.LidarrRequest, bool, error) {
if err := validateKindFields(p); err != nil {
return dbq.LidarrRequest{}, false, err
}
q := dbq.New(s.pool)
// Dedup: if a non-terminal request for this MBID already exists,
// return it instead of inserting a duplicate. The Discover/search UI
// already hides requested candidates; this backstops races and direct
// API callers so we don't accrue duplicate request rows. Match on the
// new request's own-kind MBID (mirrors HasNonTerminalRequestForMBID).
dedupMBID := p.LidarrArtistMBID
switch p.Kind {
case "album":
dedupMBID = p.LidarrAlbumMBID
case "track":
dedupMBID = p.LidarrTrackMBID
}
if existing, derr := q.GetNonTerminalRequestForMBID(ctx, dedupMBID); derr == nil {
return existing, false, nil
} else if !errors.Is(derr, pgx.ErrNoRows) {
return dbq.LidarrRequest{}, false, fmt.Errorf("create: dedup check: %w", derr)
}
row, err := q.CreateLidarrRequest(ctx, dbq.CreateLidarrRequestParams{
UserID: userID,
Kind: dbq.LidarrRequestKind(p.Kind),
LidarrArtistMbid: p.LidarrArtistMBID,
LidarrAlbumMbid: strPtr(p.LidarrAlbumMBID),
LidarrTrackMbid: strPtr(p.LidarrTrackMBID),
ArtistName: p.ArtistName,
AlbumTitle: strPtr(p.AlbumTitle),
TrackTitle: strPtr(p.TrackTitle),
})
if err != nil {
return dbq.LidarrRequest{}, false, fmt.Errorf("create: %w", err)
}
return row, true, nil
}
// DisplayName is a request as a person would say it: "Moe Shop" for an
// artist, "Moe Shop – WWW" for an album or a track.
func DisplayName(row dbq.LidarrRequest) string {
switch row.Kind {
case dbq.LidarrRequestKindTrack:
if row.TrackTitle != nil && *row.TrackTitle != "" {
return row.ArtistName + " – " + *row.TrackTitle
}
case dbq.LidarrRequestKindAlbum:
if row.AlbumTitle != nil && *row.AlbumTitle != "" {
return row.ArtistName + " – " + *row.AlbumTitle
}
}
return row.ArtistName
}
// requestedTitle is the album or track a request names, or "" for an
// artist request. DisplayName is the artist and this, joined.
func requestedTitle(row dbq.LidarrRequest) string {
switch {
case row.Kind == dbq.LidarrRequestKindTrack && row.TrackTitle != nil:
return *row.TrackTitle
case row.Kind == dbq.LidarrRequestKindAlbum && row.AlbumTitle != nil:
return *row.AlbumTitle
}
return ""
}
func validateKindFields(p CreateParams) error {
if p.LidarrArtistMBID == "" || p.ArtistName == "" {
return fmt.Errorf("%w: artist_mbid and artist_name are always required", ErrInvalidKindFields)
}
switch p.Kind {
case "artist":
// fine
case "album":
if p.LidarrAlbumMBID == "" || p.AlbumTitle == "" {
return fmt.Errorf("%w: album kind requires album_mbid and album_title", ErrInvalidKindFields)
}
case "track":
if p.LidarrAlbumMBID == "" || p.AlbumTitle == "" {
return fmt.Errorf("%w: track kind requires album_mbid and album_title (track will be promoted)", ErrInvalidKindFields)
}
if p.LidarrTrackMBID == "" || p.TrackTitle == "" {
return fmt.Errorf("%w: track kind requires track_mbid and track_title", ErrInvalidKindFields)
}
default:
return fmt.Errorf("%w: unknown kind %q", ErrInvalidKindFields, p.Kind)
}
return nil
}
// ListPending returns pending requests ordered by requested_at DESC.
func (s *Service) ListPending(ctx context.Context, limit int32) ([]dbq.LidarrRequest, error) {
return dbq.New(s.pool).ListLidarrRequestsByStatus(ctx, dbq.ListLidarrRequestsByStatusParams{
Status: dbq.LidarrRequestStatusPending, Limit: limit,
})
}
// ListByStatus returns requests with the given status, ordered by requested_at DESC.
func (s *Service) ListByStatus(ctx context.Context, status string, limit int32) ([]dbq.LidarrRequest, error) {
return dbq.New(s.pool).ListLidarrRequestsByStatus(ctx, dbq.ListLidarrRequestsByStatusParams{
Status: dbq.LidarrRequestStatus(status), Limit: limit,
})
}
// ListForUser returns all requests by a user, ordered by requested_at DESC.
func (s *Service) ListForUser(ctx context.Context, userID pgtype.UUID, limit int32) ([]dbq.LidarrRequest, error) {
return dbq.New(s.pool).ListLidarrRequestsForUser(ctx, dbq.ListLidarrRequestsForUserParams{
UserID: userID, Limit: limit,
})
}
// Approve records the admin's approval DURABLY first (snapshotting the
// chosen quality profile + root folder), then makes a best-effort Lidarr
// add and triggers a library scan. The add is no longer a gate: if Lidarr
// is unreachable the request still becomes approved and the reconciler
// idempotently (re)sends the add on each tick until it sticks (see
// sendLidarrAdd + Reconciler.ensureLidarrAdd) — "Lidarr just keeps
// trying" without the admin re-clicking. Pre-conditions (lidarr disabled,
// not pending, defaults incomplete) still fail fast before any approval.
func (s *Service) Approve(ctx context.Context, requestID pgtype.UUID, adminID pgtype.UUID, ov ApproveOverrides) (dbq.LidarrRequest, error) {
cfg, err := s.lidarrCfg.Get(ctx)
if err != nil {
return dbq.LidarrRequest{}, fmt.Errorf("approve: load config: %w", err)
}
client := s.clientFn()
if !cfg.Enabled || client == nil {
return dbq.LidarrRequest{}, ErrLidarrDisabled
}
row, err := dbq.New(s.pool).GetLidarrRequestByID(ctx, requestID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return dbq.LidarrRequest{}, ErrNotFound
}
return dbq.LidarrRequest{}, fmt.Errorf("approve: get: %w", err)
}
if row.Status != dbq.LidarrRequestStatusPending {
return dbq.LidarrRequest{}, ErrNotPending
}
qp := cfg.DefaultQualityProfileID
if ov.QualityProfileID != 0 {
qp = ov.QualityProfileID
}
rf := cfg.DefaultRootFolderPath
if ov.RootFolderPath != "" {
rf = ov.RootFolderPath
}
if qp == 0 || rf == "" {
return dbq.LidarrRequest{}, ErrDefaultsIncomplete
}
// Record the approval durably FIRST so a Lidarr outage can't lose the
// admin's decision. The snapshotted qp/rf also feed the reconciler's
// retry, so a deferred add uses exactly what the admin approved with.
approved, err := dbq.New(s.pool).ApproveLidarrRequest(ctx, dbq.ApproveLidarrRequestParams{
ID: requestID,
QualityProfileID: int32Ptr(qp),
RootFolderPath: strPtr(rf),
DecidedBy: adminID,
})
if err != nil {
return dbq.LidarrRequest{}, fmt.Errorf("approve: persist: %w", err)
}
// Best-effort immediate add for the common (Lidarr-up) path. Any
// failure is deliberately swallowed: the request is already durably
// approved and the reconciler retries the add on its next tick,
// leaving lidarr_add_confirmed_at NULL until it succeeds.
if addErr := sendLidarrAdd(ctx, client, cfg, approved); addErr == nil {
_ = dbq.New(s.pool).MarkLidarrRequestAddConfirmed(ctx, requestID)
}
s.scanFn()
return approved, nil
}
// sendLidarrAdd performs the idempotent Lidarr add for an approved
// request. Used by Service.Approve (best-effort, common path) and by the
// Reconciler (retry path). qp/rf come from the request's snapshot (set at
// Approve time) with a config-default fallback; the metadata profile is
// resolved from config, falling back to Lidarr's first profile for
// operators who upgraded from before metadata-profile support. Treats
// lidarr.ErrAlreadyExists as success — the artist/album is already in
// Lidarr, which is exactly the desired end state.
func sendLidarrAdd(ctx context.Context, client *lidarr.Client, cfg lidarrconfig.Config, row dbq.LidarrRequest) error {
qp := cfg.DefaultQualityProfileID
if row.QualityProfileID != nil && *row.QualityProfileID != 0 {
qp = int(*row.QualityProfileID)
}
rf := cfg.DefaultRootFolderPath
if row.RootFolderPath != nil && *row.RootFolderPath != "" {
rf = *row.RootFolderPath
}
if qp == 0 || rf == "" {
return ErrDefaultsIncomplete
}
mdProfileID := cfg.DefaultMetadataProfileID
if mdProfileID == 0 {
mdProfiles, mdErr := client.ListMetadataProfiles(ctx)
if mdErr != nil {
return fmt.Errorf("list metadata profiles: %w", mdErr)
}
if len(mdProfiles) == 0 {
return fmt.Errorf("no metadata profiles configured in lidarr")
}
mdProfileID = mdProfiles[0].ID
}
var err error
switch row.Kind {
case dbq.LidarrRequestKindArtist:
err = client.AddArtist(ctx, lidarr.AddArtistParams{
ForeignArtistID: row.LidarrArtistMbid,
ArtistName: row.ArtistName,
QualityProfileID: qp,
MetadataProfileID: mdProfileID,
RootFolderPath: rf,
MonitorAll: true,
})
case dbq.LidarrRequestKindAlbum, dbq.LidarrRequestKindTrack:
// Track-kind requests promote to album-add; the spec is explicit.
albumMBID := ""
if row.LidarrAlbumMbid != nil {
albumMBID = *row.LidarrAlbumMbid
}
err = client.AddAlbum(ctx, lidarr.AddAlbumParams{
ForeignAlbumID: albumMBID,
ForeignArtistID: row.LidarrArtistMbid,
ArtistName: row.ArtistName,
QualityProfileID: qp,
MetadataProfileID: mdProfileID,
RootFolderPath: rf,
})
}
if err != nil && !errors.Is(err, lidarr.ErrAlreadyExists) {
return err
}
return nil
}
// Reject transitions a pending request to rejected, recording notes and
// who decided. Returns ErrNotPending if the request is not pending,
// ErrNotFound if no such request exists.
func (s *Service) Reject(ctx context.Context, requestID pgtype.UUID, adminID pgtype.UUID, notes string) (dbq.LidarrRequest, error) {
row, err := dbq.New(s.pool).RejectLidarrRequest(ctx, dbq.RejectLidarrRequestParams{
ID: requestID, Notes: strPtr(notes), DecidedBy: adminID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// Either not found OR not pending — check which.
cur, gerr := dbq.New(s.pool).GetLidarrRequestByID(ctx, requestID)
if gerr != nil {
return dbq.LidarrRequest{}, ErrNotFound
}
if cur.Status != dbq.LidarrRequestStatusPending {
return dbq.LidarrRequest{}, ErrNotPending
}
return dbq.LidarrRequest{}, ErrNotFound
}
return dbq.LidarrRequest{}, fmt.Errorf("reject: %w", err)
}
return row, nil
}
// Cancel lets a user withdraw their own pending request. The SQL WHERE
// clause enforces both ownership (user_id = $2) and pending status
// (status = 'pending'). A zero-rows result always means ErrNotPending
// from the caller's perspective.
func (s *Service) Cancel(ctx context.Context, requestID pgtype.UUID, userID pgtype.UUID) (dbq.LidarrRequest, error) {
row, err := dbq.New(s.pool).CancelLidarrRequest(ctx, dbq.CancelLidarrRequestParams{
ID: requestID, DecidedBy: userID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return dbq.LidarrRequest{}, ErrNotPending
}
return dbq.LidarrRequest{}, fmt.Errorf("cancel: %w", err)
}
return row, nil
}
func strPtr(s string) *string {
if s == "" {
return nil
}
return &s
}
func int32Ptr(i int) *int32 {
if i == 0 {
return nil
}
v := int32(i)
return &v
}
-343
View File
@@ -1,343 +0,0 @@
package lidarrrequests
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
)
func newPool(t *testing.T) *pgxpool.Pool {
t.Helper()
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
dbtest.ResetDB(t, pool)
if _, err := pool.Exec(context.Background(),
"DELETE FROM lidarr_requests; UPDATE lidarr_config SET enabled=false, base_url=NULL, api_key=NULL WHERE id=1",
); err != nil {
t.Fatalf("reset lidarr tables: %v", err)
}
return pool
}
func seedUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID {
t.Helper()
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "rqtester", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: false,
})
if err != nil {
t.Fatalf("seed user: %v", err)
}
return u.ID
}
func TestCreate_HappyPath_Artist(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool)
svc := NewService(pool, lidarrconfig.New(pool), nil, nil)
r, err := svc.Create(context.Background(), user, CreateParams{
Kind: "artist",
LidarrArtistMBID: "069b64b6-7884-4f6a-94cc-e4c1d6c87a01",
ArtistName: "Boards of Canada",
})
if err != nil {
t.Fatalf("Create: %v", err)
}
if r.Status != dbq.LidarrRequestStatusPending {
t.Errorf("status = %v", r.Status)
}
}
func TestCreate_TrackKindRequiresAlbumMBID(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool)
svc := NewService(pool, lidarrconfig.New(pool), nil, nil)
_, err := svc.Create(context.Background(), user, CreateParams{
Kind: "track",
LidarrArtistMBID: "a-mbid", ArtistName: "X",
LidarrTrackMBID: "t-mbid", TrackTitle: "Y",
// missing album fields
})
if !errors.Is(err, ErrInvalidKindFields) {
t.Fatalf("err = %v, want ErrInvalidKindFields", err)
}
}
func TestApprove_NotConfigured(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool)
svc := NewService(pool, lidarrconfig.New(pool), nil, nil)
r, _ := svc.Create(context.Background(), user, CreateParams{
Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X",
})
_, err := svc.Approve(context.Background(), r.ID, user, ApproveOverrides{})
if !errors.Is(err, ErrLidarrDisabled) {
t.Fatalf("err = %v, want ErrLidarrDisabled", err)
}
}
func TestReject_TransitionsToRejected(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool)
svc := NewService(pool, lidarrconfig.New(pool), nil, nil)
r, _ := svc.Create(context.Background(), user, CreateParams{
Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X",
})
rejected, err := svc.Reject(context.Background(), r.ID, user, "low quality")
if err != nil {
t.Fatalf("Reject: %v", err)
}
if rejected.Status != dbq.LidarrRequestStatusRejected {
t.Errorf("status = %v", rejected.Status)
}
}
func TestReject_AlreadyRejectedReturnsErrNotPending(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool)
svc := NewService(pool, lidarrconfig.New(pool), nil, nil)
r, _ := svc.Create(context.Background(), user, CreateParams{
Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X",
})
_, _ = svc.Reject(context.Background(), r.ID, user, "first")
_, err := svc.Reject(context.Background(), r.ID, user, "second")
if !errors.Is(err, ErrNotPending) {
t.Fatalf("err = %v, want ErrNotPending", err)
}
}
func TestCancel_OwnPendingOnly(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool)
svc := NewService(pool, lidarrconfig.New(pool), nil, nil)
r, _ := svc.Create(context.Background(), user, CreateParams{
Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X",
})
if _, err := svc.Cancel(context.Background(), r.ID, user); err != nil {
t.Fatalf("Cancel: %v", err)
}
// Second cancel hits "not pending" because we just rejected it.
if _, err := svc.Cancel(context.Background(), r.ID, user); !errors.Is(err, ErrNotPending) {
t.Errorf("err = %v, want ErrNotPending", err)
}
}
func TestCreate_AlbumKindRequiresAlbumMBID(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool)
svc := NewService(pool, lidarrconfig.New(pool), nil, nil)
_, err := svc.Create(context.Background(), user, CreateParams{
Kind: "album",
LidarrArtistMBID: "a-mbid", ArtistName: "X",
// missing album_mbid + album_title
})
if !errors.Is(err, ErrInvalidKindFields) {
t.Fatalf("err = %v, want ErrInvalidKindFields", err)
}
}
func TestCreate_UnknownKindRejected(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool)
svc := NewService(pool, lidarrconfig.New(pool), nil, nil)
_, err := svc.Create(context.Background(), user, CreateParams{
Kind: "playlist", LidarrArtistMBID: "a-mbid", ArtistName: "X",
})
if !errors.Is(err, ErrInvalidKindFields) {
t.Fatalf("err = %v, want ErrInvalidKindFields", err)
}
}
func TestListPending_AndListByStatus(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool)
svc := NewService(pool, lidarrconfig.New(pool), nil, nil)
for _, name := range []string{"A", "B"} {
if _, err := svc.Create(context.Background(), user, CreateParams{
Kind: "artist", LidarrArtistMBID: name + "-mbid", ArtistName: name,
}); err != nil {
t.Fatalf("Create(%s): %v", name, err)
}
}
pending, err := svc.ListPending(context.Background(), 50)
if err != nil {
t.Fatalf("ListPending: %v", err)
}
if len(pending) != 2 {
t.Errorf("ListPending len = %d, want 2", len(pending))
}
rejected, err := svc.ListByStatus(context.Background(), "rejected", 50)
if err != nil {
t.Fatalf("ListByStatus(rejected): %v", err)
}
if len(rejected) != 0 {
t.Errorf("ListByStatus(rejected) len = %d, want 0", len(rejected))
}
}
func TestListForUser_OnlyOwnRows(t *testing.T) {
pool := newPool(t)
alice := seedUser(t, pool)
bob, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiTokenHash: "x2", IsAdmin: false,
})
if err != nil {
t.Fatalf("seed bob: %v", err)
}
svc := NewService(pool, lidarrconfig.New(pool), nil, nil)
if _, err := svc.Create(context.Background(), alice, CreateParams{
Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "Alice's request",
}); err != nil {
t.Fatalf("Create(alice): %v", err)
}
if _, err := svc.Create(context.Background(), bob.ID, CreateParams{
Kind: "artist", LidarrArtistMBID: "b-mbid", ArtistName: "Bob's request",
}); err != nil {
t.Fatalf("Create(bob): %v", err)
}
rows, err := svc.ListForUser(context.Background(), alice, 50)
if err != nil {
t.Fatalf("ListForUser: %v", err)
}
if len(rows) != 1 || rows[0].ArtistName != "Alice's request" {
t.Errorf("ListForUser = %+v, want only Alice's row", rows)
}
}
// approveTestSetup wires Service against a stub Lidarr server. Returns the
// Service, an admin user id, and the captured Lidarr request body for the
// most recent call (handler updates it in-place).
func approveTestSetup(t *testing.T) (*Service, pgtype.UUID, *pgxpool.Pool, *httptest.Server) {
t.Helper()
pool := newPool(t)
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
// Approve resolves metadata/quality profiles before the add;
// those list endpoints return JSON arrays, not the {"id":1}
// object the add endpoints return.
switch {
case strings.Contains(r.URL.Path, "/metadataprofile"):
_, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`))
case strings.Contains(r.URL.Path, "/qualityprofile"):
_, _ = w.Write([]byte(`[{"id":7,"name":"Lossless"}]`))
default:
_, _ = w.Write([]byte(`{"id":1}`))
}
}))
t.Cleanup(stub.Close)
cfg := lidarrconfig.New(pool)
if err := cfg.Save(context.Background(), lidarrconfig.Config{
Enabled: true,
BaseURL: stub.URL,
APIKey: "k",
DefaultQualityProfileID: 7,
DefaultRootFolderPath: "/music",
}); err != nil {
t.Fatalf("save config: %v", err)
}
clientFn := func() *lidarr.Client { return lidarr.NewClient(stub.URL, "k") }
svc := NewService(pool, cfg, clientFn, nil)
user := seedUser(t, pool)
return svc, user, pool, stub
}
func TestApprove_HappyPath_ArtistKind(t *testing.T) {
svc, user, _, _ := approveTestSetup(t)
r, err := svc.Create(context.Background(), user, CreateParams{
Kind: "artist", LidarrArtistMBID: "ar-mbid", ArtistName: "Approvee",
})
if err != nil {
t.Fatalf("Create: %v", err)
}
approved, err := svc.Approve(context.Background(), r.ID, user, ApproveOverrides{})
if err != nil {
t.Fatalf("Approve: %v", err)
}
if approved.Status != dbq.LidarrRequestStatusApproved {
t.Errorf("status = %v, want approved", approved.Status)
}
// Default snapshot from config.
if approved.QualityProfileID == nil || *approved.QualityProfileID != 7 {
t.Errorf("quality_profile_id = %v, want 7", approved.QualityProfileID)
}
if approved.RootFolderPath == nil || *approved.RootFolderPath != "/music" {
t.Errorf("root_folder_path = %v, want /music", approved.RootFolderPath)
}
}
func TestApprove_HappyPath_AlbumKindWithOverride(t *testing.T) {
svc, user, _, _ := approveTestSetup(t)
r, err := svc.Create(context.Background(), user, CreateParams{
Kind: "album",
LidarrArtistMBID: "ar-mbid", ArtistName: "X",
LidarrAlbumMBID: "al-mbid", AlbumTitle: "Y",
})
if err != nil {
t.Fatalf("Create: %v", err)
}
approved, err := svc.Approve(context.Background(), r.ID, user, ApproveOverrides{
QualityProfileID: 99,
RootFolderPath: "/elsewhere",
})
if err != nil {
t.Fatalf("Approve: %v", err)
}
if approved.QualityProfileID == nil || *approved.QualityProfileID != 99 {
t.Errorf("quality_profile_id = %v, want 99 (override)", approved.QualityProfileID)
}
if approved.RootFolderPath == nil || *approved.RootFolderPath != "/elsewhere" {
t.Errorf("root_folder_path = %v, want /elsewhere", approved.RootFolderPath)
}
}
func TestApprove_AlreadyApprovedReturnsErrNotPending(t *testing.T) {
svc, user, _, _ := approveTestSetup(t)
r, _ := svc.Create(context.Background(), user, CreateParams{
Kind: "artist", LidarrArtistMBID: "ar-mbid", ArtistName: "X",
})
if _, err := svc.Approve(context.Background(), r.ID, user, ApproveOverrides{}); err != nil {
t.Fatalf("first approve: %v", err)
}
if _, err := svc.Approve(context.Background(), r.ID, user, ApproveOverrides{}); !errors.Is(err, ErrNotPending) {
t.Errorf("second approve err = %v, want ErrNotPending", err)
}
}
func TestApprove_NotFound(t *testing.T) {
svc, user, _, _ := approveTestSetup(t)
var bogus pgtype.UUID
bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
bogus.Valid = true
if _, err := svc.Approve(context.Background(), bogus, user, ApproveOverrides{}); !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
+19 -68
View File
@@ -1,11 +1,12 @@
// Package reacquisition turns a missing file back into a Lidarr request
// without anyone pressing anything (milestone #290).
// Package reacquisition wants an album back when its files go missing,
// without anyone pressing anything (milestone #290; a want rather than a
// Lidarr request since M502 #5520).
//
// The unit of work is the ALBUM, not the track. Lidarr acquires releases;
// there is no meaningful "fetch me one track", and a track-kind request needs
// a recording MBID plenty of files lack. Grouping also does most of the
// The unit of work is the ALBUM, not the track. Music is acquired as
// releases; there is no meaningful "fetch me one track", and a recording
// MBID is something plenty of files lack. Grouping also does most of the
// safety work: the loss that produced #2523 — three reorganised albums, ~40
// missing files — becomes three requests rather than forty.
// missing files — becomes three wants rather than forty.
package reacquisition
import (
@@ -14,7 +15,6 @@ import (
"fmt"
"log/slog"
"sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
@@ -24,13 +24,9 @@ import (
// Settings is the operator-tunable policy, mirroring the columns and CHECK
// ranges in migration 0056.
type Settings struct {
Enabled bool
GraceHours int32
BackoffBaseHours int32
BackoffMaxHours int32
MaxAttempts int32
MaxPerPass int32
AutoApprove bool
Enabled bool
GraceHours int32
MaxPerPass int32
}
// Defaults mirror migration 0056's column defaults. Duplicated here so a
@@ -38,13 +34,9 @@ type Settings struct {
// zero-valued one — a zero grace window would fire on every transient
// unmount, which is the exact failure the grace period exists to prevent.
var Defaults = Settings{
Enabled: true,
GraceHours: 24,
BackoffBaseHours: 6,
BackoffMaxHours: 168,
MaxAttempts: 3,
MaxPerPass: 20,
AutoApprove: true,
Enabled: true,
GraceHours: 24,
MaxPerPass: 20,
}
// ErrOutOfRange is returned by Set for values migration 0056's CHECKs would
@@ -92,13 +84,9 @@ func (s *SettingsService) Set(ctx context.Context, in Settings) (Settings, error
return Settings{}, err
}
row, err := dbq.New(s.pool).UpdateReacquisitionSettings(ctx, dbq.UpdateReacquisitionSettingsParams{
Enabled: in.Enabled,
GraceHours: in.GraceHours,
BackoffBaseHours: in.BackoffBaseHours,
BackoffMaxHours: in.BackoffMaxHours,
MaxAttempts: in.MaxAttempts,
MaxPerPass: in.MaxPerPass,
AutoApprove: in.AutoApprove,
Enabled: in.Enabled,
GraceHours: in.GraceHours,
MaxPerPass: in.MaxPerPass,
})
if err != nil {
return Settings{}, fmt.Errorf("reacquisition: save settings: %w", err)
@@ -110,43 +98,10 @@ func (s *SettingsService) Set(ctx context.Context, in Settings) (Settings, error
return out, nil
}
// Backoff is how long to wait before the next attempt on an album that has
// already been tried [attempts] times: base * 2^(attempts-1), clamped to the
// configured maximum. Zero attempts means "never tried", which is always due.
//
// Exported and pure so the schedule is testable without a database, and so
// the admin surface can show the same number the sweeper will act on.
func (s Settings) Backoff(attempts int32) time.Duration {
if attempts <= 0 {
return 0
}
hours := s.BackoffBaseHours
for i := int32(1); i < attempts; i++ {
hours *= 2
// Clamp inside the loop as well as after: doubling from a large base
// enough times would overflow int32 before the comparison ran.
if hours >= s.BackoffMaxHours {
return time.Duration(s.BackoffMaxHours) * time.Hour
}
}
if hours > s.BackoffMaxHours {
hours = s.BackoffMaxHours
}
return time.Duration(hours) * time.Hour
}
func validate(in Settings) error {
switch {
case in.GraceHours < 1 || in.GraceHours > 720:
return fmt.Errorf("%w: grace_hours must be 1-720", ErrOutOfRange)
case in.BackoffBaseHours < 1 || in.BackoffBaseHours > 168:
return fmt.Errorf("%w: backoff_base_hours must be 1-168", ErrOutOfRange)
case in.BackoffMaxHours < 1 || in.BackoffMaxHours > 720:
return fmt.Errorf("%w: backoff_max_hours must be 1-720", ErrOutOfRange)
case in.BackoffMaxHours < in.BackoffBaseHours:
return fmt.Errorf("%w: backoff_max_hours must be >= backoff_base_hours", ErrOutOfRange)
case in.MaxAttempts < 1 || in.MaxAttempts > 10:
return fmt.Errorf("%w: max_attempts must be 1-10", ErrOutOfRange)
case in.MaxPerPass < 1 || in.MaxPerPass > 200:
return fmt.Errorf("%w: max_per_pass must be 1-200", ErrOutOfRange)
}
@@ -155,12 +110,8 @@ func validate(in Settings) error {
func fromRow(row dbq.ReacquisitionSetting) Settings {
return Settings{
Enabled: row.Enabled,
GraceHours: row.GraceHours,
BackoffBaseHours: row.BackoffBaseHours,
BackoffMaxHours: row.BackoffMaxHours,
MaxAttempts: row.MaxAttempts,
MaxPerPass: row.MaxPerPass,
AutoApprove: row.AutoApprove,
Enabled: row.Enabled,
GraceHours: row.GraceHours,
MaxPerPass: row.MaxPerPass,
}
}
-85
View File
@@ -3,69 +3,8 @@ package reacquisition
import (
"errors"
"testing"
"time"
)
func TestBackoffSchedule(t *testing.T) {
s := Defaults // 6h base, 168h (one week) cap, 3 attempts
cases := []struct {
name string
attempts int32
want time.Duration
}{
// Never tried is always due — the grace window, not the backoff, is
// what holds the first attempt back.
{"never attempted", 0, 0},
{"negative is treated as never", -1, 0},
{"after one attempt", 1, 6 * time.Hour},
{"after two", 2, 12 * time.Hour},
{"after three", 3, 24 * time.Hour},
{"after four", 4, 48 * time.Hour},
{"after five", 5, 96 * time.Hour},
// 6h * 2^5 = 192h, past the one-week cap.
{"clamped at the cap", 6, 168 * time.Hour},
{"still clamped far out", 20, 168 * time.Hour},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := s.Backoff(c.attempts); got != c.want {
t.Errorf("Backoff(%d) = %v, want %v", c.attempts, got, c.want)
}
})
}
}
// The doubling must not be able to overflow int32 before the clamp is
// consulted — a large base with a high attempt count is the case that would
// wrap negative and make a spent album look due immediately.
func TestBackoffDoesNotOverflow(t *testing.T) {
s := Settings{BackoffBaseHours: 168, BackoffMaxHours: 720}
for attempts := int32(1); attempts <= 40; attempts++ {
got := s.Backoff(attempts)
if got <= 0 {
t.Fatalf("Backoff(%d) = %v, want a positive duration", attempts, got)
}
if got > 720*time.Hour {
t.Fatalf("Backoff(%d) = %v, want <= the 720h cap", attempts, got)
}
}
}
func TestBackoffRespectsCustomSettings(t *testing.T) {
s := Settings{BackoffBaseHours: 1, BackoffMaxHours: 4}
for attempts, want := range map[int32]time.Duration{
1: 1 * time.Hour,
2: 2 * time.Hour,
3: 4 * time.Hour,
4: 4 * time.Hour, // clamped
} {
if got := s.Backoff(attempts); got != want {
t.Errorf("Backoff(%d) = %v, want %v", attempts, got, want)
}
}
}
func TestValidateRejectsWhatTheCheckWouldReject(t *testing.T) {
// Each case mirrors a CHECK in migration 0056. Validating in Go as well
// means the API answers 400 with a readable message instead of surfacing
@@ -77,15 +16,6 @@ func TestValidateRejectsWhatTheCheckWouldReject(t *testing.T) {
{"zero grace would fire on every transient unmount",
mutate(func(s *Settings) { s.GraceHours = 0 })},
{"grace beyond a month", mutate(func(s *Settings) { s.GraceHours = 721 })},
{"zero backoff base", mutate(func(s *Settings) { s.BackoffBaseHours = 0 })},
{"backoff base beyond a week", mutate(func(s *Settings) { s.BackoffBaseHours = 169 })},
{"zero backoff cap", mutate(func(s *Settings) { s.BackoffMaxHours = 0 })},
{"cap below base is incoherent", mutate(func(s *Settings) {
s.BackoffBaseHours = 48
s.BackoffMaxHours = 24
})},
{"zero attempts means never try", mutate(func(s *Settings) { s.MaxAttempts = 0 })},
{"attempts beyond ten", mutate(func(s *Settings) { s.MaxAttempts = 11 })},
{"zero per pass means never sweep", mutate(func(s *Settings) { s.MaxPerPass = 0 })},
{"per pass beyond the cap", mutate(func(s *Settings) { s.MaxPerPass = 201 })},
}
@@ -104,21 +34,6 @@ func TestValidateAcceptsDefaults(t *testing.T) {
}
}
// Equal base and cap is legal — it is how an operator asks for a flat retry
// interval rather than an escalating one.
func TestValidateAcceptsFlatBackoff(t *testing.T) {
s := mutate(func(s *Settings) {
s.BackoffBaseHours = 12
s.BackoffMaxHours = 12
})
if err := validate(s); err != nil {
t.Fatalf("flat backoff should be allowed, got %v", err)
}
if got := s.Backoff(5); got != 12*time.Hour {
t.Errorf("flat backoff gave %v, want 12h at every attempt", got)
}
}
func mutate(f func(*Settings)) Settings {
s := Defaults
f(&s)
+37 -166
View File
@@ -7,61 +7,38 @@ import (
"log/slog"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
// requestCreator is the slice of lidarrrequests.Service the sweeper needs,
// narrowed to an interface so the pass can be tested without a Lidarr client
// or an approval path that talks to one.
type requestCreator interface {
CreateTracked(ctx context.Context, userID pgtype.UUID, p lidarrrequests.CreateParams) (dbq.LidarrRequest, bool, error)
Approve(ctx context.Context, requestID, adminID pgtype.UUID, ov lidarrrequests.ApproveOverrides) (dbq.LidarrRequest, error)
}
// Sweeper periodically turns albums with long-missing files into Lidarr
// requests (milestone #290).
// Sweeper periodically wants back albums whose files have been missing past
// the grace window (milestone #290, M502 #5520). Searching for them is the
// search schedule's (#5521), which retries per want.
//
// Deliberately a periodic worker rather than a hook inside the scanner's
// reconcile pass. Reconcile runs inside a scan and has no business deciding
// to talk to a third-party service; it also re-runs often, which would make
// "attempt once, then back off" awkward to express. A worker paces itself,
// survives a restart, and retries without needing another scan.
// reconcile pass: reconcile runs inside a scan and re-runs often, and a
// filesystem lies transiently (an unmounted volume, a NAS mid-reboot) in ways
// the grace window exists to ride out.
type Sweeper struct {
pool *pgxpool.Pool
settings *SettingsService
requests requestCreator
logger *slog.Logger
// notifier tells admins about a request the sweeper filed that still
// needs their approval (M489). Nil: nobody is told.
notifier *notifications.Notifier
tick time.Duration
// releaseGroup names the MusicBrainz release group of a release id, for an
// album whose tags never carried one (#5241). Lidarr knows albums only by
// release group. A field so tests can stand in for MusicBrainz.
// album whose tags never carried one (#5241). Wants are kept by release
// group. A field so tests can stand in for MusicBrainz.
releaseGroup func(ctx context.Context, releaseMBID string) (string, error)
}
// NewSweeper constructs a Sweeper. The tick is deliberately coarse: the
// smallest meaningful backoff is measured in hours, so waking more often than
// hourly would only re-read settings and find nothing due.
func NewSweeper(
pool *pgxpool.Pool,
settings *SettingsService,
requests requestCreator,
logger *slog.Logger,
) *Sweeper {
// NewSweeper constructs a Sweeper. The tick is deliberately coarse: the grace
// window is measured in hours.
func NewSweeper(pool *pgxpool.Pool, settings *SettingsService, logger *slog.Logger) *Sweeper {
return &Sweeper{
pool: pool,
settings: settings,
requests: requests,
logger: logger,
tick: 1 * time.Hour,
releaseGroup: tags.ReleaseGroupForRelease,
@@ -86,100 +63,49 @@ func (s *Sweeper) Run(ctx context.Context) {
// PassResult reports what a single sweep did, for logging and tests.
type PassResult struct {
Cleared int64 // albums whose files came back; state dropped
Requested int // requests created this pass
Approved int // of those, sent on to Lidarr
GaveUp int // albums that spent their attempt budget
Unnameable int64 // albums with missing files but no MBID to ask for
Wanted int // albums newly wanted, or wanted again after being held
Unnameable int64 // albums with missing files but no MBID to want them by
// Unresolved: albums skipped this pass because MusicBrainz could not name
// their release group (switched off, or no such release). Lidarr cannot be
// asked for them, and no attempt is spent; the next pass tries again.
// their release group; the next pass tries again.
Unresolved int
}
// SetNotifier makes requests the sweeper files, and cannot approve itself,
// reach the admins' notifications inbox (M489).
func (s *Sweeper) SetNotifier(n *notifications.Notifier) { s.notifier = n }
// SweepOnce runs one pass. Exported so the admin surface can offer a "run
// now" without waiting out the tick, and so tests drive it directly.
// SweepOnce runs one pass. Exported so tests drive it directly.
func (s *Sweeper) SweepOnce(ctx context.Context) error {
cfg := s.settings.Get()
if !cfg.Enabled {
return nil
}
q := dbq.New(s.pool)
// Before selecting work: drop state for albums whose files came back, or
// were adopted at a new path (#2528). Doing this first means a recovered
// album cannot be picked in the same pass that would have retried it.
cleared, err := q.ClearRecoveredReacquisitions(ctx)
if err != nil {
return fmt.Errorf("clear recovered: %w", err)
}
res := PassResult{Cleared: cleared}
var res PassResult
// Counted, not acted on: an album MusicBrainz cannot name is not a
// failure to retry, it is a permanent gap the operator should see.
if n, cerr := q.CountAlbumsMissingWithoutMbid(ctx); cerr == nil {
res.Unnameable = n
}
due, err := q.ListAlbumsDueReacquisition(ctx, dbq.ListAlbumsDueReacquisitionParams{
GraceHours: cfg.GraceHours,
BackoffBaseHours: cfg.BackoffBaseHours,
BackoffMaxHours: cfg.BackoffMaxHours,
PageLimit: cfg.MaxPerPass,
GraceHours: cfg.GraceHours,
PageLimit: cfg.MaxPerPass,
})
if err != nil {
return fmt.Errorf("list due: %w", err)
}
if len(due) == 0 {
s.logSummary(res)
return nil
}
// One admin lookup per pass, not per album. A library with no admin at
// all cannot own a request, so the pass stops rather than half-working.
admin, err := q.GetOldestAdmin(ctx)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
s.logger.Warn("reacquisition: no admin account to own requests; skipping pass")
return nil
}
return fmt.Errorf("owner lookup: %w", err)
}
for _, album := range due {
if err := s.attempt(ctx, q, cfg, admin.ID, album, &res); err != nil {
// One album's failure must not abandon the rest of the pass: a
// single unmatched MBID or a transient Lidarr error says nothing
// about the next album in the list.
s.logger.Warn("reacquisition: attempt failed",
"album", album.AlbumTitle, "err", err)
if err := s.want(ctx, q, album, &res); err != nil {
// One album's failure must not abandon the rest of the pass.
s.logger.Warn("reacquisition: want failed", "album", album.AlbumTitle, "err", err)
}
}
s.logSummary(res)
return nil
}
// attempt creates (and optionally approves) the request for one album, then
// records the attempt against its backoff budget.
func (s *Sweeper) attempt(
ctx context.Context,
q *dbq.Queries,
cfg Settings,
adminID pgtype.UUID,
album dbq.ListAlbumsDueReacquisitionRow,
res *PassResult,
) error {
// The query already filters these out; belt and braces, because Create
// would reject the request and burn an attempt for no reason.
// want records one album as wanted under its release group.
func (s *Sweeper) want(ctx context.Context, q *dbq.Queries, album dbq.ListAlbumsDueReacquisitionRow, res *PassResult) error {
// The query already filters these out; belt and braces.
if album.AlbumMbid == nil || album.ArtistMbid == nil {
return nil
}
// Lidarr names albums by release group; albums.mbid is the release.
group, err := s.albumReleaseGroup(ctx, q, album)
if err != nil {
if errors.Is(err, tags.ErrNotFound) {
@@ -190,77 +116,25 @@ func (s *Sweeper) attempt(
}
return fmt.Errorf("release group: %w", err)
}
req, created, err := s.requests.CreateTracked(ctx, adminID, lidarrrequests.CreateParams{
Kind: "album",
LidarrArtistMBID: *album.ArtistMbid,
ArtistName: album.ArtistName,
LidarrAlbumMBID: group,
AlbumTitle: album.AlbumTitle,
_, err = q.WantMissingAlbum(ctx, dbq.WantMissingAlbumParams{
ReleaseGroupMbid: group, ArtistMbid: *album.ArtistMbid, ArtistName: album.ArtistName,
Title: album.AlbumTitle, AlbumID: album.AlbumID,
})
if errors.Is(err, pgx.ErrNoRows) {
// Already wanted, open: by a request, a follow, or this sweeper.
return nil
}
if err != nil {
return fmt.Errorf("create request: %w", err)
}
res.Requested++
// Record the attempt even when Create deduped into somebody else's
// existing request: the point of the counter is "how often have we gone
// looking for this album", and a manual request in flight is a reason to
// wait rather than to keep asking.
row, err := q.RecordReacquisitionAttempt(ctx, dbq.RecordReacquisitionAttemptParams{
AlbumID: album.AlbumID,
LastRequestID: req.ID,
})
if err != nil {
return fmt.Errorf("record attempt: %w", err)
}
// A request the sweeper filed and could not approve waits on an admin.
// One it deduped into was announced when it was first filed.
awaitingAdmin := created
if cfg.AutoApprove {
_, aerr := s.requests.Approve(ctx, req.ID, adminID, lidarrrequests.ApproveOverrides{})
switch {
case aerr == nil:
res.Approved++
awaitingAdmin = false
case errors.Is(aerr, lidarrrequests.ErrLidarrDisabled):
// Leave it pending rather than treating it as a failure. The
// request is still the right record of intent, and it becomes
// actionable the moment Lidarr is configured.
s.logger.Info("reacquisition: request left pending, Lidarr disabled",
"album", album.AlbumTitle)
case errors.Is(aerr, lidarrrequests.ErrNotPending):
// Deduped onto a request somebody already approved. Nothing to do
// and nothing wrong.
default:
return fmt.Errorf("approve: %w", aerr)
}
}
if awaitingAdmin {
s.notifier.NotifyLogged(ctx, notifications.KindRequestPending, notifications.ToAdmins(pgtype.UUID{}),
notifications.Payload{
RequestID: uuid.UUID(req.ID.Bytes).String(),
RequestKind: string(req.Kind),
Name: lidarrrequests.DisplayName(req),
Actor: "Re-acquisition",
}.Map())
}
if row.Attempts >= cfg.MaxAttempts {
if err := q.MarkReacquisitionGaveUp(ctx, album.AlbumID); err != nil {
return fmt.Errorf("mark gave up: %w", err)
}
res.GaveUp++
return fmt.Errorf("want: %w", err)
}
res.Wanted++
return nil
}
// albumReleaseGroup is the album's release group: the one its tags carried,
// else MusicBrainz's answer for its release id, cached onto the album so the
// next pass (and request completion) need not ask again. Returns
// tags.ErrNotFound when MusicBrainz cannot name it.
// next pass need not ask again. Returns tags.ErrNotFound when MusicBrainz
// cannot name it.
func (s *Sweeper) albumReleaseGroup(ctx context.Context, q *dbq.Queries, album dbq.ListAlbumsDueReacquisitionRow) (string, error) {
if album.AlbumReleaseGroupMbid != nil && *album.AlbumReleaseGroupMbid != "" {
return *album.AlbumReleaseGroupMbid, nil
@@ -281,14 +155,11 @@ func (s *Sweeper) albumReleaseGroup(ctx context.Context, q *dbq.Queries, album d
func (s *Sweeper) logSummary(res PassResult) {
// Silence when a pass did nothing at all — this runs hourly forever, and
// an unconditional line would bury the passes that mattered.
if res.Requested == 0 && res.Cleared == 0 && res.GaveUp == 0 && res.Unresolved == 0 {
if res.Wanted == 0 && res.Unresolved == 0 {
return
}
s.logger.Info("reacquisition: sweep",
"requested", res.Requested,
"approved", res.Approved,
"gave_up", res.GaveUp,
"cleared", res.Cleared,
"wanted", res.Wanted,
"unnameable", res.Unnameable,
"unresolved", res.Unresolved,
)
+56 -60
View File
@@ -13,9 +13,6 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
@@ -37,9 +34,6 @@ func newSweepPool(t *testing.T) *pgxpool.Pool {
}
t.Cleanup(pool.Close)
dbtest.ResetDB(t, pool)
if _, err := pool.Exec(context.Background(), "DELETE FROM lidarr_requests"); err != nil {
t.Fatalf("reset requests: %v", err)
}
return pool
}
@@ -75,39 +69,25 @@ func lostAlbum(t *testing.T, pool *pgxpool.Pool, title, release, group string) p
return al.ID
}
// #5241: re-acquisition asks Lidarr for the album's release group, never its
// release id. A tag-supplied group is used as is; otherwise MusicBrainz names
// it and the answer is cached onto the album; an album MusicBrainz cannot
// name is skipped without spending an attempt.
func TestSweep_RequestsAlbumsByReleaseGroup_Integration(t *testing.T) {
// #5241: an album is wanted by its release group, never its release id. A
// tag-supplied group is used as is; otherwise MusicBrainz names it and the
// answer is cached onto the album; an album MusicBrainz cannot name is
// skipped and tried again next pass.
func TestSweep_WantsAlbumsByReleaseGroup_Integration(t *testing.T) {
pool := newSweepPool(t)
ctx := context.Background()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
q := dbq.New(pool)
admin, err := q.CreateUser(ctx, dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "sweepadmin", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: true,
})
if err != nil {
t.Fatal(err)
}
settings, err := NewSettingsService(ctx, pool, logger)
if err != nil {
t.Fatal(err)
}
cfg := Defaults
cfg.AutoApprove = false
if _, err := settings.Set(ctx, cfg); err != nil {
t.Fatal(err)
}
tagged := lostAlbum(t, pool, "Tagged", "release-tagged", "group-tagged")
resolved := lostAlbum(t, pool, "Resolved", "release-resolved", "")
unknown := lostAlbum(t, pool, "Unknown", "release-unknown", "")
_ = lostAlbum(t, pool, "Unknown", "release-unknown", "")
requests := lidarrrequests.NewService(pool, lidarrconfig.New(pool), nil, nil)
s := NewSweeper(pool, settings, requests, logger)
s.SetNotifier(notifications.New(pool, nil, nil))
s := NewSweeper(pool, settings, logger)
asked := map[string]int{}
s.releaseGroup = func(_ context.Context, release string) (string, error) {
asked[release]++
@@ -120,28 +100,21 @@ func TestSweep_RequestsAlbumsByReleaseGroup_Integration(t *testing.T) {
t.Fatalf("sweep: %v", err)
}
requested := func(album pgtype.UUID) (string, bool) {
wantFor := func(group string, album pgtype.UUID) {
t.Helper()
var mbid *string
err := pool.QueryRow(ctx, `
SELECT lr.lidarr_album_mbid
FROM missing_reacquisitions r
JOIN lidarr_requests lr ON lr.id = r.last_request_id
WHERE r.album_id = $1`, album).Scan(&mbid)
if err != nil || mbid == nil {
return "", false
w, err := q.GetWantByReleaseGroup(ctx, group)
if err != nil {
t.Fatalf("no want for %s: %v", group, err)
}
if w.Reason != "missing" || w.AlbumID != album || w.FulfilledAt.Valid {
t.Errorf("want %s = %+v: an open missing want about the album", group, w)
}
return *mbid, true
}
if got, ok := requested(tagged); !ok || got != "group-tagged" {
t.Errorf("tagged album requested as %q (ok=%v), want group-tagged", got, ok)
}
wantFor("group-tagged", tagged)
wantFor("group-resolved", resolved)
if asked["release-tagged"] != 0 {
t.Error("MusicBrainz asked about an album whose tags named its group")
}
if got, ok := requested(resolved); !ok || got != "group-resolved" {
t.Errorf("resolved album requested as %q (ok=%v), want group-resolved", got, ok)
}
var cached *string
if err := pool.QueryRow(ctx, "SELECT release_group_mbid FROM albums WHERE id = $1", resolved).Scan(&cached); err != nil {
t.Fatal(err)
@@ -149,32 +122,55 @@ SELECT lr.lidarr_album_mbid
if cached == nil || *cached != "group-resolved" {
t.Errorf("resolved group not cached on the album: %v", cached)
}
var attempts int
if err := pool.QueryRow(ctx, "SELECT count(*) FROM missing_reacquisitions WHERE album_id = $1", unknown).Scan(&attempts); err != nil {
var n int
if err := pool.QueryRow(ctx, "SELECT count(*) FROM wants").Scan(&n); err != nil {
t.Fatal(err)
}
if attempts != 0 {
t.Errorf("unresolvable album spent an attempt (%d state rows)", attempts)
if n != 2 {
t.Errorf("%d wants, want 2: the unresolvable album is skipped", n)
}
// M489: the two requests filed without auto-approval wait on an admin,
// so each reaches the admins' inbox; the unresolvable album filed none.
inbox, err := q.ListNotifications(ctx, dbq.ListNotificationsParams{UserID: admin.ID, PageLimit: 10})
// The next pass finds both already wanted and asks nothing new.
asked = map[string]int{}
if err := s.SweepOnce(ctx); err != nil {
t.Fatal(err)
}
if asked["release-resolved"] != 0 || asked["release-unknown"] != 1 {
t.Errorf("second pass asked MusicBrainz %v: only the unresolved album again", asked)
}
}
// A want held after the files went missing means the library has a full copy
// despite the missing rows (#5245): the album is not wanted again. One held
// before they went missing is reopened.
func TestSweep_HeldWantsAreReopenedOnlyForANewLoss_Integration(t *testing.T) {
pool := newSweepPool(t)
ctx := context.Background()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
settings, err := NewSettingsService(ctx, pool, logger)
if err != nil {
t.Fatal(err)
}
if len(inbox) != 2 {
t.Errorf("admin inbox holds %d notifications, want 2", len(inbox))
}
for _, n := range inbox {
if n.Kind != string(notifications.KindRequestPending) {
t.Errorf("admin inbox holds %q, want request_pending", n.Kind)
stale := lostAlbum(t, pool, "Stale", "release-stale", "group-stale")
lost := lostAlbum(t, pool, "Lost Again", "release-lost", "group-lost")
for _, c := range []struct {
group, heldAgo string
album pgtype.UUID
}{{"group-stale", "1 hour", stale}, {"group-lost", "3 days", lost}} {
if _, err := pool.Exec(ctx, `
INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, reason, album_id, fulfilled_at)
VALUES ($1, 'a', 'A', 'T', 'request', $2, now() - $3::interval)`, c.group, c.album, c.heldAgo); err != nil {
t.Fatal(err)
}
}
var stray int
if err := pool.QueryRow(ctx, "SELECT count(*) FROM lidarr_requests WHERE lidarr_album_mbid LIKE 'release-%'").Scan(&stray); err != nil {
if err := NewSweeper(pool, settings, logger).SweepOnce(ctx); err != nil {
t.Fatal(err)
}
if stray != 0 {
t.Errorf("%d request(s) named an album by its release id", stray)
q := dbq.New(pool)
if w, _ := q.GetWantByReleaseGroup(ctx, "group-stale"); !w.FulfilledAt.Valid {
t.Error("a want held since the loss was reopened")
}
if w, _ := q.GetWantByReleaseGroup(ctx, "group-lost"); w.FulfilledAt.Valid || w.Reason != "missing" {
t.Errorf("a want held before the loss should reopen as missing: %+v", w)
}
}
@@ -270,13 +270,13 @@ func TestSuggestArtists_FiltersAlreadyRequested(t *testing.T) {
seed := seedArtist(t, pool, "Seed", "")
likeArtist(t, pool, user.ID, seed.ID)
seedUnmatched(t, pool, seed.ID, "req-mbid", "Pending Request", 0.9)
if _, err := dbq.New(pool).CreateLidarrRequest(context.Background(), dbq.CreateLidarrRequestParams{
UserID: user.ID,
Kind: dbq.LidarrRequestKindArtist,
LidarrArtistMbid: "req-mbid",
ArtistName: "Pending Request",
if _, err := dbq.New(pool).CreateRequest(context.Background(), dbq.CreateRequestParams{
UserID: user.ID,
Kind: dbq.RequestKindArtist,
ArtistMbid: "req-mbid",
ArtistName: "Pending Request",
}); err != nil {
t.Fatalf("CreateLidarrRequest: %v", err)
t.Fatalf("CreateRequest: %v", err)
}
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
@@ -294,20 +294,20 @@ func TestSuggestArtists_RejectedRequestStillShown(t *testing.T) {
seed := seedArtist(t, pool, "Seed", "")
likeArtist(t, pool, user.ID, seed.ID)
seedUnmatched(t, pool, seed.ID, "rej-mbid", "Rejected Once", 0.9)
req, err := dbq.New(pool).CreateLidarrRequest(context.Background(), dbq.CreateLidarrRequestParams{
UserID: user.ID,
Kind: dbq.LidarrRequestKindArtist,
LidarrArtistMbid: "rej-mbid",
ArtistName: "Rejected Once",
req, err := dbq.New(pool).CreateRequest(context.Background(), dbq.CreateRequestParams{
UserID: user.ID,
Kind: dbq.RequestKindArtist,
ArtistMbid: "rej-mbid",
ArtistName: "Rejected Once",
})
if err != nil {
t.Fatalf("CreateLidarrRequest: %v", err)
t.Fatalf("CreateRequest: %v", err)
}
rejNotes := "wrong artist"
if _, err := dbq.New(pool).RejectLidarrRequest(context.Background(), dbq.RejectLidarrRequestParams{
if _, err := dbq.New(pool).RejectRequest(context.Background(), dbq.RejectRequestParams{
ID: req.ID, Notes: &rejNotes, DecidedBy: user.ID,
}); err != nil {
t.Fatalf("RejectLidarrRequest: %v", err)
t.Fatalf("RejectRequest: %v", err)
}
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
+198
View File
@@ -0,0 +1,198 @@
package requests
import (
"context"
"errors"
"log/slog"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
)
// Reconciler is a background worker that completes approved requests once
// the library holds what they asked for: an artist request when the artist
// is in the library, an album or track request when the want it recorded is
// held (internal/wanted settles wants). Errors per row are logged at WARN and
// do not abort the tick.
type Reconciler struct {
pool *pgxpool.Pool
logger *slog.Logger
bus *eventbus.Bus
notifier *notifications.Notifier // nil: no inbox notifications
tick time.Duration
batch int32
}
// NewReconciler constructs a Reconciler with production defaults: 5-minute
// tick, batch size 50. Pass nil for bus when no SSE publishing is desired.
func NewReconciler(pool *pgxpool.Pool, logger *slog.Logger, bus *eventbus.Bus) *Reconciler {
return &Reconciler{pool: pool, logger: logger, bus: bus, tick: 5 * time.Minute, batch: 50}
}
// SetNotifier makes completions land in the requester's notifications inbox
// (M489). Without one, completions are only broadcast on the bus.
func (r *Reconciler) SetNotifier(n *notifications.Notifier) { r.notifier = n }
// publishCompleted tells the requester their request arrived: a
// request.status_changed event for open screens, and a notification that
// outlives the connection.
func (r *Reconciler) publishCompleted(ctx context.Context, row dbq.Request) {
p := notifications.Payload{
RequestID: formatUUIDForBus(row.ID),
RequestKind: string(row.Kind),
Name: DisplayName(row),
Artist: row.ArtistName,
Title: requestedTitle(row),
}
if row.MatchedAlbumID.Valid {
p.AlbumID = formatUUIDForBus(row.MatchedAlbumID)
} else if row.MatchedArtistID.Valid {
p.ArtistID = formatUUIDForBus(row.MatchedArtistID)
}
r.notifier.NotifyLogged(ctx, notifications.KindRequestCompleted, notifications.ToUser(row.UserID), p.Map())
if r.bus == nil {
return
}
r.bus.Publish(eventbus.Event{
Kind: "request.status_changed",
UserID: formatUUIDForBus(row.UserID),
Data: map[string]any{
"request_id": formatUUIDForBus(row.ID),
"status": "completed",
"kind": string(row.Kind),
},
})
}
// formatUUIDForBus renders a pgtype.UUID as the canonical 8-4-4-4-12 hex
// string. Mirrors helpers in internal/api/convert.go and other packages;
// inlined here to avoid a back-edge dependency from requests onto
// the api layer.
func formatUUIDForBus(u pgtype.UUID) string {
if !u.Valid {
return ""
}
const hex = "0123456789abcdef"
out := make([]byte, 36)
pos := 0
for i, b := range u.Bytes {
if i == 4 || i == 6 || i == 8 || i == 10 {
out[pos] = '-'
pos++
}
out[pos] = hex[b>>4]
out[pos+1] = hex[b&0xf]
pos += 2
}
return string(out)
}
// Run blocks until ctx is cancelled, ticking every r.tick. Errors from
// tickOnce are logged at WARN and never propagated.
func (r *Reconciler) Run(ctx context.Context) {
t := time.NewTicker(r.tick)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := r.tickOnce(ctx); err != nil {
r.logger.Warn("requests: reconciler tick failed", "err", err)
}
}
}
}
// tickOnce loads approved requests (oldest first, up to r.batch) and
// completes those the library now holds.
func (r *Reconciler) tickOnce(ctx context.Context) error {
q := dbq.New(r.pool)
rows, err := q.ListApprovedRequestsForReconcile(ctx, r.batch)
if err != nil {
return err
}
for _, row := range rows {
if err := r.reconcileRow(ctx, q, row); err != nil {
r.logger.Warn("requests: reconciler row failed",
"request_id", row.ID, "kind", row.Kind, "err", err)
}
}
return nil
}
// reconcileRow completes one request if the library holds it. A request
// not yet held is not an error.
func (r *Reconciler) reconcileRow(ctx context.Context, q *dbq.Queries, row dbq.Request) error {
if row.Kind == dbq.RequestKindArtist {
return r.reconcileArtist(ctx, q, row)
}
return r.reconcileAlbum(ctx, q, row)
}
func (r *Reconciler) reconcileArtist(ctx context.Context, q *dbq.Queries, row dbq.Request) error {
var artistID pgtype.UUID
err := r.pool.QueryRow(ctx, "SELECT id FROM artists WHERE mbid = $1", row.ArtistMbid).Scan(&artistID)
if errors.Is(err, pgx.ErrNoRows) {
return nil // not in library yet
}
if err != nil {
return err
}
completed, err := q.CompleteRequest(ctx, dbq.CompleteRequestParams{ID: row.ID, MatchedArtistID: artistID})
if err != nil {
return err
}
r.publishCompleted(ctx, completed)
return nil
}
// reconcileAlbum completes an album or track request once its want is held,
// matched to the album that answered it. A track request is matched to the
// track it named when that recording arrived, else the newest arrival.
func (r *Reconciler) reconcileAlbum(ctx context.Context, q *dbq.Queries, row dbq.Request) error {
if row.AlbumMbid == nil || *row.AlbumMbid == "" {
return nil
}
w, err := q.GetWantByReleaseGroup(ctx, *row.AlbumMbid)
if errors.Is(err, pgx.ErrNoRows) {
// Removed from the wanted list while the request waited. Wanting it
// again keeps the approval meaningful; the admin who removed it can
// reject a request instead.
_, err = wanted.WantWith(ctx, q, wantFor(row))
return err
}
if err != nil {
return err
}
if !w.FulfilledAt.Valid || !w.AlbumID.Valid {
return nil
}
params := dbq.CompleteRequestParams{ID: row.ID, MatchedAlbumID: w.AlbumID}
if row.Kind == dbq.RequestKindTrack {
var trackID pgtype.UUID
err := r.pool.QueryRow(ctx, `
SELECT id FROM tracks
WHERE album_id = $1 AND missing_since IS NULL
ORDER BY (mbid IS NOT DISTINCT FROM $2) DESC, added_at DESC, id
LIMIT 1`, w.AlbumID, row.TrackMbid).Scan(&trackID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return err
}
params.MatchedTrackID = trackID
}
completed, err := q.CompleteRequest(ctx, params)
if err != nil {
return err
}
r.publishCompleted(ctx, completed)
return nil
}
+310
View File
@@ -0,0 +1,310 @@
package requests
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"os"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
)
func newPool(t *testing.T) *pgxpool.Pool {
t.Helper()
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
dbtest.ResetDB(t, pool)
return pool
}
func seedUser(t *testing.T, pool *pgxpool.Pool, name string) pgtype.UUID {
t.Helper()
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiTokenHash: "x-" + name,
})
if err != nil {
t.Fatalf("seed user: %v", err)
}
return u.ID
}
func seedAlbum(t *testing.T, q *dbq.Queries, artistName, artistMBID, title, group string) (dbq.Artist, dbq.Album) {
t.Helper()
ctx := context.Background()
artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: artistName, SortName: artistName, Mbid: &artistMBID})
if err != nil {
t.Fatal(err)
}
release := group + "-release"
album, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: title, SortTitle: title, ArtistID: artist.ID, Mbid: &release})
if err != nil {
t.Fatal(err)
}
if err := q.SetAlbumReleaseGroupMbidIfNull(ctx, dbq.SetAlbumReleaseGroupMbidIfNullParams{ID: album.ID, ReleaseGroupMbid: &group}); err != nil {
t.Fatal(err)
}
return artist, album
}
func seedTrack(t *testing.T, q *dbq.Queries, album dbq.Album, artist dbq.Artist, title, path string) dbq.Track {
t.Helper()
tr, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: title, AlbumID: album.ID, ArtistID: artist.ID,
DurationMs: 180000, FilePath: path, FileSize: 1024, FileFormat: "flac",
})
if err != nil {
t.Fatal(err)
}
return tr
}
func quietLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
func TestCreate_ValidatesEachKind(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "creator")
svc := NewService(pool)
ctx := context.Background()
if r, err := svc.Create(ctx, user, CreateParams{Kind: "artist", ArtistMBID: "a-mbid", ArtistName: "X"}); err != nil ||
r.Status != dbq.RequestStatusPending {
t.Fatalf("artist: %+v, %v", r, err)
}
for name, p := range map[string]CreateParams{
"album without its group": {Kind: "album", ArtistMBID: "a", ArtistName: "X"},
"track without its album": {Kind: "track", ArtistMBID: "a", ArtistName: "X", TrackMBID: "t", TrackTitle: "Y"},
"unknown kind": {Kind: "playlist", ArtistMBID: "a", ArtistName: "X"},
} {
if _, err := svc.Create(ctx, user, p); !errors.Is(err, ErrInvalidKindFields) {
t.Errorf("%s: err = %v, want ErrInvalidKindFields", name, err)
}
}
again, created, err := svc.CreateTracked(ctx, user, CreateParams{Kind: "artist", ArtistMBID: "a-mbid", ArtistName: "X"})
if err != nil || created || again.Kind != dbq.RequestKindArtist {
t.Errorf("a repeat request dedupes: created=%v, %v", created, err)
}
}
func TestApprove_AlbumAndTrackRequestsWantTheirReleaseGroup(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
user := seedUser(t, pool, "wanter")
admin := seedUser(t, pool, "approver")
svc := NewService(pool)
ctx := context.Background()
album, _ := svc.Create(ctx, user, CreateParams{
Kind: "album", ArtistMBID: "artist-1", ArtistName: "Gorillaz", AlbumMBID: "group-demon-days", AlbumTitle: "Demon Days",
})
track, _ := svc.Create(ctx, user, CreateParams{
Kind: "track", ArtistMBID: "artist-1", ArtistName: "Gorillaz", AlbumMBID: "group-humanz", AlbumTitle: "Humanz",
TrackMBID: "rec-1", TrackTitle: "Andromeda",
})
for _, r := range []dbq.Request{album, track} {
got, err := svc.Approve(ctx, r.ID, admin, pgtype.UUID{})
if err != nil || got.Status != dbq.RequestStatusApproved || got.DecidedBy != admin {
t.Fatalf("approve %s: %+v, %v", r.Kind, got, err)
}
}
for group, title := range map[string]string{"group-demon-days": "Demon Days", "group-humanz": "Humanz"} {
w, err := q.GetWantByReleaseGroup(ctx, group)
if err != nil {
t.Fatalf("want %s: %v", group, err)
}
if w.Title != title || w.Reason != "request" || w.CreatedBy != user || w.FulfilledAt.Valid {
t.Errorf("want %s = %+v: the requester's open want", group, w)
}
}
if _, err := svc.Approve(ctx, album.ID, admin, pgtype.UUID{}); !errors.Is(err, ErrNotPending) {
t.Errorf("approving twice: err = %v, want ErrNotPending", err)
}
if _, err := svc.Approve(ctx, pgtype.UUID{Bytes: [16]byte{1}, Valid: true}, admin, pgtype.UUID{}); !errors.Is(err, ErrNotFound) {
t.Errorf("no such request: err = %v", err)
}
}
func TestApprove_ArtistRequestFollowsAndWantsWhatExists(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
user := seedUser(t, pool, "follower")
ctx := context.Background()
svc := NewService(pool)
albumsOnly, err := q.CreateReleaseTypeProfile(ctx, dbq.CreateReleaseTypeProfileParams{
Name: "Albums only", PrimaryTypes: []string{"Album"}, SecondaryTypes: []string{},
})
if err != nil {
t.Fatal(err)
}
r, _ := svc.Create(ctx, user, CreateParams{Kind: "artist", ArtistMBID: "artist-2", ArtistName: "Moe Shop"})
if _, err := svc.Approve(ctx, r.ID, user, albumsOnly.ID); err != nil {
t.Fatal(err)
}
f, err := q.GetFollow(ctx, "artist-2")
if err != nil {
t.Fatal(err)
}
if f.ProfileID != albumsOnly.ID || !f.WantExisting || f.BaselineAt.Valid {
t.Errorf("follow = %+v: on the chosen profile, wanting what exists, baseline still to read", f)
}
r2, _ := svc.Create(ctx, user, CreateParams{Kind: "artist", ArtistMBID: "artist-3", ArtistName: "Kero Kero Bonito"})
if _, err := svc.Approve(ctx, r2.ID, user, pgtype.UUID{}); err != nil {
t.Fatal(err)
}
if f, _ := q.GetFollow(ctx, "artist-3"); f.ProfileName != "Standard" {
t.Errorf("no profile chosen: on %q, want the default", f.ProfileName)
}
}
func TestRejectAndCancel(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "rejected")
svc := NewService(pool)
ctx := context.Background()
r, _ := svc.Create(ctx, user, CreateParams{Kind: "artist", ArtistMBID: "a-1", ArtistName: "X"})
if got, err := svc.Reject(ctx, r.ID, user, "low quality"); err != nil || got.Status != dbq.RequestStatusRejected {
t.Fatalf("reject: %+v, %v", got, err)
}
if _, err := svc.Reject(ctx, r.ID, user, "again"); !errors.Is(err, ErrNotPending) {
t.Errorf("reject twice: err = %v", err)
}
c, _ := svc.Create(ctx, user, CreateParams{Kind: "artist", ArtistMBID: "a-2", ArtistName: "Y"})
if _, err := svc.Cancel(ctx, c.ID, user); err != nil {
t.Fatal(err)
}
if _, err := svc.Cancel(ctx, c.ID, user); !errors.Is(err, ErrNotPending) {
t.Errorf("cancel twice: err = %v", err)
}
}
func TestReconciler_CompletesWhenTheWantIsHeld(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
user := seedUser(t, pool, "reconciled")
svc := NewService(pool)
ctx := context.Background()
req, _ := svc.Create(ctx, user, CreateParams{
Kind: "track", ArtistMBID: "artist-4", ArtistName: "Notify Artist", AlbumMBID: "group-notify", AlbumTitle: "Notify Album",
TrackMBID: "rec-wanted", TrackTitle: "Wanted Track",
})
if _, err := svc.Approve(ctx, req.ID, user, pgtype.UUID{}); err != nil {
t.Fatal(err)
}
rec := NewReconciler(pool, quietLogger(), nil)
rec.SetNotifier(notifications.New(pool, nil, nil))
if err := rec.tickOnce(ctx); err != nil {
t.Fatal(err)
}
if got, _ := q.GetRequestByID(ctx, req.ID); got.Status != dbq.RequestStatusApproved {
t.Fatalf("status %v before the want is held, want approved", got.Status)
}
artist, album := seedAlbum(t, q, "Notify Artist", "artist-4", "Notify Album", "group-notify")
_ = seedTrack(t, q, album, artist, "Other Track", "/music/notify/01.flac")
wantedTrack := seedTrack(t, q, album, artist, "Wanted Track", "/music/notify/02.flac")
if _, err := pool.Exec(ctx, "UPDATE tracks SET mbid = 'rec-wanted', added_at = added_at - interval '1 hour' WHERE id = $1", wantedTrack.ID); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, "UPDATE wants SET fulfilled_at = now(), album_id = $1 WHERE release_group_mbid = 'group-notify'", album.ID); err != nil {
t.Fatal(err)
}
for range 2 { // the second tick finds nothing left to complete
if err := rec.tickOnce(ctx); err != nil {
t.Fatal(err)
}
}
got, _ := q.GetRequestByID(ctx, req.ID)
if got.Status != dbq.RequestStatusCompleted || got.MatchedAlbumID != album.ID || got.MatchedTrackID != wantedTrack.ID {
t.Errorf("request = %+v: completed, matched to the album and the track it named", got)
}
inbox, err := q.ListNotifications(ctx, dbq.ListNotificationsParams{UserID: user, PageLimit: 10})
if err != nil {
t.Fatal(err)
}
if len(inbox) != 1 || inbox[0].Kind != string(notifications.KindRequestCompleted) {
t.Fatalf("inbox = %+v, want one request_completed", inbox)
}
var p notifications.Payload
if err := json.Unmarshal(inbox[0].Payload, &p); err != nil {
t.Fatal(err)
}
if p.Name != "Notify Artist – Wanted Track" || p.AlbumID != formatUUIDForBus(album.ID) {
t.Errorf("payload = %+v", p)
}
}
func TestReconciler_ArtistRequestCompletesOnceTheArtistIsHere(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
user := seedUser(t, pool, "artistreq")
svc := NewService(pool)
ctx := context.Background()
req, _ := svc.Create(ctx, user, CreateParams{Kind: "artist", ArtistMBID: "artist-5", ArtistName: "Arrives"})
if _, err := svc.Approve(ctx, req.ID, user, pgtype.UUID{}); err != nil {
t.Fatal(err)
}
rec := NewReconciler(pool, quietLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatal(err)
}
if got, _ := q.GetRequestByID(ctx, req.ID); got.Status != dbq.RequestStatusApproved {
t.Fatalf("status %v with nothing here", got.Status)
}
artist, _ := seedAlbum(t, q, "Arrives", "artist-5", "First", "group-first")
if err := rec.tickOnce(ctx); err != nil {
t.Fatal(err)
}
if got, _ := q.GetRequestByID(ctx, req.ID); got.Status != dbq.RequestStatusCompleted || got.MatchedArtistID != artist.ID {
t.Errorf("request = %+v", got)
}
}
func TestReconciler_RewantsWhatWasRemovedWhileTheRequestWaited(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
user := seedUser(t, pool, "rewant")
svc := NewService(pool)
ctx := context.Background()
req, _ := svc.Create(ctx, user, CreateParams{
Kind: "album", ArtistMBID: "artist-6", ArtistName: "Gone", AlbumMBID: "group-gone", AlbumTitle: "Gone",
})
if _, err := svc.Approve(ctx, req.ID, user, pgtype.UUID{}); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, "DELETE FROM wants"); err != nil {
t.Fatal(err)
}
if err := NewReconciler(pool, quietLogger(), nil).tickOnce(ctx); err != nil {
t.Fatal(err)
}
if _, err := q.GetWantByReleaseGroup(ctx, "group-gone"); err != nil {
t.Errorf("an approved request's want should be recorded again: %v", err)
}
}
+263
View File
@@ -0,0 +1,263 @@
// Package requests owns the lifecycle of what users ask to have added. The
// synchronous Service handles Create/List/Approve/Reject/Cancel; approving
// records the request on the wanted list (M502 #5520): an album or a track
// wants its album's release group, an artist is followed. The async
// Reconciler (separate file) completes approved requests once the library
// holds what they asked for.
package requests
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
)
// Public errors. Handlers map these to API error codes.
var (
ErrInvalidKindFields = errors.New("requests: missing required fields for kind")
ErrNotPending = errors.New("requests: request is not pending")
// ErrNotFound aliases apierror.ErrNotFound so handlers can errors.Is
// against the shared sentinel.
ErrNotFound = apierror.ErrNotFound
)
// CreateParams is the input for a new request from a user. AlbumMBID is the
// album's MusicBrainz release group.
type CreateParams struct {
Kind string // "artist", "album", or "track"
ArtistMBID string
AlbumMBID string // required for kind=album/track
TrackMBID string // required for kind=track
ArtistName string
AlbumTitle string // required for kind=album/track
TrackTitle string // required for kind=track
}
// Service owns the request lifecycle.
type Service struct {
pool *pgxpool.Pool
}
// NewService constructs a Service.
func NewService(pool *pgxpool.Pool) *Service {
return &Service{pool: pool}
}
// Create validates the kind→required-fields invariant and inserts a
// pending row.
func (s *Service) Create(ctx context.Context, userID pgtype.UUID, p CreateParams) (dbq.Request, error) {
row, _, err := s.CreateTracked(ctx, userID, p)
return row, err
}
// CreateTracked is Create, also reporting whether a row was inserted. A
// request that dedupes into one already in flight is not new, so callers
// that announce new requests (M489) say nothing for it.
func (s *Service) CreateTracked(ctx context.Context, userID pgtype.UUID, p CreateParams) (dbq.Request, bool, error) {
if err := validateKindFields(p); err != nil {
return dbq.Request{}, false, err
}
q := dbq.New(s.pool)
// Dedup: if a non-terminal request for this MBID already exists,
// return it instead of inserting a duplicate. The Discover/search UI
// already hides requested candidates; this backstops races and direct
// API callers. Match on the new request's own-kind MBID.
dedupMBID := p.ArtistMBID
switch p.Kind {
case "album":
dedupMBID = p.AlbumMBID
case "track":
dedupMBID = p.TrackMBID
}
if existing, derr := q.GetNonTerminalRequestForMBID(ctx, dedupMBID); derr == nil {
return existing, false, nil
} else if !errors.Is(derr, pgx.ErrNoRows) {
return dbq.Request{}, false, fmt.Errorf("create: dedup check: %w", derr)
}
row, err := q.CreateRequest(ctx, dbq.CreateRequestParams{
UserID: userID,
Kind: dbq.RequestKind(p.Kind),
ArtistMbid: p.ArtistMBID,
AlbumMbid: strPtr(p.AlbumMBID),
TrackMbid: strPtr(p.TrackMBID),
ArtistName: p.ArtistName,
AlbumTitle: strPtr(p.AlbumTitle),
TrackTitle: strPtr(p.TrackTitle),
})
if err != nil {
return dbq.Request{}, false, fmt.Errorf("create: %w", err)
}
return row, true, nil
}
// DisplayName is a request as a person would say it: "Moe Shop" for an
// artist, "Moe Shop – WWW" for an album or a track.
func DisplayName(row dbq.Request) string {
if t := requestedTitle(row); t != "" {
return row.ArtistName + " – " + t
}
return row.ArtistName
}
// requestedTitle is the album or track a request names, or "" for an
// artist request. DisplayName is the artist and this, joined.
func requestedTitle(row dbq.Request) string {
switch {
case row.Kind == dbq.RequestKindTrack && row.TrackTitle != nil:
return *row.TrackTitle
case row.Kind == dbq.RequestKindAlbum && row.AlbumTitle != nil:
return *row.AlbumTitle
}
return ""
}
func validateKindFields(p CreateParams) error {
if p.ArtistMBID == "" || p.ArtistName == "" {
return fmt.Errorf("%w: artist_mbid and artist_name are always required", ErrInvalidKindFields)
}
switch p.Kind {
case "artist":
// fine
case "album":
if p.AlbumMBID == "" || p.AlbumTitle == "" {
return fmt.Errorf("%w: album kind requires album_mbid and album_title", ErrInvalidKindFields)
}
case "track":
if p.AlbumMBID == "" || p.AlbumTitle == "" {
return fmt.Errorf("%w: track kind requires album_mbid and album_title (its album is what is wanted)", ErrInvalidKindFields)
}
if p.TrackMBID == "" || p.TrackTitle == "" {
return fmt.Errorf("%w: track kind requires track_mbid and track_title", ErrInvalidKindFields)
}
default:
return fmt.Errorf("%w: unknown kind %q", ErrInvalidKindFields, p.Kind)
}
return nil
}
// ListByStatus returns requests with the given status, ordered by requested_at DESC.
func (s *Service) ListByStatus(ctx context.Context, status string, limit int32) ([]dbq.Request, error) {
return dbq.New(s.pool).ListRequestsByStatus(ctx, dbq.ListRequestsByStatusParams{
Status: dbq.RequestStatus(status), Limit: limit,
})
}
// ListForUser returns all requests by a user, ordered by requested_at DESC.
func (s *Service) ListForUser(ctx context.Context, userID pgtype.UUID, limit int32) ([]dbq.Request, error) {
return dbq.New(s.pool).ListRequestsForUser(ctx, dbq.ListRequestsForUserParams{
UserID: userID, Limit: limit,
})
}
// Approve puts a pending request on the wanted list and marks it approved,
// in one transaction. An album or track request wants the album's release
// group, recorded as the requester's. An artist request follows the artist
// on profileID (the zero id means the default profile) and also wants what
// they have already released that the profile passes: someone asking for an
// artist wants their music, not only what comes next.
func (s *Service) Approve(ctx context.Context, requestID, approverID, profileID pgtype.UUID) (dbq.Request, error) {
var approved dbq.Request
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := dbq.New(tx)
row, err := q.GetRequestByID(ctx, requestID)
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("approve: get: %w", err)
}
if row.Status != dbq.RequestStatusPending {
return ErrNotPending
}
switch row.Kind {
case dbq.RequestKindArtist:
if _, err := wanted.FollowWith(ctx, q, wanted.FollowParams{
ArtistMBID: row.ArtistMbid, ArtistName: row.ArtistName,
ProfileID: profileID, By: approverID, WantExisting: true,
}); err != nil {
return err
}
default:
if _, err := wanted.WantWith(ctx, q, wantFor(row)); err != nil {
return err
}
}
approved, err = q.ApproveRequest(ctx, dbq.ApproveRequestParams{ID: requestID, DecidedBy: approverID})
if err != nil {
return fmt.Errorf("approve: persist: %w", err)
}
return nil
})
return approved, err
}
// wantFor is the want an approved album or track request records.
func wantFor(row dbq.Request) wanted.WantParams {
p := wanted.WantParams{
ArtistMBID: row.ArtistMbid, ArtistName: row.ArtistName,
Reason: wanted.ReasonRequest, CreatedBy: row.UserID,
}
if row.AlbumMbid != nil {
p.GroupMBID = *row.AlbumMbid
}
if row.AlbumTitle != nil {
p.Title = *row.AlbumTitle
}
return p
}
// Reject transitions a pending request to rejected, recording notes and
// who decided. Returns ErrNotPending if the request is not pending,
// ErrNotFound if no such request exists.
func (s *Service) Reject(ctx context.Context, requestID pgtype.UUID, adminID pgtype.UUID, notes string) (dbq.Request, error) {
row, err := dbq.New(s.pool).RejectRequest(ctx, dbq.RejectRequestParams{
ID: requestID, Notes: strPtr(notes), DecidedBy: adminID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// Either not found OR not pending — check which.
cur, gerr := dbq.New(s.pool).GetRequestByID(ctx, requestID)
if gerr != nil {
return dbq.Request{}, ErrNotFound
}
if cur.Status != dbq.RequestStatusPending {
return dbq.Request{}, ErrNotPending
}
return dbq.Request{}, ErrNotFound
}
return dbq.Request{}, fmt.Errorf("reject: %w", err)
}
return row, nil
}
// Cancel lets a user withdraw their own pending request. The SQL WHERE
// clause enforces both ownership (user_id = $2) and pending status
// (status = 'pending'). A zero-rows result always means ErrNotPending
// from the caller's perspective.
func (s *Service) Cancel(ctx context.Context, requestID pgtype.UUID, userID pgtype.UUID) (dbq.Request, error) {
row, err := dbq.New(s.pool).CancelRequest(ctx, dbq.CancelRequestParams{
ID: requestID, DecidedBy: userID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return dbq.Request{}, ErrNotPending
}
return dbq.Request{}, fmt.Errorf("cancel: %w", err)
}
return row, nil
}
func strPtr(s string) *string {
if s == "" {
return nil
}
return &s
}
+4 -4
View File
@@ -23,13 +23,13 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
@@ -171,7 +171,7 @@ func (s *Server) Router() http.Handler {
}
return lidarr.NewClient(cfg.BaseURL, cfg.APIKey)
}
lidarrReqs := lidarrrequests.NewService(s.Pool, lidarrCfg, lidarrClientFn, nil)
requestSvc := requests.NewService(s.Pool)
reacqSettings := s.ReacqSettings
if reacqSettings == nil {
// Test contexts construct Server without main.go's boot wiring.
@@ -190,7 +190,7 @@ func (s *Server) Router() http.Handler {
playlistsSvc := playlists.NewService(s.Pool, s.Logger, s.DataDir)
smtpSender := mailer.NewSMTPSender(s.Pool, s.Logger.With("component", "mailer"))
// Live-event bus for SSE subscribers (#392). Constructed per-process;
// producers in playevents / lidarrrequests / scanner publish into
// producers in playevents / requests / scanner publish into
// the same instance. Background workers (lidarr reconciler, scan
// scheduler) live in cmd/minstrel/main.go where they're constructed
// before Router() runs; they read s.Bus directly so they share this
@@ -237,7 +237,7 @@ func (s *Server) Router() http.Handler {
}
acoustIDLookup = library.NewAcoustIDLookupWorker(s.Pool, s.Logger, acoustIDSettings, nil)
}
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret, netSettings, reacqSettings, fpSettings, loudSettings, acoustIDLookup)
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, requestSvc, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret, netSettings, reacqSettings, fpSettings, loudSettings, acoustIDLookup)
// /api/admin/scan is the only admin route owned by the server package
// (it needs the Scanner). Register it as a single inline-middleware
// route — using r.Route("/api/admin", ...) here would create a second
+3 -7
View File
@@ -316,13 +316,9 @@ func TestRouter_ReacquisitionSettingsSavedThroughTheAPIReachTheSweeper(t *testin
defer ts.Close()
body, err := json.Marshal(map[string]any{
"enabled": before.Enabled,
"grace_hours": wantGrace,
"backoff_base_hours": before.BackoffBaseHours,
"backoff_max_hours": before.BackoffMaxHours,
"max_attempts": before.MaxAttempts,
"max_per_pass": before.MaxPerPass,
"auto_approve": before.AutoApprove,
"enabled": before.Enabled,
"grace_hours": wantGrace,
"max_per_pass": before.MaxPerPass,
})
if err != nil {
t.Fatalf("marshal: %v", err)
+219
View File
@@ -0,0 +1,219 @@
package wanted
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// PrimaryTypes and SecondaryTypes are MusicBrainz's release-group types, in
// the order the profile editor lists them.
var (
PrimaryTypes = []string{"Album", "EP", "Single", "Broadcast", "Other"}
SecondaryTypes = []string{
"Compilation", "Live", "Remix", "Soundtrack", "DJ-mix", "Mixtape/Street",
"Demo", "Spokenword", "Interview", "Audiobook", "Audio drama", "Field recording",
}
)
// Profile errors, which the API answers as 400 or 409.
var (
ErrProfileInvalid = errors.New("wanted: profile is not valid")
ErrProfileNotFound = errors.New("wanted: no such profile")
ErrProfileInUse = errors.New("wanted: profile is in use")
ErrProfileDefault = errors.New("wanted: the default profile cannot be deleted")
ErrProfileNameUsed = errors.New("wanted: a profile already has that name")
)
// Passes reports whether a release group of the given types is one a follow
// on profile p wants: its primary type is listed, and every secondary type
// it carries is allowed. A group with no secondary type is a studio release,
// which every profile passes on its primary type alone.
func Passes(p dbq.ReleaseTypeProfile, primary string, secondary []string) bool {
if !containsFold(p.PrimaryTypes, primary) {
return false
}
for _, s := range secondary {
if !containsFold(p.SecondaryTypes, s) {
return false
}
}
return true
}
func containsFold(list []string, s string) bool {
return slices.ContainsFunc(list, func(v string) bool { return strings.EqualFold(v, s) })
}
// ProfileInput is a profile as the editor sends it.
type ProfileInput struct {
Name string
PrimaryTypes []string
SecondaryTypes []string
IsDefault bool
}
// normalize checks the input against MusicBrainz's types and returns it with
// each type in MusicBrainz's spelling and the editor's order.
func (in ProfileInput) normalize() (ProfileInput, error) {
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" || len(in.Name) > 60 {
return in, fmt.Errorf("%w: a name of 1 to 60 characters is required", ErrProfileInvalid)
}
primary, err := pick(PrimaryTypes, in.PrimaryTypes)
if err != nil {
return in, err
}
if len(primary) == 0 {
return in, fmt.Errorf("%w: choose at least one kind of release", ErrProfileInvalid)
}
secondary, err := pick(SecondaryTypes, in.SecondaryTypes)
if err != nil {
return in, err
}
in.PrimaryTypes, in.SecondaryTypes = primary, secondary
return in, nil
}
func pick(known, chosen []string) ([]string, error) {
for _, c := range chosen {
if !containsFold(known, c) {
return nil, fmt.Errorf("%w: %q is not a MusicBrainz release type", ErrProfileInvalid, c)
}
}
out := []string{}
for _, k := range known {
if containsFold(chosen, k) {
out = append(out, k)
}
}
return out, nil
}
// CreateProfile adds a profile, making it the default when asked.
func (s *Service) CreateProfile(ctx context.Context, in ProfileInput) (dbq.ReleaseTypeProfile, error) {
in, err := in.normalize()
if err != nil {
return dbq.ReleaseTypeProfile{}, err
}
var out dbq.ReleaseTypeProfile
err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := dbq.New(tx)
p, err := q.CreateReleaseTypeProfile(ctx, dbq.CreateReleaseTypeProfileParams{
Name: in.Name, PrimaryTypes: in.PrimaryTypes, SecondaryTypes: in.SecondaryTypes,
})
if err != nil {
return nameErr(err)
}
if in.IsDefault {
if err := makeDefault(ctx, q, p.ID); err != nil {
return err
}
p.IsDefault = true
}
out = p
return nil
})
return out, err
}
// UpdateProfile replaces a profile's name and types. IsDefault only ever
// moves the default here; to unset it, make another profile the default.
func (s *Service) UpdateProfile(ctx context.Context, id pgtype.UUID, in ProfileInput) (dbq.ReleaseTypeProfile, error) {
in, err := in.normalize()
if err != nil {
return dbq.ReleaseTypeProfile{}, err
}
var out dbq.ReleaseTypeProfile
err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := dbq.New(tx)
p, err := q.UpdateReleaseTypeProfile(ctx, dbq.UpdateReleaseTypeProfileParams{
ID: id, Name: in.Name, PrimaryTypes: in.PrimaryTypes, SecondaryTypes: in.SecondaryTypes,
})
if errors.Is(err, pgx.ErrNoRows) {
return ErrProfileNotFound
}
if err != nil {
return nameErr(err)
}
if in.IsDefault && !p.IsDefault {
if err := makeDefault(ctx, q, p.ID); err != nil {
return err
}
p.IsDefault = true
}
out = p
return nil
})
return out, err
}
func makeDefault(ctx context.Context, q *dbq.Queries, id pgtype.UUID) error {
if err := q.ClearDefaultReleaseTypeProfile(ctx); err != nil {
return fmt.Errorf("wanted: clear default: %w", err)
}
if _, err := q.SetDefaultReleaseTypeProfile(ctx, id); err != nil {
return fmt.Errorf("wanted: set default: %w", err)
}
return nil
}
// DeleteProfile removes a profile no follow uses. The default stays.
func (s *Service) DeleteProfile(ctx context.Context, id pgtype.UUID) error {
q := dbq.New(s.pool)
p, err := q.GetReleaseTypeProfile(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return ErrProfileNotFound
}
if err != nil {
return fmt.Errorf("wanted: read profile: %w", err)
}
if p.IsDefault {
return ErrProfileDefault
}
if _, err := q.DeleteReleaseTypeProfile(ctx, id); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23503" {
return ErrProfileInUse
}
return fmt.Errorf("wanted: delete profile: %w", err)
}
return nil
}
// profileOrDefault reads the profile a follow names; the zero id means the
// default.
func profileOrDefault(ctx context.Context, q *dbq.Queries, id pgtype.UUID) (dbq.ReleaseTypeProfile, error) {
var (
p dbq.ReleaseTypeProfile
err error
)
if id.Valid {
p, err = q.GetReleaseTypeProfile(ctx, id)
} else {
p, err = q.GetDefaultReleaseTypeProfile(ctx)
}
if errors.Is(err, pgx.ErrNoRows) {
return p, ErrProfileNotFound
}
if err != nil {
return p, fmt.Errorf("wanted: read profile: %w", err)
}
return p, nil
}
func nameErr(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return ErrProfileNameUsed
}
return fmt.Errorf("wanted: save profile: %w", err)
}
+494
View File
@@ -0,0 +1,494 @@
// Package wanted is what Minstrel is looking for (M502 #5520): followed
// artists, each on a release-type profile, and wanted release groups. A want
// comes from an approved request, a followed artist's new release, or an
// album whose files went missing; it is held once the library has a full
// copy. Searching for open wants is #5521's.
package wanted
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
// Reasons a release group is wanted, matching wants_reason_check.
const (
ReasonRequest = "request"
ReasonFollow = "follow"
ReasonMissing = "missing"
)
// variousArtists is MusicBrainz's Various Artists. Its "discography" is every
// compilation ever catalogued; nobody follows it.
const variousArtists = "89ad4ac3-39f7-470e-963a-56509c546377"
// ErrNotFollowable: an artist nobody follows (Various Artists, or one of
// MusicBrainz's bracketed placeholders such as [unknown]).
var ErrNotFollowable = errors.New("wanted: not an artist anyone follows")
// Service owns follows, wants and the worker that keeps them current.
type Service struct {
pool *pgxpool.Pool
cat *catalogue.Service
logger *slog.Logger
now func() time.Time
// releaseGroup names the group of a MusicBrainz release id, to move a
// want recorded under a release id (#5241) to its group. A field so tests
// stand in for MusicBrainz.
releaseGroup func(ctx context.Context, releaseMBID string) (string, error)
}
// New makes a Service reading discographies and editions through cat.
func New(pool *pgxpool.Pool, cat *catalogue.Service, logger *slog.Logger) *Service {
if logger == nil {
logger = slog.Default()
}
return &Service{pool: pool, cat: cat, logger: logger, now: time.Now, releaseGroup: tags.ReleaseGroupForRelease}
}
// Followable reports whether an artist is one anyone would follow.
func Followable(mbid, name string) bool {
if mbid == "" || mbid == variousArtists {
return false
}
return len(name) < 2 || name[0] != '[' || name[len(name)-1] != ']'
}
// FollowParams is a follow as the caller asks for it. A zero ProfileID means
// the default profile. WantExisting also wants what the artist has already
// released that the profile passes and the library lacks.
type FollowParams struct {
ArtistMBID string
ArtistName string
ProfileID pgtype.UUID
By pgtype.UUID
WantExisting bool
}
// Follow starts or changes a follow. The worker reads the discography.
func (s *Service) Follow(ctx context.Context, p FollowParams) (dbq.Follow, error) {
return FollowWith(ctx, dbq.New(s.pool), p)
}
// FollowWith is Follow inside the caller's transaction (request approval).
func FollowWith(ctx context.Context, q *dbq.Queries, p FollowParams) (dbq.Follow, error) {
if !Followable(p.ArtistMBID, p.ArtistName) {
return dbq.Follow{}, ErrNotFollowable
}
profile, err := profileOrDefault(ctx, q, p.ProfileID)
if err != nil {
return dbq.Follow{}, err
}
f, err := q.UpsertFollow(ctx, dbq.UpsertFollowParams{
ArtistMbid: p.ArtistMBID, ArtistName: p.ArtistName, ProfileID: profile.ID,
FollowedBy: p.By, WantExisting: p.WantExisting,
})
if err != nil {
return dbq.Follow{}, fmt.Errorf("wanted: follow: %w", err)
}
return f, nil
}
// Unfollow stops following. Wants already recorded stay.
func (s *Service) Unfollow(ctx context.Context, artistMBID string) (bool, error) {
n, err := dbq.New(s.pool).DeleteFollow(ctx, artistMBID)
if err != nil {
return false, fmt.Errorf("wanted: unfollow: %w", err)
}
return n > 0, nil
}
// WantParams is a release group to want.
type WantParams struct {
GroupMBID string
ArtistMBID string
ArtistName string
Title string
PrimaryType string
FirstReleaseDate string
Reason string
CreatedBy pgtype.UUID
}
// WantWith records a want inside the caller's transaction, or returns the
// want already recorded for the group.
func WantWith(ctx context.Context, q *dbq.Queries, p WantParams) (dbq.Want, error) {
if p.GroupMBID == "" || p.ArtistMBID == "" {
return dbq.Want{}, errors.New("wanted: a release group and its artist are required")
}
w, err := q.WantReleaseGroup(ctx, dbq.WantReleaseGroupParams{
ReleaseGroupMbid: p.GroupMBID, ArtistMbid: p.ArtistMBID, ArtistName: p.ArtistName,
Title: p.Title, PrimaryType: p.PrimaryType, FirstReleaseDate: p.FirstReleaseDate,
Reason: p.Reason, CreatedBy: p.CreatedBy,
})
if err != nil {
return dbq.Want{}, fmt.Errorf("wanted: want: %w", err)
}
return w, nil
}
// RemoveWant drops a want, open or held.
func (s *Service) RemoveWant(ctx context.Context, id pgtype.UUID) (bool, error) {
n, err := dbq.New(s.pool).DeleteWant(ctx, id)
if err != nil {
return false, fmt.Errorf("wanted: remove want: %w", err)
}
return n > 0, nil
}
const (
// FollowRefreshAge is how often a followed artist's discography is read
// for new releases: daily, as Lidarr refreshed its artists.
FollowRefreshAge = 24 * time.Hour
// NewReleaseGrace: a group first seen after the baseline is wanted only
// if it came out no earlier than this before the follow began. MusicBrainz
// gains old releases all the time; a single from 1998 catalogued today is
// not a new release.
NewReleaseGrace = 30 * 24 * time.Hour
// Interval is how often the worker looks for work; followsPerPass and
// wantsPerPass bound one pass so neither starves the other.
Interval = 5 * time.Minute
followsPerPass = 60
wantsPerPass = 500
)
// Run applies follows and settles wants every Interval until ctx ends.
func (s *Service) Run(ctx context.Context) {
ticker := time.NewTicker(Interval)
defer ticker.Stop()
for {
res, err := s.Pass(ctx)
switch {
case err != nil && ctx.Err() == nil:
s.logger.Warn("wanted: pass stopped", "err", err, "follows", res.Follows, "wanted", res.Wanted, "held", res.Held)
case res.Wanted > 0 || res.Held > 0 || res.Baselined > 0:
s.logger.Info("wanted: pass complete", "follows", res.Follows, "baselined", res.Baselined,
"wanted", res.Wanted, "held", res.Held)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
// PassResult is what one pass did.
type PassResult struct {
Follows int // follows whose discography was applied
Baselined int // of those, first reads
Wanted int // wants recorded from follows
Held int // wants the library now answers
}
// Pass applies the follows due and settles open wants. An unreachable
// MusicBrainz stops the follows (they need it) but not the settling, which
// falls back to what is held.
func (s *Service) Pass(ctx context.Context) (PassResult, error) {
var res PassResult
ferr := s.applyFollows(ctx, &res)
if ctx.Err() != nil {
return res, ctx.Err()
}
if err := s.settleWants(ctx, &res); err != nil {
return res, err
}
return res, ferr
}
func (s *Service) applyFollows(ctx context.Context, res *PassResult) error {
q := dbq.New(s.pool)
due, err := q.ListFollowsDue(ctx, dbq.ListFollowsDueParams{
Before: pgtype.Timestamptz{Time: s.now().Add(-FollowRefreshAge), Valid: true},
MaxRows: followsPerPass,
})
if err != nil {
return fmt.Errorf("wanted: list follows due: %w", err)
}
for _, f := range due {
n, err := s.applyFollow(ctx, q, f)
if err != nil {
if errors.Is(err, musicbrainz.ErrUnreachable) || ctx.Err() != nil {
return err
}
s.logger.Warn("wanted: apply follow", "artist", f.ArtistName, "mbid", f.ArtistMbid, "err", err)
continue
}
res.Follows++
res.Wanted += n
if !f.BaselineAt.Valid {
res.Baselined++
}
}
return nil
}
// applyFollow reads the artist's discography and judges each release group
// the follow has not judged before. On the first read (the baseline) they
// are only marked seen, unless the follow asked to want what already exists.
// After it, a group is wanted when the profile passes it, it is recent, and
// the library does not hold it.
func (s *Service) applyFollow(ctx context.Context, q *dbq.Queries, f dbq.Follow) (int, error) {
d, err := s.cat.DiscographyWithin(ctx, f.ArtistMbid, FollowRefreshAge)
if err == nil && d.Stale {
// The held copy, served because MusicBrainz could not be asked:
// applying it would mark the follow checked without having looked.
return 0, musicbrainz.ErrUnreachable
}
if err != nil {
if !errors.Is(err, catalogue.ErrNotFound) {
return 0, err
}
// Merged away or removed from MusicBrainz: nothing to read. Marked
// checked so it is asked again tomorrow, not every pass.
s.logger.Info("wanted: followed artist not in MusicBrainz", "artist", f.ArtistName, "mbid", f.ArtistMbid)
return 0, q.MarkFollowChecked(ctx, f.ArtistMbid)
}
profile, err := q.GetReleaseTypeProfile(ctx, f.ProfileID)
if err != nil {
return 0, fmt.Errorf("read profile: %w", err)
}
groups, err := q.ListFollowGroups(ctx, f.ArtistMbid)
if err != nil {
return 0, fmt.Errorf("read discography: %w", err)
}
baseline := !f.BaselineAt.Valid
wanted := 0
for _, g := range groups {
judge := (baseline && f.WantExisting) || (!baseline && !g.Seen && s.recent(g.FirstReleaseDate, f.BaselineAt.Time))
if judge && Passes(profile, g.PrimaryType, g.SecondaryTypes) {
held, err := q.LibraryHoldsReleaseGroup(ctx, dbq.LibraryHoldsReleaseGroupParams{
GroupMbid: g.Mbid, Title: g.Title, ArtistMbid: f.ArtistMbid,
})
if err != nil {
return wanted, fmt.Errorf("library check: %w", err)
}
if !held {
w, err := WantWith(ctx, q, WantParams{
GroupMBID: g.Mbid, ArtistMBID: f.ArtistMbid, ArtistName: f.ArtistName, Title: g.Title,
PrimaryType: g.PrimaryType, FirstReleaseDate: g.FirstReleaseDate,
Reason: ReasonFollow, CreatedBy: f.FollowedBy,
})
if err != nil {
return wanted, err
}
if !w.FulfilledAt.Valid {
wanted++
}
}
}
if !g.Seen {
if err := q.MarkGroupSeen(ctx, dbq.MarkGroupSeenParams{ArtistMbid: f.ArtistMbid, ReleaseGroupMbid: g.Mbid}); err != nil {
return wanted, fmt.Errorf("mark seen: %w", err)
}
}
}
return wanted, q.MarkFollowChecked(ctx, f.ArtistMbid)
}
// recent reports whether a release dated date (MusicBrainz's partial date:
// "2026", "2026-10", "2026-10-09", or empty when not yet known) came out no
// earlier than NewReleaseGrace before the follow's baseline. A partial date
// is read as the last day it could mean, so a year-only date is not
// dismissed for being "January".
func (s *Service) recent(date string, baseline time.Time) bool {
t, ok := latestDate(date)
if !ok {
return true
}
return !t.Before(baseline.Add(-NewReleaseGrace))
}
func latestDate(date string) (time.Time, bool) {
if t, err := time.Parse("2006-01-02", date); err == nil {
return t, true
}
if t, err := time.Parse("2006-01", date); err == nil {
return t.AddDate(0, 1, -1), true
}
if t, err := time.Parse("2006", date); err == nil {
return t.AddDate(1, 0, -1), true
}
return time.Time{}, false
}
// settleWants marks open wants held when the library answers them. Each
// want's editions come from the catalogue, read from MusicBrainz the first
// time; once MusicBrainz is unreachable the rest use only what is held.
func (s *Service) settleWants(ctx context.Context, res *PassResult) error {
q := dbq.New(s.pool)
open, err := q.ListOpenWants(ctx, wantsPerPass)
if err != nil {
return fmt.Errorf("wanted: list open wants: %w", err)
}
mbDown := false
for _, w := range open {
if ctx.Err() != nil {
return ctx.Err()
}
shortest, moved, err := s.shortestEdition(ctx, q, w, &mbDown)
if err != nil {
s.logger.Warn("wanted: read editions", "want", w.Title, "group", w.ReleaseGroupMbid, "err", err)
}
if moved {
continue // settled next pass, under its group
}
album, ok, err := s.answeredBy(ctx, q, w, shortest)
if err != nil {
return err
}
if !ok {
continue
}
if err := q.FulfillWant(ctx, dbq.FulfillWantParams{ID: w.ID, AlbumID: album}); err != nil {
return fmt.Errorf("wanted: fulfil: %w", err)
}
res.Held++
}
return nil
}
// shortestEdition is the track count of the group's shortest official
// edition (any edition when none is official), or 0 when the catalogue
// cannot say. moved reports that the want was recorded under a release id
// and has been moved to its group.
func (s *Service) shortestEdition(ctx context.Context, q *dbq.Queries, w dbq.Want, mbDown *bool) (int, bool, error) {
var releases []dbq.MbRelease
if *mbDown {
held, err := q.ListMBReleasesForGroup(ctx, w.ReleaseGroupMbid)
if err != nil {
return 0, false, err
}
releases = held
} else {
ed, err := s.cat.Releases(ctx, w.ReleaseGroupMbid)
switch {
case err == nil:
for _, e := range ed.Editions {
releases = append(releases, e.Release)
}
case errors.Is(err, catalogue.ErrNotFound):
moved, merr := s.moveToGroup(ctx, w)
return 0, moved, merr
case errors.Is(err, musicbrainz.ErrUnreachable):
*mbDown = true
return s.shortestEdition(ctx, q, w, mbDown)
default:
return 0, false, err
}
}
return Shortest(releases), false, nil
}
// Shortest is the track count of the shortest official release with tracks,
// or of the shortest release with tracks when none is official, or 0.
func Shortest(releases []dbq.MbRelease) int {
best, bestAny := 0, 0
for _, r := range releases {
n := int(r.TrackCount)
if n <= 0 {
continue
}
if bestAny == 0 || n < bestAny {
bestAny = n
}
if r.Status == "Official" && (best == 0 || n < best) {
best = n
}
}
if best > 0 {
return best
}
return bestAny
}
// moveToGroup handles a want MusicBrainz has no release group under: one
// recorded under a release id before #5241. It is moved to the release's
// group, with the requests that name it; when the group is already wanted,
// this copy is dropped.
func (s *Service) moveToGroup(ctx context.Context, w dbq.Want) (bool, error) {
group, err := s.releaseGroup(ctx, w.ReleaseGroupMbid)
if err != nil {
if errors.Is(err, tags.ErrNotFound) {
return false, nil
}
return false, fmt.Errorf("release group of %s: %w", w.ReleaseGroupMbid, err)
}
if group == "" || group == w.ReleaseGroupMbid {
return false, nil
}
err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
tq := dbq.New(tx)
if _, gerr := tq.GetWantByReleaseGroup(ctx, group); gerr == nil {
if _, derr := tq.DeleteWant(ctx, w.ID); derr != nil {
return derr
}
} else if errors.Is(gerr, pgx.ErrNoRows) {
if rerr := tq.RepointWant(ctx, dbq.RepointWantParams{ID: w.ID, ReleaseGroupMbid: group}); rerr != nil {
return rerr
}
} else {
return gerr
}
return tq.RepointRequestsAlbumMbid(ctx, dbq.RepointRequestsAlbumMbidParams{
NewMbid: &group, OldMbid: &w.ReleaseGroupMbid,
})
})
if err != nil {
return false, fmt.Errorf("move want to its group: %w", err)
}
s.logger.Info("wanted: want moved from a release id to its group",
"want", w.Title, "release_mbid", w.ReleaseGroupMbid, "release_group_mbid", group)
return true, nil
}
// answeredBy finds the library album that answers a want, if any:
//
// - for a want about an album whose files went missing, that album once no
// missing track is left without a track on disk in its place;
// - otherwise an album of the group with at least as many tracks on disk as
// the shortest official edition;
// - and when the catalogue cannot say how long the group is, the test from
// #5263: a track arrived since the want, or nothing missing before it is
// still missing.
//
// Only albums with a track on disk count.
func (s *Service) answeredBy(ctx context.Context, q *dbq.Queries, w dbq.Want, shortest int) (pgtype.UUID, bool, error) {
holdings, err := q.WantHoldings(ctx, dbq.WantHoldingsParams{
Since: w.WantedAt, GroupMbid: w.ReleaseGroupMbid, AlbumID: w.AlbumID,
})
if err != nil {
return pgtype.UUID{}, false, fmt.Errorf("wanted: read holdings: %w", err)
}
for _, h := range holdings {
if Answers(w, h, shortest) {
return h.AlbumID, true, nil
}
}
return pgtype.UUID{}, false, nil
}
// Answers is answeredBy's verdict on one album.
func Answers(w dbq.Want, h dbq.WantHoldingsRow, shortest int) bool {
if h.Present == 0 {
return false
}
if w.Reason == ReasonMissing && w.AlbumID.Valid && h.AlbumID == w.AlbumID {
return h.UncoveredMissing == 0
}
if shortest > 0 {
return int(h.Present) >= shortest
}
return h.ArrivedSince || !h.MissingBefore
}
+457
View File
@@ -0,0 +1,457 @@
package wanted
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"sync/atomic"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
func TestPasses_PrimaryListedAndEverySecondaryAllowed(t *testing.T) {
standard := dbq.ReleaseTypeProfile{PrimaryTypes: []string{"Album", "EP", "Single"}, SecondaryTypes: []string{}}
withLive := dbq.ReleaseTypeProfile{PrimaryTypes: []string{"Album"}, SecondaryTypes: []string{"Live"}}
for _, c := range []struct {
profile dbq.ReleaseTypeProfile
primary string
secondary []string
want bool
}{
{standard, "Album", nil, true},
{standard, "single", nil, true}, // MusicBrainz's case is not relied on
{standard, "Album", []string{"Live"}, false},
{standard, "Broadcast", nil, false},
{withLive, "Album", []string{"Live"}, true},
{withLive, "Album", []string{"Live", "Compilation"}, false},
{withLive, "EP", nil, false},
} {
if got := Passes(c.profile, c.primary, c.secondary); got != c.want {
t.Errorf("Passes(%v/%v, %s %v) = %v, want %v", c.profile.PrimaryTypes, c.profile.SecondaryTypes,
c.primary, c.secondary, got, c.want)
}
}
}
func TestProfileInput_NormalizesToMusicBrainzTypes(t *testing.T) {
in, err := ProfileInput{Name: " Live too ", PrimaryTypes: []string{"single", "Album"}, SecondaryTypes: []string{"live"}}.normalize()
if err != nil {
t.Fatal(err)
}
if in.Name != "Live too" || fmt.Sprint(in.PrimaryTypes) != "[Album Single]" || fmt.Sprint(in.SecondaryTypes) != "[Live]" {
t.Errorf("normalized = %+v", in)
}
for name, bad := range map[string]ProfileInput{
"no name": {PrimaryTypes: []string{"Album"}},
"no primary type": {Name: "x"},
"unknown type": {Name: "x", PrimaryTypes: []string{"Mixtape"}},
} {
if _, err := bad.normalize(); !errors.Is(err, ErrProfileInvalid) {
t.Errorf("%s: err = %v", name, err)
}
}
}
func TestRecent_ReadsAPartialDateAsItsLastDay(t *testing.T) {
s := &Service{}
baseline := time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC)
for date, want := range map[string]bool{
"": true, // not yet known: an announced release
"2026-11-01": true,
"2026-09-15": true, // inside the grace before the follow
"2026-08-01": false,
"2026": true, // could be December
"2025": false,
"2026-09": true,
"1998-04-01": false,
} {
if got := s.recent(date, baseline); got != want {
t.Errorf("recent(%q) = %v, want %v", date, got, want)
}
}
}
func TestShortest_PrefersOfficialEditions(t *testing.T) {
rel := func(status string, n int32) dbq.MbRelease { return dbq.MbRelease{Status: status, TrackCount: n} }
for _, c := range []struct {
releases []dbq.MbRelease
want int
}{
{nil, 0},
{[]dbq.MbRelease{rel("Official", 14), rel("Official", 12), rel("Promotion", 3)}, 12},
{[]dbq.MbRelease{rel("Bootleg", 9), rel("", 11)}, 9},
{[]dbq.MbRelease{rel("Official", 0), rel("Bootleg", 9)}, 9},
} {
if got := Shortest(c.releases); got != c.want {
t.Errorf("Shortest(%v) = %d, want %d", c.releases, got, c.want)
}
}
}
func TestAnswers(t *testing.T) {
album := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
other := pgtype.UUID{Bytes: [16]byte{2}, Valid: true}
missing := dbq.Want{Reason: ReasonMissing, AlbumID: album}
request := dbq.Want{Reason: ReasonRequest}
for name, c := range map[string]struct {
w dbq.Want
h dbq.WantHoldingsRow
shortest int
want bool
}{
"nothing on disk": {request, dbq.WantHoldingsRow{AlbumID: other, ArrivedSince: true}, 10, false},
"a full edition": {request, dbq.WantHoldingsRow{AlbumID: other, Present: 10}, 10, true},
"short of every edition": {request, dbq.WantHoldingsRow{AlbumID: other, Present: 9, ArrivedSince: true}, 10, false},
"no editions known, an arrival": {request, dbq.WantHoldingsRow{AlbumID: other, Present: 1, ArrivedSince: true, MissingBefore: true}, 0, true},
"no editions known, still missing": {request, dbq.WantHoldingsRow{AlbumID: other, Present: 5, MissingBefore: true}, 0, false},
"the lost album, a gap remains": {missing, dbq.WantHoldingsRow{AlbumID: album, Present: 12, UncoveredMissing: 1}, 10, false},
"the lost album, every gap filled": {missing, dbq.WantHoldingsRow{AlbumID: album, Present: 9, UncoveredMissing: 0}, 10, true},
"another copy of the lost album": {missing, dbq.WantHoldingsRow{AlbumID: other, Present: 10}, 10, true},
} {
if got := Answers(c.w, c.h, c.shortest); got != c.want {
t.Errorf("%s: Answers = %v, want %v", name, got, c.want)
}
}
}
func TestFollowable(t *testing.T) {
for _, c := range []struct {
mbid, name string
want bool
}{
{"a", "Gorillaz", true},
{variousArtists, "Various Artists", false},
{"b", "[unknown]", false},
{"", "Nameless", false},
{"c", "[ ]Bracketed Band", true},
} {
if got := Followable(c.mbid, c.name); got != c.want {
t.Errorf("Followable(%q, %q) = %v", c.mbid, c.name, got)
}
}
}
// --- integration ---
func newPool(t *testing.T) *pgxpool.Pool {
t.Helper()
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
dbtest.ResetDB(t, pool)
return pool
}
const (
blur = "ba853904-ae25-4ebb-89d6-c44cfbd71bd2"
parklife = "11111111-0000-4000-8000-000000000001" // 1994 album, held
liveAtWemb = "11111111-0000-4000-8000-000000000002" // 2009 live album
theBallad = "11111111-0000-4000-8000-000000000003" // 2023 album, not held
newSingle = "11111111-0000-4000-8000-000000000004" // appears after the baseline
oldSingle = "11111111-0000-4000-8000-000000000005" // 1997 single, catalogued late
parkRelease = "22222222-0000-4000-8000-000000000001"
)
// fakeMB is a MusicBrainz holding Blur's discography; added groups appear in
// it after the baseline, and down makes every request a 503.
type fakeMB struct {
srv *httptest.Server
added atomic.Bool
down atomic.Bool
}
func newFakeMB(t *testing.T) *fakeMB {
t.Helper()
f := &fakeMB{}
group := func(id, title, primary, secondary, date string) string {
return fmt.Sprintf(`{"id": %q, "title": %q, "primary-type": %q, "secondary-types": [%s], "first-release-date": %q,
"artist-credit": [{"name": "Blur", "artist": {"id": %q}}]}`, id, title, primary, secondary, date, blur)
}
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if f.down.Load() {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
switch {
case r.URL.Path == "/artist/"+blur:
_, _ = fmt.Fprintf(w, `{"id": %q, "name": "Blur", "type": "Group", "country": "GB"}`, blur)
case r.URL.Path == "/release-group" && r.URL.Query().Get("artist") == blur:
groups := group(parklife, "Parklife", "Album", "", "1994-04-25") + "," +
group(liveAtWemb, "Live at Wembley Stadium", "Album", `"Live"`, "2009-07-02") + "," +
group(theBallad, "The Ballad of Darren", "Album", "", "2023-07-21")
n := 3
if f.added.Load() {
groups += "," + group(newSingle, "New Single", "Single", "", "") +
"," + group(oldSingle, "Old Single", "Single", "", "1997-02-03")
n = 5
}
_, _ = fmt.Fprintf(w, `{"release-group-count": %d, "release-groups": [%s]}`, n, groups)
case r.URL.Path == "/release-group/"+theBallad:
_, _ = fmt.Fprint(w, group(theBallad, "The Ballad of Darren", "Album", "", "2023-07-21"))
case r.URL.Path == "/release" && r.URL.Query().Get("release-group") == theBallad:
_, _ = fmt.Fprint(w, `{"release-count": 2, "releases": [
{"id": "r-ballad-1", "title": "The Ballad of Darren", "status": "Official", "media": [{"position": 1,
"tracks": [{"position": 1, "number": "1", "title": "The Ballad", "recording": {"id": "rec-1"}},
{"position": 2, "number": "2", "title": "St. Charles Square", "recording": {"id": "rec-2"}}]}]},
{"id": "r-ballad-2", "title": "The Ballad of Darren", "status": "Official", "media": [{"position": 1,
"tracks": [{"position": 1, "number": "1", "title": "The Ballad", "recording": {"id": "rec-1"}},
{"position": 2, "number": "2", "title": "St. Charles Square", "recording": {"id": "rec-2"}},
{"position": 3, "number": "3", "title": "Bonus", "recording": {"id": "rec-3"}}]}]}]}`)
case r.URL.Path == "/release":
_, _ = fmt.Fprint(w, `{"release-count": 0, "releases": []}`)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(f.srv.Close)
return f
}
func newService(t *testing.T, pool *pgxpool.Pool, f *fakeMB) *Service {
t.Helper()
mb := &musicbrainz.Client{BaseURL: f.srv.URL, Pace: &musicbrainz.Pacer{}, Retries: -1}
s := New(pool, catalogue.New(pool, mb, nil), slog.New(slog.NewTextHandler(io.Discard, nil)))
// Never the real MusicBrainz from a test.
s.releaseGroup = func(context.Context, string) (string, error) { return "", tags.ErrNotFound }
return s
}
// seedHeld puts an album of Blur's in the library with n tracks on disk.
func seedHeld(t *testing.T, q *dbq.Queries, title, release, group string, n int) dbq.Album {
t.Helper()
ctx := context.Background()
mbid := blur
artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "Blur", SortName: "Blur", Mbid: &mbid})
if err != nil {
t.Fatal(err)
}
params := dbq.UpsertAlbumParams{Title: title, SortTitle: title, ArtistID: artist.ID, Mbid: &release}
if group != "" {
params.ReleaseGroupMbid = &group
}
album, err := q.UpsertAlbum(ctx, params)
if err != nil {
t.Fatal(err)
}
for i := range n {
num := int32(i + 1)
if _, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: fmt.Sprintf("Track %d", i+1), AlbumID: album.ID, ArtistID: artist.ID, TrackNumber: &num,
DurationMs: 180000, FilePath: fmt.Sprintf("/music/%s/%02d.flac", release, i+1), FileSize: 1, FileFormat: "flac",
}); err != nil {
t.Fatal(err)
}
}
return album
}
func TestFollow_BaselineThenOnlyNewReleasesThatPass_Integration(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
f := newFakeMB(t)
s := newService(t, pool, f)
_ = seedHeld(t, q, "Parklife", parkRelease, parklife, 2)
if _, err := s.Follow(ctx, FollowParams{ArtistMBID: blur, ArtistName: "Blur"}); err != nil {
t.Fatal(err)
}
res, err := s.Pass(ctx)
if err != nil {
t.Fatal(err)
}
if res.Baselined != 1 || res.Wanted != 0 {
t.Fatalf("baseline pass = %+v: a follow wants nothing it already lists", res)
}
// MusicBrainz gains two singles: one new, one from 1997. A day later the
// follow reads the discography again.
f.added.Store(true)
if _, err := pool.Exec(ctx, `
UPDATE mb_artists SET discography_refreshed_at = now() - interval '2 days';
UPDATE follows SET checked_at = now() - interval '2 days'`); err != nil {
t.Fatal(err)
}
if res, err = s.Pass(ctx); err != nil {
t.Fatal(err)
}
if res.Wanted != 1 {
t.Errorf("second pass wanted %d, want 1 (the new single)", res.Wanted)
}
if w, err := q.GetWantByReleaseGroup(ctx, newSingle); err != nil || w.Reason != ReasonFollow || w.PrimaryType != "Single" {
t.Errorf("new single: %+v, %v", w, err)
}
if _, err := q.GetWantByReleaseGroup(ctx, oldSingle); err == nil {
t.Error("a 1997 single catalogued late is not a new release")
}
}
func TestFollow_WantExistingWantsWhatPassesAndIsNotHeld_Integration(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
f := newFakeMB(t)
s := newService(t, pool, f)
_ = seedHeld(t, q, "Parklife", parkRelease, parklife, 2)
if _, err := s.Follow(ctx, FollowParams{ArtistMBID: blur, ArtistName: "Blur", WantExisting: true}); err != nil {
t.Fatal(err)
}
if _, err := s.Pass(ctx); err != nil {
t.Fatal(err)
}
if _, err := q.GetWantByReleaseGroup(ctx, theBallad); err != nil {
t.Errorf("the studio album not held should be wanted: %v", err)
}
for group, why := range map[string]string{parklife: "held", liveAtWemb: "live, and Standard is studio only"} {
if _, err := q.GetWantByReleaseGroup(ctx, group); err == nil {
t.Errorf("%s wanted, but it is %s", group, why)
}
}
if fol, _ := q.GetFollow(ctx, blur); fol.WantExisting || !fol.BaselineAt.Valid {
t.Errorf("follow = %+v: want_existing applied once, then cleared", fol)
}
// A profile that allows live releases, applied with "want what exists".
live, err := s.CreateProfile(ctx, ProfileInput{Name: "With live", PrimaryTypes: []string{"Album"}, SecondaryTypes: []string{"Live"}})
if err != nil {
t.Fatal(err)
}
if _, err := s.Follow(ctx, FollowParams{ArtistMBID: blur, ArtistName: "Blur", ProfileID: live.ID, WantExisting: true}); err != nil {
t.Fatal(err)
}
if _, err := s.Pass(ctx); err != nil {
t.Fatal(err)
}
if _, err := q.GetWantByReleaseGroup(ctx, liveAtWemb); err != nil {
t.Errorf("with live allowed, the live album is wanted: %v", err)
}
if err := s.DeleteProfile(ctx, live.ID); !errors.Is(err, ErrProfileInUse) {
t.Errorf("deleting a profile in use: err = %v", err)
}
}
func TestSettle_HeldOnceAFullEditionIsOnDisk_Integration(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
s := newService(t, pool, newFakeMB(t))
if _, err := WantWith(ctx, q, WantParams{
GroupMBID: theBallad, ArtistMBID: blur, ArtistName: "Blur", Title: "The Ballad of Darren", Reason: ReasonRequest,
}); err != nil {
t.Fatal(err)
}
album := seedHeld(t, q, "The Ballad of Darren", "r-ballad-1", theBallad, 1)
if res, err := s.Pass(ctx); err != nil || res.Held != 0 {
t.Fatalf("one track of a two-track edition: %+v, %v", res, err)
}
_ = seedHeld(t, q, "The Ballad of Darren", "r-ballad-1", theBallad, 2)
if res, err := s.Pass(ctx); err != nil || res.Held != 1 {
t.Fatalf("the shortest edition complete: %+v, %v", res, err)
}
w, _ := q.GetWantByReleaseGroup(ctx, theBallad)
if !w.FulfilledAt.Valid || w.AlbumID != album.ID {
t.Errorf("want = %+v: held by the album", w)
}
}
func TestSettle_MovesAWantNamedByReleaseIdToItsGroup_Integration(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
s := newService(t, pool, newFakeMB(t))
s.releaseGroup = func(_ context.Context, release string) (string, error) {
if release == "r-ballad-1" {
return theBallad, nil
}
return "", tags.ErrNotFound
}
if _, err := WantWith(ctx, q, WantParams{
GroupMBID: "r-ballad-1", ArtistMBID: blur, ArtistName: "Blur", Title: "The Ballad of Darren", Reason: ReasonRequest,
}); err != nil {
t.Fatal(err)
}
group := "r-ballad-1"
if _, err := q.CreateRequest(ctx, dbq.CreateRequestParams{
UserID: seedUser(t, pool), Kind: dbq.RequestKindAlbum, ArtistMbid: blur, AlbumMbid: &group, ArtistName: "Blur",
}); err != nil {
t.Fatal(err)
}
if _, err := s.Pass(ctx); err != nil {
t.Fatal(err)
}
if _, err := q.GetWantByReleaseGroup(ctx, theBallad); err != nil {
t.Errorf("the want should be under its group now: %v", err)
}
var moved string
if err := pool.QueryRow(ctx, "SELECT album_mbid FROM requests").Scan(&moved); err != nil || moved != theBallad {
t.Errorf("the request should follow its want: %q, %v", moved, err)
}
}
func TestPass_MusicBrainzDownStopsFollowsButStillSettles_Integration(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
f := newFakeMB(t)
s := newService(t, pool, f)
if _, err := s.Follow(ctx, FollowParams{ArtistMBID: blur, ArtistName: "Blur"}); err != nil {
t.Fatal(err)
}
if _, err := WantWith(ctx, q, WantParams{
GroupMBID: "some-group", ArtistMBID: blur, ArtistName: "Blur", Title: "Some", Reason: ReasonRequest,
}); err != nil {
t.Fatal(err)
}
_ = seedHeld(t, q, "Some", "some-release", "some-group", 3)
f.down.Store(true)
res, err := s.Pass(ctx)
if !errors.Is(err, musicbrainz.ErrUnreachable) {
t.Errorf("err = %v, want ErrUnreachable", err)
}
if res.Held != 1 {
t.Errorf("held = %d: with no editions known, an album that arrived since the want answers it", res.Held)
}
if fol, _ := q.GetFollow(ctx, blur); fol.BaselineAt.Valid {
t.Error("a follow is not baselined without reading its discography")
}
}
func seedUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID {
t.Helper()
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "wanter", PasswordHash: "x", ApiTokenHash: "x",
})
if err != nil {
t.Fatal(err)
}
return u.ID
}