From f29cafdbd3d4d049308466a2c3efc6178533569d Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Sat, 26 Sep 2026 10:35:46 -0400 Subject: [PATCH 001/103] fix(requests): guard lifecycle transitions and make reconcile fair Request status and outcome writes now name the states they may start from, so two admins approving at once, or an approval racing a decline, apply exactly one transition. Sending a request to the router plugin requires an atomic claim on the row, so admin approval, auto-approval and the reconcile pass on any server cannot submit it twice. A failed submission keeps the approval: it records the error and retries with backoff (5 minutes doubling to an hour), then marks the request failed after ten attempts. Approve, Retry and auto-approved creates return the saved request instead of a 500 after the approval has committed. Retry reopens a failed request in one write, answers 409 when another account has since requested the title, and drops failed targets for qualities the request no longer wants (#582). Re-requesting a failed title deletes only the requester's own failed rows, inside the create transaction and before the quota check; it used to delete every account's failed rows for the title. The reconcile pass takes a cluster advisory lock, rotates through candidates by last_reconciled_at, and completes from the library only when no submission claim is running. The admin queue offers Decline only for pending requests, matching the server. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/silo/main.go | 2 +- docs/architecture/media-requests.md | 79 +++ internal/requests/lifecycle_guard_test.go | 478 ++++++++++++++++++ internal/requests/repository.go | 273 ++++++++-- internal/requests/repository_page_test.go | 5 +- internal/requests/service.go | 248 ++++++--- internal/requests/service_test.go | 248 +++++++-- internal/requests/store.go | 36 +- internal/requests/types.go | 23 +- .../taskmanager/tasks/reconcile_requests.go | 31 +- ...0260926140759_request_submission_state.sql | 19 + web/src/pages/AdminRequests.tsx | 4 +- web/src/pages/AdminRequests.v2.test.tsx | 37 ++ 13 files changed, 1310 insertions(+), 173 deletions(-) create mode 100644 docs/architecture/media-requests.md create mode 100644 internal/requests/lifecycle_guard_test.go create mode 100644 migrations/sql/20260926140759_request_submission_state.sql diff --git a/cmd/silo/main.go b/cmd/silo/main.go index 44d909b54d..e61862a347 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -2930,7 +2930,7 @@ func main() { if notificationSystem != nil { requestReconcileSvc.SetFulfillmentNotifier(notifications.NewRequestFulfillmentNotifier(notificationSystem)) } - taskMgr.Register(tasks.NewReconcileRequestsTask(requestReconcileSvc, 100)) + taskMgr.Register(tasks.NewReconcileRequestsTask(requestReconcileSvc, 100, deps.DB)) if deps.FolderRepo != nil && deps.LibraryScanQueue != nil && pluginService != nil && pluginInstallationStore != nil { autoscanRepo := autoscan.NewRepository(deps.DB, deps.SecretCipher) if err := autoscanRepo.MarkInterruptedEvents(appCtx); err != nil { diff --git a/docs/architecture/media-requests.md b/docs/architecture/media-requests.md new file mode 100644 index 0000000000..32e13eb260 --- /dev/null +++ b/docs/architecture/media-requests.md @@ -0,0 +1,79 @@ +# Media requests + +A user asks for a movie or series the server does not have, an admin approves it +(or the user's policy approves it automatically), a request-router plugin sends it +to a downstream service such as Sonarr or Radarr, and the request completes when +the media is in the library. The code lives in `internal/requests`; the reconcile +pass is the `reconcile_requests` task in `internal/taskmanager/tasks`. + +## State + +A request row carries two fields: + +- `status`: `pending → approved → queued → downloading → completed`. +- `outcome`: `active`, or the terminal `declined`, `cancelled`, `failed`. + +Once a request is submitted, it fans out into one `media_request_targets` row per +quality. Each target change recomputes the request's status and outcome in the +same transaction (`aggregateStatus`), so after submission the targets own the +request's state. + +## Transitions are guarded + +Every status or outcome write made by an admin, a user, or the reconcile pass +names the states it may start from (`StateGuard`), and the store applies it with +a single `UPDATE … WHERE status = ANY(…) AND outcome = ANY(…)`. A write whose row +has already moved fails with `ErrInvalidState`. Two admins approving at once, or +an approval racing a decline, therefore apply exactly one transition. Services +never check the state in Go and then write it unconditionally. The exception is +the target aggregate: a target change always recomputes the request's status from +its targets, because after submission the targets are the source of truth. + +Decline and cancel apply only to `pending` + `active` requests. An approved +request may already be on its way to a downstream service, so it stays in the +pipeline until it completes or fails. Retry reopens a `failed` request to +`approved` + `active` in one guarded write. + +## Submission is claimed + +Approval commits before anything is sent. The submission itself runs only for +the caller that claims it: `ClaimSubmission` sets `submit_lease_until` and counts +the attempt, and succeeds only while the request is `approved` and `active`, no +lease is live, and its `next_submit_at` backoff has passed. Admin approval, auto-approval and the +reconcile pass on every server all go through the claim, so a request is never +submitted twice at once. The claim is a lease: if the claiming server dies +mid-call, the reconcile pass picks the request up after the lease. + +A failed attempt keeps the approval. It records `last_error`, releases the lease, +schedules the next attempt in `next_submit_at` (5 minutes, doubling to an hour), +and answers the caller with +the approved request rather than an error. After `maxSubmitAttempts` the request +is marked `failed` for an admin to retry. + +A submission converges the request's targets to the qualities it currently +wants. A failed target for a quality it no longer wants is deleted, but only +when that quality set was resolved without error: a failed entitlement lookup or +a connection skipped for a missing key also shrinks it, and must not erase a +failure an admin still needs to see. If nothing is left to send, the remaining +targets decide the status. + +## Reconcile + +Every five minutes the reconcile pass submits approved requests, asks the router +for target status, and completes requests whose media is present in the library. +Completing from the library skips an approved request while its submission lease +is live, so it cannot race a router call that is creating targets. +Every API process runs the task manager, so an advisory lock lets one server run +each pass. Candidates are taken in `last_reconciled_at` order and each one is +stamped when checked, even if it errors, so a large backlog rotates instead of +the same batch being checked every time. + +## Re-requesting a failed title + +Creating a request deletes the requester's own failed requests for the same +title inside the insert transaction, before the quota check, so the re-request +does not count against itself. The quota is checked only there, under the +requester's advisory lock. Other accounts' failed requests are left alone: they +are those users' history and count against their quota. Retrying one of them +after someone else has requested the title answers `ErrAlreadyRequested`, since +only one active request per title may exist. diff --git a/internal/requests/lifecycle_guard_test.go b/internal/requests/lifecycle_guard_test.go new file mode 100644 index 0000000000..058abb4a9d --- /dev/null +++ b/internal/requests/lifecycle_guard_test.go @@ -0,0 +1,478 @@ +package requests + +import ( + "context" + "errors" + "fmt" + "os" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// lifecycleTestRepository gives each test its own schema holding copies of the +// migrated request tables, so the guarded writes run against the real columns +// and constraints without touching shared rows. +func lifecycleTestRepository(t *testing.T) (*Repository, *pgxpool.Pool) { + t.Helper() + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + admin, err := pgxpool.New(t.Context(), dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(admin.Close) + schema := fmt.Sprintf("request_lifecycle_%d", time.Now().UnixNano()) + quoted := pgx.Identifier{schema}.Sanitize() + if _, err = admin.Exec(t.Context(), `CREATE SCHEMA `+quoted); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = admin.Exec(context.Background(), `DROP SCHEMA `+quoted+` CASCADE`) }) + config, err := pgxpool.ParseConfig(dsn) + if err != nil { + t.Fatal(err) + } + config.ConnConfig.RuntimeParams["search_path"] = schema + ",public" + pool, err := pgxpool.NewWithConfig(t.Context(), config) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + for _, table := range []string{"media_requests", "media_request_events", "media_request_targets", "request_integrations"} { + if _, err = pool.Exec(t.Context(), `CREATE TABLE `+table+` (LIKE public.`+table+` INCLUDING ALL)`); err != nil { + t.Fatalf("copy %s: %v", table, err) + } + } + return NewRepository(pool, nil), pool +} + +func insertLifecycleRequest(t *testing.T, repo *Repository, id string, userID, tmdbID int, status Status) *Request { + t.Helper() + req, err := repo.CreateRequest(t.Context(), CreateRequestRecord{ + ID: id, + Input: CreateRequestInput{MediaType: MediaTypeMovie, TMDBID: tmdbID, Title: "Title " + id}, + Status: status, + Outcome: OutcomeActive, + Requester: Viewer{UserID: userID, ProfileID: "profile"}, + }) + if err != nil { + t.Fatalf("create %s: %v", id, err) + } + return req +} + +// raceLifecycle runs write from many goroutines at once and returns how many +// succeeded. Every loser must fail with wantLoss. +func raceLifecycle(t *testing.T, write func() error, wantLoss error) int { + t.Helper() + var wg sync.WaitGroup + start := make(chan struct{}) + var won atomic.Int32 + for range 8 { + wg.Go(func() { + <-start + err := write() + switch { + case err == nil: + won.Add(1) + case wantLoss == nil || !errors.Is(err, wantLoss): + t.Errorf("write: %v", err) + } + }) + } + close(start) + wg.Wait() + return int(won.Load()) +} + +func TestGuardedTransitionsDatabase(t *testing.T) { + repo, _ := lifecycleTestRepository(t) + ctx := t.Context() + admin := Viewer{} + insertLifecycleRequest(t, repo, "req-approve", 1, 101, StatusPending) + + won := raceLifecycle(t, func() error { + _, err := repo.SetStatus(ctx, "req-approve", guardPending, StatusApproved, admin) + return err + }, ErrInvalidState) + if won != 1 { + t.Fatalf("concurrent approvals applied %d times, want exactly 1", won) + } + if _, err := repo.SetOutcome(ctx, "req-approve", guardPending, OutcomeDeclined, admin, "late decline"); !errors.Is(err, ErrInvalidState) { + t.Fatalf("decline after approval: err = %v, want ErrInvalidState", err) + } + if _, err := repo.SetStatus(ctx, "missing", guardPending, StatusApproved, admin); !errors.Is(err, ErrNotFound) { + t.Fatalf("approve missing request: err = %v, want ErrNotFound", err) + } + got, err := repo.GetRequest(ctx, "req-approve") + if err != nil { + t.Fatal(err) + } + if got.Status != StatusApproved || got.Outcome != OutcomeActive { + t.Fatalf("request = %s/%s, want approved/active", got.Status, got.Outcome) + } +} + +func TestSubmissionClaimDatabase(t *testing.T) { + repo, _ := lifecycleTestRepository(t) + ctx := t.Context() + insertLifecycleRequest(t, repo, "req-claim", 1, 202, StatusApproved) + + won := raceLifecycle(t, func() error { + _, claimed, err := repo.ClaimSubmission(ctx, "req-claim", time.Minute) + if err != nil { + return err + } + if !claimed { + return ErrInvalidState + } + return nil + }, ErrInvalidState) + if won != 1 { + t.Fatalf("concurrent claims succeeded %d times, want exactly 1", won) + } + + deferred, err := repo.DeferSubmission(ctx, "req-claim", time.Hour, "radarr unreachable") + if err != nil { + t.Fatal(err) + } + if deferred.LastError != "radarr unreachable" || deferred.SubmitAttempts != 1 || deferred.NextSubmitAt == nil || deferred.SubmitLeaseUntil != nil { + t.Fatalf("deferred = %+v, want last error, one attempt, a next attempt time, and the claim released", deferred) + } + if _, claimed, err := repo.ClaimSubmission(ctx, "req-claim", time.Minute); err != nil || claimed { + t.Fatalf("claim during backoff: claimed = %v, err = %v; want refused", claimed, err) + } + + // Once the request leaves approved, nobody can claim or defer it. + if _, err := repo.SetOutcome(ctx, "req-claim", StateGuard{Statuses: []Status{StatusApproved}}, OutcomeFailed, Viewer{}, "gave up"); err != nil { + t.Fatal(err) + } + if _, err := repo.DeferSubmission(ctx, "req-claim", time.Minute, "late"); !errors.Is(err, ErrInvalidState) { + t.Fatalf("defer on failed request: err = %v, want ErrInvalidState", err) + } + + reopened, err := repo.ReopenFailed(ctx, "req-claim", Viewer{}) + if err != nil { + t.Fatal(err) + } + if reopened.Status != StatusApproved || reopened.Outcome != OutcomeActive || reopened.LastError != "" || + reopened.SubmitAttempts != 0 || reopened.SubmitLeaseUntil != nil || reopened.NextSubmitAt != nil { + t.Fatalf("reopened = %+v, want approved/active with a fresh submission budget", reopened) + } + if _, err := repo.ReopenFailed(ctx, "req-claim", Viewer{}); !errors.Is(err, ErrInvalidState) { + t.Fatalf("second reopen: err = %v, want ErrInvalidState", err) + } + if _, claimed, err := repo.ClaimSubmission(ctx, "req-claim", time.Minute); err != nil || !claimed { + t.Fatalf("claim after reopen: claimed = %v, err = %v; want claimed", claimed, err) + } +} + +func TestReplaceFailedIsScopedToRequesterDatabase(t *testing.T) { + repo, pool := lifecycleTestRepository(t) + ctx := t.Context() + insertLifecycleRequest(t, repo, "mine-failed", 1, 303, StatusApproved) + insertLifecycleRequest(t, repo, "theirs-failed", 2, 404, StatusApproved) + // Two accounts' failed requests for one title can coexist; the active-title + // unique index only covers active rows. + if _, err := pool.Exec(ctx, `UPDATE media_requests SET outcome = 'failed', tmdb_id = 303`); err != nil { + t.Fatal(err) + } + + if _, err := repo.CreateRequest(ctx, CreateRequestRecord{ + ID: "mine-again", + Input: CreateRequestInput{MediaType: MediaTypeMovie, TMDBID: 303, Title: "Again"}, + Status: StatusPending, + Outcome: OutcomeActive, + Requester: Viewer{UserID: 1}, + ReplaceFailed: true, + Quota: &QuotaCheck{UserID: 1, WindowStart: time.Now().Add(-time.Hour), MaxRequests: 1}, + }); err != nil { + t.Fatalf("re-request: %v", err) + } + if _, err := repo.GetRequest(ctx, "mine-failed"); !errors.Is(err, ErrNotFound) { + t.Fatalf("requester's failed row: err = %v, want deleted", err) + } + if _, err := repo.GetRequest(ctx, "theirs-failed"); err != nil { + t.Fatalf("other account's failed row: %v, want kept", err) + } +} + +func TestReopenFailedWhileAnotherRequestIsActiveDatabase(t *testing.T) { + repo, pool := lifecycleTestRepository(t) + ctx := t.Context() + insertLifecycleRequest(t, repo, "first-failed", 1, 606, StatusApproved) + if _, err := pool.Exec(ctx, `UPDATE media_requests SET outcome = 'failed' WHERE id = 'first-failed'`); err != nil { + t.Fatal(err) + } + insertLifecycleRequest(t, repo, "second-active", 2, 606, StatusPending) + + if _, err := repo.ReopenFailed(ctx, "first-failed", Viewer{}); !errors.Is(err, ErrAlreadyRequested) { + t.Fatalf("reopen while another request is active: err = %v, want ErrAlreadyRequested", err) + } +} + +func TestMarkAvailableWaitsForSubmissionClaimDatabase(t *testing.T) { + repo, pool := lifecycleTestRepository(t) + ctx := t.Context() + insertLifecycleRequest(t, repo, "req-present", 1, 707, StatusApproved) + if _, claimed, err := repo.ClaimSubmission(ctx, "req-present", time.Minute); err != nil || !claimed { + t.Fatalf("claim: claimed = %v, err = %v", claimed, err) + } + if _, err := repo.MarkAvailable(ctx, "req-present", Viewer{}); !errors.Is(err, ErrInvalidState) { + t.Fatalf("mark available during a live claim: err = %v, want ErrInvalidState", err) + } + if _, err := pool.Exec(ctx, `UPDATE media_requests SET submit_lease_until = now() - interval '1 second'`); err != nil { + t.Fatal(err) + } + got, err := repo.MarkAvailable(ctx, "req-present", Viewer{}) + if err != nil { + t.Fatalf("mark available after the lease: %v", err) + } + if got.Status != StatusCompleted || got.CompletedAt == nil { + t.Fatalf("request = %s completed_at=%v, want completed with a timestamp", got.Status, got.CompletedAt) + } +} + +func TestReconcileCandidatesRotateDatabase(t *testing.T) { + repo, _ := lifecycleTestRepository(t) + ctx := t.Context() + for i, id := range []string{"rot-a", "rot-b", "rot-c"} { + insertLifecycleRequest(t, repo, id, 1, 500+i, StatusApproved) + } + first, err := repo.ListReconciliationCandidates(ctx, 2) + if err != nil { + t.Fatal(err) + } + if len(first) != 2 { + t.Fatalf("first batch = %d, want 2", len(first)) + } + for _, req := range first { + if err := repo.MarkReconciled(ctx, req.ID); err != nil { + t.Fatal(err) + } + } + second, err := repo.ListReconciliationCandidates(ctx, 2) + if err != nil { + t.Fatal(err) + } + seen := map[string]bool{first[0].ID: true, first[1].ID: true} + if len(second) == 0 || seen[second[0].ID] { + t.Fatalf("second batch starts with %v, want the request the first batch skipped", second) + } +} + +func TestApproveTwiceSubmitsOnce(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInst("router-1")} + store.requests["r1"] = &Request{ID: "r1", MediaType: MediaTypeMovie, TMDBID: 550, Status: StatusPending, Outcome: OutcomeActive} + router := &fakeRouterProvider{} + svc := newTestService(store) + svc.SetRouterProvider(router) + admin := Viewer{UserID: 1, IsAdmin: true} + + if _, err := svc.Approve(context.Background(), admin, "r1"); err != nil { + t.Fatalf("first approve: %v", err) + } + if _, err := svc.Approve(context.Background(), admin, "r1"); !errors.Is(err, ErrInvalidState) { + t.Fatalf("second approve: err = %v, want ErrInvalidState", err) + } + if router.fulfillCalls != 1 { + t.Fatalf("fulfill calls = %d, want 1", router.fulfillCalls) + } +} + +func TestApproveKeepsApprovalWhenSubmissionFails(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInst("router-1")} + store.requests["r1"] = &Request{ID: "r1", MediaType: MediaTypeMovie, TMDBID: 550, Status: StatusPending, Outcome: OutcomeActive} + router := &fakeRouterProvider{fulfillErr: ErrIntegrationUnreachable} + svc := newTestService(store) + svc.SetRouterProvider(router) + + req, err := svc.Approve(context.Background(), Viewer{UserID: 1, IsAdmin: true}, "r1") + if err != nil { + t.Fatalf("Approve returned error: %v", err) + } + if req.Status != StatusApproved || req.Outcome != OutcomeActive { + t.Fatalf("request = %s/%s, want approved/active", req.Status, req.Outcome) + } + if req.LastError == "" || req.NextSubmitAt == nil { + t.Fatalf("request = %+v, want the failure recorded and a retry scheduled", req) + } + + // A reconcile pass during the backoff does not call the router again. + if _, err := svc.submitApprovedRequest(context.Background(), *req, Viewer{}, nil); err != nil { + t.Fatalf("resubmit during backoff: %v", err) + } + if router.fulfillCalls != 1 { + t.Fatalf("fulfill calls = %d, want 1 (backoff not elapsed)", router.fulfillCalls) + } +} + +func TestCreateRequestKeepsAutoApprovedRequestWhenSubmissionFails(t *testing.T) { + store := newFakeStore() + store.settings.GlobalAutoApprovalEnabled = true + store.integrations = []Integration{autoApproveRouterInst("router-1", "radarr-key")} + svc := newTestService(store) + svc.SetRouterProvider(&fakeRouterProvider{fulfillErr: ErrIntegrationUnreachable}) + + req, err := svc.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ + MediaType: MediaTypeMovie, + TMDBID: 550, + Title: "Fight Club", + }) + if err != nil { + t.Fatalf("CreateRequest returned error: %v", err) + } + if req.Status != StatusApproved || req.Outcome != OutcomeActive || req.LastError == "" { + t.Fatalf("request = %+v, want approved/active with the submission error recorded", req) + } +} + +func TestSubmitApprovedMarksFailedAfterLastAttempt(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInst("router-1")} + store.requests["r1"] = &Request{ + ID: "r1", MediaType: MediaTypeMovie, TMDBID: 550, Status: StatusApproved, Outcome: OutcomeActive, + SubmitAttempts: maxSubmitAttempts - 1, + } + svc := newTestService(store) + svc.SetRouterProvider(&fakeRouterProvider{fulfillErr: errors.New("radarr: connection refused")}) + + req, err := svc.submitApprovedRequest(context.Background(), *store.requests["r1"], Viewer{}, nil) + if err != nil { + t.Fatalf("submit: %v", err) + } + if req.Outcome != OutcomeFailed || req.LastError != "radarr: connection refused" { + t.Fatalf("request = %+v, want failed with the last error", req) + } +} + +func TestCreateRequestReplacesOwnFailedRequestAtQuota(t *testing.T) { + store := newFakeStore() + store.settings.GlobalMaxRequests = 1 + now := time.Date(2026, 5, 24, 12, 0, 0, 0, time.UTC) + // The failed request fills the user's only slot; re-requesting the same + // title replaces it, so it must not count against the re-request. + store.requests["mine-failed"] = &Request{ + ID: "mine-failed", MediaType: MediaTypeMovie, TMDBID: 550, Status: StatusApproved, + Outcome: OutcomeFailed, RequestedByUserID: 1, CreatedAt: now, + } + svc := newTestService(store) + + req, err := svc.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ + MediaType: MediaTypeMovie, + TMDBID: 550, + Title: "Fight Club", + }) + if err != nil { + t.Fatalf("CreateRequest returned error: %v", err) + } + if req.Status != StatusPending { + t.Fatalf("request = %+v, want a new pending request", req) + } + if _, ok := store.requests["mine-failed"]; ok { + t.Fatal("the failed request was not replaced") + } +} + +func TestSubmitKeepsFailedTargetWhenEntitlementLookupFails(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInst("router-1")} + store.requests["r1"] = &Request{ID: "r1", MediaType: MediaTypeMovie, TMDBID: 550, Status: StatusQueued, Outcome: OutcomeFailed} + store.targets = map[string][]Target{"r1": { + {ID: 1, RequestID: "r1", IntegrationID: "router-1", Quality: Quality1080p, Status: StatusCompleted}, + {ID: 2, RequestID: "r1", Quality: Quality2160p, Status: StatusFailed, LastError: "radarr 4k unreachable"}, + }} + store.targetSeq = 2 + svc := newTestService(store) + svc.SetRouterProvider(&fakeRouterProvider{}) + svc.SetEntitlementResolver(failingCeiling{}) + + if _, err := svc.Retry(context.Background(), Viewer{UserID: 1, IsAdmin: true}, "r1"); err != nil { + t.Fatalf("Retry returned error: %v", err) + } + targets, _ := store.ListTargets(context.Background(), "r1") + if len(targets) != 2 { + t.Fatalf("targets = %+v, want the failed 4K target kept after a failed entitlement lookup", targets) + } +} + +func TestSubmitBackoff(t *testing.T) { + for attempts, want := range map[int]time.Duration{ + 1: 5 * time.Minute, + 2: 10 * time.Minute, + 3: 20 * time.Minute, + 4: 40 * time.Minute, + 5: time.Hour, + 10: time.Hour, + } { + if got := submitBackoff(attempts); got != want { + t.Errorf("submitBackoff(%d) = %s, want %s", attempts, got, want) + } + } +} + +// Issue #582: after the 4K tier is turned off, Retry must drop the failed 4K +// target instead of leaving the request failed next to a finished 1080p copy. +func TestRetryDropsFailedTargetForUnwantedQuality(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInst("router-1")} + store.requests["r1"] = &Request{ID: "r1", MediaType: MediaTypeMovie, TMDBID: 550, Status: StatusQueued, Outcome: OutcomeFailed} + store.targets = map[string][]Target{"r1": { + {ID: 1, RequestID: "r1", IntegrationID: "router-1", Quality: Quality1080p, Status: StatusCompleted}, + {ID: 2, RequestID: "r1", Quality: Quality2160p, Status: StatusFailed, LastError: "fulfillment backend returned no target for this quality"}, + }} + store.targetSeq = 2 + router := &fakeRouterProvider{} + svc := newTestService(store) + svc.SetRouterProvider(router) + svc.SetEntitlementResolver(fixedCeiling{q: "1080p"}) + + req, err := svc.Retry(context.Background(), Viewer{UserID: 1, IsAdmin: true}, "r1") + if err != nil { + t.Fatalf("Retry returned error: %v", err) + } + if router.fulfillCalls != 0 { + t.Fatalf("fulfill calls = %d, want 0 (nothing left to send)", router.fulfillCalls) + } + if req.Status != StatusCompleted || req.Outcome != OutcomeActive { + t.Fatalf("request = %s/%s, want completed/active", req.Status, req.Outcome) + } + targets, _ := store.ListTargets(context.Background(), "r1") + if len(targets) != 1 || targets[0].Quality != Quality1080p { + t.Fatalf("targets = %+v, want only the 1080p target", targets) + } +} + +func TestReconcileStampsEveryCandidate(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInst("router-1")} + store.candidates = []*Request{ + {ID: "ok", MediaType: MediaTypeMovie, TMDBID: 1, Status: StatusQueued, Outcome: OutcomeActive}, + {ID: "broken", MediaType: MediaTypeMovie, TMDBID: 2, Status: StatusQueued, Outcome: OutcomeActive}, + } + store.targets = map[string][]Target{ + "ok": {{ID: 1, RequestID: "ok", IntegrationID: "router-1", Quality: Quality1080p, Status: StatusQueued}}, + "broken": {{ID: 2, RequestID: "broken", IntegrationID: "router-1", Quality: Quality1080p, Status: StatusQueued}}, + } + svc := newTestService(store) + svc.SetRouterProvider(&fakeRouterProvider{statusErr: errors.New("sonarr timeout")}) + + result, err := svc.ReconcileRequests(context.Background(), 10) + if err != nil { + t.Fatalf("ReconcileRequests: %v", err) + } + if result.Errors != 2 { + t.Fatalf("errors = %d, want 2", result.Errors) + } + if len(store.reconciled) != 2 { + t.Fatalf("stamped = %v, want both candidates stamped even though they errored", store.reconciled) + } +} diff --git a/internal/requests/repository.go b/internal/requests/repository.go index e9ae3c0dc1..80fc837415 100644 --- a/internal/requests/repository.go +++ b/internal/requests/repository.go @@ -208,23 +208,6 @@ func (r *Repository) ListActiveByTMDB(ctx context.Context, mediaType MediaType, return out, nil } -func (r *Repository) DeleteFailedByTMDB(ctx context.Context, mediaType MediaType, tmdbID int) (int, error) { - if tmdbID <= 0 { - return 0, nil - } - tag, err := r.pool.Exec(ctx, ` - DELETE FROM media_requests - WHERE media_type = $1 - AND provider = 'tmdb' - AND tmdb_id = $2 - AND outcome = 'failed' - `, mediaType, tmdbID) - if err != nil { - return 0, fmt.Errorf("delete failed requests by tmdb: %w", err) - } - return int(tag.RowsAffected()), nil -} - // quotaLockNamespace partitions advisory locks so request-quota locks do not // collide with advisory locks held elsewhere in the database. The value is // arbitrary; what matters is that it is stable. @@ -242,6 +225,23 @@ func (r *Repository) CreateRequest(ctx context.Context, input CreateRequestRecor quotaLockNamespace, input.Quota.UserID); err != nil { return nil, fmt.Errorf("acquire request quota lock: %w", err) } + } + if input.ReplaceFailed { + // Only the requester's own rows: other accounts' failed requests for + // the title are their history, and deleting them would also refund + // their quota. + if _, err := tx.Exec(ctx, ` + DELETE FROM media_requests + WHERE requested_by_user_id = $1 + AND media_type = $2 + AND provider = 'tmdb' + AND tmdb_id = $3 + AND outcome = 'failed' + `, input.Requester.UserID, input.Input.MediaType, input.Input.TMDBID); err != nil { + return nil, fmt.Errorf("replace failed requests: %w", err) + } + } + if input.Quota != nil { var count int if err := tx.QueryRow(ctx, ` SELECT COUNT(*) @@ -362,7 +362,7 @@ func (r *Repository) ListReconciliationCandidates(ctx context.Context, limit int rows, err := r.pool.Query(ctx, requestSelectSQL()+` WHERE outcome = 'active' AND status IN ('approved', 'queued', 'downloading') - ORDER BY updated_at ASC + ORDER BY last_reconciled_at ASC NULLS FIRST, id LIMIT $1 `, limit) if err != nil { @@ -461,24 +461,60 @@ func (r *Repository) listRequests(ctx context.Context, sqlText string, args []an return out, nil } -func (r *Repository) SetStatus(ctx context.Context, id string, status Status, actor Viewer) (*Request, error) { +// guardCondition restricts an UPDATE to rows a StateGuard accepts. $2 and $3 +// carry the guard's statuses and outcomes; an empty array accepts any value. +const guardCondition = `(cardinality($2::text[]) = 0 OR status = ANY($2::text[])) + AND (cardinality($3::text[]) = 0 OR outcome = ANY($3::text[]))` + +func guardArgs(g StateGuard) ([]string, []string) { + statuses := make([]string, 0, len(g.Statuses)) + for _, s := range g.Statuses { + statuses = append(statuses, string(s)) + } + outcomes := make([]string, 0, len(g.Outcomes)) + for _, o := range g.Outcomes { + outcomes = append(outcomes, string(o)) + } + return statuses, outcomes +} + +// guardMiss explains a guarded UPDATE that matched no row: the request is +// gone, or it has moved past the states the guard accepts. +func guardMiss(ctx context.Context, exec requestExecutor, id string) error { + var exists bool + if err := exec.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM media_requests WHERE id = $1)`, id).Scan(&exists); err != nil { + return fmt.Errorf("check request existence: %w", err) + } + if !exists { + return ErrNotFound + } + return ErrInvalidState +} + +func (r *Repository) SetStatus(ctx context.Context, id string, from StateGuard, status Status, actor Viewer) (*Request, error) { tx, err := r.pool.Begin(ctx) if err != nil { return nil, fmt.Errorf("begin request status transaction: %w", err) } defer tx.Rollback(ctx) + statuses, outcomes := guardArgs(from) + // A fresh approval starts a fresh submission budget. req, err := scanRequest(tx.QueryRow(ctx, ` UPDATE media_requests - SET status = $2, + SET status = $4, updated_at = now(), - approved_at = CASE WHEN $2 = 'approved' AND approved_at IS NULL THEN now() ELSE approved_at END, - completed_at = CASE WHEN $2 = 'completed' AND completed_at IS NULL THEN now() ELSE completed_at END + approved_at = CASE WHEN $4 = 'approved' AND approved_at IS NULL THEN now() ELSE approved_at END, + completed_at = CASE WHEN $4 = 'completed' AND completed_at IS NULL THEN now() ELSE completed_at END, + submit_attempts = CASE WHEN $4 = 'approved' THEN 0 ELSE submit_attempts END, + submit_lease_until = CASE WHEN $4 = 'approved' THEN NULL ELSE submit_lease_until END, + next_submit_at = CASE WHEN $4 = 'approved' THEN NULL ELSE next_submit_at END WHERE id = $1 - RETURNING `+requestColumns(), id, status)) + AND `+guardCondition+` + RETURNING `+requestColumns(), id, statuses, outcomes, status)) if err != nil { if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrNotFound + return nil, guardMiss(ctx, tx, id) } return nil, fmt.Errorf("set request status: %w", err) } @@ -491,27 +527,29 @@ func (r *Repository) SetStatus(ctx context.Context, id string, status Status, ac return req, nil } -func (r *Repository) SetOutcome(ctx context.Context, id string, outcome Outcome, actor Viewer, message string) (*Request, error) { +func (r *Repository) SetOutcome(ctx context.Context, id string, from StateGuard, outcome Outcome, actor Viewer, message string) (*Request, error) { tx, err := r.pool.Begin(ctx) if err != nil { return nil, fmt.Errorf("begin request outcome transaction: %w", err) } defer tx.Rollback(ctx) + statuses, outcomes := guardArgs(from) req, err := scanRequest(tx.QueryRow(ctx, ` UPDATE media_requests - SET outcome = $2, + SET outcome = $4, last_error = CASE - WHEN $2 = 'failed' THEN $3 - WHEN $2 = 'active' THEN '' + WHEN $4 = 'failed' THEN $5 + WHEN $4 = 'active' THEN '' ELSE last_error END, updated_at = now() WHERE id = $1 - RETURNING `+requestColumns(), id, outcome, strings.TrimSpace(message))) + AND `+guardCondition+` + RETURNING `+requestColumns(), id, statuses, outcomes, outcome, strings.TrimSpace(message))) if err != nil { if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrNotFound + return nil, guardMiss(ctx, tx, id) } return nil, fmt.Errorf("set request outcome: %w", err) } @@ -524,6 +562,167 @@ func (r *Repository) SetOutcome(ctx context.Context, id string, outcome Outcome, return req, nil } +func (r *Repository) ReopenFailed(ctx context.Context, id string, actor Viewer) (*Request, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin request reopen transaction: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + req, err := scanRequest(tx.QueryRow(ctx, ` + UPDATE media_requests + SET outcome = 'active', + status = 'approved', + last_error = '', + submit_attempts = 0, + submit_lease_until = NULL, + next_submit_at = NULL, + approved_at = COALESCE(approved_at, now()), + updated_at = now() + WHERE id = $1 + AND outcome = 'failed' + RETURNING `+requestColumns(), id)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, guardMiss(ctx, tx, id) + } + // Another account requested the title after this one failed, and only + // one active request per title may exist. + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return nil, ErrAlreadyRequested + } + return nil, fmt.Errorf("reopen failed request: %w", err) + } + if err := r.recordEvent(ctx, tx, id, "retried", actor, ""); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit request reopen transaction: %w", err) + } + return req, nil +} + +func (r *Repository) ClaimSubmission(ctx context.Context, id string, lease time.Duration) (*Request, bool, error) { + req, err := scanRequest(r.pool.QueryRow(ctx, ` + UPDATE media_requests + SET submit_attempts = submit_attempts + 1, + submit_lease_until = now() + make_interval(secs => $2) + WHERE id = $1 + AND status = 'approved' + AND outcome = 'active' + AND (submit_lease_until IS NULL OR submit_lease_until <= now()) + AND (next_submit_at IS NULL OR next_submit_at <= now()) + RETURNING `+requestColumns(), id, lease.Seconds())) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, false, nil + } + return nil, false, fmt.Errorf("claim request submission: %w", err) + } + return req, true, nil +} + +func (r *Repository) DeferSubmission(ctx context.Context, id string, delay time.Duration, message string) (*Request, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin request defer transaction: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + message = strings.TrimSpace(message) + req, err := scanRequest(tx.QueryRow(ctx, ` + UPDATE media_requests + SET next_submit_at = now() + make_interval(secs => $2), + submit_lease_until = NULL, + last_error = $3, + updated_at = now() + WHERE id = $1 + AND status = 'approved' + AND outcome = 'active' + RETURNING `+requestColumns(), id, delay.Seconds(), message)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, guardMiss(ctx, tx, id) + } + return nil, fmt.Errorf("defer request submission: %w", err) + } + if err := r.recordEvent(ctx, tx, id, "submit_deferred", Viewer{}, message); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit request defer transaction: %w", err) + } + return req, nil +} + +func (r *Repository) MarkAvailable(ctx context.Context, id string, actor Viewer) (*Request, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin request available transaction: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + req, err := scanRequest(tx.QueryRow(ctx, ` + UPDATE media_requests + SET status = 'completed', + completed_at = COALESCE(completed_at, now()), + updated_at = now() + WHERE id = $1 + AND outcome = 'active' + AND status IN ('approved', 'queued', 'downloading') + AND (status <> 'approved' OR submit_lease_until IS NULL OR submit_lease_until <= now()) + RETURNING `+requestColumns(), id)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, guardMiss(ctx, tx, id) + } + return nil, fmt.Errorf("mark request available: %w", err) + } + if err := r.recordEvent(ctx, tx, id, "available_in_library", actor, ""); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit request available transaction: %w", err) + } + return req, nil +} + +func (r *Repository) MarkReconciled(ctx context.Context, id string) error { + if _, err := r.pool.Exec(ctx, `UPDATE media_requests SET last_reconciled_at = now() WHERE id = $1`, id); err != nil { + return fmt.Errorf("mark request reconciled: %w", err) + } + return nil +} + +func (r *Repository) RecomputeStatus(ctx context.Context, id string, actor Viewer) (*Request, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin request recompute transaction: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + var status Status + var outcome Outcome + if err := tx.QueryRow(ctx, `SELECT status, outcome FROM media_requests WHERE id = $1 FOR UPDATE`, id).Scan(&status, &outcome); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("lock request for recompute: %w", err) + } + if status != StatusApproved || outcome != OutcomeActive { + return nil, ErrInvalidState + } + req, err := r.recomputeAggregate(ctx, tx, id, actor) + if err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit request recompute transaction: %w", err) + } + return req, nil +} + const integrationColumns = `id, name, enabled, base_url, api_key_ref, last_check_at, last_check_status, last_check_error, updated_at, capability_id, installation_id, supported_media_types, plugin_config, revision` @@ -777,7 +976,8 @@ func requestColumns() string { return `id, provider, media_type, tmdb_id, tvdb_id, imdb_id, title, year, overview, poster_path, backdrop_path, status, outcome, requested_by_user_id, requested_by_profile_id, is_anime, - last_error, created_at, updated_at, approved_at, completed_at` + last_error, created_at, updated_at, approved_at, completed_at, + submit_attempts, submit_lease_until, next_submit_at` } type requestScanner interface { @@ -787,7 +987,7 @@ type requestScanner interface { func scanRequest(row requestScanner) (*Request, error) { var req Request var tvdbID, year sql.NullInt64 - var approvedAt, completedAt sql.NullTime + var approvedAt, completedAt, submitLeaseUntil, nextSubmitAt sql.NullTime if err := row.Scan( &req.ID, &req.Provider, @@ -810,6 +1010,9 @@ func scanRequest(row requestScanner) (*Request, error) { &req.UpdatedAt, &approvedAt, &completedAt, + &req.SubmitAttempts, + &submitLeaseUntil, + &nextSubmitAt, ); err != nil { return nil, err } @@ -827,6 +1030,12 @@ func scanRequest(row requestScanner) (*Request, error) { if completedAt.Valid { req.CompletedAt = &completedAt.Time } + if submitLeaseUntil.Valid { + req.SubmitLeaseUntil = &submitLeaseUntil.Time + } + if nextSubmitAt.Valid { + req.NextSubmitAt = &nextSubmitAt.Time + } return &req, nil } diff --git a/internal/requests/repository_page_test.go b/internal/requests/repository_page_test.go index f285d77d8b..6c71d33118 100644 --- a/internal/requests/repository_page_test.go +++ b/internal/requests/repository_page_test.go @@ -27,7 +27,8 @@ func TestRequestListKeysetDatabase(t *testing.T) { id text PRIMARY KEY, provider text, media_type text, tmdb_id int, tvdb_id int, imdb_id text, title text, year int, overview text, poster_path text, backdrop_path text, status text, outcome text, requested_by_user_id int, requested_by_profile_id text, is_anime bool, - last_error text, created_at timestamptz, updated_at timestamptz, approved_at timestamptz, completed_at timestamptz); + last_error text, created_at timestamptz, updated_at timestamptz, approved_at timestamptz, completed_at timestamptz, + submit_attempts int NOT NULL DEFAULT 0, submit_lease_until timestamptz, next_submit_at timestamptz); CREATE INDEX ON media_requests (requested_by_user_id, created_at DESC, id DESC)`) if err != nil { t.Fatal(err) @@ -35,7 +36,7 @@ func TestRequestListKeysetDatabase(t *testing.T) { stamp := time.Date(2026, 1, 2, 3, 4, 5, 123456000, time.UTC) insert := func(id string, user int, at time.Time) { t.Helper() - _, err := pool.Exec(t.Context(), `INSERT INTO media_requests VALUES ($1,'tmdb','movie',1,NULL,'','',NULL,'','','','pending','active',$2,'profile',false,'',$3,$3,NULL,NULL)`, id, user, at) + _, err := pool.Exec(t.Context(), `INSERT INTO media_requests VALUES ($1,'tmdb','movie',1,NULL,'','',NULL,'','','','pending','active',$2,'profile',false,'',$3,$3,NULL,NULL,0,NULL,NULL)`, id, user, at) if err != nil { t.Fatal(err) } diff --git a/internal/requests/service.go b/internal/requests/service.go index 93dce268da..4efc480c04 100644 --- a/internal/requests/service.go +++ b/internal/requests/service.go @@ -123,15 +123,17 @@ func (s *Service) populateRequesterIdentity(ctx context.Context, req *Request) { req.RequesterEmail, req.RequesterUsername = email, username } -func (s *Service) requesterCeiling(ctx context.Context, userID int, profileID string) string { +// requesterCeiling resolves the requester's playback quality ceiling. resolved +// is false when the lookup failed and the HD-only fail-safe was used instead. +func (s *Service) requesterCeiling(ctx context.Context, userID int, profileID string) (ceiling string, resolved bool) { if s.entitlements == nil { - return "" // no resolver -> unlimited (1080p baseline still applies) + return "", true // no resolver -> unlimited (1080p baseline still applies) } q, err := s.entitlements.MaxPlaybackQuality(ctx, userID, profileID) if err != nil { - return access.PlaybackQualityStandard // fail safe: HD only + return access.PlaybackQualityStandard, false // fail safe: HD only } - return q + return q, true } // viewerContentCeiling resolves the viewer's parental rating ceiling. Empty @@ -349,16 +351,19 @@ func (s *Service) filterPageByCeiling(ctx context.Context, raw *tmdb.MediaPage, // allowedQualities returns the qualities a request may receive: 1080p always, // plus 2160p when force-dual is on or the requester's entitlement ceiling allows 4K. -func (s *Service) allowedQualities(ctx context.Context, req Request, settings Settings) []Quality { +// allowedQualities returns the qualities the request should be fulfilled in. +// resolved is false when the requester's entitlement could not be looked up, +// so the answer is the HD-only fail-safe rather than the real policy. +func (s *Service) allowedQualities(ctx context.Context, req Request, settings Settings) (qualities []Quality, resolved bool) { out := []Quality{Quality1080p} - ceiling := s.requesterCeiling(ctx, req.RequestedByUserID, req.RequestedByProfileID) + ceiling, resolved := s.requesterCeiling(ctx, req.RequestedByUserID, req.RequestedByProfileID) // QualityAllowed treats an empty ceiling as "no cap" (the "Any" preset), so a // requester with unlimited playback quality correctly gets 4K. A raw // CompareQuality would rank "" as the LOWEST quality and wrongly drop 4K. if settings.ForceDualQuality || access.QualityAllowed(access.PlaybackQuality4K, ceiling) { out = append(out, Quality2160p) } - return out + return out, resolved } // fulfillContext caches the global fulfillment inputs for one reconcile cycle @@ -425,6 +430,18 @@ func (s *Service) resolveRouterConnections(ctx context.Context, fc *fulfillConte return conns, installationID, capabilityID, nil } +// skippedRouterConnection reports whether resolveRouterConnections leaves out +// a connection that would otherwise serve the media type, because its API key +// is missing. +func skippedRouterConnection(fc *fulfillContext, mediaType MediaType) bool { + for _, in := range fc.integrations { + if eligibleRouterConnection(in, mediaType) && strings.TrimSpace(in.APIKeyRef) == "" { + return true + } + } + return false +} + // eligibleRouterConnection reports whether a connection is a candidate fulfillment // backend for the media type: enabled, bound to an installation, and naming a // capability sub-id that serves the media type. resolveRouterConnections (which @@ -702,17 +719,11 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create return nil, ErrAlreadyRequested } - // Re-requesting media that previously failed (e.g., transient integration - // error) should not leave stale failed rows behind in user/admin lists. - if _, err := s.store.DeleteFailedByTMDB(ctx, normalized.MediaType, normalized.TMDBID); err != nil { - return nil, err - } - policy, err := s.EffectivePolicy(ctx, viewer.UserID) if err != nil { return nil, err } - if err := validateCreatePolicy(policy); err != nil { + if err := validateCreateAccess(policy); err != nil { return nil, err } @@ -735,6 +746,9 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create IsAnime: isAnime, Requester: viewer, Now: s.now(), + // Re-requesting a title that failed for this user (e.g. a transient + // integration error) replaces their failed row. + ReplaceFailed: true, } if !policy.Unlimited { record.Quota = &QuotaCheck{ @@ -762,7 +776,7 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create // Auto-approval is a real approval transition; channels subscribed to // approvals see it alongside the submission. s.notifyApproval(ctx, *req, ApprovalOriginPolicy) - return s.submitApprovedRequest(ctx, *req, viewer, nil) + return s.submitAfterCommit(ctx, *req, viewer), nil } return req, nil } @@ -887,41 +901,22 @@ func (s *Service) Approve(ctx context.Context, viewer Viewer, id string) (*Reque if !viewer.IsAdmin { return nil, ErrForbidden } - req, err := s.store.GetRequest(ctx, strings.TrimSpace(id)) - if err != nil { - return nil, err - } - if req.Outcome != OutcomeActive || req.Status != StatusPending { - return nil, ErrInvalidState - } - approved, err := s.store.SetStatus(ctx, req.ID, StatusApproved, viewer) + approved, err := s.store.SetStatus(ctx, strings.TrimSpace(id), guardPending, StatusApproved, viewer) if err != nil { return nil, err } s.notifyApproval(ctx, *approved, ApprovalOriginAdmin) - return s.submitApprovedRequest(ctx, *approved, viewer, nil) + return s.submitAfterCommit(ctx, *approved, viewer), nil } +// Decline rejects a request that is still pending. Approved requests may +// already be on their way to a downstream service, so declining one could leave +// the external state diverged from Silo's; the guard refuses it. func (s *Service) Decline(ctx context.Context, viewer Viewer, id, reason string) (*Request, error) { if !viewer.IsAdmin { return nil, ErrForbidden } - req, err := s.store.GetRequest(ctx, strings.TrimSpace(id)) - if err != nil { - return nil, err - } - // Approved requests are pending submission by the reconciler; declining - // while submission may be in flight risks a divergent external state. - if req.Outcome != OutcomeActive || - req.Status == StatusApproved || - req.Status == StatusCompleted || - req.Status == StatusQueued || - req.Status == StatusDownloading || - strings.TrimSpace(req.ExternalID) != "" || - strings.TrimSpace(req.IntegrationKind) != "" { - return nil, ErrInvalidState - } - declined, err := s.store.SetOutcome(ctx, req.ID, OutcomeDeclined, viewer, reason) + declined, err := s.store.SetOutcome(ctx, strings.TrimSpace(id), guardPending, OutcomeDeclined, viewer, reason) if err != nil { return nil, err } @@ -931,10 +926,9 @@ func (s *Service) Decline(ctx context.Context, viewer Viewer, id, reason string) } // Cancel withdraws a request that has not yet been submitted to a downstream -// integration. Owners can cancel their own pending requests; admins can cancel -// any active request that has not entered the fulfillment pipeline. Requests -// already approved, queued, downloading, or completed cannot be cancelled — -// callers should decline (admin) or wait for completion in those cases. +// integration. Owners can withdraw their own pending requests; admins can +// withdraw any pending request. Once a request is approved it stays in the +// fulfillment pipeline until it completes or fails. func (s *Service) Cancel(ctx context.Context, viewer Viewer, id, reason string) (*Request, error) { if viewer.UserID == 0 { return nil, ErrForbidden @@ -951,39 +945,18 @@ func (s *Service) Cancel(ctx context.Context, viewer Viewer, id, reason string) if !viewer.IsAdmin && req.RequestedByUserID != viewer.UserID { return nil, ErrForbidden } - if req.Outcome != OutcomeActive || - req.Status == StatusApproved || - req.Status == StatusCompleted || - req.Status == StatusQueued || - req.Status == StatusDownloading || - strings.TrimSpace(req.ExternalID) != "" || - strings.TrimSpace(req.IntegrationKind) != "" { - return nil, ErrInvalidState - } - return s.store.SetOutcome(ctx, req.ID, OutcomeCancelled, viewer, reason) + return s.store.SetOutcome(ctx, req.ID, guardPending, OutcomeCancelled, viewer, reason) } func (s *Service) Retry(ctx context.Context, viewer Viewer, id string) (*Request, error) { if !viewer.IsAdmin { return nil, ErrForbidden } - req, err := s.store.GetRequest(ctx, strings.TrimSpace(id)) + reopened, err := s.store.ReopenFailed(ctx, strings.TrimSpace(id), viewer) if err != nil { return nil, err } - if req.Outcome != OutcomeFailed { - return nil, ErrInvalidState - } - if _, err := s.store.SetOutcome(ctx, req.ID, OutcomeActive, viewer, "retry requested"); err != nil { - return nil, err - } - // submitApprovedRequest only re-submits qualities lacking a healthy target, so - // it is idempotent; gate it on the approved status it expects. - active, err := s.store.SetStatus(ctx, req.ID, StatusApproved, viewer) - if err != nil { - return nil, err - } - return s.submitApprovedRequest(ctx, *active, viewer, nil) + return s.submitAfterCommit(ctx, *reopened, viewer), nil } func (s *Service) ReconcileRequests(ctx context.Context, limit int) (ReconcileResult, error) { @@ -1007,6 +980,11 @@ func (s *Service) ReconcileRequests(ctx context.Context, limit int) (ReconcileRe return result, err } change, err := s.reconcileRequest(ctx, *req, fc) + // Stamp every candidate, including ones that errored, so the next pass + // starts with the requests this one did not reach. + if markErr := s.store.MarkReconciled(ctx, req.ID); markErr != nil { + slog.WarnContext(ctx, "request reconcile stamp failed", "component", "requests", "request_id", req.ID, "err", markErr) + } if err != nil { slog.WarnContext(ctx, "request reconcile failed", "component", "requests", "request_id", req.ID, @@ -1028,6 +1006,8 @@ func (s *Service) ReconcileRequests(ctx context.Context, limit int) (ReconcileRe result.Completed++ case reconcileFailed: result.Failed++ + case reconcileDeferred: + result.Deferred++ case reconcileSkipped: result.Skipped++ } @@ -1697,10 +1677,87 @@ func integrationSupportsMediaType(in Integration, mediaType MediaType) bool { return false } +// submitLease bounds how long one server's submission claim keeps the others +// out. It must outlast a router call; if the claiming server dies mid-call, a +// reconcile pass after the lease picks the request up. +const submitLease = 10 * time.Minute + +// maxSubmitAttempts is how many claimed submissions may fail before the request +// is marked failed for an admin to retry. With submitBackoff that is about six +// hours of retries, enough to ride out a restarting or briefly offline service. +const maxSubmitAttempts = 10 + +// submitBackoff is the wait after the given number of failed attempts: 5 +// minutes doubling to a one-hour cap. The reconcile pass runs every 5 minutes, +// so shorter waits would not be honored anyway. +func submitBackoff(attempts int) time.Duration { + d := 5 * time.Minute + for i := 1; i < attempts && d < time.Hour; i++ { + d *= 2 + } + return min(d, time.Hour) +} + +// submitAfterCommit submits a request whose approval is already committed. The +// approval stands whatever happens next, so a submission error is logged and +// the committed request returned: answering an error would tell the caller the +// approval failed, and the reconcile pass retries the submission anyway. +func (s *Service) submitAfterCommit(ctx context.Context, req Request, actor Viewer) *Request { + submitted, err := s.submitApprovedRequest(ctx, req, actor, nil) + if err != nil { + slog.WarnContext(ctx, "requests: submission after approval failed; reconcile will retry", "component", "requests", + "request_id", req.ID, "err", err) + return &req + } + return submitted +} + +// submitApprovedRequest sends an approved request to the router plugin. Only +// the caller that claims the submission sends it, so concurrent approvals and +// reconcile passes on any server cannot double-submit. A failed attempt is +// recorded on the request and retried with backoff until maxSubmitAttempts, +// after which the request is marked failed. func (s *Service) submitApprovedRequest(ctx context.Context, req Request, actor Viewer, fc *fulfillContext) (*Request, error) { if req.Outcome != OutcomeActive || req.Status != StatusApproved { return &req, nil } + claimed, ok, err := s.store.ClaimSubmission(ctx, req.ID, submitLease) + if err != nil { + return nil, err + } + if !ok { + // Another caller holds the claim, or a failed attempt's backoff has + // not elapsed; a later reconcile pass submits it. + return &req, nil + } + submitted, submitErr := s.submitClaimed(ctx, *claimed, actor, fc) + if submitErr == nil { + return submitted, nil + } + if claimed.SubmitAttempts >= maxSubmitAttempts { + return s.markSubmissionFailed(ctx, claimed.ID, actor, submitErr) + } + deferred, err := s.store.DeferSubmission(ctx, claimed.ID, submitBackoff(claimed.SubmitAttempts), submitErr.Error()) + if err != nil { + if errors.Is(err, ErrInvalidState) { + // The attempt created targets before failing, which moved the + // request past approved; the per-target state now owns it. + return s.store.GetRequest(ctx, claimed.ID) + } + return nil, fmt.Errorf("submit request: %w; schedule retry: %w", submitErr, err) + } + slog.WarnContext(ctx, "requests: submission failed; will retry", "component", "requests", + "request_id", claimed.ID, + "attempt", claimed.SubmitAttempts, + "retry_in", submitBackoff(claimed.SubmitAttempts), + "err", submitErr, + ) + return deferred, nil +} + +// submitClaimed does the submission work for a request whose claim the caller +// holds. +func (s *Service) submitClaimed(ctx context.Context, req Request, actor Viewer, fc *fulfillContext) (*Request, error) { if s.router == nil { return s.markSubmissionFailed(ctx, req.ID, actor, fmt.Errorf("no fulfillment backend configured")) } @@ -1738,10 +1795,29 @@ func (s *Service) submitApprovedRequest(ctx context.Context, req Request, actor healthy[t.Quality] = true } } - allowed := s.allowedQualities(ctx, req, fc.settings) + allowed, resolved := s.allowedQualities(ctx, req, fc.settings) if !fc.settings.ForceDualQuality { allowed = filterUnconfiguredOptionalQualities(allowed, conns) } + // A failed target for a quality the request no longer wants (4K turned + // off, the requester lost 4K, the 4K server removed) would keep the request + // failed forever; converge to the current quality set instead. Only when + // that set is certain: an entitlement lookup error or a connection skipped + // for a missing key also shrinks it, and a transient error must not + // discard the failed target an admin still needs to see. + if resolved && !skippedRouterConnection(fc, req.MediaType) { + allowedSet := make(map[Quality]bool, len(allowed)) + for _, q := range allowed { + allowedSet[q] = true + } + for _, t := range existing { + if t.Status == StatusFailed && !allowedSet[t.Quality] { + if err := s.store.DeleteTarget(ctx, t.ID); err != nil && !errors.Is(err, ErrNotFound) { + return nil, err + } + } + } + } var want []Quality for _, q := range allowed { if !healthy[q] { @@ -1749,7 +1825,13 @@ func (s *Service) submitApprovedRequest(ctx context.Context, req Request, actor } } if len(want) == 0 { - return &req, nil + // Nothing left to send: let the remaining targets decide the status so + // the request does not sit in approved. + updated, err := s.store.RecomputeStatus(ctx, req.ID, actor) + if errors.Is(err, ErrInvalidState) { + return s.store.GetRequest(ctx, req.ID) + } + return updated, err } for _, t := range existing { // drop stale failed targets for the qualities we re-submit if t.Status == StatusFailed { @@ -1907,8 +1989,12 @@ func boolConfig(config map[string]any, key string) bool { } func (s *Service) markSubmissionFailed(ctx context.Context, requestID string, actor Viewer, submitErr error) (*Request, error) { - failed, err := s.store.SetOutcome(ctx, requestID, OutcomeFailed, actor, submitErr.Error()) + guard := StateGuard{Statuses: []Status{StatusApproved}, Outcomes: []Outcome{OutcomeActive}} + failed, err := s.store.SetOutcome(ctx, requestID, guard, OutcomeFailed, actor, submitErr.Error()) if err != nil { + if errors.Is(err, ErrInvalidState) { + return s.store.GetRequest(ctx, requestID) + } return nil, fmt.Errorf("submit request failed: %w; mark failed: %v", submitErr, err) } return failed, nil @@ -1923,6 +2009,7 @@ const ( reconcileDownloading reconcileChange = "downloading" reconcileCompleted reconcileChange = "completed" reconcileFailed reconcileChange = "failed" + reconcileDeferred reconcileChange = "deferred" ) func (s *Service) reconcileRequest(ctx context.Context, req Request, fc *fulfillContext) (reconcileChange, error) { @@ -1944,7 +2031,12 @@ func (s *Service) reconcileRequest(ctx context.Context, req Request, fc *fulfill if req.Status == StatusCompleted { return reconcileUnchanged, nil } - if _, err := s.store.SetStatus(ctx, req.ID, StatusCompleted, Viewer{}); err != nil { + if _, err := s.store.MarkAvailable(ctx, req.ID, Viewer{}); err != nil { + if errors.Is(err, ErrInvalidState) { + // Another actor moved it first, or a submission holds the + // claim; a later pass completes it. + return reconcileUnchanged, nil + } return reconcileUnchanged, err } return reconcileCompleted, nil @@ -1968,6 +2060,10 @@ func (s *Service) reconcileRequest(ctx context.Context, req Request, fc *fulfill return reconcileFailed, nil case updated.Status == StatusQueued: return reconcileSubmitted, nil + case updated.Status == StatusApproved && updated.SubmitAttempts > req.SubmitAttempts: + // This pass made an attempt and it failed; a request still in + // backoff comes back unchanged and counts as skipped. + return reconcileDeferred, nil default: return reconcileSkipped, nil } @@ -2149,14 +2245,16 @@ func requestStateFor(viewer Viewer, policy EffectivePolicy, available bool, req } } -func validateCreatePolicy(policy EffectivePolicy) error { +// validateCreateAccess applies the policy rules a create decides up front. The +// quota is not one of them: the store checks it under the requester's lock, +// after it has replaced the requester's failed request for the same title, so +// that re-request does not count against itself. +func validateCreateAccess(policy EffectivePolicy) error { switch { case !policy.RequestsEnabled: return ErrRequestsDisabled case policy.Blocked: return ErrUserBlocked - case !policy.Unlimited && policy.Used >= policy.MaxRequests: - return QuotaError{Used: policy.Used, Limit: policy.MaxRequests, WindowDays: policy.WindowDays} default: return nil } diff --git a/internal/requests/service_test.go b/internal/requests/service_test.go index 8b86cbbcc5..d3f0d97993 100644 --- a/internal/requests/service_test.go +++ b/internal/requests/service_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "slices" "strings" "sync" "sync/atomic" @@ -480,18 +481,29 @@ func TestCreateRequestClearsPriorFailedRequest(t *testing.T) { store := newFakeStore() store.settings.RequestsEnabled = true store.requests["req-prior-failed"] = &Request{ - ID: "req-prior-failed", - MediaType: MediaTypeMovie, - TMDBID: 550, - Outcome: OutcomeFailed, - Status: StatusApproved, - LastError: "arr: decode response: json: cannot unmarshal object into Go value of type []radarr.movieResource", + ID: "req-prior-failed", + MediaType: MediaTypeMovie, + TMDBID: 550, + Outcome: OutcomeFailed, + Status: StatusApproved, + RequestedByUserID: 1, + LastError: "arr: decode response: json: cannot unmarshal object into Go value of type []radarr.movieResource", } store.requests["req-other-media-failed"] = &Request{ - ID: "req-other-media-failed", - MediaType: MediaTypeMovie, - TMDBID: 999, - Outcome: OutcomeFailed, + ID: "req-other-media-failed", + MediaType: MediaTypeMovie, + TMDBID: 999, + Outcome: OutcomeFailed, + RequestedByUserID: 1, + } + // Another account's failed request for the same title is their history + // and their quota; a re-request must leave it alone. + store.requests["req-other-user-failed"] = &Request{ + ID: "req-other-user-failed", + MediaType: MediaTypeMovie, + TMDBID: 550, + Outcome: OutcomeFailed, + RequestedByUserID: 2, } service := newTestService(store) @@ -509,6 +521,9 @@ func TestCreateRequestClearsPriorFailedRequest(t *testing.T) { if _, ok := store.requests["req-other-media-failed"]; !ok { t.Fatal("failed request for different media should not be cleared") } + if _, ok := store.requests["req-other-user-failed"]; !ok { + t.Fatal("another user's failed request for the same media must not be cleared") + } } func TestSearchMarksSeriesAvailableByHydratedTVDBID(t *testing.T) { @@ -1538,6 +1553,7 @@ type fakeStore struct { targetSeq int64 unnotified []string notified []string + reconciled []string listIntegrationsCalls int getSettingsCalls int @@ -1597,17 +1613,20 @@ func (f *fakeStore) UpsertUserLimit(_ context.Context, limit UserLimit) (*UserLi func (f *fakeStore) CountUserRequestsSince(_ context.Context, userID int, since time.Time) (int, error) { f.mu.Lock() defer f.mu.Unlock() + return f.usedLocked(userID, since), nil +} + +// usedLocked counts the user's stored requests created since the window start, +// on top of the count baseline, the way the repository counts rows. A deleted +// row stops counting. Callers hold f.mu. +func (f *fakeStore) usedLocked(userID int, since time.Time) int { used := f.count - for _, prior := range f.created { - if prior.Requester.UserID != userID { - continue - } - if prior.Now.Before(since) { - continue + for _, req := range f.requests { + if req.RequestedByUserID == userID && !req.CreatedAt.Before(since) { + used++ } - used++ } - return used, nil + return used } func (f *fakeStore) ListActiveByTMDB(_ context.Context, mediaType MediaType, ids []int) (map[int]*Request, error) { @@ -1622,37 +1641,20 @@ func (f *fakeStore) ListActiveByTMDB(_ context.Context, mediaType MediaType, ids return out, nil } -func (f *fakeStore) DeleteFailedByTMDB(_ context.Context, mediaType MediaType, tmdbID int) (int, error) { - f.mu.Lock() - defer f.mu.Unlock() - deleted := 0 - for id, req := range f.requests { - if req.MediaType == mediaType && req.TMDBID == tmdbID && req.Outcome == OutcomeFailed { - delete(f.requests, id) - deleted++ - } - } - return deleted, nil -} - func (f *fakeStore) CreateRequest(_ context.Context, input CreateRequestRecord) (*Request, error) { f.mu.Lock() defer f.mu.Unlock() - if input.Quota != nil { - used := f.count - for _, prior := range f.created { - if prior.Requester.UserID != input.Quota.UserID { - continue - } - if prior.Now.Before(input.Quota.WindowStart) { - continue + if input.ReplaceFailed { + for id, req := range f.requests { + if req.RequestedByUserID == input.Requester.UserID && req.MediaType == input.Input.MediaType && + req.TMDBID == input.Input.TMDBID && req.Outcome == OutcomeFailed { + delete(f.requests, id) } - used++ - } - if used >= input.Quota.MaxRequests { - return nil, ErrQuotaExceeded } } + if input.Quota != nil && f.usedLocked(input.Quota.UserID, input.Quota.WindowStart) >= input.Quota.MaxRequests { + return nil, ErrQuotaExceeded + } f.created = append(f.created, input) req := &Request{ ID: input.ID, @@ -1729,27 +1731,58 @@ func (f *fakeStore) ListAdmin(context.Context, ListFilter) ([]*Request, error) { return nil, nil } -func (f *fakeStore) SetStatus(_ context.Context, id string, status Status, _ Viewer) (*Request, error) { +// guardAccepts mirrors the repository's guarded UPDATE. +func guardAccepts(g StateGuard, req *Request) bool { + return (len(g.Statuses) == 0 || slices.Contains(g.Statuses, req.Status)) && + (len(g.Outcomes) == 0 || slices.Contains(g.Outcomes, req.Outcome)) +} + +// lookupLocked finds a request by id, falling back to the reconcile candidates +// so tests that only seed candidates still resolve. Callers hold f.mu. +func (f *fakeStore) lookupLocked(id string) *Request { + if req := f.requests[id]; req != nil { + return req + } + for _, c := range f.candidates { + if c != nil && c.ID == id { + copy := *c + f.requests[id] = © + return © + } + } + return nil +} + +func (f *fakeStore) SetStatus(_ context.Context, id string, from StateGuard, status Status, _ Viewer) (*Request, error) { f.mu.Lock() defer f.mu.Unlock() - f.statusUpdates = append(f.statusUpdates, status) - req := f.requests[id] + req := f.lookupLocked(id) if req == nil { req = &Request{ID: id, Outcome: OutcomeActive} f.requests[id] = req + } else if !guardAccepts(from, req) { + return nil, ErrInvalidState } + f.statusUpdates = append(f.statusUpdates, status) req.Status = status + if status == StatusApproved { + req.SubmitAttempts = 0 + req.SubmitLeaseUntil = nil + req.NextSubmitAt = nil + } copy := *req return ©, nil } -func (f *fakeStore) SetOutcome(_ context.Context, id string, outcome Outcome, _ Viewer, message string) (*Request, error) { +func (f *fakeStore) SetOutcome(_ context.Context, id string, from StateGuard, outcome Outcome, _ Viewer, message string) (*Request, error) { f.mu.Lock() defer f.mu.Unlock() - req := f.requests[id] + req := f.lookupLocked(id) if req == nil { req = &Request{ID: id} f.requests[id] = req + } else if !guardAccepts(from, req) { + return nil, ErrInvalidState } req.Outcome = outcome req.LastError = message @@ -1757,6 +1790,102 @@ func (f *fakeStore) SetOutcome(_ context.Context, id string, outcome Outcome, _ return ©, nil } +func (f *fakeStore) ReopenFailed(_ context.Context, id string, _ Viewer) (*Request, error) { + f.mu.Lock() + defer f.mu.Unlock() + req := f.lookupLocked(id) + if req == nil { + return nil, ErrNotFound + } + if req.Outcome != OutcomeFailed { + return nil, ErrInvalidState + } + req.Outcome = OutcomeActive + req.Status = StatusApproved + req.LastError = "" + req.SubmitAttempts = 0 + req.SubmitLeaseUntil = nil + req.NextSubmitAt = nil + copy := *req + return ©, nil +} + +func (f *fakeStore) ClaimSubmission(_ context.Context, id string, lease time.Duration) (*Request, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + req := f.lookupLocked(id) + if req == nil || req.Status != StatusApproved || req.Outcome != OutcomeActive { + return nil, false, nil + } + now := time.Now() + if (req.SubmitLeaseUntil != nil && req.SubmitLeaseUntil.After(now)) || (req.NextSubmitAt != nil && req.NextSubmitAt.After(now)) { + return nil, false, nil + } + req.SubmitAttempts++ + until := now.Add(lease) + req.SubmitLeaseUntil = &until + copy := *req + return ©, true, nil +} + +func (f *fakeStore) DeferSubmission(_ context.Context, id string, delay time.Duration, message string) (*Request, error) { + f.mu.Lock() + defer f.mu.Unlock() + req := f.lookupLocked(id) + if req == nil { + return nil, ErrNotFound + } + if req.Status != StatusApproved || req.Outcome != OutcomeActive { + return nil, ErrInvalidState + } + next := time.Now().Add(delay) + req.NextSubmitAt = &next + req.SubmitLeaseUntil = nil + req.LastError = message + copy := *req + return ©, nil +} + +func (f *fakeStore) MarkAvailable(_ context.Context, id string, _ Viewer) (*Request, error) { + f.mu.Lock() + defer f.mu.Unlock() + req := f.lookupLocked(id) + if req == nil { + return nil, ErrNotFound + } + inFlight := req.Status == StatusApproved || req.Status == StatusQueued || req.Status == StatusDownloading + claimed := req.Status == StatusApproved && req.SubmitLeaseUntil != nil && req.SubmitLeaseUntil.After(time.Now()) + if req.Outcome != OutcomeActive || !inFlight || claimed { + return nil, ErrInvalidState + } + f.statusUpdates = append(f.statusUpdates, StatusCompleted) + req.Status = StatusCompleted + copy := *req + return ©, nil +} + +func (f *fakeStore) MarkReconciled(_ context.Context, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.reconciled = append(f.reconciled, id) + return nil +} + +func (f *fakeStore) RecomputeStatus(_ context.Context, id string, _ Viewer) (*Request, error) { + f.mu.Lock() + defer f.mu.Unlock() + req := f.lookupLocked(id) + if req == nil { + return nil, ErrNotFound + } + if req.Status != StatusApproved || req.Outcome != OutcomeActive { + return nil, ErrInvalidState + } + req.Status, req.Outcome = aggregateStatus(f.targets[id]) + copy := *req + return ©, nil +} + func (f *fakeStore) ListIntegrations(context.Context) ([]Integration, error) { f.mu.Lock() defer f.mu.Unlock() @@ -2216,6 +2345,13 @@ func (f *certTMDBClient) GetCertification(_ context.Context, _ string, id int) ( type fixedCeiling struct{ q string } +// failingCeiling is an entitlement resolver whose lookup always errors. +type failingCeiling struct{} + +func (failingCeiling) MaxPlaybackQuality(context.Context, int, string) (string, error) { + return "", errors.New("entitlement lookup failed") +} + func (f fixedCeiling) MaxPlaybackQuality(context.Context, int, string) (string, error) { return f.q, nil } @@ -2409,7 +2545,7 @@ func TestAllowedQualities(t *testing.T) { t.Run("hd ceiling stays 1080p only", func(t *testing.T) { svcHD := newTestService(newFakeStore()) svcHD.SetEntitlementResolver(fixedCeiling{q: "1080p"}) - got := svcHD.allowedQualities(context.Background(), Request{}, Settings{}) + got, _ := svcHD.allowedQualities(context.Background(), Request{}, Settings{}) if len(got) != 1 || got[0] != Quality1080p { t.Fatalf("qualities = %v, want [1080p]", got) } @@ -2421,14 +2557,14 @@ func TestAllowedQualities(t *testing.T) { // alongside 1080p — it must not be read as "below 4K". svcAny := newTestService(newFakeStore()) svcAny.SetEntitlementResolver(fixedCeiling{q: ""}) - got := svcAny.allowedQualities(context.Background(), Request{}, Settings{}) + got, _ := svcAny.allowedQualities(context.Background(), Request{}, Settings{}) if len(got) != 2 || got[1] != Quality2160p { t.Fatalf("qualities = %v, want [1080p 2160p]", got) } }) t.Run("force dual adds 2160p", func(t *testing.T) { - got := svc.allowedQualities(context.Background(), Request{}, Settings{ForceDualQuality: true}) + got, _ := svc.allowedQualities(context.Background(), Request{}, Settings{ForceDualQuality: true}) if len(got) != 2 || got[1] != Quality2160p { t.Fatalf("qualities = %v, want [1080p 2160p]", got) } @@ -2437,7 +2573,7 @@ func TestAllowedQualities(t *testing.T) { t.Run("4k ceiling adds 2160p", func(t *testing.T) { svc4k := newTestService(newFakeStore()) svc4k.SetEntitlementResolver(fixedCeiling{q: "2160p"}) - got := svc4k.allowedQualities(context.Background(), Request{}, Settings{}) + got, _ := svc4k.allowedQualities(context.Background(), Request{}, Settings{}) if len(got) != 2 || got[1] != Quality2160p { t.Fatalf("qualities = %v, want [1080p 2160p]", got) } @@ -2638,9 +2774,13 @@ func TestSubmitApprovedRecordsDroppedQualityAsFailed(t *testing.T) { // re-attempts only that quality. Provide a normal provider for the re-run. retryRouter := &fakeRouterProvider{} svc.SetRouterProvider(retryRouter) + // Put the stored row back where ReopenFailed leaves it, so the re-run can + // claim the submission. + store.requests["r1"].Status = StatusApproved + store.requests["r1"].Outcome = OutcomeActive + store.requests["r1"].SubmitLeaseUntil = nil + store.requests["r1"].NextSubmitAt = nil cur := *store.requests["r1"] - cur.Status = StatusApproved - cur.Outcome = OutcomeActive if _, err := svc.submitApprovedRequest(context.Background(), cur, Viewer{UserID: 7, IsAdmin: true}, nil); err != nil { t.Fatalf("retry submit: %v", err) } diff --git a/internal/requests/store.go b/internal/requests/store.go index 2b0d8ebf8c..f022d99291 100644 --- a/internal/requests/store.go +++ b/internal/requests/store.go @@ -12,9 +12,6 @@ type Store interface { UpsertUserLimit(ctx context.Context, limit UserLimit) (*UserLimit, error) CountUserRequestsSince(ctx context.Context, userID int, since time.Time) (int, error) ListActiveByTMDB(ctx context.Context, mediaType MediaType, tmdbIDs []int) (map[int]*Request, error) - // DeleteFailedByTMDB removes prior failed requests for a given media so a - // re-request does not leave behind stale rows in user/admin lists. - DeleteFailedByTMDB(ctx context.Context, mediaType MediaType, tmdbID int) (int, error) CreateRequest(ctx context.Context, input CreateRequestRecord) (*Request, error) GetRequest(ctx context.Context, id string) (*Request, error) ListReconciliationCandidates(ctx context.Context, limit int) ([]*Request, error) @@ -26,8 +23,33 @@ type Store interface { MarkFulfilledNotified(ctx context.Context, id string) error ListMine(ctx context.Context, userID int, filter ListFilter) ([]*Request, error) ListAdmin(ctx context.Context, filter ListFilter) ([]*Request, error) - SetStatus(ctx context.Context, id string, status Status, actor Viewer) (*Request, error) - SetOutcome(ctx context.Context, id string, outcome Outcome, actor Viewer, message string) (*Request, error) + // SetStatus and SetOutcome apply a transition only while the request is in + // a state the guard accepts; otherwise they return ErrInvalidState. + SetStatus(ctx context.Context, id string, from StateGuard, status Status, actor Viewer) (*Request, error) + SetOutcome(ctx context.Context, id string, from StateGuard, outcome Outcome, actor Viewer, message string) (*Request, error) + // ReopenFailed moves a failed request back to active + approved with a + // fresh submission budget, in one guarded write. + ReopenFailed(ctx context.Context, id string, actor Viewer) (*Request, error) + // ClaimSubmission takes the right to submit an approved request: it + // succeeds for one caller at a time, only while the request is active, + // approved, not leased, and past its backoff, and it counts the attempt. + // The claim holds for lease. claimed is false when another caller holds it + // or the backoff has not elapsed. + ClaimSubmission(ctx context.Context, id string, lease time.Duration) (req *Request, claimed bool, err error) + // DeferSubmission records a failed submission attempt on a still-approved + // request, releases the claim, and schedules the next attempt after delay. + DeferSubmission(ctx context.Context, id string, delay time.Duration, message string) (*Request, error) + // MarkReconciled stamps last_reconciled_at so the reconcile pass rotates + // through every candidate. + MarkReconciled(ctx context.Context, id string) error + // MarkAvailable completes an in-flight request whose media is already in + // the library. It refuses (ErrInvalidState) a request another actor has + // moved on, and an approved request whose submission claim is live, so it + // cannot race a router call that is creating targets. + MarkAvailable(ctx context.Context, id string, actor Viewer) (*Request, error) + // RecomputeStatus re-derives an approved request's status and outcome from + // its targets, for a submission that found nothing left to send. + RecomputeStatus(ctx context.Context, id string, actor Viewer) (*Request, error) ListTargets(ctx context.Context, requestID string) ([]Target, error) CreateTarget(ctx context.Context, target Target) (Target, error) DeleteTarget(ctx context.Context, id int64) error @@ -55,6 +77,10 @@ type CreateRequestRecord struct { // runs inside the same transaction as the insert with a per-user // advisory lock so concurrent submissions cannot both exceed the limit. Quota *QuotaCheck + // ReplaceFailed deletes the requester's own failed requests for the same + // title in the insert transaction, before the quota check, so a re-request + // replaces the failed row instead of sitting next to it. + ReplaceFailed bool } type QuotaCheck struct { diff --git a/internal/requests/types.go b/internal/requests/types.go index 1e75462a4c..c6268195d0 100644 --- a/internal/requests/types.go +++ b/internal/requests/types.go @@ -163,8 +163,27 @@ type Request struct { UpdatedAt time.Time `json:"updated_at"` ApprovedAt *time.Time `json:"approved_at,omitempty"` CompletedAt *time.Time `json:"completed_at,omitempty"` + // SubmitAttempts counts router submissions claimed for the current + // approval. SubmitLeaseUntil is set while a claimed submission is in + // flight; NextSubmitAt is the backoff after a failed attempt. + SubmitAttempts int `json:"-"` + SubmitLeaseUntil *time.Time `json:"-"` + NextSubmitAt *time.Time `json:"-"` } +// StateGuard names the states a transition may start from. The store applies +// the write only while the row is still in one of them and otherwise answers +// ErrInvalidState, so two actors racing on one request (two admins, or an +// admin and the reconciler) cannot both apply a transition. An empty list +// accepts any value. +type StateGuard struct { + Statuses []Status + Outcomes []Outcome +} + +// guardPending matches a request that is still waiting for an admin. +var guardPending = StateGuard{Statuses: []Status{StatusPending}, Outcomes: []Outcome{OutcomeActive}} + type RequestEvent struct { ID int64 `json:"id"` RequestID string `json:"request_id"` @@ -312,5 +331,7 @@ type ReconcileResult struct { Completed int `json:"completed"` Failed int `json:"failed"` Skipped int `json:"skipped"` - Errors int `json:"errors"` + // Deferred counts submissions that failed and were rescheduled. + Deferred int `json:"deferred"` + Errors int `json:"errors"` } diff --git a/internal/taskmanager/tasks/reconcile_requests.go b/internal/taskmanager/tasks/reconcile_requests.go index deb6751e97..d239b6f195 100644 --- a/internal/taskmanager/tasks/reconcile_requests.go +++ b/internal/taskmanager/tasks/reconcile_requests.go @@ -7,22 +7,38 @@ import ( "github.com/Silo-Server/silo-server/internal/requests" "github.com/Silo-Server/silo-server/internal/taskmanager" + "github.com/jackc/pgx/v5/pgxpool" ) +// requestReconcileAdvisoryLock spells "SILORQRC". +const requestReconcileAdvisoryLock int64 = 0x53494C4F52515243 + type RequestReconciler interface { ReconcileRequests(ctx context.Context, limit int) (requests.ReconcileResult, error) } +// ReconcileRequestsTask moves in-flight media requests forward. Every API +// process runs the task manager, so an advisory lock lets one server run each +// pass; the others skip. The per-request submission claim already prevents a +// double submission, so the lock only saves the duplicate router status calls +// and presence lookups. type ReconcileRequestsTask struct { reconciler RequestReconciler limit int + lock clusterLock } -func NewReconcileRequestsTask(reconciler RequestReconciler, limit int) *ReconcileRequestsTask { +// NewReconcileRequestsTask constructs the task. A nil pool runs without the +// cluster lock. +func NewReconcileRequestsTask(reconciler RequestReconciler, limit int, pool *pgxpool.Pool) *ReconcileRequestsTask { if limit <= 0 { limit = 100 } - return &ReconcileRequestsTask{reconciler: reconciler, limit: limit} + t := &ReconcileRequestsTask{reconciler: reconciler, limit: limit} + if pool != nil { + t.lock = advisoryClusterLock{pool: pool, key: requestReconcileAdvisoryLock} + } + return t } func (t *ReconcileRequestsTask) Key() string { return "reconcile_requests" } @@ -47,6 +63,17 @@ func (t *ReconcileRequestsTask) Execute(ctx context.Context, progress taskmanage progress.Report(100, "Request reconciliation unavailable") return nil } + if t.lock != nil { + release, acquired, err := t.lock.TryAcquire(ctx) + if err != nil { + return fmt.Errorf("acquiring request reconcile lock: %w", err) + } + if !acquired { + progress.Report(100, "Another server is reconciling media requests") + return nil + } + defer release() + } result, err := t.reconciler.ReconcileRequests(ctx, t.limit) if err != nil { return fmt.Errorf("reconcile media requests: %w", err) diff --git a/migrations/sql/20260926140759_request_submission_state.sql b/migrations/sql/20260926140759_request_submission_state.sql new file mode 100644 index 0000000000..73c823f6aa --- /dev/null +++ b/migrations/sql/20260926140759_request_submission_state.sql @@ -0,0 +1,19 @@ +-- +goose Up +-- Submission bookkeeping for approved requests. A server claims a request by +-- setting submit_lease_until before it calls the router plugin, so two servers +-- (or an admin approval and the reconciler) never submit the same request at +-- once. After a failed attempt the lease is cleared and next_submit_at holds +-- the backoff. last_reconciled_at orders the reconcile pass so every in-flight +-- request gets checked, not only the oldest batch. +ALTER TABLE media_requests + ADD COLUMN submit_attempts integer NOT NULL DEFAULT 0, + ADD COLUMN submit_lease_until timestamptz, + ADD COLUMN next_submit_at timestamptz, + ADD COLUMN last_reconciled_at timestamptz; + +-- +goose Down +ALTER TABLE media_requests + DROP COLUMN last_reconciled_at, + DROP COLUMN next_submit_at, + DROP COLUMN submit_lease_until, + DROP COLUMN submit_attempts; diff --git a/web/src/pages/AdminRequests.tsx b/web/src/pages/AdminRequests.tsx index bd260aa102..ddf57433d4 100644 --- a/web/src/pages/AdminRequests.tsx +++ b/web/src/pages/AdminRequests.tsx @@ -361,8 +361,10 @@ function RequestQueueRow({ onDecline: () => void; onRetry: () => void; }) { + // The server declines only requests still waiting for approval; an approved + // request may already be on its way to a downstream service. const canApprove = request.status === "pending" && request.outcome === "active"; - const canDecline = request.status !== "completed" && request.outcome === "active"; + const canDecline = canApprove; const canRetry = request.outcome === "failed"; const requesterLabel = requesterUsername ?? `User ${request.requested_by_user_id}`; const requestDetailHref = `/requests/${request.media_type}/${request.tmdb_id}`; diff --git a/web/src/pages/AdminRequests.v2.test.tsx b/web/src/pages/AdminRequests.v2.test.tsx index 3e80d4b456..0ed83cd8e0 100644 --- a/web/src/pages/AdminRequests.v2.test.tsx +++ b/web/src/pages/AdminRequests.v2.test.tsx @@ -229,6 +229,43 @@ describe("request administration conflict handling", () => { expect(screen.queryByText("Connection name is rejected")).toBeNull(); expect(name.getAttribute("aria-invalid")).toBe("false"); }); + it("offers Decline only for requests still waiting for approval", async () => { + const request = (id: string, title: string, status: string) => ({ + id, + provider: "tmdb", + media_type: "movie", + tmdb_id: Number(id.replace(/\D/g, "")), + title, + status, + outcome: "active", + requested_by_user_id: "1", + is_anime: false, + targets: [], + created_at: "2026-09-01T00:00:00Z", + updated_at: "2026-09-01T00:00:00Z", + }); + vi.mocked(v2).mockImplementation((operation, options) => { + if (operation === "GET /api/v2/admin/requests/capabilities") + return reply(options, { available: true, guarded_configuration: true }); + if (operation === "GET /api/v2/admin/requests") + return reply(options, { + items: [ + request("r1", "Waiting Title", "pending"), + request("r2", "Approved Title", "approved"), + ], + page: { has_more: false }, + }); + throw new Error(operation); + }); + mount("queue"); + const declineFor = async (title: string) => { + const row = (await screen.findByText(title)).closest("tr") as HTMLElement; + return within(row).getByRole("button", { name: "Decline" }) as HTMLButtonElement; + }; + expect((await declineFor("Waiting Title")).disabled).toBe(false); + expect((await declineFor("Approved Title")).disabled).toBe(true); + }); + it("keeps user override edits and validator until explicit reload after a stale response", async () => { let reads = 0; vi.mocked(v2).mockImplementation((operation, options) => { From e05f2fb44cfdf0aaca95301e48cb004cc506bfb3 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Sat, 26 Sep 2026 10:48:50 -0400 Subject: [PATCH 002/103] feat(requests): complete requests from the library without a router Requests no longer need Sonarr, Radarr or another router plugin. When no router connection serves the media type, an approved request stays approved and the reconcile pass completes it once the title is in the library, then notifies the requester. Auto-approval no longer waits for a configured connection, so an auto-approved user's request skips pending on a server without one. A second reconcile rotation checks pending requests, and requests that failed in the last 30 days without delivering anything, against the library: a pending request whose title appears needs no approval any more, and a failed one whose title appears is complete. It uses one batched presence lookup per media type and runs apart from the requests that need router calls, so a backlog cannot slow router polling. The in-flight rotation now batches its presence lookups too. An approved request nothing has been sent for (no target, no submission in flight) can be declined by an admin or cancelled by its owner, so a request waiting for the library can still be closed. A router connection that exists but cannot be used (no API key, not bound to a plugin installation) now records the reason and retries with backoff instead of failing the request, so fixing the connection lets it through. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/architecture/media-requests.md | 41 +++- internal/requests/library_completion_test.go | 162 ++++++++++++++ internal/requests/lifecycle_guard_test.go | 23 ++ internal/requests/repository.go | 90 ++++++-- internal/requests/service.go | 220 ++++++++++++------- internal/requests/service_test.go | 171 +++++++++----- internal/requests/store.go | 15 +- internal/requests/types.go | 13 ++ web/src/pages/AdminRequests.tsx | 11 +- web/src/pages/AdminRequests.v2.test.tsx | 20 +- 10 files changed, 604 insertions(+), 162 deletions(-) create mode 100644 internal/requests/library_completion_test.go diff --git a/docs/architecture/media-requests.md b/docs/architecture/media-requests.md index 32e13eb260..00751a9b76 100644 --- a/docs/architecture/media-requests.md +++ b/docs/architecture/media-requests.md @@ -29,10 +29,13 @@ never check the state in Go and then write it unconditionally. The exception is the target aggregate: a target change always recomputes the request's status from its targets, because after submission the targets are the source of truth. -Decline and cancel apply only to `pending` + `active` requests. An approved -request may already be on its way to a downstream service, so it stays in the -pipeline until it completes or fails. Retry reopens a `failed` request to -`approved` + `active` in one guarded write. +Decline and cancel apply to requests nothing has been sent for: `pending` ones, +and `approved` ones with no target and no live submission lease (waiting for the +library, or backing off after a failed send). Once a submission is in flight or +a target exists, the request stays in the pipeline until it completes or fails, +because withdrawing it could leave the downstream service's state diverged from +Silo's. Retry reopens a `failed` request to `approved` + `active` in one guarded +write. ## Submission is claimed @@ -57,16 +60,38 @@ a connection skipped for a missing key also shrinks it, and must not erase a failure an admin still needs to see. If nothing is left to send, the remaining targets decide the status. +## Without a router + +Requests do not need Sonarr, Radarr or any other router plugin. When no +enabled router connection serves the request's media type, approval (by an +admin or by the requester's auto-approve policy) leaves the request `approved`, +and the reconcile pass completes it once the title is in the library. A +connection that exists but cannot be used (no API key, not bound to a plugin +installation) is a setup problem instead: the submission retries with backoff +and records the reason in `last_error`, so fixing the connection lets the +request through. + +The library also completes requests that never reached a router: a `pending` +request whose title appears needs no approval any more, and a `failed` request +whose title appears is complete, unless it failed after delivering one quality, +in which case the failure stays for an admin to retry. Both go through the same +guarded write as any other completion, and the requester is notified. + ## Reconcile Every five minutes the reconcile pass submits approved requests, asks the router -for target status, and completes requests whose media is present in the library. +for target status, and completes open and failed requests whose media is present +in the library. Completing from the library skips an approved request while its submission lease is live, so it cannot race a router call that is creating targets. Every API process runs the task manager, so an advisory lock lets one server run -each pass. Candidates are taken in `last_reconciled_at` order and each one is -stamped when checked, even if it errors, so a large backlog rotates instead of -the same batch being checked every time. +each pass. A pass has two rotations. In-flight requests (`approved`, `queued`, +`downloading`) get router calls. Requests only the library can complete +(`pending`, and `failed` in the last 30 days) get a presence check and nothing +else, so a backlog of them cannot slow the router polling. Each rotation takes +candidates in `last_reconciled_at` order, stamps each one when checked, even if +it errors, and looks presence up in one batch per media type. The 30-day bound +keeps an upgrade from completing, and notifying, a backlog of old failures. ## Re-requesting a failed title diff --git a/internal/requests/library_completion_test.go b/internal/requests/library_completion_test.go new file mode 100644 index 0000000000..5658d86e55 --- /dev/null +++ b/internal/requests/library_completion_test.go @@ -0,0 +1,162 @@ +package requests + +import ( + "context" + "errors" + "testing" +) + +func presentMovie(tmdbID int) *fakePresence { + return &fakePresence{available: map[MediaType]map[int]bool{MediaTypeMovie: {tmdbID: true}}} +} + +// A pending request whose title reaches the library by any route is complete: +// there is nothing left for an admin to approve (AC4). +func TestReconcileCompletesPendingRequestFromLibrary(t *testing.T) { + store := newFakeStore() + store.waiting = []*Request{{ID: "req-1", MediaType: MediaTypeMovie, TMDBID: 550, Status: StatusPending, Outcome: OutcomeActive}} + router := &fakeRouterProvider{} + svc := NewService(store, &fakeTMDBClient{}, presentMovie(550)) + svc.SetRouterProvider(router) + + result, err := svc.ReconcileRequests(context.Background(), 100) + if err != nil { + t.Fatalf("ReconcileRequests: %v", err) + } + got := store.requests["req-1"] + if result.Completed != 1 || got.Status != StatusCompleted || got.Outcome != OutcomeActive { + t.Fatalf("result = %+v request = %+v, want completed", result, got) + } + if router.fulfillCalls != 0 || router.statusCalls != 0 { + t.Fatalf("router calls = fulfill %d / status %d, want none for a pending request", router.fulfillCalls, router.statusCalls) + } +} + +func TestReconcileLeavesPendingRequestWithoutMediaAlone(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInst("router-1")} + store.waiting = []*Request{{ID: "req-1", MediaType: MediaTypeMovie, TMDBID: 550, Status: StatusPending, Outcome: OutcomeActive}} + router := &fakeRouterProvider{} + svc := NewService(store, &fakeTMDBClient{}, &fakePresence{}) + svc.SetRouterProvider(router) + + if _, err := svc.ReconcileRequests(context.Background(), 100); err != nil { + t.Fatalf("ReconcileRequests: %v", err) + } + if got := store.requests["req-1"]; got != nil && got.Status != StatusPending { + t.Fatalf("request = %+v, want still pending", got) + } + if router.fulfillCalls != 0 || router.statusCalls != 0 { + t.Fatalf("router calls = fulfill %d / status %d, want none for a pending request", router.fulfillCalls, router.statusCalls) + } +} + +func TestReconcileCompletesFailedRequestFromLibrary(t *testing.T) { + store := newFakeStore() + store.waiting = []*Request{{ + ID: "req-1", MediaType: MediaTypeMovie, TMDBID: 550, Status: StatusApproved, Outcome: OutcomeFailed, + LastError: "radarr unreachable", + }} + svc := NewService(store, &fakeTMDBClient{}, presentMovie(550)) + + if _, err := svc.ReconcileRequests(context.Background(), 100); err != nil { + t.Fatalf("ReconcileRequests: %v", err) + } + got := store.requests["req-1"] + if got.Status != StatusCompleted || got.Outcome != OutcomeActive || got.LastError != "" { + t.Fatalf("request = %+v, want completed and active with the error cleared", got) + } +} + +// Without Sonarr/Radarr, an approved request waits for the library and the +// reconcile pass completes it once the title is scanned in (AC2, AC4). +func TestApprovedRequestWithoutRouterCompletesFromLibrary(t *testing.T) { + store := newFakeStore() + store.requests["req-1"] = &Request{ID: "req-1", MediaType: MediaTypeMovie, TMDBID: 550, Status: StatusPending, Outcome: OutcomeActive} + presence := &fakePresence{} + svc := NewService(store, &fakeTMDBClient{}, presence) + svc.SetRouterProvider(&fakeRouterProvider{}) + + approved, err := svc.Approve(context.Background(), Viewer{UserID: 1, IsAdmin: true}, "req-1") + if err != nil { + t.Fatalf("Approve: %v", err) + } + if approved.Status != StatusApproved || approved.Outcome != OutcomeActive || approved.LastError != "" { + t.Fatalf("approved = %+v, want approved and waiting", approved) + } + + store.candidates = []*Request{approved} + if _, err := svc.ReconcileRequests(context.Background(), 100); err != nil { + t.Fatalf("ReconcileRequests before scan: %v", err) + } + if got := store.requests["req-1"]; got.Status != StatusApproved { + t.Fatalf("before the scan: request = %+v, want still approved", got) + } + + presence.available = map[MediaType]map[int]bool{MediaTypeMovie: {550: true}} + if _, err := svc.ReconcileRequests(context.Background(), 100); err != nil { + t.Fatalf("ReconcileRequests after scan: %v", err) + } + if got := store.requests["req-1"]; got.Status != StatusCompleted { + t.Fatalf("after the scan: request = %+v, want completed", got) + } +} + +func TestLibraryCompletionDatabase(t *testing.T) { + repo, pool := lifecycleTestRepository(t) + ctx := t.Context() + insertLifecycleRequest(t, repo, "pending", 1, 801, StatusPending) + insertLifecycleRequest(t, repo, "failed", 1, 802, StatusApproved) + insertLifecycleRequest(t, repo, "declined", 1, 803, StatusPending) + insertLifecycleRequest(t, repo, "done", 1, 804, StatusApproved) + insertLifecycleRequest(t, repo, "partly-delivered", 1, 805, StatusApproved) + insertLifecycleRequest(t, repo, "old-failure", 1, 806, StatusApproved) + if _, err := pool.Exec(ctx, ` + UPDATE media_requests SET outcome = 'failed', last_error = 'boom' WHERE id IN ('failed', 'partly-delivered', 'old-failure'); + UPDATE media_requests SET updated_at = now() - interval '45 days' WHERE id = 'old-failure'; + UPDATE media_requests SET outcome = 'declined' WHERE id = 'declined'; + UPDATE media_requests SET status = 'completed' WHERE id = 'done'; + INSERT INTO media_request_targets (request_id, quality, status, updated_at) VALUES + ('partly-delivered', '1080p', 'completed', now()), + ('partly-delivered', '2160p', 'failed', now());`); err != nil { + t.Fatal(err) + } + + candidates, err := repo.ListLibraryWaitCandidates(ctx, 100) + if err != nil { + t.Fatal(err) + } + ids := map[string]bool{} + for _, c := range candidates { + ids[c.ID] = true + } + if !ids["pending"] || !ids["failed"] || ids["declined"] || ids["done"] || ids["partly-delivered"] || ids["old-failure"] { + t.Fatalf("waiting = %v, want pending and recent failed, not declined, completed, partly delivered or old failures", ids) + } + inFlight, err := repo.ListReconciliationCandidates(ctx, 100) + if err != nil { + t.Fatal(err) + } + for _, c := range inFlight { + if c.ID == "pending" || c.ID == "failed" { + t.Fatalf("in-flight candidates include %s", c.ID) + } + } + + for _, id := range []string{"pending", "failed"} { + got, err := repo.MarkAvailable(ctx, id, Viewer{}) + if err != nil { + t.Fatalf("mark %s available: %v", id, err) + } + if got.Status != StatusCompleted || got.Outcome != OutcomeActive || got.LastError != "" || got.CompletedAt == nil { + t.Fatalf("%s = %+v, want completed, active, no error, completed_at set", id, got) + } + } + // A request that failed after delivering one quality keeps its failure + // visible for an admin to retry. + for _, id := range []string{"declined", "partly-delivered"} { + if _, err := repo.MarkAvailable(ctx, id, Viewer{}); !errors.Is(err, ErrInvalidState) { + t.Fatalf("mark %s available: err = %v, want ErrInvalidState", id, err) + } + } +} diff --git a/internal/requests/lifecycle_guard_test.go b/internal/requests/lifecycle_guard_test.go index 058abb4a9d..199d76c6c0 100644 --- a/internal/requests/lifecycle_guard_test.go +++ b/internal/requests/lifecycle_guard_test.go @@ -119,6 +119,29 @@ func TestGuardedTransitionsDatabase(t *testing.T) { } } +func TestWithdrawGuardDatabase(t *testing.T) { + repo, pool := lifecycleTestRepository(t) + ctx := t.Context() + insertLifecycleRequest(t, repo, "waiting", 1, 111, StatusApproved) + insertLifecycleRequest(t, repo, "leased", 1, 112, StatusApproved) + insertLifecycleRequest(t, repo, "targeted", 1, 113, StatusApproved) + if _, claimed, err := repo.ClaimSubmission(ctx, "leased", time.Minute); err != nil || !claimed { + t.Fatalf("claim: claimed = %v, err = %v", claimed, err) + } + if _, err := pool.Exec(ctx, `INSERT INTO media_request_targets (request_id, quality, status, updated_at) VALUES ('targeted', '1080p', 'queued', now())`); err != nil { + t.Fatal(err) + } + + if _, err := repo.SetOutcome(ctx, "waiting", guardWithdrawable, OutcomeDeclined, Viewer{}, ""); err != nil { + t.Fatalf("decline an approved request nothing was sent for: %v", err) + } + for _, id := range []string{"leased", "targeted"} { + if _, err := repo.SetOutcome(ctx, id, guardWithdrawable, OutcomeDeclined, Viewer{}, ""); !errors.Is(err, ErrInvalidState) { + t.Fatalf("decline %s: err = %v, want ErrInvalidState", id, err) + } + } +} + func TestSubmissionClaimDatabase(t *testing.T) { repo, _ := lifecycleTestRepository(t) ctx := t.Context() diff --git a/internal/requests/repository.go b/internal/requests/repository.go index 80fc837415..6097e1e8cb 100644 --- a/internal/requests/repository.go +++ b/internal/requests/repository.go @@ -384,6 +384,42 @@ func (r *Repository) ListReconciliationCandidates(ctx context.Context, limit int return out, nil } +// ListLibraryWaitCandidates returns the requests that only the library can +// complete: pending ones, and ones that failed in the last 30 days without +// delivering anything. Older failures are left alone so an upgrade does not +// suddenly complete, and notify, a backlog of stale requests. +func (r *Repository) ListLibraryWaitCandidates(ctx context.Context, limit int) ([]*Request, error) { + if limit <= 0 || limit > 1000 { + limit = 200 + } + rows, err := r.pool.Query(ctx, requestSelectSQL()+` + WHERE (outcome = 'active' AND status = 'pending') + OR (outcome = 'failed' + AND updated_at > now() - interval '30 days' + AND NOT EXISTS ( + SELECT 1 FROM media_request_targets t + WHERE t.request_id = media_requests.id AND t.status = 'completed')) + ORDER BY last_reconciled_at ASC NULLS FIRST, id + LIMIT $1 + `, limit) + if err != nil { + return nil, fmt.Errorf("list requests waiting for the library: %w", err) + } + defer rows.Close() + var out []*Request + for rows.Next() { + req, err := scanRequest(rows) + if err != nil { + return nil, err + } + out = append(out, req) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate requests waiting for the library: %w", err) + } + return out, nil +} + // ListFulfilledUnnotified returns completed requests whose fulfillment // notification has not fired, oldest first. The horizon bounds how long a // completed request keeps being presence-polled when its media never appears @@ -462,11 +498,15 @@ func (r *Repository) listRequests(ctx context.Context, sqlText string, args []an } // guardCondition restricts an UPDATE to rows a StateGuard accepts. $2 and $3 -// carry the guard's statuses and outcomes; an empty array accepts any value. +// carry the guard's statuses and outcomes (an empty array accepts any value), +// and $4 its UnsentOnly flag. const guardCondition = `(cardinality($2::text[]) = 0 OR status = ANY($2::text[])) - AND (cardinality($3::text[]) = 0 OR outcome = ANY($3::text[]))` + AND (cardinality($3::text[]) = 0 OR outcome = ANY($3::text[])) + AND (NOT $4::boolean OR status <> 'approved' OR ( + (submit_lease_until IS NULL OR submit_lease_until <= now()) + AND NOT EXISTS (SELECT 1 FROM media_request_targets t WHERE t.request_id = media_requests.id)))` -func guardArgs(g StateGuard) ([]string, []string) { +func guardArgs(g StateGuard) ([]string, []string, bool) { statuses := make([]string, 0, len(g.Statuses)) for _, s := range g.Statuses { statuses = append(statuses, string(s)) @@ -475,7 +515,7 @@ func guardArgs(g StateGuard) ([]string, []string) { for _, o := range g.Outcomes { outcomes = append(outcomes, string(o)) } - return statuses, outcomes + return statuses, outcomes, g.UnsentOnly } // guardMiss explains a guarded UPDATE that matched no row: the request is @@ -498,20 +538,20 @@ func (r *Repository) SetStatus(ctx context.Context, id string, from StateGuard, } defer tx.Rollback(ctx) - statuses, outcomes := guardArgs(from) + statuses, outcomes, unsent := guardArgs(from) // A fresh approval starts a fresh submission budget. req, err := scanRequest(tx.QueryRow(ctx, ` UPDATE media_requests - SET status = $4, + SET status = $5, updated_at = now(), - approved_at = CASE WHEN $4 = 'approved' AND approved_at IS NULL THEN now() ELSE approved_at END, - completed_at = CASE WHEN $4 = 'completed' AND completed_at IS NULL THEN now() ELSE completed_at END, - submit_attempts = CASE WHEN $4 = 'approved' THEN 0 ELSE submit_attempts END, - submit_lease_until = CASE WHEN $4 = 'approved' THEN NULL ELSE submit_lease_until END, - next_submit_at = CASE WHEN $4 = 'approved' THEN NULL ELSE next_submit_at END + approved_at = CASE WHEN $5 = 'approved' AND approved_at IS NULL THEN now() ELSE approved_at END, + completed_at = CASE WHEN $5 = 'completed' AND completed_at IS NULL THEN now() ELSE completed_at END, + submit_attempts = CASE WHEN $5 = 'approved' THEN 0 ELSE submit_attempts END, + submit_lease_until = CASE WHEN $5 = 'approved' THEN NULL ELSE submit_lease_until END, + next_submit_at = CASE WHEN $5 = 'approved' THEN NULL ELSE next_submit_at END WHERE id = $1 AND `+guardCondition+` - RETURNING `+requestColumns(), id, statuses, outcomes, status)) + RETURNING `+requestColumns(), id, statuses, outcomes, unsent, status)) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, guardMiss(ctx, tx, id) @@ -534,19 +574,19 @@ func (r *Repository) SetOutcome(ctx context.Context, id string, from StateGuard, } defer tx.Rollback(ctx) - statuses, outcomes := guardArgs(from) + statuses, outcomes, unsent := guardArgs(from) req, err := scanRequest(tx.QueryRow(ctx, ` UPDATE media_requests - SET outcome = $4, + SET outcome = $5, last_error = CASE - WHEN $4 = 'failed' THEN $5 - WHEN $4 = 'active' THEN '' + WHEN $5 = 'failed' THEN $6 + WHEN $5 = 'active' THEN '' ELSE last_error END, updated_at = now() WHERE id = $1 AND `+guardCondition+` - RETURNING `+requestColumns(), id, statuses, outcomes, outcome, strings.TrimSpace(message))) + RETURNING `+requestColumns(), id, statuses, outcomes, unsent, outcome, strings.TrimSpace(message))) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, guardMiss(ctx, tx, id) @@ -666,12 +706,22 @@ func (r *Repository) MarkAvailable(ctx context.Context, id string, actor Viewer) req, err := scanRequest(tx.QueryRow(ctx, ` UPDATE media_requests SET status = 'completed', + outcome = 'active', + last_error = '', completed_at = COALESCE(completed_at, now()), updated_at = now() WHERE id = $1 - AND outcome = 'active' - AND status IN ('approved', 'queued', 'downloading') - AND (status <> 'approved' OR submit_lease_until IS NULL OR submit_lease_until <= now()) + AND ( + (outcome = 'active' + AND status IN ('pending', 'approved', 'queued', 'downloading') + AND (status <> 'approved' OR submit_lease_until IS NULL OR submit_lease_until <= now())) + -- A failed request is complete only when the title came from + -- elsewhere. One that failed after delivering some quality keeps + -- its failure visible for an admin to retry. + OR (outcome = 'failed' AND NOT EXISTS ( + SELECT 1 FROM media_request_targets t + WHERE t.request_id = media_requests.id AND t.status = 'completed')) + ) RETURNING `+requestColumns(), id)) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/requests/service.go b/internal/requests/service.go index 4efc480c04..dd790a42cc 100644 --- a/internal/requests/service.go +++ b/internal/requests/service.go @@ -392,8 +392,8 @@ func (s *Service) newFulfillContext(ctx context.Context) (*fulfillContext, error // plaintext, plugin_config attached), and returns the installation+capability to // dispatch to. // -// It filters by media type to match the integrationConfigured auto-approve gate -// (so a series-only connection is never used for a movie request). Multi- +// It filters by media type (so a series-only connection is never used for a +// movie request). Multi- // installation routing isn't supported yet: it picks the first eligible // connection's installation and includes ONLY connections belonging to it, so a // second installation's resolved plaintext credentials are never handed to the @@ -430,6 +430,45 @@ func (s *Service) resolveRouterConnections(ctx context.Context, fc *fulfillConte return conns, installationID, capabilityID, nil } +// Reasons a configured router connection cannot take a submission, recorded in +// the request's last_error. +const ( + msgRouterUnbound = "request backend connection is not bound to a plugin installation; re-save it in admin" + msgRouterNoKey = "request backend connection has no API key; add it in admin" +) + +// unusableRouterMessage explains why no configured connection could take a +// submission, for the request's last_error. +func unusableRouterMessage(fc *fulfillContext, mediaType MediaType) string { + for _, in := range fc.integrations { + if !in.Enabled || in.CapabilityID == "" || !integrationSupportsMediaType(in, mediaType) { + continue + } + if in.InstallationID == nil { + // The migration left installation_id NULL on rows that predate the + // plugin install and were never re-bound. + return msgRouterUnbound + } + if strings.TrimSpace(in.APIKeyRef) == "" { + return msgRouterNoKey + } + } + return "no usable request backend connection" +} + +// routerConfiguredFor reports whether any enabled router connection is meant to +// serve the media type, including a misconfigured one (no installation bound, +// no key). Only when none is does a request fall back to waiting for the +// library; a misconfigured connection surfaces as a submission failure instead. +func routerConfiguredFor(fc *fulfillContext, mediaType MediaType) bool { + for _, in := range fc.integrations { + if in.Enabled && in.CapabilityID != "" && integrationSupportsMediaType(in, mediaType) { + return true + } + } + return false +} + // skippedRouterConnection reports whether resolveRouterConnections leaves out // a connection that would otherwise serve the media type, because its API key // is missing. @@ -444,9 +483,8 @@ func skippedRouterConnection(fc *fulfillContext, mediaType MediaType) bool { // eligibleRouterConnection reports whether a connection is a candidate fulfillment // backend for the media type: enabled, bound to an installation, and naming a -// capability sub-id that serves the media type. resolveRouterConnections (which -// then resolves credentials) and integrationConfigured (the auto-approval gate) -// share this predicate so the two cannot drift. +// capability sub-id that serves the media type. resolveRouterConnections then +// resolves credentials for the ones it uses. func eligibleRouterConnection(in Integration, mediaType MediaType) bool { return in.Enabled && in.CapabilityID != "" && in.InstallationID != nil && integrationSupportsMediaType(in, mediaType) @@ -731,12 +769,11 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create if err != nil { return nil, err } + // Auto-approval does not depend on a router: without one, an approved + // request waits for the title to appear in the library. status := StatusPending if policy.AutoApprove { - configured, err := s.integrationConfigured(ctx, normalized.MediaType) - if err == nil && configured { - status = StatusApproved - } + status = StatusApproved } record := CreateRequestRecord{ ID: id, @@ -909,14 +946,15 @@ func (s *Service) Approve(ctx context.Context, viewer Viewer, id string) (*Reque return s.submitAfterCommit(ctx, *approved, viewer), nil } -// Decline rejects a request that is still pending. Approved requests may -// already be on their way to a downstream service, so declining one could leave -// the external state diverged from Silo's; the guard refuses it. +// Decline rejects a request nothing has been sent for: a pending one, or an +// approved one still waiting for the library or backing off. Once a submission +// is in flight or a target exists, declining could leave the downstream +// service's state diverged from Silo's, so the guard refuses it. func (s *Service) Decline(ctx context.Context, viewer Viewer, id, reason string) (*Request, error) { if !viewer.IsAdmin { return nil, ErrForbidden } - declined, err := s.store.SetOutcome(ctx, strings.TrimSpace(id), guardPending, OutcomeDeclined, viewer, reason) + declined, err := s.store.SetOutcome(ctx, strings.TrimSpace(id), guardWithdrawable, OutcomeDeclined, viewer, reason) if err != nil { return nil, err } @@ -925,10 +963,11 @@ func (s *Service) Decline(ctx context.Context, viewer Viewer, id, reason string) return declined, nil } -// Cancel withdraws a request that has not yet been submitted to a downstream -// integration. Owners can withdraw their own pending requests; admins can -// withdraw any pending request. Once a request is approved it stays in the -// fulfillment pipeline until it completes or fails. +// Cancel withdraws a request that has not been sent to a downstream service: +// pending, or approved but not yet sent (see guardWithdrawable). Owners can +// withdraw their own; admins can withdraw any. Once a submission is in flight +// or a target exists, the request stays in the pipeline until it completes or +// fails. func (s *Service) Cancel(ctx context.Context, viewer Viewer, id, reason string) (*Request, error) { if viewer.UserID == 0 { return nil, ErrForbidden @@ -945,7 +984,7 @@ func (s *Service) Cancel(ctx context.Context, viewer Viewer, id, reason string) if !viewer.IsAdmin && req.RequestedByUserID != viewer.UserID { return nil, ErrForbidden } - return s.store.SetOutcome(ctx, req.ID, guardPending, OutcomeCancelled, viewer, reason) + return s.store.SetOutcome(ctx, req.ID, guardWithdrawable, OutcomeCancelled, viewer, reason) } func (s *Service) Retry(ctx context.Context, viewer Viewer, id string) (*Request, error) { @@ -974,12 +1013,16 @@ func (s *Service) ReconcileRequests(ctx context.Context, limit int) (ReconcileRe if err != nil { return ReconcileResult{}, err } + present, err := s.presentRequests(ctx, candidates) + if err != nil { + return ReconcileResult{}, err + } result := ReconcileResult{Checked: len(candidates)} for _, req := range candidates { if err := ctx.Err(); err != nil { return result, err } - change, err := s.reconcileRequest(ctx, *req, fc) + change, err := s.reconcileRequest(ctx, *req, fc, present[req.ID]) // Stamp every candidate, including ones that errored, so the next pass // starts with the requests this one did not reach. if markErr := s.store.MarkReconciled(ctx, req.ID); markErr != nil { @@ -1012,6 +1055,9 @@ func (s *Service) ReconcileRequests(ctx context.Context, limit int) (ReconcileRe result.Skipped++ } } + if err := s.completeWaitingFromLibrary(ctx, 2*limit, &result); err != nil { + return result, err + } // Presence-gated fulfillment notifications: completion above (and via the // per-target aggregate path) only marks status; the notification fires // once the media is confirmed present in the catalog. @@ -1643,26 +1689,6 @@ func (s *Service) detectRequestAnime(ctx context.Context, mediaType MediaType, t return detectAnime(detail.KeywordIDs) } -// integrationConfigured reports whether a fulfillment backend exists for the -// media type, gating auto-approval (pending vs approved). It uses the same -// router-connection selection as resolveRouterConnections — an enabled -// request_router.v1 connection with an installation — and additionally honors a -// connection's declared media-type support so a movie request only auto-approves -// when a router connection supporting "movie" exists. -func (s *Service) integrationConfigured(ctx context.Context, mediaType MediaType) (bool, error) { - instances, err := s.store.ListIntegrations(ctx) - if err != nil { - return false, err - } - for _, in := range instances { - if eligibleRouterConnection(in, mediaType) && - strings.TrimSpace(in.BaseURL) != "" && strings.TrimSpace(in.APIKeyRef) != "" { - return true, nil - } - } - return false, nil -} - // integrationSupportsMediaType reports whether a router connection serves the // given media type. An empty SupportedMediaTypes is treated as "supports all". func integrationSupportsMediaType(in Integration, mediaType MediaType) bool { @@ -1721,6 +1747,18 @@ func (s *Service) submitApprovedRequest(ctx context.Context, req Request, actor if req.Outcome != OutcomeActive || req.Status != StatusApproved { return &req, nil } + if fc == nil { + built, err := s.newFulfillContext(ctx) + if err != nil { + return nil, err + } + fc = built + } + if s.router == nil || !routerConfiguredFor(fc, req.MediaType) { + // No router serves this media type: the request stays approved and + // the reconcile pass completes it when the title reaches the library. + return &req, nil + } claimed, ok, err := s.store.ClaimSubmission(ctx, req.ID, submitLease) if err != nil { return nil, err @@ -1758,32 +1796,16 @@ func (s *Service) submitApprovedRequest(ctx context.Context, req Request, actor // submitClaimed does the submission work for a request whose claim the caller // holds. func (s *Service) submitClaimed(ctx context.Context, req Request, actor Viewer, fc *fulfillContext) (*Request, error) { - if s.router == nil { - return s.markSubmissionFailed(ctx, req.ID, actor, fmt.Errorf("no fulfillment backend configured")) - } - if fc == nil { - built, err := s.newFulfillContext(ctx) - if err != nil { - return nil, err - } - fc = built - } conns, installationID, capabilityID, err := s.resolveRouterConnections(ctx, fc, req.MediaType) if err != nil { return nil, err } if len(conns) == 0 { - // Distinguish "no backend at all" from the migration breakage where an - // existing connection row exists but its installation_id is NULL (the row - // predates the plugin install and was never re-bound). - msg := "no fulfillment backend configured" - for _, in := range fc.integrations { - if in.Enabled && in.CapabilityID != "" && in.InstallationID == nil { - msg = "request backend connection is not bound to a plugin installation; re-save it in admin" - break - } - } - return s.markSubmissionFailed(ctx, req.ID, actor, errors.New(msg)) + // A connection is configured for the media type (routerConfiguredFor) + // but none is usable. That is an admin-fixable setup problem, so it is + // returned as a submission error: the request keeps its approval and + // retries with backoff, and goes through once the connection is fixed. + return nil, errors.New(unusableRouterMessage(fc, req.MediaType)) } existing, err := s.store.ListTargets(ctx, req.ID) if err != nil { @@ -2012,16 +2034,74 @@ const ( reconcileDeferred reconcileChange = "deferred" ) -func (s *Service) reconcileRequest(ctx context.Context, req Request, fc *fulfillContext) (reconcileChange, error) { - completed, err := s.requestAvailable(ctx, req) +// completeWaitingFromLibrary completes pending and recently failed requests +// whose title has reached the library. Nothing is in flight for them, so they +// run in their own rotation, apart from the requests that need router calls, +// and one batched presence lookup per media type covers the whole batch. +func (s *Service) completeWaitingFromLibrary(ctx context.Context, limit int, result *ReconcileResult) error { + waiting, err := s.store.ListLibraryWaitCandidates(ctx, limit) if err != nil { - return reconcileUnchanged, err + return err + } + present, err := s.presentRequests(ctx, waiting) + if err != nil { + return err + } + result.Checked += len(waiting) + for _, req := range waiting { + if err := ctx.Err(); err != nil { + return err + } + if present[req.ID] { + if _, err := s.store.MarkAvailable(ctx, req.ID, Viewer{}); err == nil { + result.Completed++ + } else if !errors.Is(err, ErrInvalidState) { + slog.WarnContext(ctx, "request library completion failed", "component", "requests", "request_id", req.ID, "err", err) + result.Errors++ + } + } + if err := s.store.MarkReconciled(ctx, req.ID); err != nil { + slog.WarnContext(ctx, "request reconcile stamp failed", "component", "requests", "request_id", req.ID, "err", err) + } } - if completed { + return nil +} + +// presentRequests reports which requests' titles are in the library, with one +// presence lookup per media type. +func (s *Service) presentRequests(ctx context.Context, reqs []*Request) (map[string]bool, error) { + byType := map[MediaType][]*Request{} + for _, req := range reqs { + if req != nil && req.TMDBID > 0 { + byType[req.MediaType] = append(byType[req.MediaType], req) + } + } + out := make(map[string]bool, len(reqs)) + for mediaType, group := range byType { + candidates := make([]PresenceCandidate, 0, len(group)) + for _, req := range group { + candidates = append(candidates, requestPresenceCandidate(*req)) + } + matches, err := s.lookupPresence(ctx, mediaType, candidates) + if err != nil { + return nil, err + } + for _, req := range group { + out[req.ID] = matches[req.TMDBID].Available + } + } + return out, nil +} + +// reconcileRequest moves one in-flight request forward. present reports +// whether its title is already in the library. +func (s *Service) reconcileRequest(ctx context.Context, req Request, fc *fulfillContext, present bool) (reconcileChange, error) { + if present { // The presence check is quality-agnostic (TMDB id only), so it must not // force-complete a request whose targets are still in flight — that would - // orphan in-progress downloads. Only take the shortcut for legacy/no-live - // -target requests; otherwise let per-target reconcile + aggregate drive + // orphan in-progress downloads. Only take the shortcut for requests with + // no live target (pending, failed, waiting for the library without a + // router, or legacy); otherwise let per-target reconcile + aggregate drive // completion. live, err := s.liveTargets(ctx, req.ID) if err != nil { @@ -2204,14 +2284,6 @@ func (s *Service) retireStalledTargets(ctx context.Context, req Request, live [] return updated, retired, nil } -func (s *Service) requestAvailable(ctx context.Context, req Request) (bool, error) { - matches, err := s.lookupPresence(ctx, req.MediaType, []PresenceCandidate{requestPresenceCandidate(req)}) - if err != nil { - return false, err - } - return matches[req.TMDBID].Available, nil -} - func (s *Service) now() time.Time { if s.Now != nil { return s.Now() diff --git a/internal/requests/service_test.go b/internal/requests/service_test.go index d3f0d97993..86d1655d16 100644 --- a/internal/requests/service_test.go +++ b/internal/requests/service_test.go @@ -196,7 +196,9 @@ func TestCreateRequestActiveDuplicateBlocks(t *testing.T) { } } -func TestCreateRequestAutoApprovalRequiresConfiguredIntegration(t *testing.T) { +// Auto-approval does not wait for a router: on a server without Sonarr/Radarr +// the approved request waits for the title to reach the library (AC3, AC4). +func TestCreateRequestAutoApprovesWithoutRouter(t *testing.T) { store := newFakeStore() store.settings.RequestsEnabled = true store.settings.GlobalAutoApprovalEnabled = true @@ -210,19 +212,15 @@ func TestCreateRequestAutoApprovalRequiresConfiguredIntegration(t *testing.T) { if err != nil { t.Fatalf("CreateRequest returned error: %v", err) } - if req.Status != StatusPending { - t.Fatalf("status = %q, want pending", req.Status) + if req.Status != StatusApproved || req.Outcome != OutcomeActive || req.LastError != "" { + t.Fatalf("request = %+v, want approved and waiting for the library", req) } } -// TestCreateRequestAutoApprovalEmptyKeyTreatedAsUnconfigured guards that a router -// connection that is enabled + bound but has no api key (empty after the repo's -// decrypt) reads as "not configured": auto-approval is declined and the request -// stays pending, rather than being auto-approved and then failing submission when -// resolveRouterConnections skips the keyless connection. This pins the empty-key -// check in integrationConfigured against the skip in resolveRouterConnections so -// the two can't drift at the public CreateRequest surface. -func TestCreateRequestAutoApprovalEmptyKeyTreatedAsUnconfigured(t *testing.T) { +// A keyless connection is a setup problem: the auto-approved request keeps its +// approval and records why it could not be sent, instead of failing, so it goes +// through once an admin adds the key. +func TestCreateRequestAutoApprovalDefersOnKeylessConnection(t *testing.T) { store := newFakeStore() store.settings.RequestsEnabled = true store.settings.GlobalAutoApprovalEnabled = true @@ -239,8 +237,9 @@ func TestCreateRequestAutoApprovalEmptyKeyTreatedAsUnconfigured(t *testing.T) { if err != nil { t.Fatalf("CreateRequest returned error: %v", err) } - if req.Status != StatusPending { - t.Fatalf("status = %q, want pending (empty-key connection is unconfigured)", req.Status) + if req.Status != StatusApproved || req.Outcome != OutcomeActive || + req.LastError != msgRouterNoKey { + t.Fatalf("request = %+v, want approved with the missing-key reason recorded", req) } if router.fulfillCalls != 0 { t.Fatalf("fulfill calls = %d, want 0 (must not submit to a keyless connection)", router.fulfillCalls) @@ -277,8 +276,8 @@ func TestCreateRequestAutoApprovalRespectsSupportedMediaTypes(t *testing.T) { store := newFakeStore() store.settings.RequestsEnabled = true store.settings.GlobalAutoApprovalEnabled = true - // A router connection that only serves series must NOT auto-approve a movie - // request; the gate falls back to manual approval (pending). + // A router connection that only serves series is never handed a movie + // request; the auto-approved movie waits for the library instead. seriesOnly := routerInst("router-series") seriesOnly.SupportedMediaTypes = []string{string(MediaTypeSeries)} store.integrations = []Integration{seriesOnly} @@ -293,8 +292,8 @@ func TestCreateRequestAutoApprovalRespectsSupportedMediaTypes(t *testing.T) { if err != nil { t.Fatalf("CreateRequest returned error: %v", err) } - if req.Status != StatusPending { - t.Fatalf("status = %q, want pending (no router connection supports movie)", req.Status) + if req.Status != StatusApproved || req.LastError != "" { + t.Fatalf("request = %+v, want approved and waiting (no router connection supports movie)", req) } } @@ -1415,11 +1414,12 @@ func TestCancelAdminCanCancelAnyPending(t *testing.T) { } func TestCancelRejectsRequestsAlreadyInFulfillment(t *testing.T) { + inFlight := time.Now().Add(time.Minute) cases := []struct { name string req Request }{ - {"approved", Request{Status: StatusApproved, Outcome: OutcomeActive}}, + {"approved and being submitted", Request{Status: StatusApproved, Outcome: OutcomeActive, SubmitLeaseUntil: &inFlight}}, {"queued", Request{Status: StatusQueued, Outcome: OutcomeActive, IntegrationKind: "radarr", ExternalID: "42"}}, {"downloading", Request{Status: StatusDownloading, Outcome: OutcomeActive, IntegrationKind: "radarr", ExternalID: "42"}}, {"completed", Request{Status: StatusCompleted, Outcome: OutcomeActive}}, @@ -1442,18 +1442,65 @@ func TestCancelRejectsRequestsAlreadyInFulfillment(t *testing.T) { func TestDeclineRejectsApprovedRequests(t *testing.T) { store := newFakeStore() + inFlight := time.Now().Add(time.Minute) store.requests["req-approved"] = &Request{ - ID: "req-approved", - MediaType: MediaTypeMovie, - TMDBID: 550, - Status: StatusApproved, - Outcome: OutcomeActive, + ID: "req-approved", + MediaType: MediaTypeMovie, + TMDBID: 550, + Status: StatusApproved, + Outcome: OutcomeActive, + SubmitLeaseUntil: &inFlight, } service := newTestService(store) _, err := service.Decline(context.Background(), Viewer{UserID: 1, IsAdmin: true}, "req-approved", "changed mind") if !errors.Is(err, ErrInvalidState) { - t.Fatalf("err = %v, want ErrInvalidState (approved is owned by the reconciler)", err) + t.Fatalf("err = %v, want ErrInvalidState (a submission is in flight)", err) + } +} + +// An approved request nothing was sent for (no router, or backing off after a +// failed attempt) can still be declined by an admin or withdrawn by its owner; +// otherwise it could never be closed. +func TestWithdrawApprovedRequestNothingWasSentFor(t *testing.T) { + for _, tc := range []struct { + name string + withdraw func(*Service) (*Request, error) + want Outcome + }{ + {"decline", func(s *Service) (*Request, error) { + return s.Decline(context.Background(), Viewer{UserID: 1, IsAdmin: true}, "req-x", "not this month") + }, OutcomeDeclined}, + {"cancel", func(s *Service) (*Request, error) { + return s.Cancel(context.Background(), Viewer{UserID: 7, ProfileID: "profile-1"}, "req-x", "") + }, OutcomeCancelled}, + } { + t.Run(tc.name, func(t *testing.T) { + store := newFakeStore() + backoff := time.Now().Add(time.Hour) + store.requests["req-x"] = &Request{ + ID: "req-x", MediaType: MediaTypeMovie, TMDBID: 550, Status: StatusApproved, Outcome: OutcomeActive, + RequestedByUserID: 7, NextSubmitAt: &backoff, LastError: "radarr unreachable", + } + got, err := tc.withdraw(newTestService(store)) + if err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + if got.Outcome != tc.want { + t.Fatalf("outcome = %q, want %q", got.Outcome, tc.want) + } + }) + } +} + +func TestDeclineRejectsApprovedRequestWithTarget(t *testing.T) { + store := newFakeStore() + store.requests["req-x"] = &Request{ID: "req-x", MediaType: MediaTypeMovie, TMDBID: 550, Status: StatusApproved, Outcome: OutcomeActive} + store.targets = map[string][]Target{"req-x": {{ID: 1, RequestID: "req-x", Quality: Quality1080p, Status: StatusQueued}}} + + _, err := newTestService(store).Decline(context.Background(), Viewer{UserID: 1, IsAdmin: true}, "req-x", "") + if !errors.Is(err, ErrInvalidState) { + t.Fatalf("err = %v, want ErrInvalidState (a target exists)", err) } } @@ -1546,6 +1593,7 @@ type fakeStore struct { created []CreateRequestRecord integrations []Integration candidates []*Request + waiting []*Request mine []*Request statusUpdates []Status requests map[string]*Request @@ -1694,6 +1742,12 @@ func (f *fakeStore) ListReconciliationCandidates(context.Context, int) ([]*Reque return f.candidates, nil } +func (f *fakeStore) ListLibraryWaitCandidates(context.Context, int) ([]*Request, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.waiting, nil +} + func (f *fakeStore) ListFulfilledUnnotified(context.Context, int) ([]*Request, error) { f.mu.Lock() defer f.mu.Unlock() @@ -1731,19 +1785,28 @@ func (f *fakeStore) ListAdmin(context.Context, ListFilter) ([]*Request, error) { return nil, nil } -// guardAccepts mirrors the repository's guarded UPDATE. -func guardAccepts(g StateGuard, req *Request) bool { - return (len(g.Statuses) == 0 || slices.Contains(g.Statuses, req.Status)) && - (len(g.Outcomes) == 0 || slices.Contains(g.Outcomes, req.Outcome)) +// guardAccepts mirrors the repository's guarded UPDATE. Callers hold f.mu. +func (f *fakeStore) guardAccepts(g StateGuard, req *Request) bool { + statusOK := len(g.Statuses) == 0 || slices.Contains(g.Statuses, req.Status) + outcomeOK := len(g.Outcomes) == 0 || slices.Contains(g.Outcomes, req.Outcome) + if !statusOK || !outcomeOK { + return false + } + if g.UnsentOnly && req.Status == StatusApproved { + leased := req.SubmitLeaseUntil != nil && req.SubmitLeaseUntil.After(time.Now()) + return !leased && len(f.targets[req.ID]) == 0 + } + return true } -// lookupLocked finds a request by id, falling back to the reconcile candidates -// so tests that only seed candidates still resolve. Callers hold f.mu. +// lookupLocked finds a request by id, falling back to the reconcile and +// library-wait candidates so tests that only seed those still resolve. +// Callers hold f.mu. func (f *fakeStore) lookupLocked(id string) *Request { if req := f.requests[id]; req != nil { return req } - for _, c := range f.candidates { + for _, c := range append(append([]*Request(nil), f.candidates...), f.waiting...) { if c != nil && c.ID == id { copy := *c f.requests[id] = © @@ -1760,7 +1823,7 @@ func (f *fakeStore) SetStatus(_ context.Context, id string, from StateGuard, sta if req == nil { req = &Request{ID: id, Outcome: OutcomeActive} f.requests[id] = req - } else if !guardAccepts(from, req) { + } else if !f.guardAccepts(from, req) { return nil, ErrInvalidState } f.statusUpdates = append(f.statusUpdates, status) @@ -1781,7 +1844,7 @@ func (f *fakeStore) SetOutcome(_ context.Context, id string, from StateGuard, ou if req == nil { req = &Request{ID: id} f.requests[id] = req - } else if !guardAccepts(from, req) { + } else if !f.guardAccepts(from, req) { return nil, ErrInvalidState } req.Outcome = outcome @@ -1853,13 +1916,22 @@ func (f *fakeStore) MarkAvailable(_ context.Context, id string, _ Viewer) (*Requ if req == nil { return nil, ErrNotFound } - inFlight := req.Status == StatusApproved || req.Status == StatusQueued || req.Status == StatusDownloading + open := req.Status == StatusPending || req.Status == StatusApproved || req.Status == StatusQueued || req.Status == StatusDownloading claimed := req.Status == StatusApproved && req.SubmitLeaseUntil != nil && req.SubmitLeaseUntil.After(time.Now()) - if req.Outcome != OutcomeActive || !inFlight || claimed { + partlyDelivered := false + for _, t := range f.targets[id] { + if t.Status == StatusCompleted { + partlyDelivered = true + } + } + failedElsewhere := req.Outcome == OutcomeFailed && !partlyDelivered + if !failedElsewhere && (req.Outcome != OutcomeActive || !open || claimed) { return nil, ErrInvalidState } f.statusUpdates = append(f.statusUpdates, StatusCompleted) req.Status = StatusCompleted + req.Outcome = OutcomeActive + req.LastError = "" copy := *req return ©, nil } @@ -2660,7 +2732,7 @@ func TestSubmitApprovedUsesConfiguredOptional4KDefault(t *testing.T) { } } -func TestSubmitApprovedNoRouterFails(t *testing.T) { +func TestSubmitApprovedNoRouterWaitsForLibrary(t *testing.T) { store := newFakeStore() store.integrations = []Integration{routerInst("router-1")} svc := newTestService(store) // no router provider set @@ -2671,12 +2743,12 @@ func TestSubmitApprovedNoRouterFails(t *testing.T) { if err != nil { t.Fatalf("submit: %v", err) } - if got.Outcome != OutcomeFailed { - t.Fatalf("outcome = %q, want failed (no router configured)", got.Outcome) + if got.Status != StatusApproved || got.Outcome != OutcomeActive || got.LastError != "" { + t.Fatalf("request = %+v, want approved and waiting (no router configured)", got) } } -func TestSubmitApprovedNoConnectionsFails(t *testing.T) { +func TestSubmitApprovedNoConnectionsWaitsForLibrary(t *testing.T) { store := newFakeStore() // no integrations svc := newTestService(store) svc.SetRouterProvider(&fakeRouterProvider{}) @@ -2687,8 +2759,8 @@ func TestSubmitApprovedNoConnectionsFails(t *testing.T) { if err != nil { t.Fatalf("submit: %v", err) } - if got.Outcome != OutcomeFailed { - t.Fatalf("outcome = %q, want failed (no enabled router connections)", got.Outcome) + if got.Status != StatusApproved || got.Outcome != OutcomeActive || got.LastError != "" { + t.Fatalf("request = %+v, want approved and waiting (no enabled router connections)", got) } } @@ -2887,8 +2959,9 @@ func TestSubmitApprovedSkipsMismatchedMediaType(t *testing.T) { if err != nil { t.Fatalf("submit: %v", err) } - if got.Outcome != OutcomeFailed || got.LastError != "no fulfillment backend configured" { - t.Fatalf("request = %+v, want failed with no-backend message", got) + // No connection serves movies, so the request waits for the library. + if got.Status != StatusApproved || got.Outcome != OutcomeActive || got.LastError != "" { + t.Fatalf("request = %+v, want approved and waiting for the library", got) } if router.fulfillCalls != 0 { t.Fatalf("fulfill calls = %d, want 0 (series connection filtered out for a movie)", router.fulfillCalls) @@ -2941,8 +3014,8 @@ func TestSubmitApprovedSkipsConnectionWithEmptyKey(t *testing.T) { if err != nil { t.Fatalf("submit: %v", err) } - if got.Outcome != OutcomeFailed { - t.Fatalf("outcome = %q, want failed (no usable connection)", got.Outcome) + if got.Outcome != OutcomeActive || got.LastError != msgRouterNoKey { + t.Fatalf("request = %+v, want the missing-key reason recorded and a retry scheduled", got) } if router.fulfillCalls != 0 { t.Fatalf("fulfill calls = %d, want 0 (empty-key connection skipped)", router.fulfillCalls) @@ -3064,7 +3137,7 @@ func TestSubmitApprovedSkipsTargetForHealthyQuality(t *testing.T) { } } -func TestSubmitApprovedUnboundInstallationFailsWithGuidance(t *testing.T) { +func TestSubmitApprovedUnboundInstallationDefersWithGuidance(t *testing.T) { store := newFakeStore() // A router connection that exists but is not bound to a plugin installation // (the migration leaves installation_id NULL for pre-existing rows). @@ -3080,9 +3153,9 @@ func TestSubmitApprovedUnboundInstallationFailsWithGuidance(t *testing.T) { if err != nil { t.Fatalf("submit: %v", err) } - if got.Outcome != OutcomeFailed || - got.LastError != "request backend connection is not bound to a plugin installation; re-save it in admin" { - t.Fatalf("request = %+v, want failed with unbound-installation guidance", got) + if got.Status != StatusApproved || got.Outcome != OutcomeActive || + got.LastError != msgRouterUnbound { + t.Fatalf("request = %+v, want approved with unbound-installation guidance and a retry scheduled", got) } } diff --git a/internal/requests/store.go b/internal/requests/store.go index f022d99291..eb8ad76c4f 100644 --- a/internal/requests/store.go +++ b/internal/requests/store.go @@ -14,7 +14,12 @@ type Store interface { ListActiveByTMDB(ctx context.Context, mediaType MediaType, tmdbIDs []int) (map[int]*Request, error) CreateRequest(ctx context.Context, input CreateRequestRecord) (*Request, error) GetRequest(ctx context.Context, id string) (*Request, error) + // ListReconciliationCandidates returns in-flight requests (approved, + // queued, downloading); ListLibraryWaitCandidates returns the pending and + // recently failed ones only the library can complete. Both rotate by + // last_reconciled_at. ListReconciliationCandidates(ctx context.Context, limit int) ([]*Request, error) + ListLibraryWaitCandidates(ctx context.Context, limit int) ([]*Request, error) // ListFulfilledUnnotified returns completed requests whose fulfillment // notification has not fired yet (presence-gated notify pass). ListFulfilledUnnotified(ctx context.Context, limit int) ([]*Request, error) @@ -42,10 +47,12 @@ type Store interface { // MarkReconciled stamps last_reconciled_at so the reconcile pass rotates // through every candidate. MarkReconciled(ctx context.Context, id string) error - // MarkAvailable completes an in-flight request whose media is already in - // the library. It refuses (ErrInvalidState) a request another actor has - // moved on, and an approved request whose submission claim is live, so it - // cannot race a router call that is creating targets. + // MarkAvailable completes a request whose media is already in the library: + // a pending one (no approval needed once the title exists), an in-flight + // one, or a failed one none of whose targets completed. It refuses + // (ErrInvalidState) a request another actor has closed, and an approved + // request whose submission claim is live, so it cannot race a router call + // that is creating targets. MarkAvailable(ctx context.Context, id string, actor Viewer) (*Request, error) // RecomputeStatus re-derives an approved request's status and outcome from // its targets, for a submission that found nothing left to send. diff --git a/internal/requests/types.go b/internal/requests/types.go index c6268195d0..310b77cdaf 100644 --- a/internal/requests/types.go +++ b/internal/requests/types.go @@ -179,11 +179,24 @@ type Request struct { type StateGuard struct { Statuses []Status Outcomes []Outcome + // UnsentOnly admits an approved request only while nothing has gone + // downstream for it: it has no target and no submission in flight. + UnsentOnly bool } // guardPending matches a request that is still waiting for an admin. var guardPending = StateGuard{Statuses: []Status{StatusPending}, Outcomes: []Outcome{OutcomeActive}} +// guardWithdrawable matches a request nobody has sent anywhere yet: pending, +// or approved but waiting for the library (no router), or backing off after a +// failed attempt. Decline and cancel accept these; once a submission is in +// flight or a target exists, the request stays in the pipeline. +var guardWithdrawable = StateGuard{ + Statuses: []Status{StatusPending, StatusApproved}, + Outcomes: []Outcome{OutcomeActive}, + UnsentOnly: true, +} + type RequestEvent struct { ID int64 `json:"id"` RequestID string `json:"request_id"` diff --git a/web/src/pages/AdminRequests.tsx b/web/src/pages/AdminRequests.tsx index ddf57433d4..5324d4a97f 100644 --- a/web/src/pages/AdminRequests.tsx +++ b/web/src/pages/AdminRequests.tsx @@ -361,10 +361,15 @@ function RequestQueueRow({ onDecline: () => void; onRetry: () => void; }) { - // The server declines only requests still waiting for approval; an approved - // request may already be on its way to a downstream service. + // The server declines only requests nothing has been sent for: pending ones, + // and approved ones with no target yet (waiting for the library, or retrying + // a failed send). It refuses one whose submission is in flight right now. const canApprove = request.status === "pending" && request.outcome === "active"; - const canDecline = canApprove; + const canDecline = + canApprove || + (request.status === "approved" && + request.outcome === "active" && + (request.targets?.length ?? 0) === 0); const canRetry = request.outcome === "failed"; const requesterLabel = requesterUsername ?? `User ${request.requested_by_user_id}`; const requestDetailHref = `/requests/${request.media_type}/${request.tmdb_id}`; diff --git a/web/src/pages/AdminRequests.v2.test.tsx b/web/src/pages/AdminRequests.v2.test.tsx index 0ed83cd8e0..98c1026f0c 100644 --- a/web/src/pages/AdminRequests.v2.test.tsx +++ b/web/src/pages/AdminRequests.v2.test.tsx @@ -229,8 +229,8 @@ describe("request administration conflict handling", () => { expect(screen.queryByText("Connection name is rejected")).toBeNull(); expect(name.getAttribute("aria-invalid")).toBe("false"); }); - it("offers Decline only for requests still waiting for approval", async () => { - const request = (id: string, title: string, status: string) => ({ + it("offers Decline only for requests nothing has been sent for", async () => { + const request = (id: string, title: string, status: string, targets: unknown[] = []) => ({ id, provider: "tmdb", media_type: "movie", @@ -240,7 +240,7 @@ describe("request administration conflict handling", () => { outcome: "active", requested_by_user_id: "1", is_anime: false, - targets: [], + targets, created_at: "2026-09-01T00:00:00Z", updated_at: "2026-09-01T00:00:00Z", }); @@ -252,6 +252,17 @@ describe("request administration conflict handling", () => { items: [ request("r1", "Waiting Title", "pending"), request("r2", "Approved Title", "approved"), + request("r3", "Sent Title", "queued", [ + { + id: "1", + request_id: "r3", + quality: "1080p", + is_anime: false, + status: "queued", + created_at: "2026-09-01T00:00:00Z", + updated_at: "2026-09-01T00:00:00Z", + }, + ]), ], page: { has_more: false }, }); @@ -263,7 +274,8 @@ describe("request administration conflict handling", () => { return within(row).getByRole("button", { name: "Decline" }) as HTMLButtonElement; }; expect((await declineFor("Waiting Title")).disabled).toBe(false); - expect((await declineFor("Approved Title")).disabled).toBe(true); + expect((await declineFor("Approved Title")).disabled).toBe(false); + expect((await declineFor("Sent Title")).disabled).toBe(true); }); it("keeps user override edits and validator until explicit reload after a stale response", async () => { From 46a17dd2be6e654d77bbffe8666a7901e951bc89 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Sat, 26 Sep 2026 10:42:58 -0400 Subject: [PATCH 003/103] fix(web): give request pages the app shell and one status badge The Requests hub and its browse pages now take the shell's single gutter like Recommendations and Calendar, and a title's request page collapses the sidebar like a library item page does. One RequestStatusBadge, built on the shared Badge variants, replaces the four hard-coded amber colour sets on the hub, the Yours tab, poster cards and the title page. Request pages use one vocabulary for request state: Pending, Approved, Processing, Available, Declined, Cancelled, Failed. Users can cancel their own requests from the Yours tab and the title page until something has been sent for them, behind a confirmation. Approved and declined notifications link to the title. In the search dialog, request rows join arrow-key navigation, close the dialog when opened, and show the request's status instead of a dead "Request" pill. Co-Authored-By: Claude Opus 5.5 (1M context) --- web/src/api/v2/requests.ts | 7 + web/src/components/CancelRequestDialog.tsx | 31 +++ web/src/components/ConfirmDialog.tsx | 5 +- web/src/components/GlobalSearch.test.tsx | 142 ++++++++-- web/src/components/GlobalSearch.tsx | 104 +++++-- web/src/components/Layout.test.tsx | 23 ++ web/src/components/Layout.tsx | 8 +- web/src/components/RequestPosterCard.test.tsx | 93 ++++++- web/src/components/RequestPosterCard.tsx | 165 +++++------ .../components/RequestStatusBadge.test.tsx | 92 +++++++ web/src/components/RequestStatusBadge.tsx | 80 ++++++ .../components/RequestToAddSection.test.tsx | 105 ++++++- web/src/components/RequestToAddSection.tsx | 139 +++++++--- web/src/hooks/queries/useRequests.test.tsx | 69 ++++- web/src/hooks/queries/useRequests.ts | 22 +- web/src/lib/mediaRequests.test.ts | 42 +++ web/src/lib/mediaRequests.ts | 77 ++++++ web/src/pages/Notifications.test.tsx | 73 +++++ web/src/pages/Notifications.tsx | 24 +- web/src/pages/RequestDetail.test.tsx | 142 ++++++++++ web/src/pages/RequestDetail.tsx | 138 +++++----- web/src/pages/Requests.test.tsx | 137 +++++++++ web/src/pages/Requests.tsx | 259 ++++++++---------- 23 files changed, 1557 insertions(+), 420 deletions(-) create mode 100644 web/src/components/CancelRequestDialog.tsx create mode 100644 web/src/components/RequestStatusBadge.test.tsx create mode 100644 web/src/components/RequestStatusBadge.tsx create mode 100644 web/src/lib/mediaRequests.test.ts create mode 100644 web/src/pages/RequestDetail.test.tsx create mode 100644 web/src/pages/Requests.test.tsx diff --git a/web/src/api/v2/requests.ts b/web/src/api/v2/requests.ts index e00e476034..cb04cbcc1b 100644 --- a/web/src/api/v2/requests.ts +++ b/web/src/api/v2/requests.ts @@ -51,6 +51,13 @@ export function createMediaRequestV2(body: V2Body<"POST /api/v2/requests">): Pro return v2("POST /api/v2/requests", { body }).then(mediaRequestFromV2); } +// The server lets an owner cancel only while the request is still pending. +export function cancelMediaRequestV2(id: string, reason?: string): Promise { + return v2("POST /api/v2/requests/{id}/cancel", { path: { id }, body: { reason } }).then( + mediaRequestFromV2, + ); +} + // v2 pages by cursor with a page size of at most 50; callers that asked for a // larger window (the Requests page shows up to 100) walk the pages. export async function listMyMediaRequestsV2( diff --git a/web/src/components/CancelRequestDialog.tsx b/web/src/components/CancelRequestDialog.tsx new file mode 100644 index 0000000000..b97bbad825 --- /dev/null +++ b/web/src/components/CancelRequestDialog.tsx @@ -0,0 +1,31 @@ +import { ConfirmDialog } from "@/components/ConfirmDialog"; + +/** Confirms withdrawing the viewer's own request while it is still pending. */ +export function CancelRequestDialog({ + title, + open, + onOpenChange, + onConfirm, + isPending, +}: { + /** The requested title, named in the confirmation. */ + title: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + isPending?: boolean; +}) { + return ( + + ); +} diff --git a/web/src/components/ConfirmDialog.tsx b/web/src/components/ConfirmDialog.tsx index 27f8336373..52fad33890 100644 --- a/web/src/components/ConfirmDialog.tsx +++ b/web/src/components/ConfirmDialog.tsx @@ -15,6 +15,8 @@ interface ConfirmDialogProps { title: string; description: string; confirmLabel?: string; + /** Label for the dismiss button; change it when "Cancel" would read as the action. */ + cancelLabel?: string; variant?: "default" | "destructive"; onConfirm: () => void; isPending?: boolean; @@ -26,6 +28,7 @@ export function ConfirmDialog({ title, description, confirmLabel = "Confirm", + cancelLabel = "Cancel", variant = "default", onConfirm, isPending, @@ -38,7 +41,7 @@ export function ConfirmDialog({ {description} - Cancel + {cancelLabel} ({ navigate: vi.fn(), useQuery: vi.fn(), useCanRequest: vi.fn(), useRequestSearch: vi.fn(), + // Most tests only check the props GlobalSearch passes; the wiring tests for + // request rows need the real section. + renderRealRequestSection: false, })); vi.mock("@tanstack/react-query", async () => { @@ -38,23 +41,20 @@ vi.mock("@/hooks/queries/useRequests", () => ({ useRequestSearch: (...args: unknown[]) => mocks.useRequestSearch(...args), })); -vi.mock("@/components/RequestToAddSection", () => ({ - RequestToAddSection: ({ - variant, - query, - libraryHadHits, - libraryResultsKnown, - }: { - variant: string; - query: string; - libraryHadHits: boolean; - libraryResultsKnown?: boolean; - }) => ( -
- {`variant="${variant}" query="${query}" libraryHadHits="${String(libraryHadHits)}" libraryResultsKnown="${String(libraryResultsKnown)}"`} -
- ), -})); +vi.mock("@/components/RequestToAddSection", async () => { + const actual = + await vi.importActual("./RequestToAddSection"); + return { + RequestToAddSection: (props: import("./RequestToAddSection").RequestToAddSectionProps) => + mocks.renderRealRequestSection ? ( + + ) : ( +
+ {`variant="${props.variant}" query="${props.query}" libraryHadHits="${String(props.libraryHadHits)}" libraryResultsKnown="${String(props.libraryResultsKnown)}"`} +
+ ), + }; +}); vi.mock("@/components/ui/dialog", () => ({ Dialog: ({ children, open }: { children: ReactNode; open: boolean }) => @@ -396,6 +396,112 @@ describe("GlobalSearch", () => { }); }); +describe("GlobalSearch request rows", () => { + const discoveryOn = { discoveryEnabled: true, isResolving: false, submitDisabledReason: null }; + + beforeEach(() => { + mocks.navigate.mockReset(); + mocks.useQuery.mockReset(); + mocks.useCanRequest.mockReset(); + mocks.useRequestSearch.mockReset(); + mocks.renderRealRequestSection = true; + mocks.useCanRequest.mockReturnValue(discoveryOn); + mocks.useQuery.mockReturnValue({ + data: { total: 1, has_more: false, items: [browseFixture] }, + isFetching: false, + isError: false, + }); + mocks.useRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 2, + results: [ + { + media_type: "series", + tmdb_id: 7, + title: "Requested Show", + availability: "missing", + request: { requestable: false, status: "queued" }, + }, + { + media_type: "movie", + tmdb_id: 8, + title: "Already Here", + availability: "available", + request: { requestable: false }, + }, + ], + }, + isLoading: false, + isError: false, + }); + }); + afterEach(() => { + mocks.renderRealRequestSection = false; + }); + + function renderOpenSearch() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + + , + ); + const input = screen.getByRole("combobox", { name: "Search" }); + input.focus(); + return input; + } + + it("mentions requests in the placeholder when discovery is on", () => { + const input = renderOpenSearch(); + + expect(input).toHaveAttribute("placeholder", "Search library or find titles to request..."); + }); + + it("closes the dialog and opens the request page when a request row is clicked", async () => { + renderOpenSearch(); + + await userEvent.click(screen.getByRole("option", { name: /Requested Show/ })); + + expect(mocks.navigate).toHaveBeenCalledExactlyOnceWith("/requests/series/7"); + expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); + }); + + it("walks from library rows into request rows with the arrow keys and opens one with Enter", () => { + const input = renderOpenSearch(); + + expect(input).toHaveAttribute( + "aria-controls", + "global-search-library-results global-search-request-results", + ); + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: "ArrowDown" }); + + expect(input).toHaveAttribute("aria-activedescendant", "search-result-1"); + expect(screen.getByRole("option", { selected: true })).toHaveTextContent("Requested Show"); + + // Past the last request row wraps back to the first library row. + fireEvent.keyDown(input, { key: "ArrowDown" }); + expect(input).toHaveAttribute("aria-activedescendant", "search-result-0"); + + fireEvent.keyDown(input, { key: "ArrowUp" }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(mocks.navigate).toHaveBeenCalledExactlyOnceWith("/requests/series/7"); + expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); + }); + + it("shows the shared status badge on a requested row", () => { + renderOpenSearch(); + + const row = screen.getByRole("option", { name: /Requested Show/ }); + expect(row.querySelector('[data-request-state="processing"]')).toHaveTextContent("Processing"); + }); +}); + describe("GlobalSearch + RequestToAddSection wiring", () => { beforeEach(() => { mocks.navigate.mockReset(); diff --git a/web/src/components/GlobalSearch.tsx b/web/src/components/GlobalSearch.tsx index 5691bbd7d3..801b84ac0b 100644 --- a/web/src/components/GlobalSearch.tsx +++ b/web/src/components/GlobalSearch.tsx @@ -8,12 +8,17 @@ import { useDebounce } from "@/hooks/useDebounce"; import { buildQueryCatalogHref } from "@/pages/catalogSearchParams"; import { prefetchCatalog } from "@/pages/catalogRoute"; import { useSidebarItemNavigation } from "@/components/sidebarItemNavigationContext"; -import { createEmptyQueryDefinition, type BrowseItem } from "@/api/types"; +import { createEmptyQueryDefinition, type BrowseItem, type RequestMediaResult } from "@/api/types"; import { createCatalogSearchState, fetchCatalogPage } from "@/hooks/queries/catalog"; import { useSearchMediaScope } from "@/hooks/useSearchMediaScope"; import { useRequestSearch } from "@/hooks/queries/useRequests"; import { useCanRequest } from "@/hooks/useCanRequest"; import { catalogKeys } from "@/hooks/queries/keys"; +import { + REQUEST_DIALOG_SUGGESTION_LIMIT, + requestDetailHref, + requestSuggestions, +} from "@/lib/mediaRequests"; import { decodeThumbhash } from "@/lib/thumbhash"; import { cn } from "@/lib/utils"; import { Search } from "lucide-react"; @@ -24,6 +29,14 @@ const PREVIEW_LIMIT = 8; const DEBOUNCE_MS = 200; const TMDB_DEBOUNCE_MS = 400; const INTERACTIVE_SEARCH_GC_TIME_MS = 30_000; +const LIBRARY_LISTBOX_ID = "global-search-library-results"; +const REQUEST_LISTBOX_ID = "global-search-request-results"; + +// Library rows and request suggestions share one option sequence, so the +// arrow keys walk from the last library row into the suggestions. +function searchResultOptionId(index: number): string { + return `search-result-${index}`; +} function typeLabel(type: BrowseItem["type"]): string { switch (type) { @@ -79,7 +92,7 @@ function GlobalSearchResultRow({ return (
0 ? String(item.year) : null, typeLabel(item.type)] @@ -161,13 +174,14 @@ export function GlobalSearch({ gcTime: INTERACTIVE_SEARCH_GC_TIME_MS, retry: false, }); - const tmdbMissingCount = - tmdbQuery.data?.results?.filter((result) => result.availability !== "available").length ?? 0; - // Cap at DIALOG_LIMIT (4) — RequestToAddSection slices results to that many rows. - const tmdbVisibleCount = Math.min(tmdbMissingCount, 4); - const tmdbStillLoading = - canRequest.discoveryEnabled && tmdbDebouncedQuery.length > 1 && tmdbQuery.isLoading; - const tmdbWillRender = canRequest.discoveryEnabled && tmdbMissingCount > 0; + const showRequestSection = canRequest.discoveryEnabled && tmdbDebouncedQuery.length > 1; + // The same rows RequestToAddSection renders: it reads this query from the + // shared cache and applies the same selection. + const requestRows = showRequestSection + ? requestSuggestions(tmdbQuery.data?.results, REQUEST_DIALOG_SUGGESTION_LIMIT) + : []; + const tmdbStillLoading = showRequestSection && tmdbQuery.isLoading; + const tmdbWillRender = requestRows.length > 0; // Hide empty state while the TMDB debounce trails the library debounce; otherwise // the user sees "No matches" flash between t=200ms and t=400ms after typing. const tmdbDebounceCatchingUp = @@ -243,6 +257,15 @@ export function GlobalSearch({ [beginSidebarItemNavigation, navigate], ); + const handlePickRequest = useCallback( + (item: RequestMediaResult) => { + navigate(requestDetailHref(item.media_type, item.tmdb_id)); + setOpen(false); + setQuery(""); + }, + [navigate], + ); + // Reset selectedIndex when query changes useEffect(() => { setSelectedIndex(-1); @@ -252,7 +275,7 @@ export function GlobalSearch({ // the input; aria-activedescendant carries the selection. useEffect(() => { if (selectedIndex >= 0) { - document.getElementById(`search-result-${selectedIndex}`)?.scrollIntoView?.({ + document.getElementById(searchResultOptionId(selectedIndex))?.scrollIntoView?.({ block: "nearest", }); } @@ -272,17 +295,31 @@ export function GlobalSearch({ !canRequest.isResolving && !tmdbDebounceCatchingUp; const showError = previewQuery.isError; + const optionCount = items.length + requestRows.length; const moveResultFocus = useCallback( (nextIndex: number) => { - if (items.length === 0) { + if (optionCount === 0) { setSelectedIndex(-1); searchInputRef.current?.focus(); return; } - setSelectedIndex(((nextIndex % items.length) + items.length) % items.length); + setSelectedIndex(((nextIndex % optionCount) + optionCount) % optionCount); }, - [items.length], + [optionCount], ); + const pickSelectedOption = () => { + const item = items[selectedIndex]; + if (item) { + handlePickItem(item.content_id); + return true; + } + const suggestion = requestRows[selectedIndex - items.length]; + if (suggestion) { + handlePickRequest(suggestion); + return true; + } + return false; + }; return ( setQuery(e.target.value)} - placeholder="Search library..." + placeholder={ + canRequest.discoveryEnabled + ? "Search library or find titles to request..." + : "Search library..." + } className="placeholder:text-muted-foreground flex h-12 w-full bg-transparent text-sm outline-none" autoFocus // Submitting opens the Catalog page, so its chunk starts loading @@ -316,9 +357,13 @@ export function GlobalSearch({ role="combobox" aria-expanded={showResultsPanel} aria-autocomplete="list" - aria-controls="global-search-library-results" + aria-controls={ + requestRows.length > 0 + ? `${LIBRARY_LISTBOX_ID} ${REQUEST_LISTBOX_ID}` + : LIBRARY_LISTBOX_ID + } aria-activedescendant={ - selectedIndex >= 0 ? `search-result-${selectedIndex}` : undefined + selectedIndex >= 0 ? searchResultOptionId(selectedIndex) : undefined } onKeyDown={(e) => { if (e.key === "ArrowDown") { @@ -326,10 +371,10 @@ export function GlobalSearch({ moveResultFocus(selectedIndex + 1); } else if (e.key === "ArrowUp") { e.preventDefault(); - moveResultFocus(selectedIndex < 0 ? items.length - 1 : selectedIndex - 1); - } else if (e.key === "Enter" && selectedIndex >= 0 && items[selectedIndex]) { - e.preventDefault(); - handlePickItem(items[selectedIndex].content_id); + moveResultFocus(selectedIndex < 0 ? optionCount - 1 : selectedIndex - 1); + } else if (e.key === "Enter" && selectedIndex >= 0) { + // With nothing highlighted, Enter submits the form instead. + if (pickSelectedOption()) e.preventDefault(); } else if (e.key === "Escape") { setOpen(false); } @@ -343,11 +388,7 @@ export function GlobalSearch({ {showResultsPanel && (
-
+
{showLoading && (
Searching... @@ -374,18 +415,25 @@ export function GlobalSearch({ /> ))}
- {tmdbDebouncedQuery.length > 1 && canRequest.discoveryEnabled && ( + {showRequestSection && ( 0} libraryResultsKnown={!previewQuery.isFetching && !previewQuery.isError} + combobox={{ + listboxId: REQUEST_LISTBOX_ID, + optionId: (index) => searchResultOptionId(items.length + index), + selectedIndex: + selectedIndex >= items.length ? selectedIndex - items.length : -1, + onPick: handlePickRequest, + }} /> )}
- {tmdbVisibleCount > 0 - ? `${items.length} library results, ${tmdbVisibleCount} request suggestions` + {requestRows.length > 0 + ? `${items.length} library results, ${requestRows.length} request suggestions` : `${items.length} results found`}
diff --git a/web/src/components/Layout.test.tsx b/web/src/components/Layout.test.tsx index 04923a8cfc..f2882d9aad 100644 --- a/web/src/components/Layout.test.tsx +++ b/web/src/components/Layout.test.tsx @@ -268,6 +268,29 @@ describe("Layout sidebar collapse", () => { }); }); +describe("Layout request routes", () => { + // The padded shell wraps the page in one gutter div; unpadded pages render + // straight into
. + const isShellPadded = () => + screen.getByRole("main").firstElementChild?.classList.contains("lg:px-10") ?? false; + + it.each([ + ["/requests", false, false], + ["/requests/browse/studio/a24", false, false], + ["/requests/browse/genre/drama", false, false], + ["/requests/movie/603", false, true], + ["/requests/series/1399", false, true], + ["/collections", true, false], + ])("renders %s with shell padding %s and a collapsed sidebar %s", (path, padded, collapsed) => { + setRoute(path, "requests"); + renderLayout(); + + expect(isShellPadded()).toBe(padded); + expect(screen.getByTestId("sidebar-surface").hasAttribute("data-collapsed")).toBe(collapsed); + expect(screen.getByRole("main")).toHaveClass(collapsed ? "lg:ml-16" : "lg:ml-[260px]"); + }); +}); + describe("Layout item navigation", () => { it("declines interception on an item route, for a non-item href, and below lg", () => { const view = renderLayout(); diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index f967a1d987..dbca174553 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -116,6 +116,11 @@ export default function Layout({ children }: LayoutProps) { })(); const isRecommendationsRoute = location.pathname === "/recommendations"; const isCalendarRoute = location.pathname === "/calendar"; + // The Requests hub and its studio/network/genre browse pages lay out their + // own gutter so text rows line up with MediaCarousel's edge padding. + const isRequestsRoute = + /^\/requests\/?$/.test(location.pathname) || location.pathname.startsWith("/requests/browse/"); + // Only /requests/movie/… and /requests/series/… are title details. const isRequestDetailRoute = /^\/requests\/(movie|series)\//.test(location.pathname); // A watch-party room owns its own full-height layout; the hub does not. const isWatchPartyRoomRoute = /^\/rooms\/(?!join$)[^/]+$/.test(location.pathname); @@ -124,6 +129,7 @@ export default function Layout({ children }: LayoutProps) { isWatchPartyRoomRoute || isLibraryRoute || isItemRoute || + isRequestsRoute || isRequestDetailRoute || isSearchLandingRoute || isRecommendationsRoute || @@ -132,7 +138,7 @@ export default function Layout({ children }: LayoutProps) { // Cold item routes commit a lightweight shell while the sidebar collapses. // A detail already cached before navigation skips that gate and renders on // the destination's first frame. - const isDetailImmersion = isItemRoute || isPersonRoute; + const isDetailImmersion = isItemRoute || isPersonRoute || isRequestDetailRoute; const targetDetailImmersion = isDetailImmersion; const visualDetailImmersion = useImmediateSidebarCollapse(targetDetailImmersion); const { diff --git a/web/src/components/RequestPosterCard.test.tsx b/web/src/components/RequestPosterCard.test.tsx index 41cc63b5d8..953e72d1de 100644 --- a/web/src/components/RequestPosterCard.test.tsx +++ b/web/src/components/RequestPosterCard.test.tsx @@ -1,9 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router"; import RequestPosterCard from "./RequestPosterCard"; -import type { RequestMediaResult } from "@/api/types"; +import type { MediaRequest, RequestMediaResult } from "@/api/types"; const requestable: RequestMediaResult = { media_type: "movie", @@ -78,4 +78,91 @@ describe("RequestPosterCard (discover variant)", () => { expect(movieMarkup).toContain(">Movie<"); expect(seriesMarkup).toContain(">Series<"); }); + + it("marks a title already in the library as Available", () => { + render( + + + , + ); + + expect(screen.getByText("Available").closest("[data-request-state]")).toHaveAttribute( + "data-request-state", + "available", + ); + }); + + it("names the reason when a title without a request cannot be requested", () => { + render( + + + , + ); + + expect(screen.getByText("Request limit reached")).toBeInTheDocument(); + }); +}); + +describe("RequestPosterCard (mine variant)", () => { + const request: MediaRequest = { + id: "req-1", + provider: "silo", + media_type: "movie", + tmdb_id: 603, + title: "The Matrix", + status: "queued", + outcome: "active", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }; + + it.each<[Partial, string]>([ + [{ status: "pending" }, "Pending"], + [{ status: "queued" }, "Processing"], + [{ status: "downloading" }, "Processing"], + [{ status: "completed" }, "Available"], + [{ status: "pending", outcome: "cancelled" }, "Cancelled"], + [{ status: "approved", outcome: "failed" }, "Failed"], + ])("labels %o as %s", (overrides, label) => { + render( + + + , + ); + + expect(screen.getByText(label)).toBeInTheDocument(); + }); + + it("shows Cancel request only when the page passes onCancel", () => { + const onCancel = vi.fn(); + const { rerender } = render( + + + , + ); + expect(screen.queryByRole("button", { name: /Cancel request/ })).not.toBeInTheDocument(); + + rerender( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Cancel request for The Matrix" })); + + expect(onCancel).toHaveBeenCalledOnce(); + }); }); diff --git a/web/src/components/RequestPosterCard.tsx b/web/src/components/RequestPosterCard.tsx index 3a41c25b61..a757d01f9c 100644 --- a/web/src/components/RequestPosterCard.tsx +++ b/web/src/components/RequestPosterCard.tsx @@ -1,8 +1,15 @@ import { Link } from "react-router"; -import { Check, Film, Library, Loader2, Plus, Tv } from "lucide-react"; +import { Film, Library, Loader2, Plus, Tv } from "lucide-react"; import type { MediaRequest, RequestMediaResult } from "@/api/types"; import { cn } from "@/lib/utils"; -import { formatRequestReason, formatRequestStatus, tmdbImageURL } from "@/lib/mediaRequests"; +import { + requestDetailHref, + requestDisplayState, + tmdbImageURL, + type RequestDisplayState, +} from "@/lib/mediaRequests"; +import { RequestReasonBadge, RequestStatusBadge } from "@/components/RequestStatusBadge"; +import { Button } from "@/components/ui/button"; import ViewTransitionLink from "@/components/ViewTransitionLink"; const POSTER_WIDTH = "w-[148px] sm:w-[164px] lg:w-[184px]"; @@ -22,13 +29,24 @@ type MineProps = { variant: "mine"; request: MediaRequest; fluid?: boolean; + /** Shows a Cancel request action. Pass it only for the viewer's own cancellable request. */ + onCancel?: () => void; + /** Disables the Cancel request action while a cancellation is in flight. */ + isCancelling?: boolean; }; export type RequestPosterCardProps = DiscoverProps | MineProps; export default function RequestPosterCard(props: RequestPosterCardProps) { if (props.variant === "mine") { - return ; + return ( + + ); } return ( - {ribbon && ( - - )} + {state ? ( + + ) : !requestable ? ( + + ) : null} {requestable && onRequest && (
void; + isCancelling?: boolean; +}) { const poster = tmdbImageURL(request.poster_path); - const isCompleted = request.status === "completed"; - const isFailed = + const state = requestDisplayState(request.status, request.outcome); + const isClosed = request.outcome === "failed" || request.outcome === "declined" || request.outcome === "cancelled"; - const kind: RibbonKind = isFailed ? "blocked" : (request.status as RibbonKind); - const label = isFailed ? formatOutcome(request.outcome) : formatRequestStatus(request.status); - return (
- - - {isCompleted && ( -
- - - Ready to watch - -
- )} + {state ? ( + + ) : null}
{request.last_error ? (

{request.last_error} @@ -196,6 +207,20 @@ function MineCard({ request, fluid }: { request: MediaRequest; fluid?: boolean } ) : null} + {onCancel ? ( + + ) : null} + {request.library_content_id ? ( ) : null} @@ -352,52 +377,10 @@ function CardMeta({ ); } -type RibbonKind = "pending" | "approved" | "queued" | "downloading" | "completed" | "blocked"; - -const RIBBON_STYLES: Record = { - pending: - "bg-amber-950/75 text-amber-100 ring-amber-400/30 [&_.dot]:bg-amber-300 [&_.dot]:animate-pulse", - approved: "bg-emerald-950/75 text-emerald-100 ring-emerald-400/30 [&_.dot]:bg-emerald-300", - queued: "bg-sky-950/75 text-sky-100 ring-sky-400/30 [&_.dot]:bg-sky-300 [&_.dot]:animate-pulse", - downloading: - "bg-sky-950/80 text-sky-100 ring-sky-400/35 [&_.dot]:bg-sky-300 [&_.dot]:animate-pulse", - completed: "bg-emerald-950/80 text-emerald-100 ring-emerald-400/30 [&_.dot]:bg-emerald-300", - blocked: "bg-zinc-900/80 text-zinc-200 ring-white/10 [&_.dot]:bg-zinc-400", -}; - -function StatusRibbon({ - status, - label, - reserveLibrarySpace, -}: { - status: string; - label: string; - reserveLibrarySpace?: boolean; -}) { - const kind = (RIBBON_STYLES[status as RibbonKind] ? status : "blocked") as RibbonKind; - return ( - - - {label} - +/** Pins the status badge in the top-right corner, clear of the Library chip. */ +function posterBadgeClassName(reserveLibrarySpace: boolean): string { + return cn( + "absolute top-2 right-2", + reserveLibrarySpace ? "max-w-[calc(100%-5.75rem)]" : "max-w-[calc(100%-1rem)]", ); } - -function formatOutcome(outcome: MediaRequest["outcome"]): string { - switch (outcome) { - case "declined": - return "Declined"; - case "cancelled": - return "Cancelled"; - case "failed": - return "Failed"; - default: - return "Active"; - } -} diff --git a/web/src/components/RequestStatusBadge.test.tsx b/web/src/components/RequestStatusBadge.test.tsx new file mode 100644 index 0000000000..a4b51b7596 --- /dev/null +++ b/web/src/components/RequestStatusBadge.test.tsx @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import type { MediaRequestOutcome, MediaRequestStatus } from "@/api/types"; +import { requestDisplayState, type RequestDisplayState } from "@/lib/mediaRequests"; +import { RequestReasonBadge, RequestStatusBadge } from "./RequestStatusBadge"; + +describe("requestDisplayState", () => { + it.each<[MediaRequestStatus | undefined, MediaRequestOutcome | undefined, RequestDisplayState]>([ + ["pending", "active", "pending"], + ["approved", "active", "approved"], + ["queued", "active", "processing"], + ["downloading", "active", "processing"], + ["completed", "active", "available"], + ["completed", undefined, "available"], + // A closed outcome wins over the status the request closed at. + ["pending", "declined", "declined"], + ["pending", "cancelled", "cancelled"], + ["downloading", "failed", "failed"], + ])("maps status %s with outcome %s to %s", (status, outcome, state) => { + expect(requestDisplayState(status, outcome)).toBe(state); + }); + + it("has no state without a status or a closed outcome", () => { + expect(requestDisplayState(undefined, "active")).toBeUndefined(); + expect(requestDisplayState()).toBeUndefined(); + }); +}); + +describe("RequestStatusBadge", () => { + it.each<[RequestDisplayState, string, string]>([ + ["pending", "Pending", "outline"], + ["approved", "Approved", "secondary"], + ["processing", "Processing", "secondary"], + ["available", "Available", "default"], + ["declined", "Declined", "outline"], + ["cancelled", "Cancelled", "outline"], + ["failed", "Failed", "destructive"], + ])("labels %s as %s on the %s badge", (state, label, variant) => { + render(); + + const badge = screen.getByText(label).closest("[data-slot='badge']"); + expect(badge).toHaveAttribute("data-variant", variant); + expect(badge).toHaveAttribute("data-request-state", state); + }); + + it("uses theme tokens rather than fixed palette colors", () => { + const states: RequestDisplayState[] = [ + "pending", + "approved", + "processing", + "available", + "declined", + "cancelled", + "failed", + ]; + const { container } = render( + <> + {states.map((state) => ( + + ))} + , + ); + + expect(container.innerHTML).not.toMatch(/(amber|emerald|sky|zinc|red)-\d/); + }); + + it("gives an outline badge a backing only when it sits over artwork", () => { + const { rerender } = render(); + expect(screen.getByText("Pending").closest("[data-slot='badge']")).not.toHaveClass( + "bg-background/85", + ); + + rerender(); + expect(screen.getByText("Pending").closest("[data-slot='badge']")).toHaveClass( + "bg-background/85", + ); + }); + + it("leads the label with a count", () => { + render(); + + expect(screen.getByText("3").closest("[data-slot='badge']")).toHaveTextContent("3Processing"); + }); +}); + +describe("RequestReasonBadge", () => { + it("explains why a title cannot be requested", () => { + render(); + + expect(screen.getByText("Request limit reached")).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/RequestStatusBadge.tsx b/web/src/components/RequestStatusBadge.tsx new file mode 100644 index 0000000000..7850c611a3 --- /dev/null +++ b/web/src/components/RequestStatusBadge.tsx @@ -0,0 +1,80 @@ +import type { ComponentProps } from "react"; +import { Badge } from "@/components/ui/badge"; +import { + formatRequestDisplayState, + formatRequestReason, + type RequestDisplayState, +} from "@/lib/mediaRequests"; +import { cn } from "@/lib/utils"; + +type BadgeProps = ComponentProps; +type BadgeVariant = NonNullable; + +// Theme variants only, so every theme (light ones included) colors them. +// Available is the one primary badge and Failed the one destructive badge; +// closed requests recede behind open ones. +const STATE_STYLES: Record = { + pending: { variant: "outline" }, + approved: { variant: "secondary" }, + processing: { variant: "secondary" }, + available: { variant: "default" }, + declined: { variant: "outline", className: "text-muted-foreground" }, + cancelled: { variant: "outline", className: "text-muted-foreground" }, + failed: { variant: "destructive" }, +}; + +// An outline badge is transparent, so over artwork it needs its own backing. +function overlayClassName(variant: BadgeVariant): string { + return cn("shadow-sm backdrop-blur-md", variant === "outline" && "bg-background/85"); +} + +type SharedProps = Omit & { + /** Keeps the badge legible when it sits on top of poster artwork. */ + overlay?: boolean; +}; + +export type RequestStatusBadgeProps = SharedProps & { + state: RequestDisplayState; + /** Leads the label with a count, as in a per-status summary. */ + count?: number; +}; + +/** The single badge the request pages use for a request's state. */ +export function RequestStatusBadge({ + state, + count, + overlay = false, + className, + ...props +}: RequestStatusBadgeProps) { + const style = STATE_STYLES[state]; + return ( + + {count !== undefined ? {count} : null} + {formatRequestDisplayState(state)} + + ); +} + +/** Explains why a title without a request cannot be requested. */ +export function RequestReasonBadge({ + reason, + overlay = false, + className, + ...props +}: SharedProps & { reason?: string }) { + return ( + + {formatRequestReason(reason)} + + ); +} diff --git a/web/src/components/RequestToAddSection.test.tsx b/web/src/components/RequestToAddSection.test.tsx index fbf372aa19..f43193095f 100644 --- a/web/src/components/RequestToAddSection.test.tsx +++ b/web/src/components/RequestToAddSection.test.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; +import { fireEvent, render as rtlRender, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -148,7 +149,8 @@ describe("RequestToAddSection (dialog variant)", () => { const markup = render( , ); - expect(markup).toContain("Not in your library, but you can request"); + expect(markup).toContain("Not in your library"); + expect(markup).not.toContain("but you can request"); expect(markup).not.toContain("Request to Add"); }); @@ -280,10 +282,107 @@ describe("RequestToAddSection (dialog variant)", () => { const markup = render(); expect(markup).toContain("Already Pending Movie"); - expect(markup).toContain("Pending"); - expect(markup).toContain('title="Pending"'); + expect(markup).toContain('data-request-state="pending"'); + expect(markup).toContain(">Pending<"); expect(markup).not.toContain('title="Blocked"'); }); + + it("shows no action pill on a requestable row, since the row itself opens the title", () => { + mocks.useRequestSearch.mockReturnValue({ + data: { page: 1, total_pages: 1, total_results: 1, results: [missingResult()] }, + isLoading: false, + isError: false, + }); + + const markup = render(); + + expect(markup).toContain("Dune: Prophecy"); + expect(markup).not.toContain(">Request<"); + expect(markup).not.toContain("data-request-state"); + }); +}); + +describe("RequestToAddSection (dialog variant in a host combobox)", () => { + beforeEach(() => { + mocks.useCanRequest.mockReset(); + mocks.useRequestSearch.mockReset(); + mocks.useCanRequest.mockReturnValue({ + discoveryEnabled: true, + isResolving: false, + submitDisabledReason: null, + }); + mocks.useRequestSearch.mockReturnValue({ + data: { + page: 1, + total_pages: 1, + total_results: 2, + results: [ + missingResult({ tmdb_id: 1, title: "First" }), + missingResult({ tmdb_id: 2, title: "Second" }), + ], + }, + isLoading: false, + isError: false, + }); + }); + + function renderInHost(onPick = vi.fn(), selectedIndex = -1) { + rtlRender( + + `host-option-${index + 3}`, + selectedIndex, + onPick, + }} + /> + , + ); + return onPick; + } + + it("exposes the rows as options under the host's ids and highlight", () => { + renderInHost(vi.fn(), 1); + + expect(screen.getByRole("listbox", { name: "Request suggestions" })).toHaveAttribute( + "id", + "host-requests", + ); + const options = screen.getAllByRole("option"); + expect(options.map((option) => option.id)).toEqual(["host-option-3", "host-option-4"]); + expect(screen.getByRole("option", { selected: true })).toHaveTextContent("Second"); + }); + + it("hands a plain click to the host instead of following the link", () => { + const onPick = renderInHost(); + + fireEvent.click(screen.getByRole("option", { name: /First/ })); + + expect(onPick).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ tmdb_id: 1 })); + }); + + it("leaves modified clicks to the link so they can open a new tab", () => { + const onPick = renderInHost(); + // jsdom cannot open the link; drop the default action after React has seen the click. + let followedLink = false; + document.addEventListener( + "click", + (event) => { + followedLink = !event.defaultPrevented; + event.preventDefault(); + }, + { once: true }, + ); + + fireEvent.click(screen.getByRole("option", { name: /First/ }), { metaKey: true }); + + expect(onPick).not.toHaveBeenCalled(); + expect(followedLink).toBe(true); + }); }); describe("RequestToAddSection (grid variant)", () => { diff --git a/web/src/components/RequestToAddSection.tsx b/web/src/components/RequestToAddSection.tsx index ee04cb2b4d..bcf7cec83d 100644 --- a/web/src/components/RequestToAddSection.tsx +++ b/web/src/components/RequestToAddSection.tsx @@ -1,36 +1,49 @@ import { useState } from "react"; +import type { MouseEvent } from "react"; import { Link } from "react-router"; import { Film, Sparkles, Tv } from "lucide-react"; import { useCanRequest } from "@/hooks/useCanRequest"; import { useCreateMediaRequest, useRequestSearch } from "@/hooks/queries/useRequests"; import type { RequestMediaResult } from "@/api/types"; import { + formatMediaType, + formatRequestDisplayState, formatRequestReason, - formatRequestStatus, + REQUEST_DIALOG_SUGGESTION_LIMIT, + requestDetailHref, + requestDisplayState, requestInputFromMediaResult, + requestSuggestions, tmdbImageURL, } from "@/lib/mediaRequests"; import { cn } from "@/lib/utils"; +import { RequestStatusBadge } from "./RequestStatusBadge"; import RequestPosterCard from "./RequestPosterCard"; function cardKey(item: Pick): string { return `${item.media_type}-${item.tmdb_id}`; } -function nonRequestableLabel(item: RequestMediaResult): string { - if (item.request.status) { - return formatRequestStatus(item.request.status); - } - if (item.request.reason) { - return formatRequestReason(item.request.reason); - } - return "Blocked"; +function unavailableReasonLabel(item: RequestMediaResult): string { + return item.request.reason ? formatRequestReason(item.request.reason) : "Blocked"; } -const DIALOG_LIMIT = 4; const GRID_LIMIT = 20; const INTERACTIVE_SEARCH_GC_TIME_MS = 30_000; +/** + * Lets a host combobox (the ⌘K dialog) treat the dialog rows as options of its + * own: it owns the highlighted row and what picking a row does. + */ +export interface RequestSuggestionCombobox { + listboxId: string; + /** DOM id for the suggestion at this position, unique across the host's options. */ + optionId: (index: number) => string; + /** Position of the highlighted suggestion, or -1 when none is highlighted. */ + selectedIndex: number; + onPick: (item: RequestMediaResult) => void; +} + export type RequestToAddSectionProps = { variant: "dialog" | "grid"; query: string; @@ -41,6 +54,8 @@ export type RequestToAddSectionProps = { * Loading and failed searches must not be presented as confirmed absences. */ libraryResultsKnown?: boolean; + /** Dialog variant only. */ + combobox?: RequestSuggestionCombobox; }; export function RequestToAddSection({ @@ -48,6 +63,7 @@ export function RequestToAddSection({ query, libraryHadHits, libraryResultsKnown = true, + combobox, }: RequestToAddSectionProps) { const { discoveryEnabled } = useCanRequest(); const search = useRequestSearch("all", query, 1, { @@ -61,11 +77,9 @@ export function RequestToAddSection({ if (!discoveryEnabled) return null; if (search.isError && !search.data) return null; - const filtered = (search.data?.results ?? []).filter((item) => item.availability !== "available"); - if (filtered.length === 0) return null; - - const limit = variant === "dialog" ? DIALOG_LIMIT : GRID_LIMIT; - const visible = filtered.slice(0, limit); + const limit = variant === "dialog" ? REQUEST_DIALOG_SUGGESTION_LIMIT : GRID_LIMIT; + const visible = requestSuggestions(search.data?.results, limit); + if (visible.length === 0) return null; if (variant === "dialog") { return ( @@ -73,6 +87,7 @@ export function RequestToAddSection({ items={visible} libraryHadHits={libraryHadHits} libraryResultsKnown={libraryResultsKnown} + combobox={combobox} /> ); } @@ -106,13 +121,13 @@ function HeaderCopy({ } if (!libraryResultsKnown) { - return

Discovery matches:
; + return ( +
Discovery matches:
+ ); } return ( -
- Not in your library, but you can request: -
+
Not in your library
); } @@ -120,39 +135,86 @@ function DialogVariant({ items, libraryHadHits, libraryResultsKnown, + combobox, }: { items: RequestMediaResult[]; libraryHadHits: boolean; libraryResultsKnown: boolean; + combobox?: RequestSuggestionCombobox; }) { return ( -
+
-
    - {items.map((item) => ( -
  • - -
  • +
    + {items.map((item, index) => ( + ))} -
+
); } -function DialogRow({ item }: { item: RequestMediaResult }) { +function DialogRow({ + item, + optionId, + isSelected, + onPick, +}: { + item: RequestMediaResult; + optionId?: string; + isSelected: boolean; + onPick?: (item: RequestMediaResult) => void; +}) { const poster = tmdbImageURL(item.poster_path); const Icon = item.media_type === "series" ? Tv : Film; const requestable = item.request.requestable; - const unavailableLabel = requestable ? null : nonRequestableLabel(item); + const state = item.request.status ? requestDisplayState(item.request.status) : undefined; + const reasonLabel = !state && !requestable ? unavailableReasonLabel(item) : null; + // A plain click goes through the host so it can close the dialog; modified + // clicks keep the link's own new-tab and new-window behavior. + function handleClick(event: MouseEvent) { + if (!onPick || event.button !== 0) return; + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + event.preventDefault(); + onPick(item); + } + + // Keyboard focus stays in the host's search input, which points at this row + // with aria-activedescendant, so the row is not a tab stop. return (
- {requestable ? ( - - Request + {state ? ( + + ) : reasonLabel ? ( + + {reasonLabel} - ) : ( - - {unavailableLabel} - - )} + ) : null} ); } diff --git a/web/src/hooks/queries/useRequests.test.tsx b/web/src/hooks/queries/useRequests.test.tsx index 458915c830..7269f7480c 100644 --- a/web/src/hooks/queries/useRequests.test.tsx +++ b/web/src/hooks/queries/useRequests.test.tsx @@ -1,8 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import type { ReactNode } from "react"; +import { renderHook } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { requestKeys } from "./keys"; +import { adminKeys, requestKeys } from "./keys"; const mocks = vi.hoisted(() => ({ useQuery: vi.fn(), @@ -27,7 +28,12 @@ vi.mock("@/api/v2/request", () => ({ v2: (...args: unknown[]) => mocks.api(...args), })); -import { useRequestFeatureStatus, useRequestSearch } from "./useRequests"; +import { + useCancelMediaRequest, + useCreateMediaRequest, + useRequestFeatureStatus, + useRequestSearch, +} from "./useRequests"; function render(node: ReactNode) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -194,6 +200,65 @@ describe("viewer-scoped cache isolation", () => { }); }); +describe("useCancelMediaRequest", () => { + const wireRequest = { + id: "req-1", + provider: "silo", + media_type: "movie", + tmdb_id: 603, + title: "The Matrix", + status: "pending", + outcome: "cancelled", + targets: [], + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }; + + function wrapperFor(client: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; + } + + beforeEach(() => { + mocks.api.mockReset(); + mocks.api.mockResolvedValue(wireRequest); + }); + + it("posts to the viewer's cancel operation", async () => { + const client = new QueryClient(); + const { result } = renderHook(() => useCancelMediaRequest(), { wrapper: wrapperFor(client) }); + + const cancelled = await result.current.mutateAsync("req-1"); + + expect(mocks.api).toHaveBeenCalledExactlyOnceWith("POST /api/v2/requests/{id}/cancel", { + path: { id: "req-1" }, + body: {}, + }); + expect(cancelled).toMatchObject({ id: "req-1", outcome: "cancelled" }); + }); + + it("refreshes the same request surfaces a new request does", async () => { + const createClient = new QueryClient(); + const createInvalidations = vi.spyOn(createClient, "invalidateQueries"); + const create = renderHook(() => useCreateMediaRequest(), { + wrapper: wrapperFor(createClient), + }); + await create.result.current.mutateAsync({ media_type: "movie", tmdb_id: 603, title: "X" }); + + const cancelClient = new QueryClient(); + const cancelInvalidations = vi.spyOn(cancelClient, "invalidateQueries"); + const cancel = renderHook(() => useCancelMediaRequest(), { + wrapper: wrapperFor(cancelClient), + }); + await cancel.result.current.mutateAsync("req-1"); + + expect(cancelInvalidations.mock.calls).toEqual(createInvalidations.mock.calls); + expect(cancelInvalidations).toHaveBeenCalledWith({ queryKey: requestKeys.all }); + expect(cancelInvalidations).toHaveBeenCalledWith({ queryKey: adminKeys.requestsRoot() }); + }); +}); + function CallStatusHook() { useRequestFeatureStatus(); return null; diff --git a/web/src/hooks/queries/useRequests.ts b/web/src/hooks/queries/useRequests.ts index 828f1813d7..7cafae5d11 100644 --- a/web/src/hooks/queries/useRequests.ts +++ b/web/src/hooks/queries/useRequests.ts @@ -18,6 +18,7 @@ import { import { v2 } from "@/api/v2/request"; import { browseDiscoverV2, + cancelMediaRequestV2, createMediaRequestV2, getDiscoverSectionV2, getRequestMediaDetailV2, @@ -196,11 +197,30 @@ export function useCreateMediaRequest() { }); } -export function useMyMediaRequests(params: RequestListParams = {}) { +export function useCancelMediaRequest() { + const queryClient = useQueryClient(); + return useMutation({ + retry: false, + mutationFn: (id: string) => cancelMediaRequestV2(id), + onSuccess: () => { + toast.success("Request cancelled"); + invalidateRequestSurfaces(queryClient); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to cancel request"); + }, + }); +} + +export function useMyMediaRequests( + params: RequestListParams = {}, + options: { enabled?: boolean } = {}, +) { const key = listParamsKey(params); return useQuery({ queryKey: requestKeys.mine(key), queryFn: () => listMyMediaRequestsV2(params), + enabled: options.enabled ?? true, staleTime: REQUESTS_STALE_TIME, }); } diff --git a/web/src/lib/mediaRequests.test.ts b/web/src/lib/mediaRequests.test.ts new file mode 100644 index 0000000000..547b4fbf5e --- /dev/null +++ b/web/src/lib/mediaRequests.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import type { MediaRequest } from "@/api/types"; +import { canCancelOwnRequest, requestDetailHref } from "./mediaRequests"; + +type CancelFields = Pick; + +describe("canCancelOwnRequest", () => { + const pending: CancelFields = { status: "pending", outcome: "active" }; + + it("allows an active pending request", () => { + expect(canCancelOwnRequest(pending)).toBe(true); + }); + + it("allows an approved request nothing has been sent for", () => { + expect(canCancelOwnRequest({ ...pending, status: "approved", targets: [] })).toBe(true); + }); + + it.each<[string, CancelFields]>([ + [ + "approved and sent", + { + ...pending, + status: "approved", + targets: [{ quality: "1080p", status: "queued" }] as MediaRequest["targets"], + }, + ], + ["queued", { ...pending, status: "queued" }], + ["downloading", { ...pending, status: "downloading" }], + ["completed", { ...pending, status: "completed" }], + ["already cancelled", { ...pending, outcome: "cancelled" }], + ["declined", { ...pending, outcome: "declined" }], + ])("refuses a request that is %s, as the server does", (_label, request) => { + expect(canCancelOwnRequest(request)).toBe(false); + }); +}); + +describe("requestDetailHref", () => { + it("builds the request detail route", () => { + expect(requestDetailHref("movie", 603)).toBe("/requests/movie/603"); + expect(requestDetailHref("series", 1399)).toBe("/requests/series/1399"); + }); +}); diff --git a/web/src/lib/mediaRequests.ts b/web/src/lib/mediaRequests.ts index 58fe29c111..eddee5d44f 100644 --- a/web/src/lib/mediaRequests.ts +++ b/web/src/lib/mediaRequests.ts @@ -87,6 +87,83 @@ export function requestOutcomeBadgeVariant(outcome?: MediaRequestOutcome): Badge } } +/** + * The request states the user-facing request pages show. Admin views keep the + * raw status and outcome; these collapse them into one vocabulary: queued and + * downloading read as Processing, completed as Available, and a closed outcome + * wins over the status it closed at. + */ +export type RequestDisplayState = + | "pending" + | "approved" + | "processing" + | "available" + | "declined" + | "cancelled" + | "failed"; + +export function requestDisplayState( + status?: MediaRequestStatus, + outcome?: MediaRequestOutcome, +): RequestDisplayState | undefined { + if (outcome === "declined" || outcome === "cancelled" || outcome === "failed") return outcome; + switch (status) { + case "pending": + case "approved": + return status; + case "queued": + case "downloading": + return "processing"; + case "completed": + return "available"; + default: + return undefined; + } +} + +export function formatRequestDisplayState(state: RequestDisplayState): string { + switch (state) { + case "pending": + case "approved": + return formatRequestStatus(state); + case "processing": + return "Processing"; + case "available": + return "Available"; + default: + return formatRequestOutcome(state); + } +} + +/** + * Mirrors the server's rule for owner cancellation: a request can be withdrawn + * until something has been sent for it, so while it is pending, or approved + * with no target yet. (The server also refuses the moment a send is in flight.) + * Callers must already know the viewer owns the request. + */ +export function canCancelOwnRequest( + request: Pick, +): boolean { + if (request.outcome !== "active") return false; + if (request.status === "pending") return true; + return request.status === "approved" && (request.targets?.length ?? 0) === 0; +} + +export function requestDetailHref(mediaType: RequestMediaType, tmdbID: number): string { + return `/requests/${mediaType}/${tmdbID}`; +} + +/** Request suggestions the ⌘K dialog lists below the library results. */ +export const REQUEST_DIALOG_SUGGESTION_LIMIT = 4; + +/** Search results worth suggesting as requests: titles not already in the library. */ +export function requestSuggestions( + results: RequestMediaResult[] | undefined, + limit: number, +): RequestMediaResult[] { + return (results ?? []).filter((item) => item.availability !== "available").slice(0, limit); +} + export function formatRequestReason(reason?: string): string { switch (reason) { case "already_requested": diff --git a/web/src/pages/Notifications.test.tsx b/web/src/pages/Notifications.test.tsx index a38da4e5e6..81573480a8 100644 --- a/web/src/pages/Notifications.test.tsx +++ b/web/src/pages/Notifications.test.tsx @@ -1,5 +1,6 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; import Notifications from "./Notifications"; const state = vi.hoisted(() => ({ list: { @@ -68,6 +69,78 @@ it("retains partial rows and requires explicit reload after a mark-all failure", expect(state.list.restart).toHaveBeenCalledOnce(); expect(state.all.mutateAsync).not.toHaveBeenCalled(); }); +it.each([ + [ + "request.approved", + { media_type: "movie", tmdb_id: 603, title: "The Matrix" }, + "/requests/movie/603", + ], + [ + "request.declined", + { media_type: "series", tmdb_id: 1399, title: "A Show" }, + "/requests/series/1399", + ], +])("links a %s row to the request's detail page", (type, flags, href) => { + state.list.data = { + pages: [{ notifications: [{ ...row, type, reason_flags: flags }], read_cutoff: "c" }], + }; + render( + + + , + ); + expect(screen.getByRole("link", { name: new RegExp(flags.title) })).toHaveAttribute("href", href); +}); +it("keeps a fulfilled request linked to its catalog item", () => { + state.list.data = { + pages: [ + { + notifications: [ + { + ...row, + type: "request.fulfilled", + series_id: "movie-603", + series_title: "The Matrix", + reason_flags: { media_type: "movie", tmdb_id: 603 }, + }, + ], + read_cutoff: "c", + }, + ], + }; + render( + + + , + ); + expect(screen.getByRole("link", { name: /The Matrix/ })).toHaveAttribute( + "href", + "/item/movie-603", + ); +}); +it("leaves a request row unlinked when its payload lacks the TMDB id", () => { + state.list.data = { + pages: [ + { + notifications: [ + { + ...row, + type: "request.approved", + reason_flags: { title: "No Id", media_type: "movie" }, + }, + ], + read_cutoff: "c", + }, + ], + }; + render( + + + , + ); + expect(screen.getByText("No Id")).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /No Id/ })).not.toBeInTheDocument(); +}); it("does not describe failed initial loading as an empty inbox or permit an unbounded read-all", () => { state.list.data = undefined; state.list.isError = true; diff --git a/web/src/pages/Notifications.tsx b/web/src/pages/Notifications.tsx index d7ed5077c9..bd5481b2d1 100644 --- a/web/src/pages/Notifications.tsx +++ b/web/src/pages/Notifications.tsx @@ -20,6 +20,7 @@ import { import { useDocumentTitle } from "@/hooks/useDocumentTitle"; import { decodeThumbhash } from "@/lib/thumbhash"; import { preferredDateLocale } from "@/lib/datetime"; +import { requestDetailHref } from "@/lib/mediaRequests"; import ViewTransitionLink from "@/components/ViewTransitionLink"; function formatNotificationTime(value: string): string { @@ -86,6 +87,23 @@ function notificationDescription(notification: AppNotification): string { return notification.type; } +/** + * Where a row leads. Episode and fulfilled-request rows open the catalog item; + * approved and declined requests have no catalog item yet, so they open the + * request's detail page from the TMDB id their payload carries. + */ +function notificationHref(notification: AppNotification): string | null { + if (notification.episode_id) return `/item/${notification.episode_id}`; + if (notification.series_id) return `/item/${notification.series_id}`; + if (notification.type === "request.approved" || notification.type === "request.declined") { + const { media_type: mediaType, tmdb_id: tmdbID } = notification.reason_flags ?? {}; + if ((mediaType === "movie" || mediaType === "series") && tmdbID && tmdbID > 0) { + return requestDetailHref(mediaType, tmdbID); + } + } + return null; +} + function reasonLabels(notification: AppNotification): string[] { const flags = notification.reason_flags ?? {}; const labels: string[] = []; @@ -115,11 +133,7 @@ function NotificationRow({ const thumbhashUrl = notification.poster_thumbhash ? decodeThumbhash(notification.poster_thumbhash) : ""; - const detailHref = notification.episode_id - ? `/item/${notification.episode_id}` - : notification.series_id - ? `/item/${notification.series_id}` - : null; + const detailHref = notificationHref(notification); const body = ( <> diff --git a/web/src/pages/RequestDetail.test.tsx b/web/src/pages/RequestDetail.test.tsx new file mode 100644 index 0000000000..2d63ce1985 --- /dev/null +++ b/web/src/pages/RequestDetail.test.tsx @@ -0,0 +1,142 @@ +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router"; +import type { MediaRequest, RequestMediaDetail } from "@/api/types"; + +const mocks = vi.hoisted(() => ({ + detail: undefined as unknown, + mine: [] as unknown[], + useMyMediaRequests: vi.fn(), + cancel: vi.fn(), +})); + +vi.mock("@/hooks/queries/useRequests", () => ({ + useRequestMediaDetail: () => ({ data: mocks.detail, isLoading: false, isError: false }), + useCreateMediaRequest: () => ({ mutate: vi.fn(), isPending: false, variables: undefined }), + useMyMediaRequests: (...args: unknown[]) => { + mocks.useMyMediaRequests(...args); + return { data: mocks.mine }; + }, + useCancelMediaRequest: () => ({ mutate: mocks.cancel, isPending: false }), +})); +vi.mock("@/hooks/useDocumentTitle", () => ({ useDocumentTitle: () => {} })); +vi.mock("@/pages/ItemDetail/DetailHero", () => ({ + default: ({ + title, + metadata, + actions, + }: { + title: string; + metadata: ReactNode; + actions: ReactNode; + }) => ( +
+

{title}

+ {metadata} + {actions} +
+ ), +})); +vi.mock("@/components/CastCarousel", () => ({ default: () => null })); +vi.mock("@/components/MediaCarousel", () => ({ default: () => null })); + +import RequestDetail from "./RequestDetail"; + +const baseDetail: RequestMediaDetail = { + media_type: "movie", + tmdb_id: 603, + title: "The Matrix", + runtime: 136, + availability: "missing", + request: { requestable: false, status: "pending", request_id: "req-1" }, +}; + +const ownPending: MediaRequest = { + id: "req-1", + provider: "silo", + media_type: "movie", + tmdb_id: 603, + title: "The Matrix", + status: "pending", + outcome: "active", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}; + +function renderDetail() { + render( + + + } /> + + , + ); +} + +describe("RequestDetail", () => { + beforeEach(() => { + mocks.detail = baseDetail; + mocks.mine = [ownPending]; + mocks.useMyMediaRequests.mockReset(); + mocks.cancel.mockReset(); + }); + + it("lets the viewer cancel their own pending request after confirming", () => { + renderDetail(); + + expect(screen.getByText("Pending").closest("[data-request-state]")).toHaveAttribute( + "data-request-state", + "pending", + ); + fireEvent.click(screen.getByRole("button", { name: "Cancel request" })); + fireEvent.click( + within(screen.getByRole("alertdialog")).getByRole("button", { name: "Cancel request" }), + ); + + expect(mocks.cancel).toHaveBeenCalledExactlyOnceWith("req-1"); + }); + + it("hides Cancel request when the pending request belongs to someone else", () => { + mocks.mine = [{ ...ownPending, id: "req-other" }]; + renderDetail(); + + expect(screen.getByText("Pending")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Cancel request" })).not.toBeInTheDocument(); + }); + + it("hides Cancel request once the request has been sent", () => { + mocks.detail = { ...baseDetail, request: { ...baseDetail.request, status: "downloading" } }; + renderDetail(); + + expect(screen.getByText("Processing")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Cancel request" })).not.toBeInTheDocument(); + // The ownership lookup only runs while the request could still be withdrawn. + expect(mocks.useMyMediaRequests).toHaveBeenCalledWith( + { outcome: "active" }, + { enabled: false }, + ); + }); + + it("marks a title already in the library as Available", () => { + mocks.detail = { + ...baseDetail, + availability: "available", + library_content_id: "movie-603", + request: { requestable: false, reason: "already_available" }, + }; + renderDetail(); + + expect(screen.getByText("Available")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Open in library/ })).toHaveAttribute( + "href", + "/item/movie-603", + ); + }); + + it("formats the runtime with the shared runtime formatter", () => { + renderDetail(); + + expect(screen.getByText("2h 16m")).toBeInTheDocument(); + }); +}); diff --git a/web/src/pages/RequestDetail.tsx b/web/src/pages/RequestDetail.tsx index 9f339bef66..85c9a5367f 100644 --- a/web/src/pages/RequestDetail.tsx +++ b/web/src/pages/RequestDetail.tsx @@ -1,29 +1,42 @@ +import { useState } from "react"; import { useParams } from "react-router"; -import { Check, Clock, Library, Loader2, Plus, Star } from "lucide-react"; +import { Library, Loader2, Plus, Star } from "lucide-react"; import CastCarousel from "@/components/CastCarousel"; +import { CancelRequestDialog } from "@/components/CancelRequestDialog"; import MediaCarousel from "@/components/MediaCarousel"; import PageBack from "@/components/PageBack"; import RequestPosterCard from "@/components/RequestPosterCard"; +import { RequestReasonBadge, RequestStatusBadge } from "@/components/RequestStatusBadge"; import DetailHero from "@/pages/ItemDetail/DetailHero"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import type { CastMember, + MediaRequest, RequestMediaCastMember, RequestMediaDetail, RequestMediaResult, } from "@/api/types"; -import { useCreateMediaRequest, useRequestMediaDetail } from "@/hooks/queries/useRequests"; +import { + useCancelMediaRequest, + useCreateMediaRequest, + useMyMediaRequests, + useRequestMediaDetail, +} from "@/hooks/queries/useRequests"; import { useDocumentTitle } from "@/hooks/useDocumentTitle"; -import { cn } from "@/lib/utils"; +import { formatRuntimeMinutes } from "@/lib/mediaFormat"; import { - formatRequestReason, - formatRequestStatus, + canCancelOwnRequest, + requestDisplayState, requestInputFromMediaResult, tmdbImageURL, } from "@/lib/mediaRequests"; import ViewTransitionLink from "@/components/ViewTransitionLink"; +// A status label in the hero's action row. Kept badge-sized so it does not +// read as one of the buttons beside it. +const HERO_BADGE_CLASS = "h-8 px-3 text-sm font-semibold"; + export default function RequestDetail() { const params = useParams<{ mediaType: string; tmdbId: string }>(); const mediaType = (params.mediaType === "series" ? "series" : "movie") as "movie" | "series"; @@ -127,7 +140,7 @@ function MetaPills({ item }: { item: RequestMediaDetail }) { const pills: string[] = []; if (item.year) pills.push(String(item.year)); if (item.content_rating) pills.push(item.content_rating); - if (item.media_type === "movie" && item.runtime) pills.push(formatDuration(item.runtime)); + if (item.media_type === "movie" && item.runtime) pills.push(formatRuntimeMinutes(item.runtime)); if (item.media_type === "series" && item.number_of_seasons) pills.push(`${item.number_of_seasons} Season${item.number_of_seasons === 1 ? "" : "s"}`); if (item.media_type === "series" && item.status) pills.push(item.status); @@ -202,10 +215,11 @@ function RequestActions({ onRequest: () => void; }) { const requestable = item.request.requestable; - const statusLabel = item.request.status ? formatRequestStatus(item.request.status) : null; - const reasonLabel = - !requestable && !item.request.status ? formatRequestReason(item.request.reason) : null; + const state = item.request.status ? requestDisplayState(item.request.status) : undefined; const availableInLibrary = item.availability === "available" && !item.request.status; + const ownRequest = useOwnCancellableRequest(item); + const cancelRequest = useCancelMediaRequest(); + const [confirmCancel, setConfirmCancel] = useState(false); return (
@@ -229,11 +243,7 @@ function RequestActions({ ) : availableInLibrary ? ( <> - } - label="Already in your library" - /> + {item.library_content_id ? ( + cancelRequest.mutate(ownRequest.id)} + isPending={cancelRequest.isPending} + /> + + ) : null} + ) : ( - } - label={reasonLabel ?? "Unavailable"} - /> + )} {item.imdb_id ? ( @@ -283,50 +306,25 @@ function RequestActions({ ); } -const STATUS_TONES: Record<"amber" | "sky" | "emerald" | "zinc", string> = { - amber: "bg-amber-500/15 text-amber-100 ring-amber-400/40", - sky: "bg-sky-500/15 text-sky-100 ring-sky-400/40", - emerald: "bg-emerald-500/15 text-emerald-100 ring-emerald-400/40", - zinc: "bg-zinc-700/60 text-zinc-200 ring-zinc-500/40", -}; - -function StatusBlock({ - tone, - icon, - label, -}: { - tone: "amber" | "sky" | "emerald" | "zinc"; - icon: React.ReactNode; - label: string; -}) { - return ( - - {icon} - {label} - +/** + * The viewer's own request for this title, while they can still cancel it. The + * detail payload says a title has a request but not whose it is, so look for + * it among the account's own active requests. + */ +function useOwnCancellableRequest(item: RequestMediaDetail): MediaRequest | undefined { + const mayCancel = item.request.status === "pending" || item.request.status === "approved"; + const mine = useMyMediaRequests({ outcome: "active" }, { enabled: mayCancel }); + if (!mayCancel) return undefined; + const requestID = item.request.request_id; + return mine.data?.find( + (request) => + (requestID + ? request.id === requestID + : request.media_type === item.media_type && request.tmdb_id === item.tmdb_id) && + canCancelOwnRequest(request), ); } -function statusToneForStatus(status: string): "amber" | "sky" | "emerald" | "zinc" { - switch (status) { - case "pending": - return "amber"; - case "approved": - case "completed": - return "emerald"; - case "queued": - case "downloading": - return "sky"; - default: - return "zinc"; - } -} - function RecommendationsRow({ recommendations, pendingTMDBID, @@ -363,14 +361,6 @@ function pickStudioLabel(item: RequestMediaDetail): string | undefined { return undefined; } -function formatDuration(minutes: number): string { - if (minutes <= 0) return ""; - const h = Math.floor(minutes / 60); - const m = minutes % 60; - if (h <= 0) return `${m}m`; - return m === 0 ? `${h}h` : `${h}h ${m}m`; -} - function formatVoteCount(count: number): string { if (count >= 1000) return `${(count / 1000).toFixed(1)}k`; return String(count); diff --git a/web/src/pages/Requests.test.tsx b/web/src/pages/Requests.test.tsx new file mode 100644 index 0000000000..392711edb0 --- /dev/null +++ b/web/src/pages/Requests.test.tsx @@ -0,0 +1,137 @@ +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import type { MediaRequest } from "@/api/types"; + +const mocks = vi.hoisted(() => ({ + mine: [] as MediaRequest[], + cancel: vi.fn(), +})); + +vi.mock("@/hooks/queries/useRequests", () => { + const idle = { data: [], isLoading: false, isError: false, refetch: vi.fn() }; + return { + useRequestDiscovery: () => idle, + useDiscoverStudios: () => idle, + useDiscoverNetworks: () => idle, + useDiscoverGenres: () => idle, + useRequestSearch: () => ({ data: undefined, isLoading: false, isFetching: false }), + useMyMediaRequests: () => ({ data: mocks.mine, isLoading: false, isError: false }), + useCreateMediaRequest: () => ({ mutate: vi.fn(), isPending: false, variables: undefined }), + useCancelMediaRequest: () => ({ mutate: mocks.cancel, isPending: false, variables: undefined }), + }; +}); +vi.mock("@/hooks/useDocumentTitle", () => ({ useDocumentTitle: () => {} })); +vi.mock("@/components/BrandCarousel", () => ({ default: () => null })); +vi.mock("@/components/MediaCarousel", () => ({ + default: ({ title, children }: { title: string; children: ReactNode }) => ( +
{children}
+ ), +})); + +import Requests from "./Requests"; + +function request(id: string, title: string, overrides: Partial = {}): MediaRequest { + return { + id, + provider: "silo", + media_type: "movie", + tmdb_id: Number(id.replace(/\D/g, "")) || 1, + title, + status: "pending", + outcome: "active", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + +function renderYours() { + render( + + + , + ); +} + +describe("Requests (Yours tab)", () => { + beforeEach(() => { + mocks.cancel.mockReset(); + mocks.mine = [ + request("r1", "Waiting Movie"), + request("r2", "Approved Movie", { + status: "approved", + targets: [{ quality: "1080p", status: "queued" }] as MediaRequest["targets"], + }), + request("r3", "Downloading Movie", { status: "downloading" }), + request("r4", "Queued Movie", { status: "queued" }), + request("r5", "Withdrawn Movie", { outcome: "cancelled" }), + ]; + }); + + it("offers Cancel request only on the viewer's pending requests", () => { + renderYours(); + + const buttons = screen.getAllByRole("button", { name: /^Cancel request for / }); + expect(buttons.map((button) => button.getAttribute("aria-label"))).toEqual([ + "Cancel request for Waiting Movie", + ]); + }); + + it("cancels only after the viewer confirms", () => { + renderYours(); + + fireEvent.click(screen.getByRole("button", { name: "Cancel request for Waiting Movie" })); + const dialog = screen.getByRole("alertdialog"); + expect(dialog).toHaveTextContent('Your request for "Waiting Movie" will be withdrawn'); + expect(mocks.cancel).not.toHaveBeenCalled(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Cancel request" })); + + expect(mocks.cancel).toHaveBeenCalledExactlyOnceWith("r1"); + }); + + it("keeps the request when the viewer backs out", () => { + renderYours(); + + fireEvent.click(screen.getByRole("button", { name: "Cancel request for Waiting Movie" })); + fireEvent.click(screen.getByRole("button", { name: "Keep request" })); + + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + expect(mocks.cancel).not.toHaveBeenCalled(); + }); + + it("uses the shared status vocabulary and makes no live-update promise", () => { + renderYours(); + + const guide = screen.getByRole("region", { name: "Status guide" }); + for (const label of [ + "Pending", + "Approved", + "Processing", + "Available", + "Declined", + "Cancelled", + "Failed", + ]) { + expect(within(guide).getByText(label)).toBeInTheDocument(); + } + expect(screen.queryByText(/update automatically/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/live status/i)).not.toBeInTheDocument(); + expect(within(guide).queryByText("Queued")).not.toBeInTheDocument(); + expect(within(guide).queryByText("Completed")).not.toBeInTheDocument(); + }); + + it("counts requests per status in the summary", () => { + renderYours(); + + const pending = document.querySelector( + '[data-request-state="pending"] .tabular-nums', + ) as HTMLElement; + expect(pending).toHaveTextContent("1"); + expect( + document.querySelector('[data-request-state="processing"] .tabular-nums'), + ).toHaveTextContent("2"); + }); +}); diff --git a/web/src/pages/Requests.tsx b/web/src/pages/Requests.tsx index 995c92406a..81cabd88ef 100644 --- a/web/src/pages/Requests.tsx +++ b/web/src/pages/Requests.tsx @@ -3,8 +3,10 @@ import type { FormEvent } from "react"; import { useSearchParams } from "react-router"; import { Search, Sparkles, X } from "lucide-react"; import BrandCarousel from "@/components/BrandCarousel"; +import { CancelRequestDialog } from "@/components/CancelRequestDialog"; import MediaCarousel from "@/components/MediaCarousel"; import RequestPosterCard from "@/components/RequestPosterCard"; +import { RequestStatusBadge } from "@/components/RequestStatusBadge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { @@ -19,12 +21,12 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import type { MediaRequest, MediaRequestOutcome, - MediaRequestStatus, RequestDiscoverySection, RequestMediaResult, RequestSearchMediaType, } from "@/api/types"; import { + useCancelMediaRequest, useCreateMediaRequest, useDiscoverGenres, useDiscoverNetworks, @@ -34,92 +36,78 @@ import { useRequestSearch, } from "@/hooks/queries/useRequests"; import { useDocumentTitle } from "@/hooks/useDocumentTitle"; -import { cn } from "@/lib/utils"; -import { formatRequestStatus, requestInputFromMediaResult } from "@/lib/mediaRequests"; +import { + canCancelOwnRequest, + requestDisplayState, + requestInputFromMediaResult, + type RequestDisplayState, +} from "@/lib/mediaRequests"; type MineBucketKey = "motion" | "completed" | "issues"; type RequestTab = "discover" | "yours"; type StatusGuideItem = { + state: RequestDisplayState; description: string; - tone: string; }; const REQUEST_TABS = ["discover", "yours"] as const; -const MINE_BUCKET_META: Record = - { - motion: { - title: "In motion", - eyebrow: "On their way", - accent: "text-amber-200/90", - }, - completed: { - title: "Landed in your library", - eyebrow: "Ready to watch", - accent: "text-emerald-200/90", - }, - issues: { - title: "Needs attention", - eyebrow: "Hit a snag", - accent: "text-red-200/90", - }, - }; +const MINE_BUCKET_META: Record = { + motion: { + title: "In motion", + eyebrow: "On their way", + }, + completed: { + title: "Landed in your library", + eyebrow: "Ready to watch", + }, + issues: { + title: "Needs attention", + eyebrow: "Hit a snag", + }, +}; -const REQUEST_PROGRESS_GUIDE: Array = [ +const REQUEST_PROGRESS_GUIDE: StatusGuideItem[] = [ { - status: "pending", - description: "Waiting for an admin to approve the request.", - tone: "bg-amber-500/15 text-amber-100 ring-amber-400/40", + state: "pending", + description: "Waiting for an admin to review the request. You can cancel it until then.", }, { - status: "approved", + state: "approved", description: "Approved, but not yet sent to the download automation.", - tone: "bg-emerald-500/15 text-emerald-100 ring-emerald-400/40", }, { - status: "queued", - description: "Sent to the request automation and waiting for download/import activity.", - tone: "bg-sky-500/15 text-sky-100 ring-sky-400/40", + state: "processing", + description: "Sent to the download automation: queued, downloading, or importing.", }, { - status: "downloading", - description: "Downloading or importing now.", - tone: "bg-sky-500/20 text-sky-100 ring-sky-400/50", - }, - { - status: "completed", + state: "available", description: "In your Silo library and ready to watch.", - tone: "bg-emerald-500/20 text-emerald-100 ring-emerald-400/40", }, ]; -const REQUEST_ISSUE_GUIDE: Array< - StatusGuideItem & { - outcome: Extract; - label: string; - } -> = [ +const REQUEST_ISSUE_GUIDE: StatusGuideItem[] = [ { - outcome: "declined", - label: "Declined", + state: "declined", description: "An admin declined the request.", - tone: "bg-zinc-700/60 text-zinc-200 ring-zinc-500/40", }, { - outcome: "cancelled", - label: "Cancelled", - description: "The request was cancelled before completion.", - tone: "bg-zinc-700/60 text-zinc-200 ring-zinc-500/40", + state: "cancelled", + description: "The request was withdrawn before it was approved.", }, { - outcome: "failed", - label: "Failed", + state: "failed", description: "Silo or the external request automation hit an error. If details are available, they appear on the request card.", - tone: "bg-red-500/15 text-red-100 ring-red-400/40", }, ]; +// The summary chips follow the status guide's order. +const REQUEST_STATE_ORDER: RequestDisplayState[] = [ + ...REQUEST_PROGRESS_GUIDE, + ...REQUEST_ISSUE_GUIDE, +].map((item) => item.state); + export default function Requests() { useDocumentTitle("Requests"); @@ -140,6 +128,9 @@ export default function Requests() { const search = useRequestSearch(mediaType, searchQuery, searchPage); const mine = useMyMediaRequests({ limit: 100 }); const createRequest = useCreateMediaRequest(); + const cancelRequest = useCancelMediaRequest(); + const [cancelTarget, setCancelTarget] = useState(null); + const cancellingRequestID = cancelRequest.isPending ? cancelRequest.variables : undefined; const pendingRequestKey = createRequest.variables ? mediaRequestKey(createRequest.variables.media_type, createRequest.variables.tmdb_id) : undefined; @@ -236,7 +227,7 @@ export default function Requests() { } const buckets = useMemo(() => groupMineRequests(mine.data ?? []), [mine.data]); - const mineCounts = useMemo(() => countMineStatuses(mine.data ?? []), [mine.data]); + const mineCounts = useMemo(() => countMineStates(mine.data ?? []), [mine.data]); const totalMine = (mine.data ?? []).length; return ( @@ -352,13 +343,33 @@ export default function Requests() { {(Object.keys(MINE_BUCKET_META) as MineBucketKey[]).map((key) => { const items = buckets[key]; if (items.length === 0) return null; - return ; + return ( + + ); })}
)} + + { + if (!open) setCancelTarget(null); + }} + onConfirm={() => { + if (cancelTarget) cancelRequest.mutate(cancelTarget.id); + }} + isPending={cancelRequest.isPending} + />
); } @@ -373,29 +384,18 @@ function RequestStatusGuide() { Status guide

- Statuses update automatically as Silo checks the library and connected request - integrations. + What each status on your requests means.

{REQUEST_PROGRESS_GUIDE.map((item) => ( - + ))} {REQUEST_ISSUE_GUIDE.map((item) => ( - + ))}
@@ -414,26 +414,11 @@ function StatusGuideGroup({ title, children }: { title: string; children: React. ); } -function StatusGuideRow({ - label, - description, - tone, -}: { - label: string; - description: string; - tone: string; -}) { +function StatusGuideRow({ state, description }: StatusGuideItem) { return (
- - {label} - +
{description}
@@ -453,9 +438,9 @@ function PageHeader() { Find something worth waiting for.

- Browse what's trending, search the full TMDB catalog, and watch your requests move from{" "} + Browse what's trending, search the full TMDB catalog, and follow your requests from{" "} pending to{" "} - ready. + available.

); @@ -562,18 +547,35 @@ function DiscoverySectionRow({ ); } -function MineBucketRow({ bucket, requests }: { bucket: MineBucketKey; requests: MediaRequest[] }) { +function MineBucketRow({ + bucket, + requests, + onCancel, + cancellingRequestID, +}: { + bucket: MineBucketKey; + requests: MediaRequest[]; + onCancel: (request: MediaRequest) => void; + cancellingRequestID?: string; +}) { const meta = MINE_BUCKET_META[bucket]; return (
- + {meta.eyebrow}
{requests.map((request) => ( - + onCancel(request) : undefined} + isCancelling={cancellingRequestID === request.id} + /> ))}
@@ -719,52 +721,13 @@ function SearchResultsView({ ); } -function MineSummary({ - counts, -}: { - counts: { pending: number; inFlight: number; completed: number; issues: number }; -}) { - const chips: Array<{ label: string; value: number; tone: string }> = []; - if (counts.pending > 0) - chips.push({ - label: "Pending review", - value: counts.pending, - tone: "bg-amber-500/15 text-amber-100 ring-amber-400/40", - }); - if (counts.inFlight > 0) - chips.push({ - label: "In motion", - value: counts.inFlight, - tone: "bg-sky-500/15 text-sky-100 ring-sky-400/40", - }); - if (counts.completed > 0) - chips.push({ - label: "Ready to watch", - value: counts.completed, - tone: "bg-emerald-500/15 text-emerald-100 ring-emerald-400/40", - }); - if (counts.issues > 0) - chips.push({ - label: "Need attention", - value: counts.issues, - tone: "bg-red-500/15 text-red-100 ring-red-400/40", - }); - - if (chips.length === 0) return null; +function MineSummary({ counts }: { counts: Array<{ state: RequestDisplayState; count: number }> }) { + if (counts.length === 0) return null; return (
- {chips.map((chip) => ( - - {chip.value} - {chip.label} - + {counts.map(({ state, count }) => ( + ))}
); @@ -776,8 +739,7 @@ function EmptyMineState() {

Your wishlist is empty.

- Browse the Discover tab or search above. The moment you request something, it'll show up - here with live status. + Browse the Discover tab or search above. Anything you request shows up here with its status.

); @@ -878,21 +840,14 @@ function groupMineRequests(requests: MediaRequest[]) { return buckets; } -function countMineStatuses(requests: MediaRequest[]) { - let pending = 0; - let inFlight = 0; - let completed = 0; - let issues = 0; +function countMineStates(requests: MediaRequest[]) { + const counts = new Map(); for (const request of requests) { - if (isIssueOutcome(request.outcome)) { - issues += 1; - } else if (request.status === "completed") { - completed += 1; - } else if (request.status === "pending") { - pending += 1; - } else { - inFlight += 1; - } + const state = requestDisplayState(request.status, request.outcome); + if (state) counts.set(state, (counts.get(state) ?? 0) + 1); } - return { pending, inFlight, completed, issues }; + return REQUEST_STATE_ORDER.flatMap((state) => { + const count = counts.get(state) ?? 0; + return count > 0 ? [{ state, count }] : []; + }); } From cf2ecfaeeb25fd779ad1a2ae4d80d2411ce9f23b Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Sat, 26 Sep 2026 10:58:09 -0400 Subject: [PATCH 004/103] feat(requests): follow a title someone else requested A profile that finds a title someone else already requested can now ask to be notified when it becomes available, instead of being turned away with "already requested". The follow is stored per title and profile, so it survives the request failing and being requested again, and it is cleared once the fulfilled notification has gone out. The requesting profile is always notified and never needs one. When a request's title reaches the library, the fulfilled notification now goes to the requester and every follower. A follower's copy is marked so every channel words it as a title they followed, not their own request. Each profile's copy uses the existing per-profile unique index, so a retry is told once, and the server-channel announcement now waits until an attempt has reached every recipient, so a retry does not repeat it. Declining or cancelling the request clears its title's follows. API (v2 only; v1 is frozen): - PUT and DELETE /api/v2/requests/follows/{media_type}/{tmdb_id}, both naturally idempotent. Following needs the same access as requesting and is refused for a title with no active request or one already in the library. - Request state gains following and requested_by_viewer. - GET /api/v2/requests/status advertises follow_supported. The title page shows "Notify me when available" on another profile's open request, and "Stop notifying me" once followed. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../api/v2/fixtures/get_system_info_ok.json | 2 +- .../api/v2/fixtures/request_status_ok.json | 5 +- contracts/api/v2/openapi.json | 377 +++++++++++++++++- docs/architecture/api-contract.md | 7 + docs/architecture/media-requests.md | 26 ++ docs/architecture/notifications.md | 7 +- internal/api/handlers/requests.go | 3 + internal/api/handlers/requests_test.go | 8 + internal/api/testdata/media_routes.txt | 4 + internal/apiv2/request_lifecycle.go | 5 +- internal/apiv2/requests.go | 52 ++- internal/apiv2/requests_test.go | 37 ++ internal/apiv2/router.go | 2 + internal/contractledger/ledger_test.go | 2 + internal/notifications/display.go | 8 +- internal/notifications/email_compose.go | 3 + internal/notifications/request_notifier.go | 113 ++++-- .../notifications/request_notifier_test.go | 150 ++++--- .../notifications/webhook_payload_discord.go | 9 +- internal/requests/capability.go | 14 +- internal/requests/errors.go | 9 +- internal/requests/follows.go | 246 ++++++++++++ internal/requests/follows_test.go | 269 +++++++++++++ internal/requests/notify.go | 21 + internal/requests/notify_test.go | 2 + internal/requests/service.go | 50 ++- internal/requests/service_test.go | 84 ++++ internal/requests/store.go | 12 + internal/requests/types.go | 9 + .../sql/20260926145026_request_follows.sql | 22 + web/src/api/types.ts | 6 + web/src/api/v2/operations.ts | 2 + web/src/api/v2/requests.ts | 14 + web/src/api/v2/schema.ts | 258 ++++++++++++ web/src/components/RealtimeEventsProvider.tsx | 11 +- web/src/hooks/queries/useRequests.test.tsx | 35 ++ web/src/hooks/queries/useRequests.ts | 29 ++ web/src/pages/Notifications.test.tsx | 27 ++ web/src/pages/Notifications.tsx | 7 + web/src/pages/RequestDetail.test.tsx | 61 ++- web/src/pages/RequestDetail.tsx | 30 +- 41 files changed, 1893 insertions(+), 145 deletions(-) create mode 100644 internal/requests/follows.go create mode 100644 internal/requests/follows_test.go create mode 100644 migrations/sql/20260926145026_request_follows.sql diff --git a/contracts/api/v2/fixtures/get_system_info_ok.json b/contracts/api/v2/fixtures/get_system_info_ok.json index 9b3ab74782..228c091929 100644 --- a/contracts/api/v2/fixtures/get_system_info_ok.json +++ b/contracts/api/v2/fixtures/get_system_info_ok.json @@ -1,7 +1,7 @@ { "server_version": "unavailable", "api_major": 2, - "contract_digest": "1e2c0224624e63da4d77e69f679ddce44aab6fa05319d59e0ce1fbcb33143bec", + "contract_digest": "8634a72e49197f17d2e0e30f11b5a4ee9d7ed76fa2c039cd4ba1cbd5d8362624", "links": { "openapi": "/api/v2/openapi.json", "capabilities": "/api/v2/capabilities", diff --git a/contracts/api/v2/fixtures/request_status_ok.json b/contracts/api/v2/fixtures/request_status_ok.json index 78f8bb5a01..f25497e64d 100644 --- a/contracts/api/v2/fixtures/request_status_ok.json +++ b/contracts/api/v2/fixtures/request_status_ok.json @@ -1,7 +1,8 @@ { - "revision": "9f99fe02e4707e64e2d61ce7d8682b809713f2cad154b46064cc7c16e0b26d61", + "revision": "b007e9a95d42ae33d2a1ff82c863640b02e114a98abaea3e471c4b0e6e887e63", "state": "available", "allowed": true, "requests_enabled": true, - "rating_restrictions_enforced": true + "rating_restrictions_enforced": true, + "follow_supported": true } diff --git a/contracts/api/v2/openapi.json b/contracts/api/v2/openapi.json index ab6d1aaafe..36892115ad 100644 --- a/contracts/api/v2/openapi.json +++ b/contracts/api/v2/openapi.json @@ -23583,6 +23583,9 @@ "description": "Whether the current principal may use the capability", "type": "boolean" }, + "follow_supported": { + "type": "boolean" + }, "rating_restrictions_enforced": { "type": "boolean" }, @@ -23607,6 +23610,7 @@ "required": [ "requests_enabled", "rating_restrictions_enforced", + "follow_supported", "revision", "state", "allowed" @@ -35362,6 +35366,13 @@ "RequestMediaState": { "additionalProperties": false, "properties": { + "following": { + "description": "Whether the viewer will be notified when the media becomes available: they requested it or follow it", + "examples": [ + false + ], + "type": "boolean" + }, "reason": { "description": "Why the media is not requestable", "examples": [ @@ -35384,6 +35395,13 @@ ], "type": "boolean" }, + "requested_by_viewer": { + "description": "Whether the viewing profile made the active request, so there is nothing to follow", + "examples": [ + false + ], + "type": "boolean" + }, "status": { "description": "Status of the active request, when one exists", "examples": [ @@ -35393,7 +35411,9 @@ } }, "required": [ - "requestable" + "requestable", + "following", + "requested_by_viewer" ], "type": "object" }, @@ -161839,6 +161859,361 @@ "x-silo-service-backed": true } }, + "/api/v2/requests/follows/{media_type}/{tmdb_id}": { + "delete": { + "operationId": "unfollowRequestMedia", + "parameters": [ + { + "description": "The household profile acting for this request; it must belong to the authenticated account.", + "in": "header", + "name": "X-Profile-Id", + "required": true, + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + }, + { + "description": "The media type", + "in": "path", + "name": "media_type", + "required": true, + "schema": { + "description": "The media type", + "enum": [ + "movie", + "series" + ], + "examples": [ + "movie" + ], + "type": "string" + } + }, + { + "description": "TMDB identifier (external, not a Silo ID)", + "in": "path", + "name": "tmdb_id", + "required": true, + "schema": { + "description": "TMDB identifier (external, not a Silo ID)", + "examples": [ + 949 + ], + "format": "int64", + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Stop following a title.", + "tags": [ + "requests" + ], + "x-silo-class": "profile_scoped", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "natural_idempotent", + "x-silo-service-backed": true + }, + "put": { + "operationId": "followRequestMedia", + "parameters": [ + { + "description": "The household profile acting for this request; it must belong to the authenticated account.", + "in": "header", + "name": "X-Profile-Id", + "required": true, + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + }, + { + "description": "The media type", + "in": "path", + "name": "media_type", + "required": true, + "schema": { + "description": "The media type", + "enum": [ + "movie", + "series" + ], + "examples": [ + "movie" + ], + "type": "string" + } + }, + { + "description": "TMDB identifier (external, not a Silo ID)", + "in": "path", + "name": "tmdb_id", + "required": true, + "schema": { + "description": "TMDB identifier (external, not a Silo ID)", + "examples": [ + 949 + ], + "format": "int64", + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestMediaState" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Get notified when a title that already has an active request becomes available.", + "tags": [ + "requests" + ], + "x-silo-class": "profile_scoped", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "natural_idempotent", + "x-silo-service-backed": true + } + }, "/api/v2/requests/mine": { "get": { "operationId": "listMyRequests", diff --git a/docs/architecture/api-contract.md b/docs/architecture/api-contract.md index 345b826c28..380d65412c 100644 --- a/docs/architecture/api-contract.md +++ b/docs/architecture/api-contract.md @@ -1813,6 +1813,13 @@ prevents concurrent active requests for the same media, but terminal requests no longer hold that uniqueness key. Safe automatic retries require a durable client request identity across terminal states. The web mutation disables retries. +A profile can follow a title another profile already requested, to be notified +when it becomes available, with `PUT` and `DELETE +/api/v2/requests/follows/{media_type}/{tmdb_id}`. Both are naturally idempotent. +Request state gains `following` and `requested_by_viewer`, and +`GET /api/v2/requests/status` advertises `follow_supported`. The frozen v1 +surface has no follow operation and does not carry these fields. + Native Apple and Android request migrations accompany this contract change; integrate those client changes before retiring their v1 routes. Jellyfin compatibility does not expose this request management surface and keeps its existing behavior. diff --git a/docs/architecture/media-requests.md b/docs/architecture/media-requests.md index 00751a9b76..51650bad60 100644 --- a/docs/architecture/media-requests.md +++ b/docs/architecture/media-requests.md @@ -60,6 +60,32 @@ a connection skipped for a missing key also shrinks it, and must not erase a failure an admin still needs to see. If nothing is left to send, the remaining targets decide the status. +## Following a title + +A profile that finds a title someone else already requested can follow it +instead of requesting it again (`PUT`/`DELETE +/api/v2/requests/follows/{media_type}/{tmdb_id}`). Following needs the same +access as requesting: requests enabled, the account allowed to request and not +blocked by its request limit, and the title within the profile's rating +ceiling. It is refused for a title with no open request (request it instead) +and for one already in the library; the insert itself checks for the open +request, so a follow cannot land just after the request completed. + +A follow belongs to the title and the profile (`media_request_follows`), not to +one request, so it survives the request failing and being retried or requested +again. Declining or cancelling the request clears the title's follows: the title +is no longer on its way, and the follower can request it themselves. The +requesting profile never needs a follow: the fulfilled notification always +reaches it. When a request's fulfilled notification goes out, it is also sent +to every follower of the title, marked `follower` so its wording does not say +"your request", and those follows are then cleared. A dispatch failure leaves +the follows for the retry, and the server-channel announcement waits until an +attempt has reached every recipient, so a retry does not repeat it. + +Request state carries `following` (the viewer requested or follows the title) +and `requested_by_viewer` (the viewing profile made the request, so there is +nothing to follow); `GET /requests/status` advertises `follow_supported`. + ## Without a router Requests do not need Sonarr, Radarr or any other router plugin. When no diff --git a/docs/architecture/notifications.md b/docs/architecture/notifications.md index 13169ba5ac..96c6619535 100644 --- a/docs/architecture/notifications.md +++ b/docs/architecture/notifications.md @@ -74,7 +74,12 @@ inbox rows to make rollback possible. Request lifecycle deliveries (`request.fulfilled`, `request.approved`, `request.declined`) are operational notices posted directly to the requesting -profile — no interest index, no fanout. Their `reason_flags` carry request +profile — no interest index, no fanout. `request.fulfilled` also goes to every +profile that followed the title (see +[media-requests.md](media-requests.md#following-a-title)), with `follower: true` +in its `reason_flags` so every channel words it as a followed title rather than +the recipient's own request. The same per-profile unique index keeps each +follower's copy idempotent. Their `reason_flags` carry request identifiers (request ID, TMDB ID, media type; approved/declined also carry the title since no catalog item exists yet) rather than the four reason booleans. Partial unique indexes per `(profile_id, request_id, type)` make diff --git a/internal/api/handlers/requests.go b/internal/api/handlers/requests.go index 4f68da5b09..21936ced5f 100644 --- a/internal/api/handlers/requests.go +++ b/internal/api/handlers/requests.go @@ -47,6 +47,9 @@ type RequestService interface { BrowseStudio(ctx context.Context, viewer mediarequests.Viewer, slug, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) BrowseNetwork(ctx context.Context, viewer mediarequests.Viewer, slug, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) BrowseGenre(ctx context.Context, viewer mediarequests.Viewer, slug string, mediaType mediarequests.MediaType, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) + // Follow and Unfollow back the v2 follow operations; v1 has no follow route. + Follow(ctx context.Context, viewer mediarequests.Viewer, mediaType mediarequests.MediaType, tmdbID int) (mediarequests.RequestState, error) + Unfollow(ctx context.Context, viewer mediarequests.Viewer, mediaType mediarequests.MediaType, tmdbID int) error } type RequestsHandler struct { diff --git a/internal/api/handlers/requests_test.go b/internal/api/handlers/requests_test.go index 91521673c8..ebb4495f9d 100644 --- a/internal/api/handlers/requests_test.go +++ b/internal/api/handlers/requests_test.go @@ -55,6 +55,14 @@ func (f *fakeRequestService) BrowseGenre(_ context.Context, _ mediarequests.View return f.browseFn("genre", slug, mediaType, sort, page) } +func (f *fakeRequestService) Follow(context.Context, mediarequests.Viewer, mediarequests.MediaType, int) (mediarequests.RequestState, error) { + return mediarequests.RequestState{}, nil +} + +func (f *fakeRequestService) Unfollow(context.Context, mediarequests.Viewer, mediarequests.MediaType, int) error { + return nil +} + func (f *fakeRequestService) Search(context.Context, mediarequests.Viewer, string, mediarequests.MediaType, int) (*mediarequests.MediaPage, error) { return nil, nil } diff --git a/internal/api/testdata/media_routes.txt b/internal/api/testdata/media_routes.txt index 341f114eca..131881e171 100644 --- a/internal/api/testdata/media_routes.txt +++ b/internal/api/testdata/media_routes.txt @@ -677,6 +677,8 @@ GET /api/v2/requests/discover/genres non-media GET /api/v2/requests/discover/networks non-media GET /api/v2/requests/discover/studios non-media GET /api/v2/requests/discover/{section} non-media +DELETE /api/v2/requests/follows/{media_type}/{tmdb_id} non-media +PUT /api/v2/requests/follows/{media_type}/{tmdb_id} non-media GET /api/v2/requests/mine non-media GET /api/v2/requests/search non-media GET /api/v2/requests/status non-media @@ -1837,6 +1839,8 @@ GET /api/v2/requests/discover/genres non-media GET /api/v2/requests/discover/networks non-media GET /api/v2/requests/discover/studios non-media GET /api/v2/requests/discover/{section} non-media +DELETE /api/v2/requests/follows/{media_type}/{tmdb_id} non-media +PUT /api/v2/requests/follows/{media_type}/{tmdb_id} non-media GET /api/v2/requests/mine non-media GET /api/v2/requests/search non-media GET /api/v2/requests/status non-media diff --git a/internal/apiv2/request_lifecycle.go b/internal/apiv2/request_lifecycle.go index 01a6f964d5..e9186eabf8 100644 --- a/internal/apiv2/request_lifecycle.go +++ b/internal/apiv2/request_lifecycle.go @@ -37,6 +37,9 @@ type FeatureStatus struct { Capability RequestsEnabled bool `json:"requests_enabled"` RatingRestrictionsEnforced bool `json:"rating_restrictions_enforced"` + // FollowSupported advertises PUT/DELETE /requests/follows/{media_type}/{tmdb_id} + // and the following flag on request state. + FollowSupported bool `json:"follow_supported"` } type RequestFeatureStatusOutput struct { Status int @@ -176,7 +179,7 @@ func registerRequestLifecycle(reg *Registry, requests RequestLifecycleService, p return nil, requestProblem(err) } } - return &RequestFeatureStatusOutput{Body: FeatureStatus{Capability: Capability{State: enabledCapabilityState(status.RequestsEnabled), Allowed: &allowed}, RequestsEnabled: status.RequestsEnabled, RatingRestrictionsEnforced: status.RatingRestrictionsEnforced}}, nil + return &RequestFeatureStatusOutput{Body: FeatureStatus{Capability: Capability{State: enabledCapabilityState(status.RequestsEnabled), Allowed: &allowed}, RequestsEnabled: status.RequestsEnabled, RatingRestrictionsEnforced: status.RatingRestrictionsEnforced, FollowSupported: true}}, nil }) Register(reg, op(http.MethodPost, "/requests/{id}/cancel", "cancelRequest", "Cancel an accessible request."), func(ctx context.Context, in *RequestCancelInput) (*MediaRequestOutput, error) { if requests == nil { diff --git a/internal/apiv2/requests.go b/internal/apiv2/requests.go index 367f9be6fc..294ffa18ac 100644 --- a/internal/apiv2/requests.go +++ b/internal/apiv2/requests.go @@ -26,6 +26,9 @@ type RequestMediaState struct { Requestable bool `json:"requestable" doc:"Whether the viewer may request this media now" example:"true"` Reason string `json:"reason,omitempty" doc:"Why the media is not requestable" example:"already_requested"` RequestID ID `json:"request_id,omitempty" doc:"The active request, when one exists" example:"1834729"` + Following bool `json:"following" doc:"Whether the viewer will be notified when the media becomes available: they requested it or follow it" example:"false"` + // RequestedByViewer tells a client whether to offer a follow toggle. + RequestedByViewer bool `json:"requested_by_viewer" doc:"Whether the viewing profile made the active request, so there is nothing to follow" example:"false"` } // RequestMediaResult is one discovery or search card. @@ -321,6 +324,8 @@ const ( opBrowseDiscoverGenre = "browseDiscoverGenre" opBrowseDiscoverNetwork = "browseDiscoverNetwork" opBrowseDiscoverStudio = "browseDiscoverStudio" + opFollowRequestMedia = "followRequestMedia" + opUnfollowRequestMedia = "unfollowRequestMedia" ) const requestsTag = "requests" @@ -331,6 +336,7 @@ var requestOperationIDs = []string{ opCreateRequest, opListMyRequests, opGetRequest, opSearchRequestMedia, opGetRequestMediaDetail, opListDiscoverSections, opGetDiscoverSection, opListDiscoverGenres, opListDiscoverNetworks, opListDiscoverStudios, opBrowseDiscoverGenre, opBrowseDiscoverNetwork, opBrowseDiscoverStudio, + opFollowRequestMedia, opUnfollowRequestMedia, } func registerRequests(reg *Registry) { @@ -411,6 +417,20 @@ func registerRequests(reg *Registry) { }) }) + // Following is keyed by title: the viewer follows a title someone else + // already requested, without learning whose request it is. + follow := humaOp(http.MethodPut, Prefix+"/requests/follows/{media_type}/{tmdb_id}", opFollowRequestMedia, requestsTag, + "Get notified when a title that already has an active request becomes available.") + // 409 when the title has no active request (request it instead) or is + // already in the library. + follow.Errors = []int{http.StatusNotFound, http.StatusConflict} + Register(reg, Operation{Operation: follow, Class: ClassProfileScoped, DemoRestricted: true, ServiceBacked: true, RetrySafety: RetrySafetyNaturalIdempotent}, reg.followRequestMedia) + + unfollow := humaOp(http.MethodDelete, Prefix+"/requests/follows/{media_type}/{tmdb_id}", opUnfollowRequestMedia, requestsTag, + "Stop following a title.") + unfollow.DefaultStatus = http.StatusNoContent + Register(reg, Operation{Operation: unfollow, Class: ClassProfileScoped, DemoRestricted: true, ServiceBacked: true, RetrySafety: RetrySafetyNaturalIdempotent}, reg.unfollowRequestMedia) + get := humaOp(http.MethodGet, Prefix+"/requests/{id}", opGetRequest, requestsTag, "Get one of the account's media requests.") get.Errors = []int{http.StatusConflict} @@ -551,6 +571,34 @@ func (reg *Registry) getRequestMediaDetail(ctx context.Context, in *RequestMedia return &RequestMediaDetailOutput{Body: requestMediaDetailOf(detail)}, nil } +// RequestMediaStateOutput is the followRequestMedia response. +type RequestMediaStateOutput struct { + Body RequestMediaState +} + +func (reg *Registry) followRequestMedia(ctx context.Context, in *RequestMediaDetailInput) (*RequestMediaStateOutput, error) { + svc, viewer, p := reg.requestViewer(ctx) + if p != nil { + return nil, p + } + state, err := svc.Follow(ctx, viewer, mediarequests.MediaType(in.MediaType), in.TMDBID) + if err != nil { + return nil, requestProblem(err) + } + return &RequestMediaStateOutput{Body: requestMediaStateOf(state)}, nil +} + +func (reg *Registry) unfollowRequestMedia(ctx context.Context, in *RequestMediaDetailInput) (*struct{}, error) { + svc, viewer, p := reg.requestViewer(ctx) + if p != nil { + return nil, p + } + if err := svc.Unfollow(ctx, viewer, mediarequests.MediaType(in.MediaType), in.TMDBID); err != nil { + return nil, requestProblem(err) + } + return nil, nil +} + // listDiscoverSections is v1 GET /requests/discover. func (reg *Registry) listDiscoverSections(ctx context.Context, _ *struct{}) (*DiscoverSectionCollectionOutput, error) { svc, viewer, p := reg.requestViewer(ctx) @@ -688,6 +736,8 @@ func requestProblem(err error) *Problem { return NewProblem(TypeConflict, "The media is already available in the library.") case errors.Is(err, mediarequests.ErrAlreadyRequested): return NewProblem(TypeConflict, "The media already has an active request.") + case errors.Is(err, mediarequests.ErrNotRequested): + return NewProblem(TypeConflict, "The media has no active request to follow; request it instead.") case errors.Is(err, mediarequests.ErrForbidden): return NewProblem(TypePermissionDenied, "Request access denied.") case errors.Is(err, mediarequests.ErrNotFound): @@ -743,7 +793,7 @@ func mediaRequestOf(r *mediarequests.Request) MediaRequest { } func requestMediaStateOf(s mediarequests.RequestState) RequestMediaState { - return RequestMediaState{Status: string(s.Status), Requestable: s.Requestable, Reason: s.Reason, RequestID: ID(s.RequestID)} + return RequestMediaState{Status: string(s.Status), Requestable: s.Requestable, Reason: s.Reason, RequestID: ID(s.RequestID), Following: s.Following, RequestedByViewer: s.RequestedByViewer} } func requestMediaResultsOf(results []mediarequests.MediaResult) []RequestMediaResult { diff --git a/internal/apiv2/requests_test.go b/internal/apiv2/requests_test.go index a50bf7dc68..00d9b1fba0 100644 --- a/internal/apiv2/requests_test.go +++ b/internal/apiv2/requests_test.go @@ -188,6 +188,17 @@ func (f *fakeRequests) BrowseGenre(_ context.Context, viewer mediarequests.Viewe return f.browse(viewer, "genre", slug, mediaType, sort, page) } +func (f *fakeRequests) Follow(_ context.Context, viewer mediarequests.Viewer, mediaType mediarequests.MediaType, tmdbID int) (mediarequests.RequestState, error) { + if err := f.record(viewer, "follow", mediaType, tmdbID); err != nil { + return mediarequests.RequestState{}, err + } + return mediarequests.RequestState{Status: mediarequests.StatusPending, Reason: "already_requested", Following: true}, nil +} + +func (f *fakeRequests) Unfollow(_ context.Context, viewer mediarequests.Viewer, mediaType mediarequests.MediaType, tmdbID int) error { + return f.record(viewer, "unfollow", mediaType, tmdbID) +} + func requestDeps(svc *fakeRequests) Dependencies { deps := pilotDeps(nil, nil) if svc != nil { @@ -512,3 +523,29 @@ func TestRequestsDenied(t *testing.T) { requireProblem(t, do(t, hu, op.method, op.path, op.body, requestOwner), TypeDependencyUnavailable) } } + +func TestFollowRequestMedia(t *testing.T) { + svc := fixtureRequests() + h := newTestHandler(t, requestDeps(svc)) + + rec := do(t, h, http.MethodPut, "/api/v2/requests/follows/movie/949", "", requestOwner) + var got RequestMediaState + decodeBody(t, rec.Body, &got) + if rec.Code != http.StatusOK || !got.Following || got.Status != "pending" { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } + if svc.lastCall != "follow" || svc.lastArgs[0] != mediarequests.MediaTypeMovie || svc.lastArgs[1] != 949 || svc.lastViewer.ProfileID != "p-owner" { + t.Fatalf("call = %s %v viewer = %+v", svc.lastCall, svc.lastArgs, svc.lastViewer) + } + + rec = do(t, h, http.MethodDelete, "/api/v2/requests/follows/series/1399", "", requestOwner) + if rec.Code != http.StatusNoContent || svc.lastCall != "unfollow" || svc.lastArgs[0] != mediarequests.MediaTypeSeries { + t.Fatalf("%d %s call = %s %v", rec.Code, rec.Body.String(), svc.lastCall, svc.lastArgs) + } + + requireProblem(t, do(t, h, http.MethodPut, "/api/v2/requests/follows/tv/949", "", requestOwner), TypeValidationFailed) + svc.err = mediarequests.ErrNotRequested + requireProblem(t, do(t, h, http.MethodPut, "/api/v2/requests/follows/movie/949", "", requestOwner), TypeConflict) + svc.err = mediarequests.ErrAlreadyAvailable + requireProblem(t, do(t, h, http.MethodPut, "/api/v2/requests/follows/movie/949", "", requestOwner), TypeConflict) +} diff --git a/internal/apiv2/router.go b/internal/apiv2/router.go index 3f5c9e11a6..0dd581e8fa 100644 --- a/internal/apiv2/router.go +++ b/internal/apiv2/router.go @@ -1020,6 +1020,8 @@ type MediaRequestService interface { BrowseStudio(ctx context.Context, viewer mediarequests.Viewer, slug, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) BrowseNetwork(ctx context.Context, viewer mediarequests.Viewer, slug, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) BrowseGenre(ctx context.Context, viewer mediarequests.Viewer, slug string, mediaType mediarequests.MediaType, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) + Follow(ctx context.Context, viewer mediarequests.Viewer, mediaType mediarequests.MediaType, tmdbID int) (mediarequests.RequestState, error) + Unfollow(ctx context.Context, viewer mediarequests.Viewer, mediaType mediarequests.MediaType, tmdbID int) error } // CatalogSettingsReader is the slice of the server settings store catalog diff --git a/internal/contractledger/ledger_test.go b/internal/contractledger/ledger_test.go index 14d68e013a..ca7835d0d4 100644 --- a/internal/contractledger/ledger_test.go +++ b/internal/contractledger/ledger_test.go @@ -1208,6 +1208,8 @@ func TestRetrySafetyMismatchesFire(t *testing.T) { // applies to concurrency. var mutationWithoutLegacyRow = map[string]string{ "transferAdminUserOwnership": "V2-only server ownership transfer (issue #1382): v1 had no Owner. Replaying a completed transfer is refused because the caller is no longer the Owner, so it cannot move ownership twice.", + "followRequestMedia": "V2-only title follow (Requests acceptance AC1/AC5): v1 had no way to follow a title someone else requested. The follow row is keyed by title and profile, so a replay converges on the same follow.", + "unfollowRequestMedia": "V2-only title unfollow, the inverse of followRequestMedia. Deleting an absent follow is a no-op, so a replay converges on no follow.", "createAdminUserPasswordReset": "V2-only password reset link issue (issue #1442): v1 had no reset links. Each call replaces the account's single live link, so a replay only supersedes the previous link; it is non-retryable because an emailed link may already have been delivered.", "completePasswordReset": "V2-only public password reset completion (issue #1442): v1 had no reset links. Deleting the single-use link commits in the same transaction as the new password, so a replay finds no link and changes nothing.", "requestPasswordReset": "V2-only self-service password reset request (issue #1443): v1 had no reset links. A replay inside the per-account cooldown changes nothing; after it, the replay replaces the link and sends another email, so it is non-retryable.", diff --git a/internal/notifications/display.go b/internal/notifications/display.go index 37918d0ec1..b71f6a4f24 100644 --- a/internal/notifications/display.go +++ b/internal/notifications/display.go @@ -39,13 +39,17 @@ func BuildNotificationDisplay(row DeliveryRow) NotificationDisplay { display.URL = "/item/" + *row.EpisodeID } case DeliveryTypeRequestFulfilled: + flags := parseRequestFlags(row.ReasonFlags) display.Category = "request_fulfilled" display.Title = "Your request is now available" + display.Body = "Your media request has arrived in the library." + if flags.Follower { + display.Title = followedTitleAvailable + display.Body = "A title you asked to hear about has arrived in the library." + } if row.SeriesTitle != "" { display.Title = row.SeriesTitle + " is now available" } - display.Body = "Your media request has arrived in the library." - flags := parseRequestFlags(row.ReasonFlags) if flags.RequestID != "" { display.ThreadID = "request:" + flags.RequestID } else if row.SeriesID != nil && *row.SeriesID != "" { diff --git a/internal/notifications/email_compose.go b/internal/notifications/email_compose.go index ad8c83ad97..44aa57033b 100644 --- a/internal/notifications/email_compose.go +++ b/internal/notifications/email_compose.go @@ -159,6 +159,9 @@ func requestLine(row DeliveryRow) string { if title != "" { return title + " is now available" } + if flags.Follower { + return followedTitleAvailable + } return "Your media request is now available" } } diff --git a/internal/notifications/request_notifier.go b/internal/notifications/request_notifier.go index ea535e8b44..8f6a9cf5f2 100644 --- a/internal/notifications/request_notifier.go +++ b/internal/notifications/request_notifier.go @@ -28,6 +28,9 @@ const ( DeliveryTypeRequestDeclined = "request.declined" ) +// followedTitleAvailable heads a request.fulfilled copy sent to a follower. +const followedTitleAvailable = "A title you followed is now available" + // RequestFlags is the decoded reason_flags shape for request.* deliveries. // Fulfilled rows carry only the identifiers (the catalog join renders their // display fields); approved/declined rows have no catalog item yet, so the @@ -41,6 +44,10 @@ type RequestFlags struct { PosterPath string `json:"poster_path,omitempty"` // Reason is the admin's decline message, when one was given. Reason string `json:"reason,omitempty"` + // Follower marks a request.fulfilled copy sent to a profile that followed + // the title rather than requested it, so the copy does not say "your + // request". + Follower bool `json:"follower,omitempty"` } // parseRequestFlags decodes a request.* delivery's reason_flags; other types @@ -61,63 +68,107 @@ func isRequestLifecycleType(deliveryType string) bool { } // RequestFulfillmentNotifier adapts the notification system to -// requests.FulfillmentNotifier: it gates on the profile's master toggle and -// dispatches one durable request.fulfilled delivery across all channels. +// requests.FulfillmentNotifier: it gates on each profile's master toggle and +// dispatches one durable request.fulfilled delivery per recipient across all +// channels. type RequestFulfillmentNotifier struct { - system *System + backend fulfillmentBackend +} + +// fulfillmentBackend is the slice of System the fulfillment adapter uses; the +// unexported methods keep *System its only production implementation. +type fulfillmentBackend interface { + PostServerChannelRequestEvent(ctx context.Context, event string, info RequestEventInfo) + notificationsEnabled(ctx context.Context, profileID string) (bool, error) + dispatchFulfilled(ctx context.Context, delivery Delivery) error +} + +func (s *System) notificationsEnabled(ctx context.Context, profileID string) (bool, error) { + prefs, err := s.Preferences.Get(ctx, profileID) + if err != nil { + return false, err + } + return prefs.Enabled, nil +} + +func (s *System) dispatchFulfilled(ctx context.Context, delivery Delivery) error { + _, err := s.DispatchOperational(ctx, delivery, OperationalDispatch{ + WebhookFilter: func(hook Webhook) bool { return hook.NotifyRequests }, + }) + return err } // NewRequestFulfillmentNotifier creates the adapter. func NewRequestFulfillmentNotifier(system *System) *RequestFulfillmentNotifier { - return &RequestFulfillmentNotifier{system: system} + if system == nil { + return &RequestFulfillmentNotifier{} + } + return &RequestFulfillmentNotifier{backend: system} } // NotifyFulfilled implements requests.FulfillmentNotifier. contentID is the // matched catalog item: deliveryRowSelect joins media_items on series_id, so // that one field renders the title, poster, and deep link for movies and -// series alike. Returning nil without dispatching (master toggle off, missing -// attribution) still counts as handled — the caller stamps the request either -// way. +// series alike. It tells the requester, then every follower, and returns the +// first dispatch error so the caller retries the whole request later; a +// recipient already told is deduped by the (profile, request) unique index. +// Skipping a recipient (master toggle off, missing attribution) still counts +// as handled. func (n *RequestFulfillmentNotifier) NotifyFulfilled(ctx context.Context, req requests.Request, contentID string) error { - if n == nil || n.system == nil { + if n == nil || n.backend == nil { return nil } - // Server-channel broadcast first: it is community-facing and must not be - // gated by the requester's personal preferences or attribution. Detached - // and best-effort — a failure here must never block the - // fulfilled_notified_at stamp, or the per-profile path would re-fire. - n.system.PostServerChannelRequestEvent(ctx, ServerChannelEventRequestFulfilled, requestEventInfoFor(req)) - - if req.RequestedByProfileID == "" || req.RequestedByUserID <= 0 { - return nil // legacy rows without attribution have no recipient + base := RequestFlags{RequestID: req.ID, TMDBID: req.TMDBID, MediaType: string(req.MediaType)} + // Legacy rows without attribution have no requester recipient. + told := map[string]bool{} + if req.RequestedByProfileID != "" && req.RequestedByUserID > 0 { + told[req.RequestedByProfileID] = true + if err := n.notifyFulfilledProfile(ctx, requests.Follower{UserID: req.RequestedByUserID, ProfileID: req.RequestedByProfileID}, contentID, base); err != nil { + return err + } + } + follower := base + follower.Follower = true + for _, recipient := range req.Followers { + if told[recipient.ProfileID] { + continue + } + told[recipient.ProfileID] = true + if err := n.notifyFulfilledProfile(ctx, recipient, contentID, follower); err != nil { + return err + } } - prefs, err := n.system.Preferences.Get(ctx, req.RequestedByProfileID) + // The server-channel post goes last, and only once every recipient was + // dispatched: a failure above makes the caller retry the request, and + // posting first would repeat the community announcement on every retry. + // It is not gated by anyone's personal preferences, and it is detached + // and best-effort. + n.backend.PostServerChannelRequestEvent(ctx, ServerChannelEventRequestFulfilled, requestEventInfoFor(req)) + return nil +} + +// notifyFulfilledProfile posts one request.fulfilled delivery to a profile +// whose notifications are on. +func (n *RequestFulfillmentNotifier) notifyFulfilledProfile(ctx context.Context, recipient requests.Follower, contentID string, flags RequestFlags) error { + enabled, err := n.backend.notificationsEnabled(ctx, recipient.ProfileID) if err != nil { return err } - if !prefs.Enabled { + if !enabled { return nil } - flags, err := json.Marshal(RequestFlags{ - RequestID: req.ID, - TMDBID: req.TMDBID, - MediaType: string(req.MediaType), - }) + raw, err := json.Marshal(flags) if err != nil { return fmt.Errorf("marshal request fulfilled flags: %w", err) } - delivery := Delivery{ + return n.backend.dispatchFulfilled(ctx, Delivery{ ID: ulid.Make().String(), - UserID: req.RequestedByUserID, - ProfileID: req.RequestedByProfileID, + UserID: recipient.UserID, + ProfileID: recipient.ProfileID, SeriesID: &contentID, Type: DeliveryTypeRequestFulfilled, - ReasonFlags: flags, - } - _, err = n.system.DispatchOperational(ctx, delivery, OperationalDispatch{ - WebhookFilter: func(hook Webhook) bool { return hook.NotifyRequests }, + ReasonFlags: raw, }) - return err } // requestLifecycleDispatchTimeout bounds one detached lifecycle dispatch. diff --git a/internal/notifications/request_notifier_test.go b/internal/notifications/request_notifier_test.go index bc8f8a4fe2..f9afb62d1b 100644 --- a/internal/notifications/request_notifier_test.go +++ b/internal/notifications/request_notifier_test.go @@ -1,114 +1,102 @@ package notifications import ( - "bytes" "context" - "log/slog" - "slices" - "strings" + "errors" "testing" "github.com/Silo-Server/silo-server/internal/requests" ) -type recordedServerRequestEvent struct { - event string - info RequestEventInfo +type fakeFulfillmentBackend struct { + disabled map[string]bool + failFor string + deliveries []Delivery + channelPost int } -// recordingRequestLifecycleBackend stands in for *System. Both backend methods -// detach in production, so recording them synchronously here keeps the -// adapter's decisions observable without a goroutine to join. -type recordingRequestLifecycleBackend struct { - serverEvents []recordedServerRequestEvent - personalDeliveries []string +func (f *fakeFulfillmentBackend) PostServerChannelRequestEvent(context.Context, string, RequestEventInfo) { + f.channelPost++ } -func (b *recordingRequestLifecycleBackend) PostServerChannelRequestEvent( - _ context.Context, - event string, - info RequestEventInfo, -) { - b.serverEvents = append(b.serverEvents, recordedServerRequestEvent{event: event, info: info}) +func (f *fakeFulfillmentBackend) notificationsEnabled(_ context.Context, profileID string) (bool, error) { + return !f.disabled[profileID], nil } -func (b *recordingRequestLifecycleBackend) dispatchRequestLifecycleDetached( - _ context.Context, - _ requests.Request, - deliveryType string, -) { - b.personalDeliveries = append(b.personalDeliveries, deliveryType) +func (f *fakeFulfillmentBackend) dispatchFulfilled(_ context.Context, delivery Delivery) error { + if delivery.ProfileID == f.failFor { + return errors.New("dispatch failed") + } + f.deliveries = append(f.deliveries, delivery) + return nil } -// approve runs one approval through the adapter, checks the server-channel -// broadcast that every origin owes, and returns the backend plus whatever was -// logged for the personal-delivery assertions below. -func approve(t *testing.T, origin requests.ApprovalOrigin) (*recordingRequestLifecycleBackend, string) { - t.Helper() - var logs bytes.Buffer - backend := &recordingRequestLifecycleBackend{} - notifier := &RequestLifecycleNotifier{backend: backend, logger: slog.New(slog.NewTextHandler(&logs, nil))} +func fulfilledRequest(followers ...requests.Follower) requests.Request { + return requests.Request{ + ID: "req-1", MediaType: requests.MediaTypeMovie, TMDBID: 949, Title: "Heat", + RequestedByUserID: 1, RequestedByProfileID: "requester", Followers: followers, + } +} - notifier.RequestApproved(context.Background(), requests.Request{ - ID: "req-1", - MediaType: requests.MediaTypeMovie, - TMDBID: 550, - Title: "Fight Club", - RequestedByUserID: 7, - RequestedByProfileID: "profile-7", - }, origin) +func TestNotifyFulfilledTellsRequesterAndFollowers(t *testing.T) { + backend := &fakeFulfillmentBackend{disabled: map[string]bool{"muted": true}} + notifier := &RequestFulfillmentNotifier{backend: backend} - if len(backend.serverEvents) != 1 { - t.Fatalf("server events = %+v, want one approval event", backend.serverEvents) + err := notifier.NotifyFulfilled(context.Background(), fulfilledRequest( + requests.Follower{UserID: 2, ProfileID: "follower"}, + requests.Follower{UserID: 1, ProfileID: "requester"}, // a leftover follow by the requester + requests.Follower{UserID: 3, ProfileID: "muted"}, + ), "movie-tmdb-949") + if err != nil { + t.Fatalf("NotifyFulfilled: %v", err) + } + if len(backend.deliveries) != 2 { + t.Fatalf("deliveries = %+v, want the requester and the one unmuted follower", backend.deliveries) + } + requester, follower := backend.deliveries[0], backend.deliveries[1] + if requester.ProfileID != "requester" || parseRequestFlags(requester.ReasonFlags).Follower { + t.Fatalf("first delivery = %+v, want the requester's own copy", requester) + } + if follower.ProfileID != "follower" || follower.UserID != 2 || !parseRequestFlags(follower.ReasonFlags).Follower { + t.Fatalf("second delivery = %+v, want the follower's copy marked as such", follower) } - got := backend.serverEvents[0] - if got.event != ServerChannelEventRequestApproved || got.info.RequestID != "req-1" { - t.Fatalf("server event = %+v, want request.approved for req-1", got) + if flags := parseRequestFlags(follower.ReasonFlags); flags.RequestID != "req-1" || flags.TMDBID != 949 { + t.Fatalf("follower flags = %+v, want the request identity", flags) + } + if backend.channelPost != 1 { + t.Fatalf("channel posts = %d, want 1", backend.channelPost) } - return backend, logs.String() } -func TestRequestApprovedByAdminKeepsServerAndPersonalDelivery(t *testing.T) { - backend, _ := approve(t, requests.ApprovalOriginAdmin) +// A failed recipient makes the caller retry the whole request; the community +// channel must not be posted until an attempt reaches everyone. +func TestNotifyFulfilledPostsChannelOnlyAfterEveryRecipient(t *testing.T) { + backend := &fakeFulfillmentBackend{failFor: "follower"} + notifier := &RequestFulfillmentNotifier{backend: backend} - want := []string{DeliveryTypeRequestApproved} - if !slices.Equal(backend.personalDeliveries, want) { - t.Fatalf("personal deliveries = %v, want %v", backend.personalDeliveries, want) + err := notifier.NotifyFulfilled(context.Background(), fulfilledRequest(requests.Follower{UserID: 2, ProfileID: "follower"}), "movie-tmdb-949") + if err == nil { + t.Fatal("NotifyFulfilled succeeded, want the follower's dispatch error") + } + if backend.channelPost != 0 { + t.Fatalf("channel posts = %d, want none before every recipient is told", backend.channelPost) } } -func TestRequestApprovedByPolicyKeepsServerEventAndSkipsPersonalDelivery(t *testing.T) { - backend, logs := approve(t, requests.ApprovalOriginPolicy) +func TestFulfilledCopyForFollowers(t *testing.T) { + requester := DeliveryRow{Delivery: Delivery{Type: DeliveryTypeRequestFulfilled, ReasonFlags: []byte(`{"request_id":"req-1"}`)}} + follower := DeliveryRow{Delivery: Delivery{Type: DeliveryTypeRequestFulfilled, ReasonFlags: []byte(`{"request_id":"req-1","follower":true}`)}} - if len(backend.personalDeliveries) != 0 { - t.Fatalf("personal deliveries = %v, want none", backend.personalDeliveries) + if got := BuildNotificationDisplay(requester); got.Title != "Your request is now available" { + t.Fatalf("requester title = %q", got.Title) } - if strings.Contains(logs, "unrecognized request approval origin") { - t.Fatalf("auto-approval logged an unrecognized origin: %s", logs) + if got := BuildNotificationDisplay(follower); got.Title != followedTitleAvailable || got.Body == "Your media request has arrived in the library." { + t.Fatalf("follower display = %+v, want copy that does not claim the request", got) } -} - -// An origin this build does not know about must fail closed: a future -// policy-driven approval path may not silently resurrect the requester notice -// (issue #590). The warning is that trade-off's safety net. -func TestRequestApprovedUnknownOriginSkipsPersonalDeliveryAndWarns(t *testing.T) { - cases := []struct { - name string - origin requests.ApprovalOrigin - }{ - {name: "unspecified", origin: requests.ApprovalOriginUnspecified}, - {name: "unknown", origin: requests.ApprovalOrigin("future")}, + if got := requestLine(follower); got != followedTitleAvailable { + t.Fatalf("follower email line = %q", got) } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - backend, logs := approve(t, tc.origin) - - if len(backend.personalDeliveries) != 0 { - t.Fatalf("personal deliveries = %v, want none", backend.personalDeliveries) - } - if !strings.Contains(logs, "unrecognized request approval origin") { - t.Fatalf("logs = %q, want a warning about the unrecognized origin", logs) - } - }) + if got := discordEmbedAuthorLine(follower); got != "Now available on Silo" { + t.Fatalf("follower Discord author = %q", got) } } diff --git a/internal/notifications/webhook_payload_discord.go b/internal/notifications/webhook_payload_discord.go index c45985e432..0800bf2496 100644 --- a/internal/notifications/webhook_payload_discord.go +++ b/internal/notifications/webhook_payload_discord.go @@ -123,11 +123,14 @@ func BuildDiscordDMPayload(rows []DeliveryRow) ([]byte, error) { // discordEmbedAuthorLine renders the small "what happened" line above the // embed title. -func discordEmbedAuthorLine(deliveryType string) string { - switch deliveryType { +func discordEmbedAuthorLine(row DeliveryRow) string { + switch row.Type { case DeliveryTypeEpisodeAvailable: return "New episode on Silo" case DeliveryTypeRequestFulfilled: + if parseRequestFlags(row.ReasonFlags).Follower { + return "Now available on Silo" + } return "Your request is now available on Silo" case DeliveryTypeRequestApproved: return "Your request was approved on Silo" @@ -218,7 +221,7 @@ func buildDiscordEmbed(row DeliveryRow, test bool) discordEmbed { URL: ids.titleURL(), Description: embedDescription(overview, ids), Color: color, - Author: &discordEmbedAuthor{Name: discordEmbedAuthorLine(row.Type)}, + Author: &discordEmbedAuthor{Name: discordEmbedAuthorLine(row)}, Footer: &discordEmbedFooter{Text: discordEmbedFooterText(row.ContentRating, test)}, Fields: fields, } diff --git a/internal/requests/capability.go b/internal/requests/capability.go index 74b281c10d..1bb8665780 100644 --- a/internal/requests/capability.go +++ b/internal/requests/capability.go @@ -18,9 +18,19 @@ func (s *Service) RequestCapabilityAllowed(ctx context.Context, viewer Viewer) ( } return false, err } - limit, err := s.store.GetUserLimit(ctx, viewer.UserID) + blocked, err := s.userLimitBlocked(ctx, viewer.UserID) if err != nil { return false, err } - return limit == nil || (limit.LimitMode != LimitModeBlocked && limit.ApprovalMode != ApprovalModeBlocked), nil + return !blocked, nil +} + +// userLimitBlocked reports whether the account's request limit or approval +// mode is set to blocked. +func (s *Service) userLimitBlocked(ctx context.Context, userID int) (bool, error) { + limit, err := s.store.GetUserLimit(ctx, userID) + if err != nil { + return false, err + } + return limit != nil && (limit.LimitMode == LimitModeBlocked || limit.ApprovalMode == ApprovalModeBlocked), nil } diff --git a/internal/requests/errors.go b/internal/requests/errors.go index 7baf8e733a..69a22a1a79 100644 --- a/internal/requests/errors.go +++ b/internal/requests/errors.go @@ -10,9 +10,12 @@ var ( ErrQuotaExceeded = errors.New("request quota exceeded") ErrAlreadyAvailable = errors.New("media is already available") ErrAlreadyRequested = errors.New("media is already requested") - ErrNotFound = errors.New("request not found") - ErrForbidden = errors.New("request forbidden") - ErrInvalidState = errors.New("invalid request state") + // ErrNotRequested answers a follow for a title nobody has an active + // request for; the viewer should request it instead. + ErrNotRequested = errors.New("media has no active request") + ErrNotFound = errors.New("request not found") + ErrForbidden = errors.New("request forbidden") + ErrInvalidState = errors.New("invalid request state") // ErrIntegrationUnreachable reports that the configured request integration // (its plugin, or the service behind it) could not be reached. It is a // dependency failure, not a bug in the request, so the API layer answers an diff --git a/internal/requests/follows.go b/internal/requests/follows.go new file mode 100644 index 0000000000..1a531d5939 --- /dev/null +++ b/internal/requests/follows.go @@ -0,0 +1,246 @@ +package requests + +import ( + "context" + "fmt" + "log/slog" + "strings" +) + +// Following a title: a profile that finds a title someone else has already +// requested can ask to be notified when it becomes available, instead of +// requesting it again. A follow belongs to the title, not to one request, so it +// survives the request failing and being retried or requested again. It is +// cleared once the fulfilled notification has gone out, and when the request +// is declined or withdrawn: the title is then no longer on its way, and the +// follower can request it themselves. The requester is always notified and +// never needs a follow. + +// Follower is a profile waiting to hear that a requested title is available. +type Follower struct { + UserID int + ProfileID string +} + +// Follow records that the viewer's profile wants to hear when the title +// becomes available. The title must have an active request and must not be in +// the library yet. Following a title the viewer requested is a no-op. +func (s *Service) Follow(ctx context.Context, viewer Viewer, mediaType MediaType, tmdbID int) (RequestState, error) { + if err := validateViewer(viewer); err != nil { + return RequestState{}, err + } + if strings.TrimSpace(viewer.ProfileID) == "" { + return RequestState{}, ErrForbidden + } + if err := s.ensureRequestsEnabled(ctx); err != nil { + return RequestState{}, err + } + if err := s.ensureViewerRequestsAllowed(ctx, viewer.UserID); err != nil { + return RequestState{}, err + } + if blocked, err := s.userLimitBlocked(ctx, viewer.UserID); err != nil { + return RequestState{}, err + } else if blocked { + return RequestState{}, ErrUserBlocked + } + mediaType, err := normalizeMediaType(mediaType) + if err != nil { + return RequestState{}, err + } + if tmdbID <= 0 { + return RequestState{}, fmt.Errorf("%w: tmdb id is required", ErrInvalidInput) + } + // Following reaches the same titles requesting does, so the profile's + // rating ceiling applies to it too. + if err := s.ensureCreateAllowedByCeiling(ctx, viewer, CreateRequestInput{MediaType: mediaType, TMDBID: tmdbID}); err != nil { + return RequestState{}, err + } + presence, err := s.lookupAvailable(ctx, mediaType, []int{tmdbID}) + if err != nil { + return RequestState{}, err + } + if presence[tmdbID].Available { + return RequestState{}, ErrAlreadyAvailable + } + active, err := s.store.ListActiveByTMDB(ctx, mediaType, []int{tmdbID}) + if err != nil { + return RequestState{}, err + } + req := active[tmdbID] + if req == nil { + return RequestState{}, ErrNotRequested + } + if req.RequestedByProfileID != viewer.ProfileID { + if err := s.store.FollowTitle(ctx, mediaType, tmdbID, viewer); err != nil { + return RequestState{}, err + } + } + state := activeRequestState(viewer, req) + state.Following = true + return state, nil +} + +// Unfollow removes the viewer's follow. It succeeds whether or not the profile +// followed the title, and whatever state the title's request is in. +func (s *Service) Unfollow(ctx context.Context, viewer Viewer, mediaType MediaType, tmdbID int) error { + if err := validateViewer(viewer); err != nil { + return err + } + if strings.TrimSpace(viewer.ProfileID) == "" { + return ErrForbidden + } + mediaType, err := normalizeMediaType(mediaType) + if err != nil { + return err + } + if tmdbID <= 0 { + return fmt.Errorf("%w: tmdb id is required", ErrInvalidInput) + } + return s.store.UnfollowTitle(ctx, mediaType, tmdbID, viewer.ProfileID) +} + +// forgetFollowsAfterWithdrawal clears a withdrawn request's title follows. +// Best effort: a leftover follow only fires if the title is requested again +// and arrives. +func (s *Service) forgetFollowsAfterWithdrawal(ctx context.Context, req *Request) { + if req == nil { + return + } + if err := s.store.ForgetTitleFollows(ctx, req.MediaType, req.TMDBID); err != nil { + slog.WarnContext(ctx, "requests: clearing follows after withdrawal failed", "component", "requests", + "request_id", req.ID, "err", err) + } +} + +// followedTitles reports which of the titles with an active request the viewer +// is waiting on, either as the requesting profile or as a follower. +func (s *Service) followedTitles(ctx context.Context, viewer Viewer, mediaType MediaType, active map[int]*Request) (map[int]bool, error) { + out := map[int]bool{} + var others []int + for tmdbID, req := range active { + if req == nil { + continue + } + if req.RequestedByProfileID == viewer.ProfileID { + out[tmdbID] = true + continue + } + others = append(others, tmdbID) + } + if len(others) == 0 || strings.TrimSpace(viewer.ProfileID) == "" { + return out, nil + } + followed, err := s.store.FollowedTitles(ctx, mediaType, others, viewer.ProfileID) + if err != nil { + return nil, err + } + for tmdbID, ok := range followed { + if ok { + out[tmdbID] = true + } + } + return out, nil +} + +// FollowTitle inserts the follow only while the title has an open request, in +// the same statement, so a follow cannot land just after the request +// completed and never be told. It answers ErrNotRequested when there is none. +func (r *Repository) FollowTitle(ctx context.Context, mediaType MediaType, tmdbID int, viewer Viewer) error { + var open bool + if err := r.pool.QueryRow(ctx, ` + WITH open_request AS ( + SELECT 1 FROM media_requests + WHERE media_type = $1 AND provider = 'tmdb' AND tmdb_id = $2 + AND outcome = 'active' AND status <> 'completed' + LIMIT 1 + ), inserted AS ( + INSERT INTO media_request_follows (media_type, tmdb_id, user_id, profile_id) + SELECT $1, $2, $3, $4 FROM open_request + ON CONFLICT (media_type, tmdb_id, profile_id) DO NOTHING + ) + SELECT EXISTS (SELECT 1 FROM open_request) + `, mediaType, tmdbID, viewer.UserID, viewer.ProfileID).Scan(&open); err != nil { + return fmt.Errorf("follow title: %w", err) + } + if !open { + return ErrNotRequested + } + return nil +} + +// ForgetTitleFollows removes every follow on the title. +func (r *Repository) ForgetTitleFollows(ctx context.Context, mediaType MediaType, tmdbID int) error { + if _, err := r.pool.Exec(ctx, ` + DELETE FROM media_request_follows WHERE media_type = $1 AND tmdb_id = $2 + `, mediaType, tmdbID); err != nil { + return fmt.Errorf("forget title follows: %w", err) + } + return nil +} + +func (r *Repository) UnfollowTitle(ctx context.Context, mediaType MediaType, tmdbID int, profileID string) error { + if _, err := r.pool.Exec(ctx, ` + DELETE FROM media_request_follows + WHERE media_type = $1 AND tmdb_id = $2 AND profile_id = $3 + `, mediaType, tmdbID, profileID); err != nil { + return fmt.Errorf("unfollow title: %w", err) + } + return nil +} + +func (r *Repository) FollowedTitles(ctx context.Context, mediaType MediaType, tmdbIDs []int, profileID string) (map[int]bool, error) { + out := map[int]bool{} + if len(tmdbIDs) == 0 { + return out, nil + } + rows, err := r.pool.Query(ctx, ` + SELECT tmdb_id FROM media_request_follows + WHERE media_type = $1 AND tmdb_id = ANY($2) AND profile_id = $3 + `, mediaType, tmdbIDs, profileID) + if err != nil { + return nil, fmt.Errorf("list followed titles: %w", err) + } + defer rows.Close() + for rows.Next() { + var tmdbID int + if err := rows.Scan(&tmdbID); err != nil { + return nil, err + } + out[tmdbID] = true + } + return out, rows.Err() +} + +func (r *Repository) ListTitleFollowers(ctx context.Context, mediaType MediaType, tmdbID int) ([]Follower, error) { + rows, err := r.pool.Query(ctx, ` + SELECT user_id, profile_id FROM media_request_follows + WHERE media_type = $1 AND tmdb_id = $2 + ORDER BY created_at, profile_id + `, mediaType, tmdbID) + if err != nil { + return nil, fmt.Errorf("list title followers: %w", err) + } + defer rows.Close() + var out []Follower + for rows.Next() { + var f Follower + if err := rows.Scan(&f.UserID, &f.ProfileID); err != nil { + return nil, err + } + out = append(out, f) + } + return out, rows.Err() +} + +func (r *Repository) ClearTitleFollowers(ctx context.Context, mediaType MediaType, tmdbID int, profileIDs []string) error { + if len(profileIDs) == 0 { + return nil + } + if _, err := r.pool.Exec(ctx, ` + DELETE FROM media_request_follows + WHERE media_type = $1 AND tmdb_id = $2 AND profile_id = ANY($3) + `, mediaType, tmdbID, profileIDs); err != nil { + return fmt.Errorf("clear title followers: %w", err) + } + return nil +} diff --git a/internal/requests/follows_test.go b/internal/requests/follows_test.go new file mode 100644 index 0000000000..85779c3455 --- /dev/null +++ b/internal/requests/follows_test.go @@ -0,0 +1,269 @@ +package requests + +import ( + "context" + "errors" + "testing" + + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" +) + +// activeRequestFor seeds an active request for the title, owned by another +// account's profile. +func activeRequestFor(store *fakeStore, tmdbID int) *Request { + req := &Request{ + ID: "req-owner", MediaType: MediaTypeMovie, TMDBID: tmdbID, Title: "Heat", + Status: StatusPending, Outcome: OutcomeActive, + RequestedByUserID: 2, RequestedByProfileID: "owner-profile", + } + store.requests[req.ID] = req + store.active[MediaTypeMovie][tmdbID] = req + return req +} + +func TestFollowTitleSomeoneElseRequested(t *testing.T) { + store := newFakeStore() + activeRequestFor(store, 949) + svc := newTestService(store) + + state, err := svc.Follow(context.Background(), testViewer(1), MediaTypeMovie, 949) + if err != nil { + t.Fatalf("Follow: %v", err) + } + if !state.Following || state.RequestedByViewer || state.Requestable || state.Reason != "already_requested" || state.RequestID != "" { + t.Fatalf("state = %+v, want following, not requestable, request id hidden from another account", state) + } + followed, _ := store.FollowedTitles(context.Background(), MediaTypeMovie, []int{949}, "profile-1") + if !followed[949] { + t.Fatal("follow was not stored") + } + if _, err := svc.Follow(context.Background(), testViewer(1), MediaTypeMovie, 949); err != nil { + t.Fatalf("second Follow: %v (want idempotent)", err) + } + + if err := svc.Unfollow(context.Background(), testViewer(1), MediaTypeMovie, 949); err != nil { + t.Fatalf("Unfollow: %v", err) + } + followed, _ = store.FollowedTitles(context.Background(), MediaTypeMovie, []int{949}, "profile-1") + if followed[949] { + t.Fatal("follow survived Unfollow") + } +} + +func TestFollowOwnRequestStoresNothing(t *testing.T) { + store := newFakeStore() + req := activeRequestFor(store, 949) + req.RequestedByUserID, req.RequestedByProfileID = 1, "profile-1" + svc := newTestService(store) + + state, err := svc.Follow(context.Background(), testViewer(1), MediaTypeMovie, 949) + if err != nil { + t.Fatalf("Follow: %v", err) + } + if !state.Following || !state.RequestedByViewer || state.RequestID != "req-owner" { + t.Fatalf("state = %+v, want following, requested by the viewer, with its request id", state) + } + if len(store.follows) != 0 { + t.Fatalf("follows = %v, want none: the requester is always notified", store.follows) + } +} + +func TestFollowRefusesTitleWithoutActiveRequest(t *testing.T) { + svc := newTestService(newFakeStore()) + if _, err := svc.Follow(context.Background(), testViewer(1), MediaTypeMovie, 949); !errors.Is(err, ErrNotRequested) { + t.Fatalf("err = %v, want ErrNotRequested", err) + } +} + +func TestFollowRefusesTitleAlreadyInLibrary(t *testing.T) { + store := newFakeStore() + activeRequestFor(store, 949) + svc := NewService(store, &fakeTMDBClient{}, presentMovie(949)) + svc.SetUserRepository(requestUserRepo{}) + if _, err := svc.Follow(context.Background(), testViewer(1), MediaTypeMovie, 949); !errors.Is(err, ErrAlreadyAvailable) { + t.Fatalf("err = %v, want ErrAlreadyAvailable", err) + } +} + +func TestFollowRefusesWhenRequestsDisabled(t *testing.T) { + store := newFakeStore() + store.settings.RequestsEnabled = false + activeRequestFor(store, 949) + if _, err := newTestService(store).Follow(context.Background(), testViewer(1), MediaTypeMovie, 949); !errors.Is(err, ErrRequestsDisabled) { + t.Fatalf("err = %v, want ErrRequestsDisabled", err) + } +} + +func TestFollowRefusesBlockedAccount(t *testing.T) { + store := newFakeStore() + activeRequestFor(store, 949) + store.limit = &UserLimit{UserID: 1, LimitMode: LimitModeBlocked, ApprovalMode: ApprovalModeInherit} + if _, err := newTestService(store).Follow(context.Background(), testViewer(1), MediaTypeMovie, 949); !errors.Is(err, ErrUserBlocked) { + t.Fatalf("err = %v, want ErrUserBlocked", err) + } +} + +// A declined or withdrawn request is no longer on its way, so its title's +// follows are dropped rather than left where the follower cannot see them. +func TestWithdrawingRequestForgetsFollows(t *testing.T) { + for _, tc := range []struct { + name string + withdraw func(*Service) error + }{ + {"decline", func(s *Service) error { + _, err := s.Decline(context.Background(), Viewer{UserID: 9, IsAdmin: true}, "req-owner", "") + return err + }}, + {"cancel", func(s *Service) error { + _, err := s.Cancel(context.Background(), Viewer{UserID: 2, ProfileID: "owner-profile"}, "req-owner", "") + return err + }}, + } { + t.Run(tc.name, func(t *testing.T) { + store := newFakeStore() + activeRequestFor(store, 949) + svc := newTestService(store) + if _, err := svc.Follow(context.Background(), testViewer(1), MediaTypeMovie, 949); err != nil { + t.Fatalf("Follow: %v", err) + } + if err := tc.withdraw(svc); err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + if followers, _ := store.ListTitleFollowers(context.Background(), MediaTypeMovie, 949); len(followers) != 0 { + t.Fatalf("followers after %s = %+v, want none", tc.name, followers) + } + }) + } +} + +func TestSearchMarksFollowedAndOwnTitles(t *testing.T) { + store := newFakeStore() + activeRequestFor(store, 949) + own := &Request{ID: "req-own", MediaType: MediaTypeMovie, TMDBID: 950, Status: StatusPending, Outcome: OutcomeActive, + RequestedByUserID: 1, RequestedByProfileID: "profile-1"} + store.requests[own.ID] = own + store.active[MediaTypeMovie][950] = own + other := &Request{ID: "req-other", MediaType: MediaTypeMovie, TMDBID: 951, Status: StatusPending, Outcome: OutcomeActive, + RequestedByUserID: 3, RequestedByProfileID: "someone"} + store.requests[other.ID] = other + store.active[MediaTypeMovie][951] = other + if err := store.FollowTitle(context.Background(), MediaTypeMovie, 949, testViewer(1)); err != nil { + t.Fatal(err) + } + svc := newTestServiceWithTMDB(store, &fakeTMDBClient{page: &tmdb.MediaPage{Page: 1, Results: []tmdb.MediaResult{ + {ID: 949, MediaType: "movie", Title: "Heat"}, + {ID: 950, MediaType: "movie", Title: "Ronin"}, + {ID: 951, MediaType: "movie", Title: "Thief"}, + }}}) + + page, err := svc.Search(context.Background(), testViewer(1), "heat", MediaTypeMovie, 1) + if err != nil { + t.Fatalf("Search: %v", err) + } + following := map[int]bool{} + for _, r := range page.Results { + following[r.TMDBID] = r.Request.Following + } + if !following[949] || !following[950] || following[951] { + t.Fatalf("following = %v, want the followed and the own title, not the other account's", following) + } +} + +func TestNotifyFulfilledTellsFollowersAndClearsThem(t *testing.T) { + store := newFakeStore() + store.requests["req1"] = completedRequestFixture("req1", 42) + store.unnotified = []string{"req1"} + store.seedFollow(MediaTypeMovie, 42, Viewer{UserID: 3, ProfileID: "follower-profile"}) + notifier := &fakeNotifier{} + svc := NewService(store, &fakeTMDBClient{}, presentMovie(42)) + svc.SetFulfillmentNotifier(notifier) + + svc.notifyFulfilledPending(context.Background()) + + if len(notifier.followers) != 1 || len(notifier.followers[0]) != 1 || notifier.followers[0][0] != (Follower{UserID: 3, ProfileID: "follower-profile"}) { + t.Fatalf("followers handed to the notifier = %+v, want the one follower", notifier.followers) + } + if followers, _ := store.ListTitleFollowers(context.Background(), MediaTypeMovie, 42); len(followers) != 0 { + t.Fatalf("followers after notifying = %+v, want cleared", followers) + } +} + +func TestNotifyFulfilledKeepsFollowersWhenDispatchFails(t *testing.T) { + store := newFakeStore() + store.requests["req1"] = completedRequestFixture("req1", 42) + store.unnotified = []string{"req1"} + store.seedFollow(MediaTypeMovie, 42, Viewer{UserID: 3, ProfileID: "follower-profile"}) + svc := NewService(store, &fakeTMDBClient{}, presentMovie(42)) + svc.SetFulfillmentNotifier(&fakeNotifier{err: errors.New("dispatch failed")}) + + svc.notifyFulfilledPending(context.Background()) + + if followers, _ := store.ListTitleFollowers(context.Background(), MediaTypeMovie, 42); len(followers) != 1 { + t.Fatalf("followers after a failed dispatch = %+v, want kept for the retry", followers) + } +} + +func TestFollowsDatabase(t *testing.T) { + repo, pool := lifecycleTestRepository(t) + ctx := t.Context() + // The schema copy has no user_profiles foreign key; the migration's key is + // exercised by the migrated database, not here. + if _, err := pool.Exec(ctx, `CREATE TABLE media_request_follows (LIKE public.media_request_follows INCLUDING ALL)`); err != nil { + t.Fatal(err) + } + if err := repo.FollowTitle(ctx, MediaTypeMovie, 949, Viewer{UserID: 1, ProfileID: "profile-a"}); !errors.Is(err, ErrNotRequested) { + t.Fatalf("follow with no open request: err = %v, want ErrNotRequested", err) + } + insertLifecycleRequest(t, repo, "movie-949", 5, 949, StatusPending) + insertLifecycleRequest(t, repo, "series-949", 5, 1, StatusPending) + if _, err := pool.Exec(ctx, `UPDATE media_requests SET media_type = 'series', tmdb_id = 949 WHERE id = 'series-949'`); err != nil { + t.Fatal(err) + } + a := Viewer{UserID: 1, ProfileID: "profile-a"} + b := Viewer{UserID: 2, ProfileID: "profile-b"} + for range 2 { + if err := repo.FollowTitle(ctx, MediaTypeMovie, 949, a); err != nil { + t.Fatalf("follow (idempotent): %v", err) + } + } + if err := repo.FollowTitle(ctx, MediaTypeMovie, 949, b); err != nil { + t.Fatal(err) + } + if err := repo.FollowTitle(ctx, MediaTypeSeries, 949, a); err != nil { + t.Fatal(err) + } + + followers, err := repo.ListTitleFollowers(ctx, MediaTypeMovie, 949) + if err != nil { + t.Fatal(err) + } + if len(followers) != 2 { + t.Fatalf("movie followers = %+v, want two (the series follow is a different title)", followers) + } + followed, err := repo.FollowedTitles(ctx, MediaTypeMovie, []int{949, 950}, "profile-a") + if err != nil { + t.Fatal(err) + } + if !followed[949] || followed[950] { + t.Fatalf("followed = %v, want only 949", followed) + } + + if err := repo.ClearTitleFollowers(ctx, MediaTypeMovie, 949, []string{"profile-a"}); err != nil { + t.Fatal(err) + } + if err := repo.UnfollowTitle(ctx, MediaTypeMovie, 949, "profile-b"); err != nil { + t.Fatal(err) + } + if followers, _ := repo.ListTitleFollowers(ctx, MediaTypeMovie, 949); len(followers) != 0 { + t.Fatalf("movie followers after clear and unfollow = %+v, want none", followers) + } + if followers, _ := repo.ListTitleFollowers(ctx, MediaTypeSeries, 949); len(followers) != 1 { + t.Fatalf("series followers = %+v, want the one untouched follow", followers) + } + if err := repo.ForgetTitleFollows(ctx, MediaTypeSeries, 949); err != nil { + t.Fatal(err) + } + if followers, _ := repo.ListTitleFollowers(ctx, MediaTypeSeries, 949); len(followers) != 0 { + t.Fatalf("series followers after forgetting = %+v, want none", followers) + } +} diff --git a/internal/requests/notify.go b/internal/requests/notify.go index 3d89980dec..a972de4a6d 100644 --- a/internal/requests/notify.go +++ b/internal/requests/notify.go @@ -11,6 +11,8 @@ import ( // the same request (delivery creation is idempotent); returning nil means the // request counts as handled and will not be retried. type FulfillmentNotifier interface { + // NotifyFulfilled tells the requester, and every profile in + // req.Followers, that the title is available. NotifyFulfilled(ctx context.Context, req Request, contentID string) error } @@ -107,6 +109,13 @@ func (s *Service) notifyFulfilledPending(ctx context.Context) { if !match.Available { continue // not in the catalog yet; retry next run } + followers, err := s.store.ListTitleFollowers(ctx, req.MediaType, req.TMDBID) + if err != nil { + slog.WarnContext(ctx, "request fulfill-notify: list followers failed", "component", "requests", + "request_id", req.ID, "err", err) + continue + } + req.Followers = followers if err := s.notifier.NotifyFulfilled(ctx, *req, match.ContentID); err != nil { slog.WarnContext(ctx, "request fulfill-notify: dispatch failed", "component", "requests", "request_id", req.ID, "err", err) @@ -115,6 +124,18 @@ func (s *Service) notifyFulfilledPending(ctx context.Context) { if err := s.store.MarkFulfilledNotified(ctx, req.ID); err != nil { slog.WarnContext(ctx, "request fulfill-notify: mark failed", "component", "requests", "request_id", req.ID, "err", err) + continue + } + // The followers have been told. Only the listed rows are cleared: + // FollowTitle needs an open request, so none can have been added + // since this request completed. + profileIDs := make([]string, 0, len(followers)) + for _, f := range followers { + profileIDs = append(profileIDs, f.ProfileID) + } + if err := s.store.ClearTitleFollowers(ctx, req.MediaType, req.TMDBID, profileIDs); err != nil { + slog.WarnContext(ctx, "request fulfill-notify: clear followers failed", "component", "requests", + "request_id", req.ID, "err", err) } } } diff --git a/internal/requests/notify_test.go b/internal/requests/notify_test.go index b28cc1e077..8e60903c5b 100644 --- a/internal/requests/notify_test.go +++ b/internal/requests/notify_test.go @@ -10,6 +10,7 @@ import ( type fakeNotifier struct { requestIDs []string contentIDs []string + followers [][]Follower err error } @@ -42,6 +43,7 @@ func (f *fakeNotifier) NotifyFulfilled(_ context.Context, req Request, contentID } f.requestIDs = append(f.requestIDs, req.ID) f.contentIDs = append(f.contentIDs, contentID) + f.followers = append(f.followers, req.Followers) return nil } diff --git a/internal/requests/service.go b/internal/requests/service.go index dd790a42cc..aca2911b44 100644 --- a/internal/requests/service.go +++ b/internal/requests/service.go @@ -658,6 +658,12 @@ func (s *Service) GetDetail(ctx context.Context, viewer Viewer, mediaType MediaT if err != nil { return nil, err } + primaryFollowing, err := s.followedTitles(ctx, viewer, mediaType, primaryRequests) + if err != nil { + return nil, err + } + primaryState := requestStateFor(viewer, policy, primaryMatch.Available, primaryRequests[raw.ID]) + primaryState.Following = primaryRequests[raw.ID] != nil && primaryFollowing[raw.ID] detail := &MediaDetail{ MediaType: mediaType, @@ -688,7 +694,7 @@ func (s *Service) GetDetail(ctx context.Context, viewer Viewer, mediaType MediaT Creators: raw.Creators, Availability: availabilityValue(primaryMatch.Available), LibraryContentID: primaryMatch.ContentID, - Request: requestStateFor(viewer, policy, primaryMatch.Available, primaryRequests[raw.ID]), + Request: primaryState, } if raw.TVDBID > 0 { tvdb := raw.TVDBID @@ -960,6 +966,7 @@ func (s *Service) Decline(ctx context.Context, viewer Viewer, id, reason string) } declined.DeclineReason = strings.TrimSpace(reason) s.notifyLifecycle(ctx, *declined, LifecycleNotifier.RequestDeclined) + s.forgetFollowsAfterWithdrawal(ctx, declined) return declined, nil } @@ -984,7 +991,12 @@ func (s *Service) Cancel(ctx context.Context, viewer Viewer, id, reason string) if !viewer.IsAdmin && req.RequestedByUserID != viewer.UserID { return nil, ErrForbidden } - return s.store.SetOutcome(ctx, req.ID, guardWithdrawable, OutcomeCancelled, viewer, reason) + withdrawn, err := s.store.SetOutcome(ctx, req.ID, guardWithdrawable, OutcomeCancelled, viewer, reason) + if err != nil { + return nil, err + } + s.forgetFollowsAfterWithdrawal(ctx, withdrawn) + return withdrawn, nil } func (s *Service) Retry(ctx context.Context, viewer Viewer, id string) (*Request, error) { @@ -1499,6 +1511,7 @@ func (s *Service) enrichPageWithCeiling(ctx context.Context, viewer Viewer, raw available := map[MediaType]map[int]PresenceMatch{} active := map[MediaType]map[int]*Request{} + following := map[MediaType]map[int]bool{} for mediaType, ids := range idsByType { presence, err := s.lookupAvailable(ctx, mediaType, ids) if err != nil { @@ -1510,6 +1523,9 @@ func (s *Service) enrichPageWithCeiling(ctx context.Context, viewer Viewer, raw return nil, err } active[mediaType] = requests + if following[mediaType], err = s.followedTitles(ctx, viewer, mediaType, requests); err != nil { + return nil, err + } } out := &MediaPage{ @@ -1525,6 +1541,8 @@ func (s *Service) enrichPageWithCeiling(ctx context.Context, viewer Viewer, raw } match := available[mediaType][item.ID] activeRequest := active[mediaType][item.ID] + state := requestStateFor(viewer, policy, match.Available, activeRequest) + state.Following = activeRequest != nil && following[mediaType][item.ID] out.Results = append(out.Results, MediaResult{ MediaType: mediaType, TMDBID: item.ID, @@ -1538,7 +1556,7 @@ func (s *Service) enrichPageWithCeiling(ctx context.Context, viewer Viewer, raw VoteAverage: item.VoteAverage, Availability: availabilityValue(match.Available), LibraryContentID: match.ContentID, - Request: requestStateFor(viewer, policy, match.Available, activeRequest), + Request: state, }) } return out, nil @@ -2293,15 +2311,7 @@ func (s *Service) now() time.Time { func requestStateFor(viewer Viewer, policy EffectivePolicy, available bool, req *Request) RequestState { if req != nil { - state := RequestState{ - Status: req.Status, - Requestable: false, - Reason: "already_requested", - } - if viewer.IsAdmin || req.RequestedByUserID == viewer.UserID { - state.RequestID = req.ID - } - return state + return activeRequestState(viewer, req) } switch { case available: @@ -2317,6 +2327,22 @@ func requestStateFor(viewer Viewer, policy EffectivePolicy, available bool, req } } +// activeRequestState is the state of a title that already has an active +// request: not requestable, and the request is visible to its account and to +// admins. +func activeRequestState(viewer Viewer, req *Request) RequestState { + state := RequestState{ + Status: req.Status, + Requestable: false, + Reason: "already_requested", + } + if viewer.IsAdmin || req.RequestedByUserID == viewer.UserID { + state.RequestID = req.ID + } + state.RequestedByViewer = req.RequestedByUserID == viewer.UserID && req.RequestedByProfileID == viewer.ProfileID + return state +} + // validateCreateAccess applies the policy rules a create decides up front. The // quota is not one of them: the store checks it under the requester's lock, // after it has replaced the requester's failed request for the same title, so diff --git a/internal/requests/service_test.go b/internal/requests/service_test.go index 86d1655d16..9a6312ac8c 100644 --- a/internal/requests/service_test.go +++ b/internal/requests/service_test.go @@ -1602,6 +1602,7 @@ type fakeStore struct { unnotified []string notified []string reconciled []string + follows map[string]Follower // key: media_type/tmdb_id/profile_id listIntegrationsCalls int getSettingsCalls int @@ -2035,6 +2036,89 @@ func (f *fakeStore) DeleteIntegration(_ context.Context, id string) error { return ErrNotFound } +func followKey(mediaType MediaType, tmdbID int, profileID string) string { + return fmt.Sprintf("%s/%d/%s", mediaType, tmdbID, profileID) +} + +func (f *fakeStore) FollowTitle(_ context.Context, mediaType MediaType, tmdbID int, viewer Viewer) error { + f.mu.Lock() + defer f.mu.Unlock() + if req := f.active[mediaType][tmdbID]; req == nil || req.Outcome != OutcomeActive || req.Status == StatusCompleted { + return ErrNotRequested + } + f.seedFollowLocked(mediaType, tmdbID, viewer) + return nil +} + +// seedFollow records a follow directly, as one added before the request +// completed. Tests use it for titles whose request is already closed. +func (f *fakeStore) seedFollow(mediaType MediaType, tmdbID int, viewer Viewer) { + f.mu.Lock() + defer f.mu.Unlock() + f.seedFollowLocked(mediaType, tmdbID, viewer) +} + +func (f *fakeStore) seedFollowLocked(mediaType MediaType, tmdbID int, viewer Viewer) { + if f.follows == nil { + f.follows = map[string]Follower{} + } + f.follows[followKey(mediaType, tmdbID, viewer.ProfileID)] = Follower{UserID: viewer.UserID, ProfileID: viewer.ProfileID} +} + +func (f *fakeStore) ForgetTitleFollows(_ context.Context, mediaType MediaType, tmdbID int) error { + f.mu.Lock() + defer f.mu.Unlock() + prefix := fmt.Sprintf("%s/%d/", mediaType, tmdbID) + for key := range f.follows { + if strings.HasPrefix(key, prefix) { + delete(f.follows, key) + } + } + return nil +} + +func (f *fakeStore) UnfollowTitle(_ context.Context, mediaType MediaType, tmdbID int, profileID string) error { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.follows, followKey(mediaType, tmdbID, profileID)) + return nil +} + +func (f *fakeStore) FollowedTitles(_ context.Context, mediaType MediaType, tmdbIDs []int, profileID string) (map[int]bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + out := map[int]bool{} + for _, id := range tmdbIDs { + if _, ok := f.follows[followKey(mediaType, id, profileID)]; ok { + out[id] = true + } + } + return out, nil +} + +func (f *fakeStore) ListTitleFollowers(_ context.Context, mediaType MediaType, tmdbID int) ([]Follower, error) { + f.mu.Lock() + defer f.mu.Unlock() + prefix := fmt.Sprintf("%s/%d/", mediaType, tmdbID) + var out []Follower + for key, follower := range f.follows { + if strings.HasPrefix(key, prefix) { + out = append(out, follower) + } + } + slices.SortFunc(out, func(a, b Follower) int { return strings.Compare(a.ProfileID, b.ProfileID) }) + return out, nil +} + +func (f *fakeStore) ClearTitleFollowers(_ context.Context, mediaType MediaType, tmdbID int, profileIDs []string) error { + f.mu.Lock() + defer f.mu.Unlock() + for _, profileID := range profileIDs { + delete(f.follows, followKey(mediaType, tmdbID, profileID)) + } + return nil +} + func (f *fakeStore) ListTargets(_ context.Context, requestID string) ([]Target, error) { f.mu.Lock() defer f.mu.Unlock() diff --git a/internal/requests/store.go b/internal/requests/store.go index eb8ad76c4f..ff23181087 100644 --- a/internal/requests/store.go +++ b/internal/requests/store.go @@ -57,6 +57,18 @@ type Store interface { // RecomputeStatus re-derives an approved request's status and outcome from // its targets, for a submission that found nothing left to send. RecomputeStatus(ctx context.Context, id string, actor Viewer) (*Request, error) + // FollowTitle, UnfollowTitle and FollowedTitles manage a profile's follows + // on titles; ListTitleFollowers and ClearTitleFollowers serve the + // fulfilled notification. All are idempotent. FollowTitle answers + // ErrNotRequested when the title has no open request. + FollowTitle(ctx context.Context, mediaType MediaType, tmdbID int, viewer Viewer) error + UnfollowTitle(ctx context.Context, mediaType MediaType, tmdbID int, profileID string) error + FollowedTitles(ctx context.Context, mediaType MediaType, tmdbIDs []int, profileID string) (map[int]bool, error) + ListTitleFollowers(ctx context.Context, mediaType MediaType, tmdbID int) ([]Follower, error) + ClearTitleFollowers(ctx context.Context, mediaType MediaType, tmdbID int, profileIDs []string) error + // ForgetTitleFollows removes every follow on a title whose request was + // withdrawn. + ForgetTitleFollows(ctx context.Context, mediaType MediaType, tmdbID int) error ListTargets(ctx context.Context, requestID string) ([]Target, error) CreateTarget(ctx context.Context, target Target) (Target, error) DeleteTarget(ctx context.Context, id int64) error diff --git a/internal/requests/types.go b/internal/requests/types.go index 310b77cdaf..ee61acedad 100644 --- a/internal/requests/types.go +++ b/internal/requests/types.go @@ -169,6 +169,9 @@ type Request struct { SubmitAttempts int `json:"-"` SubmitLeaseUntil *time.Time `json:"-"` NextSubmitAt *time.Time `json:"-"` + // Followers are the profiles, other than the requester's, that asked to be + // told when the title is available; loaded for the fulfilled notification. + Followers []Follower `json:"-"` } // StateGuard names the states a transition may start from. The store applies @@ -212,6 +215,12 @@ type RequestState struct { Requestable bool `json:"requestable"` Reason string `json:"reason,omitempty"` RequestID string `json:"request_id,omitempty"` + // Following reports that the viewer will be notified when the title + // becomes available: they requested it or follow it. RequestedByViewer + // reports that the viewing profile made the active request, so there is + // nothing to follow. v2 only; the frozen v1 shape carries neither. + Following bool `json:"-"` + RequestedByViewer bool `json:"-"` } type MediaResult struct { diff --git a/migrations/sql/20260926145026_request_follows.sql b/migrations/sql/20260926145026_request_follows.sql new file mode 100644 index 0000000000..e6b8b172ec --- /dev/null +++ b/migrations/sql/20260926145026_request_follows.sql @@ -0,0 +1,22 @@ +-- +goose Up +-- Profiles that asked to be told when a requested title becomes available. +-- Keyed by title rather than by request so a follow survives the request +-- failing and being requested again. The requester needs no row: the +-- fulfilled notification always reaches them. +CREATE TABLE media_request_follows ( + media_type text NOT NULL, + tmdb_id integer NOT NULL, + user_id integer NOT NULL, + profile_id text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (media_type, tmdb_id, profile_id), + CONSTRAINT media_request_follows_media_type_check CHECK (media_type IN ('movie', 'series')), + CONSTRAINT media_request_follows_tmdb_positive CHECK (tmdb_id > 0), + CONSTRAINT media_request_follows_profile_fkey FOREIGN KEY (user_id, profile_id) + REFERENCES user_profiles (user_id, id) ON DELETE CASCADE +); + +CREATE INDEX media_request_follows_profile_idx ON media_request_follows (profile_id); + +-- +goose Down +DROP TABLE media_request_follows; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 6189bdd71c..108eb913e6 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1820,6 +1820,10 @@ export interface RequestState { requestable: boolean; reason?: string; request_id?: string; + /** The viewer is notified when the title becomes available: they requested or follow it. */ + following?: boolean; + /** The viewing profile made the active request, so there is nothing to follow. */ + requested_by_viewer?: boolean; } export interface RequestMediaResult { @@ -2811,6 +2815,8 @@ export interface NotificationReasonFlags { title?: string; year?: number; reason?: string; + /** request.fulfilled sent to a profile that followed the title, not requested it. */ + follower?: boolean; } export interface AppNotification { diff --git a/web/src/api/v2/operations.ts b/web/src/api/v2/operations.ts index df29fcfb8b..1f92f47d17 100644 --- a/web/src/api/v2/operations.ts +++ b/web/src/api/v2/operations.ts @@ -67,6 +67,7 @@ export const v2Operations = { "DELETE /api/v2/profiles/{id}": "deleteProfile", "DELETE /api/v2/profiles/{id}/avatar": "deleteProfileAvatar", "DELETE /api/v2/ratings/{item_id}": "deleteRating", + "DELETE /api/v2/requests/follows/{media_type}/{tmdb_id}": "unfollowRequestMedia", "DELETE /api/v2/settings/device/subtitle-appearance": "deleteSubtitleAppearanceDeviceOverride", "DELETE /api/v2/settings/values/{key}": "deleteSettingValue", "DELETE /api/v2/subtitle-prefs/{series_id}": "deleteSubtitlePreference", @@ -774,6 +775,7 @@ export const v2Operations = { "PUT /api/v2/profile/sections": "replaceProfileSectionOverrides", "PUT /api/v2/profiles/{id}/avatar": "uploadProfileAvatar", "PUT /api/v2/ratings/{item_id}": "setRating", + "PUT /api/v2/requests/follows/{media_type}/{tmdb_id}": "followRequestMedia", "PUT /api/v2/settings/device/subtitle-appearance": "updateSubtitleAppearanceDeviceOverride", "PUT /api/v2/settings/plugins/{installation_id}": "updatePluginSettings", "PUT /api/v2/settings/values/nav.shortcuts/item": "updateNavigationShortcut", diff --git a/web/src/api/v2/requests.ts b/web/src/api/v2/requests.ts index cb04cbcc1b..b1dc72e89a 100644 --- a/web/src/api/v2/requests.ts +++ b/web/src/api/v2/requests.ts @@ -58,6 +58,20 @@ export function cancelMediaRequestV2(id: string, reason?: string): Promise { await options.queryFn(); expect(mocks.api).toHaveBeenCalledWith("GET /api/v2/requests/status"); }); + +describe("useToggleRequestFollow", () => { + function wrapperFor(client: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; + } + + beforeEach(() => { + mocks.api.mockReset(); + mocks.api.mockResolvedValue({ requestable: false, following: true }); + }); + + it("follows and unfollows by title, then refreshes request surfaces", async () => { + const client = new QueryClient(); + const invalidations = vi.spyOn(client, "invalidateQueries"); + const { result } = renderHook(() => useToggleRequestFollow(), { wrapper: wrapperFor(client) }); + + await result.current.mutateAsync({ mediaType: "movie", tmdbID: 603, follow: true }); + await result.current.mutateAsync({ mediaType: "series", tmdbID: 1399, follow: false }); + + expect(mocks.api.mock.calls).toEqual([ + [ + "PUT /api/v2/requests/follows/{media_type}/{tmdb_id}", + { path: { media_type: "movie", tmdb_id: 603 } }, + ], + [ + "DELETE /api/v2/requests/follows/{media_type}/{tmdb_id}", + { path: { media_type: "series", tmdb_id: 1399 } }, + ], + ]); + expect(invalidations).toHaveBeenCalledWith({ queryKey: ["requests"] }); + }); +}); diff --git a/web/src/hooks/queries/useRequests.ts b/web/src/hooks/queries/useRequests.ts index 7cafae5d11..cc6081d805 100644 --- a/web/src/hooks/queries/useRequests.ts +++ b/web/src/hooks/queries/useRequests.ts @@ -20,8 +20,10 @@ import { browseDiscoverV2, cancelMediaRequestV2, createMediaRequestV2, + followRequestMediaV2, getDiscoverSectionV2, getRequestMediaDetailV2, + unfollowRequestMediaV2, listDiscoverGenresV2, listDiscoverNetworksV2, listDiscoverSectionsV2, @@ -197,6 +199,33 @@ export function useCreateMediaRequest() { }); } +/** Follows or unfollows a title someone else has already requested. */ +export function useToggleRequestFollow() { + const queryClient = useQueryClient(); + return useMutation({ + retry: false, + mutationFn: ({ + mediaType, + tmdbID, + follow, + }: { + mediaType: RequestMediaType; + tmdbID: number; + follow: boolean; + }) => + follow + ? followRequestMediaV2(mediaType, tmdbID).then(() => undefined) + : unfollowRequestMediaV2(mediaType, tmdbID), + onSuccess: (_data, { follow }) => { + toast.success(follow ? "We'll let you know when it's available" : "Notification turned off"); + invalidateRequestSurfaces(queryClient); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to update notification"); + }, + }); +} + export function useCancelMediaRequest() { const queryClient = useQueryClient(); return useMutation({ diff --git a/web/src/pages/Notifications.test.tsx b/web/src/pages/Notifications.test.tsx index 81573480a8..f9cadcb691 100644 --- a/web/src/pages/Notifications.test.tsx +++ b/web/src/pages/Notifications.test.tsx @@ -118,6 +118,33 @@ it("keeps a fulfilled request linked to its catalog item", () => { "/item/movie-603", ); }); +it.each([ + [{ media_type: "movie", tmdb_id: 603 }, "Your requested movie is now available"], + [{ media_type: "movie", tmdb_id: 603, follower: true }, "A movie you followed is now available"], +])("words a fulfilled row for the requester or a follower (%o)", (flags, text) => { + state.list.data = { + pages: [ + { + notifications: [ + { + ...row, + type: "request.fulfilled", + series_id: "movie-603", + series_title: "The Matrix", + reason_flags: flags, + }, + ], + read_cutoff: "c", + }, + ], + }; + render( + + + , + ); + expect(screen.getByText(text)).toBeInTheDocument(); +}); it("leaves a request row unlinked when its payload lacks the TMDB id", () => { state.list.data = { pages: [ diff --git a/web/src/pages/Notifications.tsx b/web/src/pages/Notifications.tsx index bd5481b2d1..21062e773e 100644 --- a/web/src/pages/Notifications.tsx +++ b/web/src/pages/Notifications.tsx @@ -71,6 +71,13 @@ function notificationDescription(notification: AppNotification): string { } if (notification.type === "request.fulfilled") { const mediaType = notification.reason_flags?.media_type; + if (notification.reason_flags?.follower) { + return mediaType === "movie" + ? "A movie you followed is now available" + : mediaType === "series" + ? "A series you followed is now available" + : "A title you followed is now available"; + } return mediaType === "movie" ? "Your requested movie is now available" : mediaType === "series" diff --git a/web/src/pages/RequestDetail.test.tsx b/web/src/pages/RequestDetail.test.tsx index 2d63ce1985..cb25f68ed1 100644 --- a/web/src/pages/RequestDetail.test.tsx +++ b/web/src/pages/RequestDetail.test.tsx @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ mine: [] as unknown[], useMyMediaRequests: vi.fn(), cancel: vi.fn(), + toggleFollow: vi.fn(), })); vi.mock("@/hooks/queries/useRequests", () => ({ @@ -19,6 +20,7 @@ vi.mock("@/hooks/queries/useRequests", () => ({ return { data: mocks.mine }; }, useCancelMediaRequest: () => ({ mutate: mocks.cancel, isPending: false }), + useToggleRequestFollow: () => ({ mutate: mocks.toggleFollow, isPending: false }), })); vi.mock("@/hooks/useDocumentTitle", () => ({ useDocumentTitle: () => {} })); vi.mock("@/pages/ItemDetail/DetailHero", () => ({ @@ -49,7 +51,14 @@ const baseDetail: RequestMediaDetail = { title: "The Matrix", runtime: 136, availability: "missing", - request: { requestable: false, status: "pending", request_id: "req-1" }, + request: { + requestable: false, + status: "pending", + reason: "already_requested", + request_id: "req-1", + following: true, + requested_by_viewer: true, + }, }; const ownPending: MediaRequest = { @@ -80,6 +89,56 @@ describe("RequestDetail", () => { mocks.mine = [ownPending]; mocks.useMyMediaRequests.mockReset(); mocks.cancel.mockReset(); + mocks.toggleFollow.mockReset(); + }); + + it("offers no follow toggle on the viewer's own request", () => { + renderDetail(); + + expect(screen.queryByRole("button", { name: /Notify me/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Stop notifying/ })).not.toBeInTheDocument(); + }); + + it("lets the viewer follow a title someone else requested", () => { + mocks.detail = { + ...baseDetail, + request: { requestable: false, status: "approved", reason: "already_requested" }, + }; + mocks.mine = []; + renderDetail(); + + fireEvent.click(screen.getByRole("button", { name: "Notify me when available" })); + + expect(mocks.toggleFollow).toHaveBeenCalledExactlyOnceWith({ + mediaType: "movie", + tmdbID: 603, + follow: true, + }); + expect(screen.queryByRole("button", { name: "Cancel request" })).not.toBeInTheDocument(); + }); + + it("lets a follower stop the notification", () => { + mocks.detail = { + ...baseDetail, + request: { + requestable: false, + status: "queued", + reason: "already_requested", + following: true, + }, + }; + mocks.mine = []; + renderDetail(); + + const button = screen.getByRole("button", { name: "Stop notifying me" }); + expect(button).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(button); + + expect(mocks.toggleFollow).toHaveBeenCalledExactlyOnceWith({ + mediaType: "movie", + tmdbID: 603, + follow: false, + }); }); it("lets the viewer cancel their own pending request after confirming", () => { diff --git a/web/src/pages/RequestDetail.tsx b/web/src/pages/RequestDetail.tsx index 85c9a5367f..ed936c4e87 100644 --- a/web/src/pages/RequestDetail.tsx +++ b/web/src/pages/RequestDetail.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useParams } from "react-router"; -import { Library, Loader2, Plus, Star } from "lucide-react"; +import { Bell, BellOff, Library, Loader2, Plus, Star } from "lucide-react"; import CastCarousel from "@/components/CastCarousel"; import { CancelRequestDialog } from "@/components/CancelRequestDialog"; import MediaCarousel from "@/components/MediaCarousel"; @@ -20,6 +20,7 @@ import type { import { useCancelMediaRequest, useCreateMediaRequest, + useToggleRequestFollow, useMyMediaRequests, useRequestMediaDetail, } from "@/hooks/queries/useRequests"; @@ -220,6 +221,15 @@ function RequestActions({ const ownRequest = useOwnCancellableRequest(item); const cancelRequest = useCancelMediaRequest(); const [confirmCancel, setConfirmCancel] = useState(false); + const toggleFollow = useToggleRequestFollow(); + // Someone else's open request: the viewer can ask to hear when it lands + // instead of requesting the title again. + const canFollow = + state !== undefined && + state !== "available" && + item.request.reason === "already_requested" && + !item.request.requested_by_viewer; + const following = item.request.following === true; return (
@@ -279,6 +289,24 @@ function RequestActions({ /> ) : null} + {canFollow ? ( + + ) : null} ) : ( From 8b0e930828a6b0eeb2a1ec1feef8717454ae8c17 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:38:43 -0400 Subject: [PATCH 005/103] feat(api): give requests one user-facing state and keep decline reasons Requests now carry a state field that clients can show as is: pending, approved, processing, available, declined, cancelled or failed. The server derives it from status, outcome and library presence, so a request whose download finished but whose title the scan has not found yet reads as processing rather than available. Request state on discovery, search and detail carries it too. status and outcome stay for admin detail and older clients. Why a request was declined or cancelled is now stored on the request (outcome_reason) and returned, instead of living only in the event log and the decline notification. A migration fills it in for existing requests from their events. The web request pages use the server's state when present and show the reason on declined requests. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../api/v2/fixtures/admin_requests_ok.json | 3 ++ .../api/v2/fixtures/cancel_request_ok.json | 1 + .../api/v2/fixtures/create_request_ok.json | 1 + .../api/v2/fixtures/get_system_info_ok.json | 2 +- .../api/v2/fixtures/list_my_requests_ok.json | 2 + contracts/api/v2/openapi.json | 19 +++++++ internal/apiv2/requests.go | 9 +++- internal/notifications/request_notifier.go | 2 +- internal/requests/lifecycle_guard_test.go | 50 ++++++++++++++++++ internal/requests/repository.go | 8 ++- internal/requests/repository_page_test.go | 5 +- internal/requests/service.go | 2 +- internal/requests/service_test.go | 16 ++++++ internal/requests/types.go | 51 +++++++++++++++++-- .../20260926152928_request_outcome_reason.sql | 20 ++++++++ web/src/api/types.ts | 15 ++++++ web/src/api/v2/requests.ts | 4 +- web/src/api/v2/schema.ts | 12 +++++ web/src/components/RequestPosterCard.test.tsx | 22 ++++++++ web/src/components/RequestPosterCard.tsx | 11 +++- web/src/components/RequestToAddSection.tsx | 4 +- web/src/lib/mediaRequests.test.ts | 13 ++++- web/src/lib/mediaRequests.ts | 19 +++---- web/src/pages/RequestDetail.tsx | 4 +- web/src/pages/Requests.tsx | 2 +- 25 files changed, 267 insertions(+), 30 deletions(-) create mode 100644 migrations/sql/20260926152928_request_outcome_reason.sql diff --git a/contracts/api/v2/fixtures/admin_requests_ok.json b/contracts/api/v2/fixtures/admin_requests_ok.json index f973969d05..77783e2162 100644 --- a/contracts/api/v2/fixtures/admin_requests_ok.json +++ b/contracts/api/v2/fixtures/admin_requests_ok.json @@ -9,6 +9,7 @@ "year": 1995, "status": "approved", "outcome": "active", + "state": "approved", "requested_by_user_id": "1", "requested_by_profile_id": "p-owner", "integration_kind": "radarr", @@ -37,6 +38,7 @@ "year": 1995, "status": "approved", "outcome": "active", + "state": "approved", "requested_by_user_id": "1", "requested_by_profile_id": "p-owner", "integration_kind": "radarr", @@ -65,6 +67,7 @@ "year": 1995, "status": "approved", "outcome": "active", + "state": "approved", "requested_by_user_id": "1", "requested_by_profile_id": "p-owner", "integration_kind": "radarr", diff --git a/contracts/api/v2/fixtures/cancel_request_ok.json b/contracts/api/v2/fixtures/cancel_request_ok.json index efe13743a5..45f2aa1206 100644 --- a/contracts/api/v2/fixtures/cancel_request_ok.json +++ b/contracts/api/v2/fixtures/cancel_request_ok.json @@ -7,6 +7,7 @@ "year": 1995, "status": "approved", "outcome": "active", + "state": "approved", "requested_by_user_id": "1", "requested_by_profile_id": "p-owner", "integration_kind": "radarr", diff --git a/contracts/api/v2/fixtures/create_request_ok.json b/contracts/api/v2/fixtures/create_request_ok.json index 9cac369422..13ccd46b64 100644 --- a/contracts/api/v2/fixtures/create_request_ok.json +++ b/contracts/api/v2/fixtures/create_request_ok.json @@ -7,6 +7,7 @@ "year": 1995, "status": "pending", "outcome": "active", + "state": "pending", "requested_by_user_id": "1", "requested_by_profile_id": "p-owner", "integration_kind": "radarr", diff --git a/contracts/api/v2/fixtures/get_system_info_ok.json b/contracts/api/v2/fixtures/get_system_info_ok.json index 228c091929..18283a2c83 100644 --- a/contracts/api/v2/fixtures/get_system_info_ok.json +++ b/contracts/api/v2/fixtures/get_system_info_ok.json @@ -1,7 +1,7 @@ { "server_version": "unavailable", "api_major": 2, - "contract_digest": "8634a72e49197f17d2e0e30f11b5a4ee9d7ed76fa2c039cd4ba1cbd5d8362624", + "contract_digest": "13f9a6a77a3422d41a2f4fee5792b754bf507c5095c6c8a908e8e53acefbc11b", "links": { "openapi": "/api/v2/openapi.json", "capabilities": "/api/v2/capabilities", diff --git a/contracts/api/v2/fixtures/list_my_requests_ok.json b/contracts/api/v2/fixtures/list_my_requests_ok.json index 3f0ef06790..bf5c4c7d4b 100644 --- a/contracts/api/v2/fixtures/list_my_requests_ok.json +++ b/contracts/api/v2/fixtures/list_my_requests_ok.json @@ -9,6 +9,7 @@ "year": 1995, "status": "pending", "outcome": "active", + "state": "pending", "requested_by_user_id": "1", "requested_by_profile_id": "p-owner", "integration_kind": "radarr", @@ -37,6 +38,7 @@ "year": 1995, "status": "approved", "outcome": "active", + "state": "approved", "requested_by_user_id": "1", "requested_by_profile_id": "p-owner", "integration_kind": "radarr", diff --git a/contracts/api/v2/openapi.json b/contracts/api/v2/openapi.json index 36892115ad..9a1576a8b6 100644 --- a/contracts/api/v2/openapi.json +++ b/contracts/api/v2/openapi.json @@ -27321,6 +27321,10 @@ ], "type": "string" }, + "outcome_reason": { + "description": "Why the request was declined or withdrawn, when a reason was given", + "type": "string" + }, "overview": { "type": "string" }, @@ -27350,6 +27354,13 @@ "minLength": 1, "type": "string" }, + "state": { + "description": "The one state to show a user: pending, approved, processing, available (in the library), declined, cancelled or failed", + "examples": [ + "pending" + ], + "type": "string" + }, "status": { "description": "pending, approved, queued, downloading, completed", "examples": [ @@ -27409,6 +27420,7 @@ "title", "status", "outcome", + "state", "is_anime", "targets", "created_at", @@ -35402,6 +35414,13 @@ ], "type": "boolean" }, + "state": { + "description": "User-facing state of the active request, when one exists: pending, approved or processing", + "examples": [ + "pending" + ], + "type": "string" + }, "status": { "description": "Status of the active request, when one exists", "examples": [ diff --git a/internal/apiv2/requests.go b/internal/apiv2/requests.go index 294ffa18ac..49a983720c 100644 --- a/internal/apiv2/requests.go +++ b/internal/apiv2/requests.go @@ -23,6 +23,7 @@ import ( // acting viewer. type RequestMediaState struct { Status string `json:"status,omitempty" doc:"Status of the active request, when one exists" example:"pending"` + State string `json:"state,omitempty" doc:"User-facing state of the active request, when one exists: pending, approved or processing" example:"pending"` Requestable bool `json:"requestable" doc:"Whether the viewer may request this media now" example:"true"` Reason string `json:"reason,omitempty" doc:"Why the media is not requestable" example:"already_requested"` RequestID ID `json:"request_id,omitempty" doc:"The active request, when one exists" example:"1834729"` @@ -179,7 +180,9 @@ type MediaRequest struct { PosterPath string `json:"poster_path,omitempty" doc:"TMDB image path"` BackdropPath string `json:"backdrop_path,omitempty" doc:"TMDB image path"` Status string `json:"status" doc:"pending, approved, queued, downloading, completed" example:"pending"` - Outcome string `json:"outcome" doc:"active, declined, cancelled, failed" example:"active"` //nolint:misspell // the store's spelling + Outcome string `json:"outcome" doc:"active, declined, cancelled, failed" example:"active"` //nolint:misspell // the store's spelling + State string `json:"state" doc:"The one state to show a user: pending, approved, processing, available (in the library), declined, cancelled or failed" example:"pending"` //nolint:misspell // the store's spelling + OutcomeReason string `json:"outcome_reason,omitempty" doc:"Why the request was declined or withdrawn, when a reason was given"` RequestedByUserID ID `json:"requested_by_user_id,omitempty" example:"1"` RequestedByProfileID ID `json:"requested_by_profile_id,omitempty" example:"p-owner"` IntegrationKind string `json:"integration_kind,omitempty" example:"radarr"` @@ -765,6 +768,8 @@ func mediaRequestOf(r *mediarequests.Request) MediaRequest { BackdropPath: r.BackdropPath, Status: string(r.Status), Outcome: string(r.Outcome), + State: string(r.State()), + OutcomeReason: r.OutcomeReason, IntegrationKind: r.IntegrationKind, IsAnime: r.IsAnime, Targets: make([]RequestTarget, 0, len(r.Targets)), @@ -793,7 +798,7 @@ func mediaRequestOf(r *mediarequests.Request) MediaRequest { } func requestMediaStateOf(s mediarequests.RequestState) RequestMediaState { - return RequestMediaState{Status: string(s.Status), Requestable: s.Requestable, Reason: s.Reason, RequestID: ID(s.RequestID), Following: s.Following, RequestedByViewer: s.RequestedByViewer} + return RequestMediaState{Status: string(s.Status), Requestable: s.Requestable, Reason: s.Reason, RequestID: ID(s.RequestID), Following: s.Following, RequestedByViewer: s.RequestedByViewer, State: string(s.State)} } func requestMediaResultsOf(results []mediarequests.MediaResult) []RequestMediaResult { diff --git a/internal/notifications/request_notifier.go b/internal/notifications/request_notifier.go index 8f6a9cf5f2..0f93a97307 100644 --- a/internal/notifications/request_notifier.go +++ b/internal/notifications/request_notifier.go @@ -277,7 +277,7 @@ func (s *System) dispatchRequestLifecycle(ctx context.Context, req requests.Requ MediaType: string(req.MediaType), Title: req.Title, PosterPath: req.PosterPath, - Reason: req.DeclineReason, + Reason: req.OutcomeReason, } if req.Year != nil { flags.Year = *req.Year diff --git a/internal/requests/lifecycle_guard_test.go b/internal/requests/lifecycle_guard_test.go index 199d76c6c0..5f35d888b1 100644 --- a/internal/requests/lifecycle_guard_test.go +++ b/internal/requests/lifecycle_guard_test.go @@ -499,3 +499,53 @@ func TestReconcileStampsEveryCandidate(t *testing.T) { t.Fatalf("stamped = %v, want both candidates stamped even though they errored", store.reconciled) } } + +func TestRequestState(t *testing.T) { + for _, tc := range []struct { + name string + req Request + want State + }{ + {"pending", Request{Status: StatusPending, Outcome: OutcomeActive}, StatePending}, + {"approved", Request{Status: StatusApproved, Outcome: OutcomeActive}, StateApproved}, + {"queued", Request{Status: StatusQueued, Outcome: OutcomeActive}, StateProcessing}, + {"downloading", Request{Status: StatusDownloading, Outcome: OutcomeActive}, StateProcessing}, + {"downloaded, not scanned in yet", Request{Status: StatusCompleted, Outcome: OutcomeActive}, StateProcessing}, + {"in the library", Request{Status: StatusCompleted, Outcome: OutcomeActive, LibraryContentID: "movie-tmdb-1"}, StateAvailable}, + {"declined", Request{Status: StatusPending, Outcome: OutcomeDeclined}, StateDeclined}, + {"withdrawn", Request{Status: StatusPending, Outcome: OutcomeCancelled}, StateCancelled}, + // A failed request keeps whatever status it failed at. + {"failed after queueing", Request{Status: StatusQueued, Outcome: OutcomeFailed}, StateFailed}, + } { + if got := tc.req.State(); got != tc.want { + t.Errorf("%s: State() = %q, want %q", tc.name, got, tc.want) + } + } +} + +func TestOutcomeReasonDatabase(t *testing.T) { + repo, _ := lifecycleTestRepository(t) + ctx := t.Context() + insertLifecycleRequest(t, repo, "declined", 1, 901, StatusPending) + insertLifecycleRequest(t, repo, "retried", 1, 902, StatusApproved) + + got, err := repo.SetOutcome(ctx, "declined", guardWithdrawable, OutcomeDeclined, Viewer{}, " Not this month ") + if err != nil { + t.Fatal(err) + } + if got.OutcomeReason != "Not this month" || got.LastError != "" { + t.Fatalf("declined = %+v, want the trimmed reason in outcome_reason and no last_error", got) + } + reread, err := repo.GetRequest(ctx, "declined") + if err != nil || reread.OutcomeReason != "Not this month" { + t.Fatalf("reread = %+v, %v; want the reason stored", reread, err) + } + + failed, err := repo.SetOutcome(ctx, "retried", StateGuard{Statuses: []Status{StatusApproved}}, OutcomeFailed, Viewer{}, "radarr down") + if err != nil { + t.Fatal(err) + } + if failed.OutcomeReason != "" || failed.LastError != "radarr down" { + t.Fatalf("failed = %+v, want the error in last_error, not outcome_reason", failed) + } +} diff --git a/internal/requests/repository.go b/internal/requests/repository.go index 6097e1e8cb..737695c8cc 100644 --- a/internal/requests/repository.go +++ b/internal/requests/repository.go @@ -583,6 +583,11 @@ func (r *Repository) SetOutcome(ctx context.Context, id string, from StateGuard, WHEN $5 = 'active' THEN '' ELSE last_error END, + outcome_reason = CASE + WHEN $5 IN ('declined', 'cancelled') THEN $6 + WHEN $5 = 'active' THEN '' + ELSE outcome_reason + END, updated_at = now() WHERE id = $1 AND `+guardCondition+` @@ -1027,7 +1032,7 @@ func requestColumns() string { overview, poster_path, backdrop_path, status, outcome, requested_by_user_id, requested_by_profile_id, is_anime, last_error, created_at, updated_at, approved_at, completed_at, - submit_attempts, submit_lease_until, next_submit_at` + submit_attempts, submit_lease_until, next_submit_at, outcome_reason` } type requestScanner interface { @@ -1063,6 +1068,7 @@ func scanRequest(row requestScanner) (*Request, error) { &req.SubmitAttempts, &submitLeaseUntil, &nextSubmitAt, + &req.OutcomeReason, ); err != nil { return nil, err } diff --git a/internal/requests/repository_page_test.go b/internal/requests/repository_page_test.go index 6c71d33118..eee39bf41f 100644 --- a/internal/requests/repository_page_test.go +++ b/internal/requests/repository_page_test.go @@ -28,7 +28,8 @@ func TestRequestListKeysetDatabase(t *testing.T) { overview text, poster_path text, backdrop_path text, status text, outcome text, requested_by_user_id int, requested_by_profile_id text, is_anime bool, last_error text, created_at timestamptz, updated_at timestamptz, approved_at timestamptz, completed_at timestamptz, - submit_attempts int NOT NULL DEFAULT 0, submit_lease_until timestamptz, next_submit_at timestamptz); + submit_attempts int NOT NULL DEFAULT 0, submit_lease_until timestamptz, next_submit_at timestamptz, + outcome_reason text NOT NULL DEFAULT ''); CREATE INDEX ON media_requests (requested_by_user_id, created_at DESC, id DESC)`) if err != nil { t.Fatal(err) @@ -36,7 +37,7 @@ func TestRequestListKeysetDatabase(t *testing.T) { stamp := time.Date(2026, 1, 2, 3, 4, 5, 123456000, time.UTC) insert := func(id string, user int, at time.Time) { t.Helper() - _, err := pool.Exec(t.Context(), `INSERT INTO media_requests VALUES ($1,'tmdb','movie',1,NULL,'','',NULL,'','','','pending','active',$2,'profile',false,'',$3,$3,NULL,NULL,0,NULL,NULL)`, id, user, at) + _, err := pool.Exec(t.Context(), `INSERT INTO media_requests VALUES ($1,'tmdb','movie',1,NULL,'','',NULL,'','','','pending','active',$2,'profile',false,'',$3,$3,NULL,NULL,0,NULL,NULL,'')`, id, user, at) if err != nil { t.Fatal(err) } diff --git a/internal/requests/service.go b/internal/requests/service.go index aca2911b44..7e655f88cc 100644 --- a/internal/requests/service.go +++ b/internal/requests/service.go @@ -964,7 +964,6 @@ func (s *Service) Decline(ctx context.Context, viewer Viewer, id, reason string) if err != nil { return nil, err } - declined.DeclineReason = strings.TrimSpace(reason) s.notifyLifecycle(ctx, *declined, LifecycleNotifier.RequestDeclined) s.forgetFollowsAfterWithdrawal(ctx, declined) return declined, nil @@ -2339,6 +2338,7 @@ func activeRequestState(viewer Viewer, req *Request) RequestState { if viewer.IsAdmin || req.RequestedByUserID == viewer.UserID { state.RequestID = req.ID } + state.State = req.State() state.RequestedByViewer = req.RequestedByUserID == viewer.UserID && req.RequestedByProfileID == viewer.ProfileID return state } diff --git a/internal/requests/service_test.go b/internal/requests/service_test.go index 9a6312ac8c..ca79db2ea4 100644 --- a/internal/requests/service_test.go +++ b/internal/requests/service_test.go @@ -1850,6 +1850,9 @@ func (f *fakeStore) SetOutcome(_ context.Context, id string, from StateGuard, ou } req.Outcome = outcome req.LastError = message + if outcome == OutcomeDeclined || outcome == OutcomeCancelled { + req.OutcomeReason = message + } copy := *req return ©, nil } @@ -3271,3 +3274,16 @@ func TestSubmitApprovedPopulatesRequesterIdentity(t *testing.T) { t.Fatalf("descriptor identity = %q/%q, want u@example.com/bob", router.gotRequesterEmail, router.gotRequesterUsername) } } + +func TestDeclineKeepsReasonOnRequest(t *testing.T) { + store := newFakeStore() + store.requests["req-1"] = &Request{ID: "req-1", MediaType: MediaTypeMovie, TMDBID: 550, Status: StatusPending, Outcome: OutcomeActive} + + got, err := newTestService(store).Decline(context.Background(), Viewer{UserID: 1, IsAdmin: true}, "req-1", "Not this month") + if err != nil { + t.Fatalf("Decline: %v", err) + } + if got.OutcomeReason != "Not this month" || got.State() != StateDeclined { + t.Fatalf("request = %+v, want declined with the reason kept", got) + } +} diff --git a/internal/requests/types.go b/internal/requests/types.go index ee61acedad..8a7c5af27c 100644 --- a/internal/requests/types.go +++ b/internal/requests/types.go @@ -148,10 +148,9 @@ type Request struct { RequestedByProfileID string `json:"requested_by_profile_id,omitempty"` RequesterEmail string `json:"-"` RequesterUsername string `json:"-"` - // DeclineReason is the admin's decline message, populated transiently for - // the lifecycle notifier (the durable copy lives in the request event - // record, not on this row). - DeclineReason string `json:"-"` + // OutcomeReason is why the request was declined or withdrawn, when a + // reason was given. v2 only; the frozen v1 shape does not carry it. + OutcomeReason string `json:"-"` IntegrationKind string `json:"integration_kind,omitempty"` IsAnime bool `json:"is_anime"` Targets []Target `json:"targets,omitempty"` @@ -200,6 +199,48 @@ var guardWithdrawable = StateGuard{ UnsentOnly: true, } +// State is the one lifecycle state a user sees for a request, derived from +// its status, outcome and library presence. Status and outcome stay on the +// wire for admin detail and older clients. +type State string + +const ( + StatePending State = "pending" + StateApproved State = "approved" + StateProcessing State = "processing" + StateAvailable State = "available" + StateDeclined State = "declined" + StateCancelled State = "cancelled" //nolint:misspell // matches the outcome spelling + StateFailed State = "failed" +) + +// State derives the request's user-facing state. A completed request is +// available once its title is in the library (LibraryContentID attached); +// until the scan finds it, it is still processing. +func (r *Request) State() State { + switch r.Outcome { + case OutcomeDeclined: + return StateDeclined + case OutcomeCancelled: + return StateCancelled + case OutcomeFailed: + return StateFailed + } + switch r.Status { + case StatusPending: + return StatePending + case StatusApproved: + return StateApproved + case StatusCompleted: + if r.LibraryContentID != "" { + return StateAvailable + } + return StateProcessing + default: + return StateProcessing + } +} + type RequestEvent struct { ID int64 `json:"id"` RequestID string `json:"request_id"` @@ -221,6 +262,8 @@ type RequestState struct { // nothing to follow. v2 only; the frozen v1 shape carries neither. Following bool `json:"-"` RequestedByViewer bool `json:"-"` + // State is the active request's user-facing state (v2 only). + State State `json:"-"` } type MediaResult struct { diff --git a/migrations/sql/20260926152928_request_outcome_reason.sql b/migrations/sql/20260926152928_request_outcome_reason.sql new file mode 100644 index 0000000000..340e9efb59 --- /dev/null +++ b/migrations/sql/20260926152928_request_outcome_reason.sql @@ -0,0 +1,20 @@ +-- +goose Up +-- Why a request was declined or cancelled, when a reason was given. It used to +-- live only in the request's event log and the decline notification, so the +-- requester could not see it on the request itself. +ALTER TABLE media_requests ADD COLUMN outcome_reason text NOT NULL DEFAULT ''; + +UPDATE media_requests r +SET outcome_reason = e.message +FROM ( + SELECT DISTINCT ON (request_id) request_id, event_type, message + FROM media_request_events + WHERE event_type IN ('outcome_declined', 'outcome_cancelled') + ORDER BY request_id, id DESC +) e +WHERE r.id = e.request_id + AND e.event_type = 'outcome_' || r.outcome + AND e.message <> ''; + +-- +goose Down +ALTER TABLE media_requests DROP COLUMN outcome_reason; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 108eb913e6..c0b2459c3c 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1812,6 +1812,15 @@ export type RequestSearchMediaType = RequestMediaType | "all"; export type MediaRequestStatus = "pending" | "approved" | "queued" | "downloading" | "completed"; export type MediaRequestOutcome = "active" | "declined" | "cancelled" | "failed"; export type RequestAvailability = "missing" | "available"; +/** The one request state the server derives for users (v2 `state`). */ +export type RequestUserState = + | "pending" + | "approved" + | "processing" + | "available" + | "declined" + | "cancelled" + | "failed"; export type RequestLimitMode = "inherit" | "custom" | "unlimited" | "blocked"; export type RequestApprovalMode = "inherit" | "manual" | "auto" | "blocked"; @@ -1824,6 +1833,8 @@ export interface RequestState { following?: boolean; /** The viewing profile made the active request, so there is nothing to follow. */ requested_by_viewer?: boolean; + /** User-facing state of the active request. */ + state?: RequestUserState; } export interface RequestMediaResult { @@ -1978,6 +1989,10 @@ export interface MediaRequest { backdrop_path?: string; status: MediaRequestStatus; outcome: MediaRequestOutcome; + /** The one state to show users; derived by the server from status, outcome and library presence. */ + state?: RequestUserState; + /** Why the request was declined or cancelled, when a reason was given. */ + outcome_reason?: string; requested_by_user_id?: number; requested_by_profile_id?: string; is_anime?: boolean; diff --git a/web/src/api/v2/requests.ts b/web/src/api/v2/requests.ts index b1dc72e89a..c5fdb1644c 100644 --- a/web/src/api/v2/requests.ts +++ b/web/src/api/v2/requests.ts @@ -13,6 +13,7 @@ import type { RequestMediaType, RequestSearchMediaType, RequestTarget, + RequestUserState, } from "@/api/types"; import { v2, type V2Body } from "@/api/v2/request"; import type { components, paths } from "@/api/v2/schema"; @@ -34,12 +35,13 @@ function requestTargetFromV2(t: Schemas["RequestTarget"]): RequestTarget { } export function mediaRequestFromV2(r: Schemas["MediaRequest"]): MediaRequest { - const { requested_by_user_id, targets, media_type, status, outcome, ...rest } = r; + const { requested_by_user_id, targets, media_type, status, outcome, state, ...rest } = r; return { ...rest, media_type: media_type as RequestMediaType, status: status as MediaRequestStatus, outcome: outcome as MediaRequestOutcome, + state: state as RequestUserState, ...(requested_by_user_id !== undefined ? { requested_by_user_id: Number(requested_by_user_id) } : {}), diff --git a/web/src/api/v2/schema.ts b/web/src/api/v2/schema.ts index 26720a83fc..6ed45baa70 100644 --- a/web/src/api/v2/schema.ts +++ b/web/src/api/v2/schema.ts @@ -20447,6 +20447,8 @@ export interface components { * @example active */ outcome: string; + /** @description Why the request was declined or withdrawn, when a reason was given */ + outcome_reason?: string; overview?: string; /** @description TMDB image path */ poster_path?: string; @@ -20462,6 +20464,11 @@ export interface components { * @example 1 */ requested_by_user_id?: string; + /** + * @description The one state to show a user: pending, approved, processing, available (in the library), declined, cancelled or failed + * @example pending + */ + state: string; /** * @description pending, approved, queued, downloading, completed * @example pending @@ -23774,6 +23781,11 @@ export interface components { * @example false */ requested_by_viewer: boolean; + /** + * @description User-facing state of the active request, when one exists: pending, approved or processing + * @example pending + */ + state?: string; /** * @description Status of the active request, when one exists * @example pending diff --git a/web/src/components/RequestPosterCard.test.tsx b/web/src/components/RequestPosterCard.test.tsx index 953e72d1de..770d7daead 100644 --- a/web/src/components/RequestPosterCard.test.tsx +++ b/web/src/components/RequestPosterCard.test.tsx @@ -133,6 +133,9 @@ describe("RequestPosterCard (mine variant)", () => { [{ status: "completed" }, "Available"], [{ status: "pending", outcome: "cancelled" }, "Cancelled"], [{ status: "approved", outcome: "failed" }, "Failed"], + // The server's derived state wins: a downloaded title not yet scanned in + // is still processing. + [{ status: "completed", state: "processing" }, "Processing"], ])("labels %o as %s", (overrides, label) => { render( @@ -143,6 +146,25 @@ describe("RequestPosterCard (mine variant)", () => { expect(screen.getByText(label)).toBeInTheDocument(); }); + it("shows why a request was declined", () => { + render( + + + , + ); + + expect(screen.getByText("Declined")).toBeInTheDocument(); + expect(screen.getByText("Not this month")).toBeInTheDocument(); + }); + it("shows Cancel request only when the page passes onCancel", () => { const onCancel = vi.fn(); const { rerender } = render( diff --git a/web/src/components/RequestPosterCard.tsx b/web/src/components/RequestPosterCard.tsx index a757d01f9c..569feb4a11 100644 --- a/web/src/components/RequestPosterCard.tsx +++ b/web/src/components/RequestPosterCard.tsx @@ -73,7 +73,7 @@ function DiscoverCard({ const requestable = item.request.requestable; const availableInLibrary = item.availability === "available" && !item.request.status; const state: RequestDisplayState | undefined = item.request.status - ? requestDisplayState(item.request.status) + ? requestDisplayState(item.request.status, undefined, item.request.state) : availableInLibrary ? "available" : undefined; @@ -163,7 +163,7 @@ function MineCard({ isCancelling?: boolean; }) { const poster = tmdbImageURL(request.poster_path); - const state = requestDisplayState(request.status, request.outcome); + const state = requestDisplayState(request.status, request.outcome, request.state); const isClosed = request.outcome === "failed" || request.outcome === "declined" || @@ -204,6 +204,13 @@ function MineCard({ > {request.last_error}

+ ) : request.outcome_reason ? ( +

+ {request.outcome_reason} +

) : null} diff --git a/web/src/components/RequestToAddSection.tsx b/web/src/components/RequestToAddSection.tsx index bcf7cec83d..82dcfe833b 100644 --- a/web/src/components/RequestToAddSection.tsx +++ b/web/src/components/RequestToAddSection.tsx @@ -183,7 +183,9 @@ function DialogRow({ const poster = tmdbImageURL(item.poster_path); const Icon = item.media_type === "series" ? Tv : Film; const requestable = item.request.requestable; - const state = item.request.status ? requestDisplayState(item.request.status) : undefined; + const state = item.request.status + ? requestDisplayState(item.request.status, undefined, item.request.state) + : undefined; const reasonLabel = !state && !requestable ? unavailableReasonLabel(item) : null; // A plain click goes through the host so it can close the dialog; modified diff --git a/web/src/lib/mediaRequests.test.ts b/web/src/lib/mediaRequests.test.ts index 547b4fbf5e..afa9f0ceea 100644 --- a/web/src/lib/mediaRequests.test.ts +++ b/web/src/lib/mediaRequests.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { MediaRequest } from "@/api/types"; -import { canCancelOwnRequest, requestDetailHref } from "./mediaRequests"; +import { canCancelOwnRequest, requestDetailHref, requestDisplayState } from "./mediaRequests"; type CancelFields = Pick; @@ -34,6 +34,17 @@ describe("canCancelOwnRequest", () => { }); }); +describe("requestDisplayState", () => { + it("prefers the state the server derived", () => { + expect(requestDisplayState("completed", "active", "processing")).toBe("processing"); + }); + + it("derives a state for a server that sends none", () => { + expect(requestDisplayState("downloading", "active")).toBe("processing"); + expect(requestDisplayState("queued", "failed")).toBe("failed"); + }); +}); + describe("requestDetailHref", () => { it("builds the request detail route", () => { expect(requestDetailHref("movie", 603)).toBe("/requests/movie/603"); diff --git a/web/src/lib/mediaRequests.ts b/web/src/lib/mediaRequests.ts index eddee5d44f..d5894ea390 100644 --- a/web/src/lib/mediaRequests.ts +++ b/web/src/lib/mediaRequests.ts @@ -5,6 +5,7 @@ import type { MediaRequestStatus, RequestMediaResult, RequestMediaType, + RequestUserState, } from "@/api/types"; import { formatDate } from "@/lib/datetime"; @@ -89,23 +90,19 @@ export function requestOutcomeBadgeVariant(outcome?: MediaRequestOutcome): Badge /** * The request states the user-facing request pages show. Admin views keep the - * raw status and outcome; these collapse them into one vocabulary: queued and - * downloading read as Processing, completed as Available, and a closed outcome - * wins over the status it closed at. + * raw status and outcome. The server derives this state (v2 `state`), and its + * value wins; the fallback for older servers collapses status and outcome the + * same way: queued and downloading read as Processing, completed as + * Available, and a closed outcome wins over the status it closed at. */ -export type RequestDisplayState = - | "pending" - | "approved" - | "processing" - | "available" - | "declined" - | "cancelled" - | "failed"; +export type RequestDisplayState = RequestUserState; export function requestDisplayState( status?: MediaRequestStatus, outcome?: MediaRequestOutcome, + state?: RequestUserState, ): RequestDisplayState | undefined { + if (state) return state; if (outcome === "declined" || outcome === "cancelled" || outcome === "failed") return outcome; switch (status) { case "pending": diff --git a/web/src/pages/RequestDetail.tsx b/web/src/pages/RequestDetail.tsx index ed936c4e87..59d0cb0092 100644 --- a/web/src/pages/RequestDetail.tsx +++ b/web/src/pages/RequestDetail.tsx @@ -216,7 +216,9 @@ function RequestActions({ onRequest: () => void; }) { const requestable = item.request.requestable; - const state = item.request.status ? requestDisplayState(item.request.status) : undefined; + const state = item.request.status + ? requestDisplayState(item.request.status, undefined, item.request.state) + : undefined; const availableInLibrary = item.availability === "available" && !item.request.status; const ownRequest = useOwnCancellableRequest(item); const cancelRequest = useCancelMediaRequest(); diff --git a/web/src/pages/Requests.tsx b/web/src/pages/Requests.tsx index 81cabd88ef..815d23a74a 100644 --- a/web/src/pages/Requests.tsx +++ b/web/src/pages/Requests.tsx @@ -843,7 +843,7 @@ function groupMineRequests(requests: MediaRequest[]) { function countMineStates(requests: MediaRequest[]) { const counts = new Map(); for (const request of requests) { - const state = requestDisplayState(request.status, request.outcome); + const state = requestDisplayState(request.status, request.outcome, request.state); if (state) counts.set(state, (counts.get(state) ?? 0) + 1); } return REQUEST_STATE_ORDER.flatMap((state) => { From 2ab85f2ea9c00047ddb5b2d2b3a4de3eb45b2fb2 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:54:38 -0400 Subject: [PATCH 006/103] feat(requests): capture routing facts when a request is created Creating a request now reads the title's TMDB detail once, after the cheap refusals (already in the library, already requested), instead of an uncached detail read before them just to detect anime. The server's copy of the title and year replaces the one the client sent. The request stores a snapshot of what routing rules will match on: TMDB genre, keyword, network and company IDs, original language, origin countries, year and the anime flag. IDs rather than names, because names follow the configured TMDB language. The TMDB client now carries those IDs and origin countries, which it already received and dropped. When TMDB cannot answer, the request is still created from the client's copy and the facts stay uncaptured until routing needs them. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/architecture/media-requests.md | 11 +++ internal/metadata/tmdb/client.go | 20 +++++ internal/metadata/tmdb/client_test.go | 35 ++++++++ internal/metadata/tmdb/types.go | 9 ++ internal/requests/repository.go | 22 ++++- internal/requests/repository_page_test.go | 4 +- internal/requests/routing_facts.go | 83 +++++++++++++++++++ internal/requests/routing_facts_test.go | 71 ++++++++++++++++ internal/requests/service.go | 27 +++--- internal/requests/service_test.go | 1 + internal/requests/store.go | 1 + internal/requests/types.go | 2 + .../20260926155103_request_routing_facts.sql | 10 +++ 13 files changed, 280 insertions(+), 16 deletions(-) create mode 100644 internal/requests/routing_facts.go create mode 100644 internal/requests/routing_facts_test.go create mode 100644 migrations/sql/20260926155103_request_routing_facts.sql diff --git a/docs/architecture/media-requests.md b/docs/architecture/media-requests.md index 51650bad60..eeb2a68e8a 100644 --- a/docs/architecture/media-requests.md +++ b/docs/architecture/media-requests.md @@ -18,6 +18,17 @@ quality. Each target change recomputes the request's status and outcome in the same transaction (`aggregateStatus`), so after submission the targets own the request's state. +## Routing facts + +Creating a request reads the title's TMDB detail once, after the cheap refusals +(already in the library, already requested). The server's copy of the title and +year replaces the client's, and a snapshot of what routing can match on is +stored with the request as `routing_facts`: TMDB genre, keyword, network and +company IDs, original language, origin countries, year, and whether TMDB tags it +anime. IDs rather than names, because names follow the configured TMDB +language. When TMDB cannot answer, the request is still created from the +client's copy, and the facts stay uncaptured until routing fetches them. + ## Transitions are guarded Every status or outcome write made by an admin, a user, or the reconcile pass diff --git a/internal/metadata/tmdb/client.go b/internal/metadata/tmdb/client.go index 99a7e46380..a2977dee71 100644 --- a/internal/metadata/tmdb/client.go +++ b/internal/metadata/tmdb/client.go @@ -880,11 +880,16 @@ func normalizeMovieDetail(resp *movieDetailResponse) *MediaDetail { ContentRating: pickMovieCertification(resp.ReleaseDates), OriginalLanguage: resp.OriginalLanguage, KeywordIDs: keywordIDs(resp.Keywords.Keywords, resp.Keywords.Results), + GenreIDs: idsFromGenres(resp.Genres), + OriginCountries: resp.OriginCountry, } for _, company := range resp.ProductionCompanies { if name := strings.TrimSpace(company.Name); name != "" { detail.ProductionCompanies = append(detail.ProductionCompanies, name) } + if company.ID > 0 { + detail.CompanyIDs = append(detail.CompanyIDs, company.ID) + } } if resp.ExternalIDs != nil { detail.TVDBID = resp.ExternalIDs.TVDBID @@ -940,6 +945,8 @@ func normalizeTVDetail(resp *tvDetailResponse) *MediaDetail { ContentRating: pickTVRating(resp.ContentRatings), OriginalLanguage: resp.OriginalLanguage, KeywordIDs: keywordIDs(resp.Keywords.Keywords, resp.Keywords.Results), + GenreIDs: idsFromGenres(resp.Genres), + OriginCountries: resp.OriginCountry, } if len(resp.EpisodeRunTime) > 0 { detail.Runtime = resp.EpisodeRunTime[0] @@ -948,6 +955,9 @@ func normalizeTVDetail(resp *tvDetailResponse) *MediaDetail { if name := strings.TrimSpace(network.Name); name != "" { detail.Networks = append(detail.Networks, name) } + if network.ID > 0 { + detail.NetworkIDs = append(detail.NetworkIDs, network.ID) + } } if resp.ExternalIDs != nil { detail.IMDbID = resp.ExternalIDs.IMDbID @@ -981,6 +991,16 @@ func normalizeTVDetail(resp *tvDetailResponse) *MediaDetail { return detail } +func idsFromGenres(genres []genreEntry) []int { + var out []int + for _, g := range genres { + if g.ID > 0 { + out = append(out, g.ID) + } + } + return out +} + func namesFromGenres(genres []genreEntry) []string { if len(genres) == 0 { return nil diff --git a/internal/metadata/tmdb/client_test.go b/internal/metadata/tmdb/client_test.go index d61b8b287d..a69e6625f0 100644 --- a/internal/metadata/tmdb/client_test.go +++ b/internal/metadata/tmdb/client_test.go @@ -2,9 +2,11 @@ package tmdb import ( "context" + "encoding/json" "errors" "net/http" "net/http/httptest" + "slices" "sync/atomic" "testing" "time" @@ -1138,3 +1140,36 @@ func TestGetCertificationSingleflightsConcurrentCallers(t *testing.T) { t.Fatalf("upstream calls = %d, want 1 (singleflight)", got) } } + +func TestDetailCarriesRoutingIdentifiers(t *testing.T) { + var movie movieDetailResponse + if err := json.Unmarshal([]byte(`{ + "id": 129, "title": "Spirited Away", "release_date": "2001-07-20", "original_language": "ja", + "origin_country": ["JP"], + "genres": [{"id": 16, "name": "Animation"}, {"id": 14, "name": "Fantasy"}], + "production_companies": [{"id": 10342, "name": "Studio Ghibli"}], + "keywords": {"keywords": [{"id": 210024, "name": "anime"}]} + }`), &movie); err != nil { + t.Fatal(err) + } + m := normalizeMovieDetail(&movie) + if !slices.Equal(m.GenreIDs, []int{16, 14}) || !slices.Equal(m.CompanyIDs, []int{10342}) || + !slices.Equal(m.OriginCountries, []string{"JP"}) || m.OriginalLanguage != "ja" || m.Year != 2001 { + t.Fatalf("movie = %+v", m) + } + + var tv tvDetailResponse + if err := json.Unmarshal([]byte(`{ + "id": 95396, "name": "Severance", "first_air_date": "2022-02-17", "original_language": "en", + "origin_country": ["US"], + "genres": [{"id": 18, "name": "Drama"}], + "networks": [{"id": 2552, "name": "Apple TV+"}] + }`), &tv); err != nil { + t.Fatal(err) + } + s := normalizeTVDetail(&tv) + if !slices.Equal(s.GenreIDs, []int{18}) || !slices.Equal(s.NetworkIDs, []int{2552}) || + !slices.Equal(s.OriginCountries, []string{"US"}) || s.Year != 2022 { + t.Fatalf("series = %+v", s) + } +} diff --git a/internal/metadata/tmdb/types.go b/internal/metadata/tmdb/types.go index 2a6cbe23f0..9491870c2c 100644 --- a/internal/metadata/tmdb/types.go +++ b/internal/metadata/tmdb/types.go @@ -193,6 +193,13 @@ type MediaDetail struct { ProductionCompanies []string OriginalLanguage string KeywordIDs []int + // GenreIDs, CompanyIDs (movies) and NetworkIDs (series) are TMDB's stable + // identifiers for Genres, ProductionCompanies and Networks, whose names + // follow the configured language. OriginCountries holds ISO 3166-1 codes. + GenreIDs []int + CompanyIDs []int + NetworkIDs []int + OriginCountries []string NumberOfSeasons int NumberOfEpisodes int @@ -312,6 +319,7 @@ type movieDetailResponse struct { Results []idEntry `json:"results"` // tv } `json:"keywords"` Genres []genreEntry `json:"genres"` + OriginCountry []string `json:"origin_country"` VoteAverage float64 `json:"vote_average"` VoteCount int `json:"vote_count"` Status string `json:"status"` @@ -342,6 +350,7 @@ type tvDetailResponse struct { Results []idEntry `json:"results"` // tv } `json:"keywords"` Genres []genreEntry `json:"genres"` + OriginCountry []string `json:"origin_country"` VoteAverage float64 `json:"vote_average"` VoteCount int `json:"vote_count"` Status string `json:"status"` diff --git a/internal/requests/repository.go b/internal/requests/repository.go index 737695c8cc..59d75a0a6c 100644 --- a/internal/requests/repository.go +++ b/internal/requests/repository.go @@ -319,22 +319,29 @@ func (r *Repository) insertRequest( if input.Input.Year != nil { year = *input.Input.Year } + facts, err := encodeRoutingFacts(input.Facts) + if err != nil { + return nil, err + } row := exec.QueryRow(ctx, ` INSERT INTO media_requests ( id, provider, media_type, tmdb_id, tvdb_id, imdb_id, title, year, overview, poster_path, backdrop_path, status, outcome, - requested_by_user_id, requested_by_profile_id, is_anime, created_at, updated_at, approved_at + requested_by_user_id, requested_by_profile_id, is_anime, created_at, updated_at, approved_at, + routing_facts ) VALUES ( $1, 'tmdb', $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, - $13, $14, $15, $16, $16, $17 + $13, $14, $15, $16, $16, $17, + $18 ) RETURNING `+requestColumns(), input.ID, input.Input.MediaType, input.Input.TMDBID, tvdbID, strings.TrimSpace(input.Input.IMDbID), strings.TrimSpace(input.Input.Title), year, strings.TrimSpace(input.Input.Overview), strings.TrimSpace(input.Input.PosterPath), strings.TrimSpace(input.Input.BackdropPath), status, outcome, - input.Requester.UserID, input.Requester.ProfileID, input.IsAnime, now, approvedAt) + input.Requester.UserID, input.Requester.ProfileID, input.IsAnime, now, approvedAt, + facts) req, err := scanRequest(row) if err != nil { return nil, fmt.Errorf("insert request: %w", err) @@ -1032,7 +1039,7 @@ func requestColumns() string { overview, poster_path, backdrop_path, status, outcome, requested_by_user_id, requested_by_profile_id, is_anime, last_error, created_at, updated_at, approved_at, completed_at, - submit_attempts, submit_lease_until, next_submit_at, outcome_reason` + submit_attempts, submit_lease_until, next_submit_at, outcome_reason, routing_facts` } type requestScanner interface { @@ -1043,6 +1050,7 @@ func scanRequest(row requestScanner) (*Request, error) { var req Request var tvdbID, year sql.NullInt64 var approvedAt, completedAt, submitLeaseUntil, nextSubmitAt sql.NullTime + var rawFacts []byte if err := row.Scan( &req.ID, &req.Provider, @@ -1069,9 +1077,15 @@ func scanRequest(row requestScanner) (*Request, error) { &submitLeaseUntil, &nextSubmitAt, &req.OutcomeReason, + &rawFacts, ); err != nil { return nil, err } + facts, err := decodeRoutingFacts(rawFacts) + if err != nil { + return nil, err + } + req.RoutingFacts = facts if tvdbID.Valid { v := int(tvdbID.Int64) req.TVDBID = &v diff --git a/internal/requests/repository_page_test.go b/internal/requests/repository_page_test.go index eee39bf41f..048b869e1a 100644 --- a/internal/requests/repository_page_test.go +++ b/internal/requests/repository_page_test.go @@ -29,7 +29,7 @@ func TestRequestListKeysetDatabase(t *testing.T) { requested_by_user_id int, requested_by_profile_id text, is_anime bool, last_error text, created_at timestamptz, updated_at timestamptz, approved_at timestamptz, completed_at timestamptz, submit_attempts int NOT NULL DEFAULT 0, submit_lease_until timestamptz, next_submit_at timestamptz, - outcome_reason text NOT NULL DEFAULT ''); + outcome_reason text NOT NULL DEFAULT '', routing_facts jsonb NOT NULL DEFAULT '{}'); CREATE INDEX ON media_requests (requested_by_user_id, created_at DESC, id DESC)`) if err != nil { t.Fatal(err) @@ -37,7 +37,7 @@ func TestRequestListKeysetDatabase(t *testing.T) { stamp := time.Date(2026, 1, 2, 3, 4, 5, 123456000, time.UTC) insert := func(id string, user int, at time.Time) { t.Helper() - _, err := pool.Exec(t.Context(), `INSERT INTO media_requests VALUES ($1,'tmdb','movie',1,NULL,'','',NULL,'','','','pending','active',$2,'profile',false,'',$3,$3,NULL,NULL,0,NULL,NULL,'')`, id, user, at) + _, err := pool.Exec(t.Context(), `INSERT INTO media_requests VALUES ($1,'tmdb','movie',1,NULL,'','',NULL,'','','','pending','active',$2,'profile',false,'',$3,$3,NULL,NULL,0,NULL,NULL,'','{}')`, id, user, at) if err != nil { t.Fatal(err) } diff --git a/internal/requests/routing_facts.go b/internal/requests/routing_facts.go new file mode 100644 index 0000000000..5e71a6f27c --- /dev/null +++ b/internal/requests/routing_facts.go @@ -0,0 +1,83 @@ +package requests + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" +) + +// RoutingFacts is what routing rules can match a request on. It is captured +// from TMDB when the request is created and stored with it, so rules evaluate +// the same way at approval, on another server, or after TMDB changes. +type RoutingFacts struct { + GenreIDs []int `json:"genre_ids,omitempty"` + KeywordIDs []int `json:"keyword_ids,omitempty"` + OriginalLanguage string `json:"original_language,omitempty"` + OriginCountries []string `json:"origin_countries,omitempty"` + Year int `json:"year,omitempty"` + // NetworkIDs are a series' networks; CompanyIDs a movie's studios. + NetworkIDs []int `json:"network_ids,omitempty"` + CompanyIDs []int `json:"company_ids,omitempty"` + Anime bool `json:"anime,omitempty"` + // CapturedAt is unset on requests from before capture, which is how + // routing tells "no facts yet" from a title TMDB knows little about. + CapturedAt *time.Time `json:"captured_at,omitempty"` +} + +// Captured reports whether the facts were ever read from TMDB. +func (f RoutingFacts) Captured() bool { return f.CapturedAt != nil } + +// routingFactsFrom reads the facts off a TMDB detail. A nil detail (TMDB +// unreachable) yields uncaptured facts, so routing retries the lookup later. +func routingFactsFrom(detail *tmdb.MediaDetail, now time.Time) RoutingFacts { + if detail == nil { + return RoutingFacts{} + } + return RoutingFacts{ + GenreIDs: detail.GenreIDs, + KeywordIDs: detail.KeywordIDs, + OriginalLanguage: detail.OriginalLanguage, + OriginCountries: detail.OriginCountries, + Year: detail.Year, + NetworkIDs: detail.NetworkIDs, + CompanyIDs: detail.CompanyIDs, + Anime: detectAnime(detail.KeywordIDs), + CapturedAt: &now, + } +} + +// requestDetail fetches the TMDB detail a request is checked and routed +// against. It returns nil when TMDB cannot answer, or when the service has no +// TMDB client (a service wired for another job). +func (s *Service) requestDetail(ctx context.Context, mediaType MediaType, tmdbID int) *tmdb.MediaDetail { + if s.tmdb == nil { + return nil + } + detail, err := s.tmdb.GetMediaDetail(ctx, tmdbMediaType(mediaType), tmdbID) + if err != nil { + return nil + } + return detail +} + +func encodeRoutingFacts(facts RoutingFacts) ([]byte, error) { + raw, err := json.Marshal(facts) + if err != nil { + return nil, fmt.Errorf("encode routing facts: %w", err) + } + return raw, nil +} + +func decodeRoutingFacts(raw []byte) (RoutingFacts, error) { + var facts RoutingFacts + if len(raw) == 0 { + return facts, nil + } + if err := json.Unmarshal(raw, &facts); err != nil { + return RoutingFacts{}, fmt.Errorf("decode routing facts: %w", err) + } + return facts, nil +} diff --git a/internal/requests/routing_facts_test.go b/internal/requests/routing_facts_test.go new file mode 100644 index 0000000000..e4c55bee79 --- /dev/null +++ b/internal/requests/routing_facts_test.go @@ -0,0 +1,71 @@ +package requests + +import ( + "context" + "slices" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" +) + +func TestCreateRequestCapturesRoutingFactsAndServerTitle(t *testing.T) { + store := newFakeStore() + tmdbClient := &fakeTMDBClient{detail: &tmdb.MediaDetail{ + MediaType: "movie", ID: 129, Title: "Spirited Away", Year: 2001, + GenreIDs: []int{16, 14}, KeywordIDs: []int{210024}, OriginalLanguage: "ja", + OriginCountries: []string{"JP"}, CompanyIDs: []int{10342}, + }} + svc := newTestServiceWithTMDB(store, tmdbClient) + + req, err := svc.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ + MediaType: MediaTypeMovie, TMDBID: 129, Title: "spirited away (client copy)", + }) + if err != nil { + t.Fatalf("CreateRequest: %v", err) + } + if req.Title != "Spirited Away" || store.created[0].Input.Year == nil || *store.created[0].Input.Year != 2001 { + t.Fatalf("title/year = %q/%v, want the server's TMDB copy", req.Title, store.created[0].Input.Year) + } + facts := store.created[0].Facts + if !facts.Captured() || !facts.Anime || !req.IsAnime || facts.OriginalLanguage != "ja" || facts.Year != 2001 || + !slices.Equal(facts.GenreIDs, []int{16, 14}) || !slices.Equal(facts.CompanyIDs, []int{10342}) { + t.Fatalf("facts = %+v, want the TMDB snapshot", facts) + } +} + +func TestCreateRequestWithoutTMDBDetailLeavesFactsUncaptured(t *testing.T) { + store := newFakeStore() + svc := newTestServiceWithTMDB(store, &fakeTMDBClient{}) + + req, err := svc.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ + MediaType: MediaTypeMovie, TMDBID: 550, Title: "Fight Club", + }) + if err != nil { + t.Fatalf("CreateRequest: %v", err) + } + if req.Title != "Fight Club" || store.created[0].Facts.Captured() { + t.Fatalf("title = %q facts = %+v, want the client's title and uncaptured facts", req.Title, store.created[0].Facts) + } +} + +func TestRoutingFactsDatabase(t *testing.T) { + repo, _ := lifecycleTestRepository(t) + ctx := t.Context() + at := time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC) + facts := RoutingFacts{GenreIDs: []int{16}, KeywordIDs: []int{210024}, OriginalLanguage: "ja", Year: 2001, Anime: true, CapturedAt: &at} + if _, err := repo.CreateRequest(ctx, CreateRequestRecord{ + ID: "facts", Input: CreateRequestInput{MediaType: MediaTypeMovie, TMDBID: 129, Title: "Spirited Away"}, + Status: StatusPending, Outcome: OutcomeActive, Requester: Viewer{UserID: 1}, Facts: facts, + }); err != nil { + t.Fatal(err) + } + got, err := repo.GetRequest(ctx, "facts") + if err != nil { + t.Fatal(err) + } + if !got.RoutingFacts.Captured() || !got.RoutingFacts.CapturedAt.Equal(at) || !got.RoutingFacts.Anime || + !slices.Equal(got.RoutingFacts.GenreIDs, []int{16}) || got.RoutingFacts.OriginalLanguage != "ja" { + t.Fatalf("facts = %+v, want the stored snapshot", got.RoutingFacts) + } +} diff --git a/internal/requests/service.go b/internal/requests/service.go index 7e655f88cc..2706fdfe1e 100644 --- a/internal/requests/service.go +++ b/internal/requests/service.go @@ -745,7 +745,6 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create return nil, err } s.enrichExternalIDs(ctx, &normalized) - isAnime := s.detectRequestAnime(ctx, normalized.MediaType, normalized.TMDBID) matches, err := s.lookupPresence(ctx, normalized.MediaType, []PresenceCandidate{createPresenceCandidate(normalized)}) if err != nil { @@ -763,6 +762,21 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create return nil, ErrAlreadyRequested } + // One TMDB detail read, after the cheap refusals, serves routing and the + // stored title: the server's copy of the title and year wins over the + // client's. + detail := s.requestDetail(ctx, normalized.MediaType, normalized.TMDBID) + if detail != nil { + if title := strings.TrimSpace(detail.Title); title != "" { + normalized.Title = title + } + if detail.Year > 0 { + year := detail.Year + normalized.Year = &year + } + } + facts := routingFactsFrom(detail, s.now()) + policy, err := s.EffectivePolicy(ctx, viewer.UserID) if err != nil { return nil, err @@ -786,7 +800,8 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create Input: normalized, Status: status, Outcome: OutcomeActive, - IsAnime: isAnime, + IsAnime: facts.Anime, + Facts: facts, Requester: viewer, Now: s.now(), // Re-requesting a title that failed for this user (e.g. a transient @@ -1698,14 +1713,6 @@ func (s *Service) enrichExternalIDs(ctx context.Context, input *CreateRequestInp } } -func (s *Service) detectRequestAnime(ctx context.Context, mediaType MediaType, tmdbID int) bool { - detail, err := s.tmdb.GetMediaDetail(ctx, tmdbMediaType(mediaType), tmdbID) - if err != nil || detail == nil { - return false - } - return detectAnime(detail.KeywordIDs) -} - // integrationSupportsMediaType reports whether a router connection serves the // given media type. An empty SupportedMediaTypes is treated as "supports all". func integrationSupportsMediaType(in Integration, mediaType MediaType) bool { diff --git a/internal/requests/service_test.go b/internal/requests/service_test.go index ca79db2ea4..2c211af145 100644 --- a/internal/requests/service_test.go +++ b/internal/requests/service_test.go @@ -1716,6 +1716,7 @@ func (f *fakeStore) CreateRequest(_ context.Context, input CreateRequestRecord) Status: input.Status, Outcome: input.Outcome, IsAnime: input.IsAnime, + RoutingFacts: input.Facts, RequestedByUserID: input.Requester.UserID, RequestedByProfileID: input.Requester.ProfileID, CreatedAt: input.Now, diff --git a/internal/requests/store.go b/internal/requests/store.go index ff23181087..79ae33a7d3 100644 --- a/internal/requests/store.go +++ b/internal/requests/store.go @@ -89,6 +89,7 @@ type CreateRequestRecord struct { Status Status Outcome Outcome IsAnime bool + Facts RoutingFacts Requester Viewer Now time.Time // Quota, when non-nil, instructs the store to atomically verify the diff --git a/internal/requests/types.go b/internal/requests/types.go index 8a7c5af27c..97bf5dff35 100644 --- a/internal/requests/types.go +++ b/internal/requests/types.go @@ -168,6 +168,8 @@ type Request struct { SubmitAttempts int `json:"-"` SubmitLeaseUntil *time.Time `json:"-"` NextSubmitAt *time.Time `json:"-"` + // RoutingFacts is the TMDB snapshot routing rules match on. + RoutingFacts RoutingFacts `json:"-"` // Followers are the profiles, other than the requester's, that asked to be // told when the title is available; loaded for the fulfilled notification. Followers []Follower `json:"-"` diff --git a/migrations/sql/20260926155103_request_routing_facts.sql b/migrations/sql/20260926155103_request_routing_facts.sql new file mode 100644 index 0000000000..38cc35caf2 --- /dev/null +++ b/migrations/sql/20260926155103_request_routing_facts.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- What routing rules can match on (genres, keywords, original language, +-- origin countries, year, networks, studios, anime), captured from TMDB when +-- the request is created. Rules then evaluate the same way at approval, on +-- another server, or after TMDB changes. '{}' marks a request from before +-- capture; routing fetches its facts when it is submitted. +ALTER TABLE media_requests ADD COLUMN routing_facts jsonb NOT NULL DEFAULT '{}'::jsonb; + +-- +goose Down +ALTER TABLE media_requests DROP COLUMN routing_facts; From 1efbc8291a0eab6ad986689dbc0ae8e1dcb5cb29 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:06:25 -0400 Subject: [PATCH 007/103] feat(requests): route requests to servers with ordered rules Silo now decides which server each quality tier of a request goes to, instead of leaving it to the Sonarr/Radarr plugin's one-default-per-tier switches. That lets an admin send anime, a genre, a decade, a language, a country, a network or studio, or one account's requests to their own Sonarr or Radarr, with their own root folder, quality profile and tags. Routes belong to a media type and hold conditions and a destination per tier: a server plus overrides for its plugin config. Per tier, the first enabled route whose conditions match and that has a destination for the tier wins; each media type's fallback route comes last. A route can skip 4K for the titles it matches. Conditions match the routing facts captured at creation (fetched at routing time for older requests, retrying while TMDB is unreachable). A routed submission calls the plugin once per tier with only the chosen server, marked as the tier's default and carrying the route's overrides, so the existing plugin follows the route unchanged. Each target records the route that sent it (v2 route_name). Status checks now go through the plugin installation that owns each target's server rather than whichever installation happens to be first. The migration turns each media type's current default and default-4K servers into its fallback route, and a default server's anime settings into an Anime route, so routing is unchanged after upgrade. Media types with no routes, such as Seerr-only setups, keep the plugin's routing. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/silo/main.go | 5 +- .../api/v2/fixtures/get_system_info_ok.json | 2 +- contracts/api/v2/openapi.json | 4 + docs/architecture/media-requests.md | 42 ++ internal/apiv2/requests.go | 3 +- internal/requests/lifecycle_guard_test.go | 2 +- internal/requests/repository.go | 25 + internal/requests/routing.go | 229 +++++++++ internal/requests/routing_repository.go | 63 +++ internal/requests/routing_test.go | 471 ++++++++++++++++++ internal/requests/service.go | 355 ++++++++++--- internal/requests/service_test.go | 63 ++- internal/requests/store.go | 6 + internal/requests/targets.go | 15 +- internal/requests/types.go | 4 + .../sql/20260926155617_request_routes.sql | 107 ++++ web/src/api/v2/schema.ts | 2 + 17 files changed, 1297 insertions(+), 101 deletions(-) create mode 100644 internal/requests/routing.go create mode 100644 internal/requests/routing_repository.go create mode 100644 internal/requests/routing_test.go create mode 100644 migrations/sql/20260926155617_request_routes.sql diff --git a/cmd/silo/main.go b/cmd/silo/main.go index e61862a347..0e50c3a19b 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -77,6 +77,7 @@ import ( "github.com/Silo-Server/silo-server/internal/markers" "github.com/Silo-Server/silo-server/internal/mdblist" "github.com/Silo-Server/silo-server/internal/metadata" + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" "github.com/Silo-Server/silo-server/internal/netaccess" // Built-in metadata providers self-register into the metadata package's @@ -2904,9 +2905,11 @@ func main() { if watchProviderService != nil { taskMgr.Register(tasks.NewSyncWatchProvidersTask(watchProviderService)) } + // The reconcile pass routes requests, which reads TMDB for requests + // whose routing facts were never captured. requestReconcileSvc := mediarequests.NewService( mediarequests.NewRepository(deps.DB, deps.SecretCipher), - nil, + tmdb.NewClient(cfg.TMDBAPIKey, 40), mediarequests.NewCatalogPresence( catalog.NewItemRepository(deps.DB), catalog.NewProviderIDRepository(deps.DB), diff --git a/contracts/api/v2/fixtures/get_system_info_ok.json b/contracts/api/v2/fixtures/get_system_info_ok.json index 18283a2c83..2d6100dd3b 100644 --- a/contracts/api/v2/fixtures/get_system_info_ok.json +++ b/contracts/api/v2/fixtures/get_system_info_ok.json @@ -1,7 +1,7 @@ { "server_version": "unavailable", "api_major": 2, - "contract_digest": "13f9a6a77a3422d41a2f4fee5792b754bf507c5095c6c8a908e8e53acefbc11b", + "contract_digest": "d0807c368f05a3622af3b823cf59ae421dcff955adff51b6d07f627948f4fd1a", "links": { "openapi": "/api/v2/openapi.json", "capabilities": "/api/v2/capabilities", diff --git a/contracts/api/v2/openapi.json b/contracts/api/v2/openapi.json index 9a1576a8b6..7c272654bd 100644 --- a/contracts/api/v2/openapi.json +++ b/contracts/api/v2/openapi.json @@ -35496,6 +35496,10 @@ "minLength": 1, "type": "string" }, + "route_name": { + "description": "The routing rule that sent this target to its server, as named when it was sent", + "type": "string" + }, "status": { "examples": [ "queued" diff --git a/docs/architecture/media-requests.md b/docs/architecture/media-requests.md index eeb2a68e8a..ff75d7ddf5 100644 --- a/docs/architecture/media-requests.md +++ b/docs/architecture/media-requests.md @@ -29,6 +29,48 @@ anime. IDs rather than names, because names follow the configured TMDB language. When TMDB cannot answer, the request is still created from the client's copy, and the facts stay uncaptured until routing fetches them. +## Routing + +Silo, not the router plugin, decides which server each quality tier of a request +goes to (`internal/requests/routing.go`). Routes (`request_routes`) belong to a +media type and hold conditions and a destination per tier: a server plus +overrides for its plugin config (root folder, quality profile, tags, series type, +minimum availability, ...). Conditions match on the request's routing facts and +requester: anime, genre, keyword, original language, origin country, year range, +network, studio, requesting account. Every set condition must hold, and a list +matches any of its values. + +Each tier is decided on its own: the first enabled route, in position order, +whose conditions match and that has a destination for the tier wins, and the +media type's fallback route (no conditions) comes last. A route with no +destination for a tier lets that tier fall through; `skip_uhd` stops a matching +title from getting a 4K copy at all, even with `force_dual_quality`. Without +`force_dual_quality`, a title no route sends to a 4K server gets no 4K copy. + +A routed submission calls the plugin once per tier with only the chosen server. +Its config carries the route's overrides and marks it the tier's default in the +Sonarr/Radarr plugin's terms, with the plugin's own anime overlay off, so the +existing plugin follows the route without knowing about routing. Each target +records the route that sent it. A server a route sends to cannot be deleted +until the route stops using it, so deleting a server never silently reroutes +titles. A chosen server that is disabled, not set up (no installation, no key) +or since switched to the other kind is an admin-fixable problem: when nothing has been +sent yet, the submission retries with backoff; a later tier that fails that way +becomes a failed target. Status checks go through the plugin installation that +owns each target's server, and one plugin failing does not discard the statuses +another reported. A media type with no routes keeps the plugin's own routing: +every usable connection is handed over and the plugin picks. + +The migration that introduced routes carried the Sonarr/Radarr plugin's routing +over unchanged: each media type's first usable default and default-4K servers +(by name) became its fallback route, and a default server's anime settings +became an Anime route. Once a media type has routes, the connections' own +default and anime switches no longer decide anything. + +A request created before facts were captured, or while TMDB was unreachable, has +them fetched when it is first routed; if TMDB still cannot answer, the +submission retries rather than route on missing facts. + ## Transitions are guarded Every status or outcome write made by an admin, a user, or the reconcile pass diff --git a/internal/apiv2/requests.go b/internal/apiv2/requests.go index 49a983720c..850bc58d61 100644 --- a/internal/apiv2/requests.go +++ b/internal/apiv2/requests.go @@ -162,6 +162,7 @@ type RequestTarget struct { ExternalStatus string `json:"external_status,omitempty"` Status string `json:"status" example:"queued"` LastError string `json:"last_error,omitempty"` + RouteName string `json:"route_name,omitempty" doc:"The routing rule that sent this target to its server, as named when it was sent"` CreatedAt Instant `json:"created_at" example:"2026-01-02T03:04:05.000Z"` UpdatedAt Instant `json:"updated_at" example:"2026-01-02T03:04:05.000Z"` } @@ -791,7 +792,7 @@ func mediaRequestOf(r *mediarequests.Request) MediaRequest { ID: IDFromInt(t.ID), RequestID: ID(t.RequestID), IntegrationID: t.IntegrationID, IntegrationKind: t.IntegrationKind, InstanceName: t.InstanceName, Quality: string(t.Quality), IsAnime: t.IsAnime, ExternalID: t.ExternalID, ExternalStatus: t.ExternalStatus, Status: string(t.Status), - LastError: t.LastError, CreatedAt: NewInstant(t.CreatedAt), UpdatedAt: NewInstant(t.UpdatedAt), + LastError: t.LastError, RouteName: t.RouteName, CreatedAt: NewInstant(t.CreatedAt), UpdatedAt: NewInstant(t.UpdatedAt), }) } return out diff --git a/internal/requests/lifecycle_guard_test.go b/internal/requests/lifecycle_guard_test.go index 5f35d888b1..594319ff76 100644 --- a/internal/requests/lifecycle_guard_test.go +++ b/internal/requests/lifecycle_guard_test.go @@ -450,7 +450,7 @@ func TestRetryDropsFailedTargetForUnwantedQuality(t *testing.T) { store.requests["r1"] = &Request{ID: "r1", MediaType: MediaTypeMovie, TMDBID: 550, Status: StatusQueued, Outcome: OutcomeFailed} store.targets = map[string][]Target{"r1": { {ID: 1, RequestID: "r1", IntegrationID: "router-1", Quality: Quality1080p, Status: StatusCompleted}, - {ID: 2, RequestID: "r1", Quality: Quality2160p, Status: StatusFailed, LastError: "fulfillment backend returned no target for this quality"}, + {ID: 2, RequestID: "r1", Quality: Quality2160p, Status: StatusFailed, LastError: msgNoTargetForQuality}, }} store.targetSeq = 2 router := &fakeRouterProvider{} diff --git a/internal/requests/repository.go b/internal/requests/repository.go index 59d75a0a6c..c13c589e8e 100644 --- a/internal/requests/repository.go +++ b/internal/requests/repository.go @@ -977,6 +977,31 @@ func (r *Repository) deleteIntegration(ctx context.Context, tx pgx.Tx, id string return ErrInvalidState } + // Deleting a server a route sends to would silently reroute its titles. + rows, err := tx.Query(ctx, ` + SELECT name FROM request_routes + WHERE hd_integration_id = $1 OR uhd_integration_id = $1 + ORDER BY media_type, is_fallback, position`, id) + if err != nil { + return fmt.Errorf("check integration routes: %w", err) + } + var routes []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + rows.Close() + return err + } + routes = append(routes, name) + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + if len(routes) > 0 { + return &ValidationError{FormError: "Routing still sends requests to this server (" + strings.Join(routes, ", ") + "); change those routes first."} + } + if _, err := tx.Exec(ctx, `DELETE FROM request_integrations WHERE id = $1`, id); err != nil { return fmt.Errorf("delete request integration: %w", err) } diff --git a/internal/requests/routing.go b/internal/requests/routing.go new file mode 100644 index 0000000000..43914a1e0b --- /dev/null +++ b/internal/requests/routing.go @@ -0,0 +1,229 @@ +package requests + +import ( + "cmp" + "fmt" + "maps" + "slices" + "strings" +) + +// Routing: Silo decides which server each quality tier of a request goes to, +// and hands the router plugin only that server. Routes are evaluated per tier +// in order: the first enabled route whose conditions match the request and +// that has a destination for the tier wins, and the media type's fallback +// route (no conditions) comes last. A media type with no routes keeps the +// plugin's own routing (every connection is handed over and the plugin picks). + +// The Sonarr/Radarr plugin's config keys and kinds that routing sets or reads. +const ( + configServiceKind = "service_kind" + configIsDefault = "is_default" + configIsDefault4K = "is_default_4k" + configIs4K = "is_4k" + configAnimeEnabled = "anime_enabled" + kindRadarr = "radarr" + kindSonarr = "sonarr" +) + +// Route sends the requests it matches to a server per quality tier. +type Route struct { + ID string + MediaType MediaType + Position int + Name string + Enabled bool + IsFallback bool + Conditions RouteConditions + HD RouteDestination + UHD RouteDestination + // SkipUHD stops a matching title from getting a 4K copy at all, rather + // than letting the 4K tier fall through to a later route. + SkipUHD bool + Revision int64 +} + +// RouteDestination is a server and the settings a route overrides on it. An +// empty IntegrationID means the route has no destination for the tier. +type RouteDestination struct { + IntegrationID string + // Overrides replace keys of the server's plugin config for requests this + // route sends, e.g. root_folder, quality_profile_id, tags, series_type. + Overrides map[string]any +} + +// RouteConditions narrow a route to some requests. Every set field must match +// (AND); a list matches when the request has any of its values (OR). An empty +// condition set matches everything. +type RouteConditions struct { + Anime *bool `json:"anime,omitempty"` + GenreIDs []int `json:"genre_ids,omitempty"` + KeywordIDs []int `json:"keyword_ids,omitempty"` + OriginalLanguages []string `json:"original_languages,omitempty"` + OriginCountries []string `json:"origin_countries,omitempty"` + // YearFrom and YearTo bound the release (or first-air) year, inclusive; + // a decade is 1980-1989. + YearFrom int `json:"year_from,omitempty"` + YearTo int `json:"year_to,omitempty"` + NetworkIDs []int `json:"network_ids,omitempty"` + CompanyIDs []int `json:"company_ids,omitempty"` + RequesterUserIDs []int `json:"requester_user_ids,omitempty"` +} + +// Matches reports whether a request satisfies every set condition, judged on +// its stored routing facts. +func (c RouteConditions) Matches(req Request) bool { + f := req.RoutingFacts + switch { + case c.Anime != nil && *c.Anime != f.Anime: + return false + case len(c.GenreIDs) > 0 && !anyInt(c.GenreIDs, f.GenreIDs): + return false + case len(c.KeywordIDs) > 0 && !anyInt(c.KeywordIDs, f.KeywordIDs): + return false + case len(c.OriginalLanguages) > 0 && !anyFold(c.OriginalLanguages, []string{f.OriginalLanguage}): + return false + case len(c.OriginCountries) > 0 && !anyFold(c.OriginCountries, f.OriginCountries): + return false + case c.YearFrom > 0 && (f.Year == 0 || f.Year < c.YearFrom): + return false + case c.YearTo > 0 && (f.Year == 0 || f.Year > c.YearTo): + return false + case len(c.NetworkIDs) > 0 && !anyInt(c.NetworkIDs, f.NetworkIDs): + return false + case len(c.CompanyIDs) > 0 && !anyInt(c.CompanyIDs, f.CompanyIDs): + return false + case len(c.RequesterUserIDs) > 0 && !slices.Contains(c.RequesterUserIDs, req.RequestedByUserID): + return false + } + return true +} + +func anyInt(want, have []int) bool { + for _, v := range have { + if slices.Contains(want, v) { + return true + } + } + return false +} + +func anyFold(want, have []string) bool { + for _, v := range have { + for _, w := range want { + if v != "" && strings.EqualFold(v, w) { + return true + } + } + } + return false +} + +// RouteDecision is where one quality tier of a request goes, and which route +// sent it there. Skip marks a tier a matching route chose not to send at all +// (skip_uhd): the title gets no copy in that tier, whatever force-dual says. +type RouteDecision struct { + RouteID string + RouteName string + IntegrationID string + Overrides map[string]any + Skip bool +} + +// orderRoutes sorts a media type's routes into evaluation order: by position, +// the fallback last. +func orderRoutes(routes []Route) []Route { + out := slices.Clone(routes) + slices.SortStableFunc(out, func(a, b Route) int { + if a.IsFallback != b.IsFallback { + if a.IsFallback { + return 1 + } + return -1 + } + return cmp.Or(cmp.Compare(a.Position, b.Position), cmp.Compare(a.ID, b.ID)) + }) + return out +} + +// decideRoutes picks a destination for each quality. A tier no route sends +// anywhere is absent from the result; a tier a route skips is present with +// Skip set. +func decideRoutes(routes []Route, req Request, qualities []Quality) map[Quality]RouteDecision { + ordered := orderRoutes(routes) + out := make(map[Quality]RouteDecision, len(qualities)) + for _, q := range qualities { + for _, route := range ordered { + if !route.Enabled || route.MediaType != req.MediaType || !route.Conditions.Matches(req) { + continue + } + if q == Quality2160p && route.SkipUHD { + out[q] = RouteDecision{RouteID: route.ID, RouteName: route.Name, Skip: true} + break + } + dest := route.HD + if q == Quality2160p { + dest = route.UHD + } + if dest.IntegrationID == "" { + continue + } + out[q] = RouteDecision{ + RouteID: route.ID, + RouteName: route.Name, + IntegrationID: dest.IntegrationID, + Overrides: dest.Overrides, + } + break + } + } + return out +} + +// routedConnection builds the one connection a routed tier is sent to. The +// route already chose the server, so the connection says so in the Sonarr/ +// Radarr plugin's own terms: it is the tier's default, and the plugin's anime +// overlay is off because the route's overrides replace it. A plugin without +// those keys ignores them. +func routedConnection(fc *fulfillContext, d RouteDecision, mediaType MediaType, q Quality) (ResolvedRouterConnection, int, string, error) { + var in *Integration + for i := range fc.integrations { + if fc.integrations[i].ID == d.IntegrationID { + in = &fc.integrations[i] + break + } + } + switch { + case in == nil: + return ResolvedRouterConnection{}, 0, "", fmt.Errorf("route %q sends to a server that no longer exists", d.RouteName) + case !in.Enabled: + return ResolvedRouterConnection{}, 0, "", fmt.Errorf("route %q sends to %q, which is disabled", d.RouteName, in.Name) + case in.InstallationID == nil || in.CapabilityID == "": + return ResolvedRouterConnection{}, 0, "", fmt.Errorf("%s (route %q)", msgRouterUnbound, d.RouteName) + case strings.TrimSpace(in.APIKeyRef) == "": + return ResolvedRouterConnection{}, 0, "", fmt.Errorf("%s (route %q)", msgRouterNoKey, d.RouteName) + } + // A server can be switched to the other kind after a route points at it. + if kind, _ := in.PluginConfig[configServiceKind].(string); kind != "" { + if want := map[MediaType]string{MediaTypeMovie: kindRadarr, MediaTypeSeries: kindSonarr}[mediaType]; want != "" && kind != want { + return ResolvedRouterConnection{}, 0, "", fmt.Errorf("route %q sends %s to %q, a %s server", d.RouteName, mediaType, in.Name, kind) + } + } + config := maps.Clone(in.PluginConfig) + if config == nil { + config = map[string]any{} + } + maps.Copy(config, d.Overrides) + config[configIsDefault] = q == Quality1080p + config[configIsDefault4K] = q == Quality2160p + if q == Quality2160p { + config[configIs4K] = true + } + config[configAnimeEnabled] = false + return ResolvedRouterConnection{ + ID: in.ID, + BaseURL: in.BaseURL, + APIKey: strings.TrimSpace(in.APIKeyRef), + Config: config, + }, *in.InstallationID, in.CapabilityID, nil +} diff --git a/internal/requests/routing_repository.go b/internal/requests/routing_repository.go new file mode 100644 index 0000000000..d4370a8348 --- /dev/null +++ b/internal/requests/routing_repository.go @@ -0,0 +1,63 @@ +package requests + +import ( + "context" + "encoding/json" + "fmt" +) + +const routeColumns = `id, media_type, position, name, enabled, is_fallback, conditions, + coalesce(hd_integration_id, ''), hd_overrides, coalesce(uhd_integration_id, ''), uhd_overrides, + skip_uhd, revision` + +func (r *Repository) ListRoutes(ctx context.Context) ([]Route, error) { + rows, err := r.pool.Query(ctx, `SELECT `+routeColumns+` FROM request_routes ORDER BY media_type, is_fallback, position, id`) + if err != nil { + return nil, fmt.Errorf("list request routes: %w", err) + } + defer rows.Close() + var out []Route + for rows.Next() { + route, err := scanRoute(rows) + if err != nil { + return nil, err + } + out = append(out, route) + } + return out, rows.Err() +} + +func scanRoute(row requestScanner) (Route, error) { + var route Route + var conditions, hdOverrides, uhdOverrides []byte + if err := row.Scan(&route.ID, &route.MediaType, &route.Position, &route.Name, &route.Enabled, &route.IsFallback, + &conditions, &route.HD.IntegrationID, &hdOverrides, &route.UHD.IntegrationID, &uhdOverrides, + &route.SkipUHD, &route.Revision); err != nil { + return Route{}, fmt.Errorf("scan request route: %w", err) + } + if err := json.Unmarshal(conditions, &route.Conditions); err != nil { + return Route{}, fmt.Errorf("decode route %s conditions: %w", route.ID, err) + } + if err := json.Unmarshal(hdOverrides, &route.HD.Overrides); err != nil { + return Route{}, fmt.Errorf("decode route %s HD overrides: %w", route.ID, err) + } + if err := json.Unmarshal(uhdOverrides, &route.UHD.Overrides); err != nil { + return Route{}, fmt.Errorf("decode route %s 4K overrides: %w", route.ID, err) + } + return route, nil +} + +func (r *Repository) SetRoutingFacts(ctx context.Context, id string, facts RoutingFacts) (*Request, error) { + raw, err := encodeRoutingFacts(facts) + if err != nil { + return nil, err + } + req, err := scanRequest(r.pool.QueryRow(ctx, ` + UPDATE media_requests SET routing_facts = $2, is_anime = $3 + WHERE id = $1 + RETURNING `+requestColumns(), id, raw, facts.Anime)) + if err != nil { + return nil, fmt.Errorf("set request routing facts: %w", err) + } + return req, nil +} diff --git a/internal/requests/routing_test.go b/internal/requests/routing_test.go new file mode 100644 index 0000000000..306958fcc7 --- /dev/null +++ b/internal/requests/routing_test.go @@ -0,0 +1,471 @@ +package requests + +import ( + "context" + "errors" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" +) + +func boolPtr(v bool) *bool { return &v } + +func capturedFacts(f RoutingFacts) RoutingFacts { + at := time.Date(2026, 9, 26, 0, 0, 0, 0, time.UTC) + f.CapturedAt = &at + return f +} + +func TestRouteConditionsMatch(t *testing.T) { + spirited := Request{MediaType: MediaTypeMovie, RequestedByUserID: 7, RoutingFacts: capturedFacts(RoutingFacts{ + GenreIDs: []int{16, 14}, KeywordIDs: []int{210024}, OriginalLanguage: "ja", OriginCountries: []string{"JP"}, + Year: 2001, CompanyIDs: []int{10342}, Anime: true, + })} + for _, tc := range []struct { + name string + c RouteConditions + want bool + }{ + {"no conditions", RouteConditions{}, true}, + {"anime", RouteConditions{Anime: boolPtr(true)}, true}, + {"not anime", RouteConditions{Anime: boolPtr(false)}, false}, + {"any genre", RouteConditions{GenreIDs: []int{28, 16}}, true}, + {"other genre", RouteConditions{GenreIDs: []int{28}}, false}, + {"keyword", RouteConditions{KeywordIDs: []int{210024}}, true}, + {"language, any case", RouteConditions{OriginalLanguages: []string{"JA", "ko"}}, true}, + {"other language", RouteConditions{OriginalLanguages: []string{"en"}}, false}, + {"country", RouteConditions{OriginCountries: []string{"jp"}}, true}, + {"the 2000s", RouteConditions{YearFrom: 2000, YearTo: 2009}, true}, + {"the 1980s", RouteConditions{YearFrom: 1980, YearTo: 1989}, false}, + {"from 2005 on", RouteConditions{YearFrom: 2005}, false}, + {"studio", RouteConditions{CompanyIDs: []int{10342}}, true}, + {"network", RouteConditions{NetworkIDs: []int{213}}, false}, + {"requester", RouteConditions{RequesterUserIDs: []int{7}}, true}, + {"other requester", RouteConditions{RequesterUserIDs: []int{8}}, false}, + {"every field must hold", RouteConditions{Anime: boolPtr(true), OriginalLanguages: []string{"en"}}, false}, + } { + if got := tc.c.Matches(spirited); got != tc.want { + t.Errorf("%s: Matches = %v, want %v", tc.name, got, tc.want) + } + } + undated := Request{RoutingFacts: capturedFacts(RoutingFacts{})} + if (RouteConditions{YearTo: 1999}).Matches(undated) { + t.Error("a year bound matched a title with no year") + } +} + +func testRoutes() []Route { + return []Route{ + {ID: "fallback", MediaType: MediaTypeMovie, Position: 1000, Name: "Everything else", Enabled: true, IsFallback: true, + HD: RouteDestination{IntegrationID: "radarr-hd"}, UHD: RouteDestination{IntegrationID: "radarr-4k"}}, + {ID: "anime", MediaType: MediaTypeMovie, Position: 0, Name: "Anime", Enabled: true, + Conditions: RouteConditions{Anime: boolPtr(true)}, + HD: RouteDestination{IntegrationID: "radarr-anime", Overrides: map[string]any{"root_folder": "/anime"}}}, + {ID: "eighties", MediaType: MediaTypeMovie, Position: 1, Name: "80s", Enabled: false, + Conditions: RouteConditions{YearFrom: 1980, YearTo: 1989}, + HD: RouteDestination{IntegrationID: "radarr-retro"}}, + } +} + +func TestDecideRoutesPerTier(t *testing.T) { + anime := Request{MediaType: MediaTypeMovie, RoutingFacts: capturedFacts(RoutingFacts{Anime: true, Year: 1988})} + got := decideRoutes(testRoutes(), anime, []Quality{Quality1080p, Quality2160p}) + if got[Quality1080p].RouteName != "Anime" || got[Quality1080p].IntegrationID != "radarr-anime" || + got[Quality1080p].Overrides["root_folder"] != "/anime" { + t.Fatalf("HD = %+v, want the Anime route's server and overrides", got[Quality1080p]) + } + // The Anime route has no 4K destination, so 4K falls through; the + // disabled 80s route never matches. + if got[Quality2160p].RouteName != "Everything else" || got[Quality2160p].IntegrationID != "radarr-4k" { + t.Fatalf("4K = %+v, want the fallback", got[Quality2160p]) + } + + routes := testRoutes() + routes[1].SkipUHD = true + got = decideRoutes(routes, anime, []Quality{Quality1080p, Quality2160p}) + if d := got[Quality2160p]; !d.Skip || d.RouteName != "Anime" || d.IntegrationID != "" { + t.Fatalf("4K = %+v, want skipped by the Anime route", d) + } + + series := Request{MediaType: MediaTypeSeries, RoutingFacts: capturedFacts(RoutingFacts{})} + if got := decideRoutes(testRoutes(), series, []Quality{Quality1080p}); len(got) != 0 { + t.Fatalf("series decisions = %+v, want none from movie routes", got) + } +} + +// routingStore seeds three Radarr servers and the routes above, plus an +// approved request for the given facts. +func routingStore(facts RoutingFacts) *fakeStore { + store := newFakeStore() + for _, id := range []string{"radarr-hd", "radarr-4k", "radarr-anime"} { + in := routerInst(id) + in.PluginConfig = map[string]any{"service_kind": "radarr", "root_folder": "/movies", "is_default": false, "anime_enabled": true} + store.integrations = append(store.integrations, in) + } + store.routes = testRoutes() + store.requests["r1"] = &Request{ID: "r1", MediaType: MediaTypeMovie, TMDBID: 129, Status: StatusApproved, + Outcome: OutcomeActive, RequestedByUserID: 7, RoutingFacts: facts, IsAnime: facts.Anime} + return store +} + +func TestSubmitRoutedSendsEachTierToItsServer(t *testing.T) { + store := routingStore(capturedFacts(RoutingFacts{Anime: true})) + router := &fakeRouterProvider{} + svc := newTestService(store) + svc.SetRouterProvider(router) + svc.SetEntitlementResolver(fixedCeiling{q: "2160p"}) + + if _, err := svc.submitApprovedRequest(context.Background(), *store.requests["r1"], Viewer{}, nil); err != nil { + t.Fatalf("submit: %v", err) + } + if len(router.fulfillLog) != 2 { + t.Fatalf("fulfill calls = %+v, want one per tier", router.fulfillLog) + } + for _, call := range router.fulfillLog { + if len(call.conns) != 1 || len(call.qualities) != 1 { + t.Fatalf("call = %+v, want exactly one server and one quality", call) + } + } + hd, uhd := router.fulfillLog[0], router.fulfillLog[1] + hdConn := hd.conns[0] + if hd.qualities[0] != Quality1080p || hdConn.ID != "radarr-anime" || hdConn.Config["root_folder"] != "/anime" || + hdConn.Config["is_default"] != true || hdConn.Config["is_default_4k"] != false || hdConn.Config["anime_enabled"] != false { + t.Fatalf("HD call = %+v, want the Anime route's server as the HD default with its overrides and no plugin anime overlay", hd) + } + uhdConn := uhd.conns[0] + if uhd.qualities[0] != Quality2160p || uhdConn.ID != "radarr-4k" || uhdConn.Config["is_default_4k"] != true || + uhdConn.Config["is_4k"] != true || uhdConn.Config["root_folder"] != "/movies" { + t.Fatalf("4K call = %+v, want the fallback's 4K server as the 4K default", uhd) + } + // The server's stored config is untouched by the per-call overrides. + if store.integrations[2].PluginConfig["root_folder"] != "/movies" { + t.Fatal("routing overrides leaked into the stored server config") + } + targets, _ := store.ListTargets(context.Background(), "r1") + names := map[Quality]string{} + for _, target := range targets { + names[target.Quality] = target.RouteName + } + if names[Quality1080p] != "Anime" || names[Quality2160p] != "Everything else" { + t.Fatalf("target routes = %v, want each target stamped with its route", names) + } +} + +func TestSubmitRoutedDropsUnroutedFourK(t *testing.T) { + store := routingStore(capturedFacts(RoutingFacts{})) + store.routes[0].UHD = RouteDestination{} + router := &fakeRouterProvider{} + svc := newTestService(store) + svc.SetRouterProvider(router) + svc.SetEntitlementResolver(fixedCeiling{q: "2160p"}) + + if _, err := svc.submitApprovedRequest(context.Background(), *store.requests["r1"], Viewer{}, nil); err != nil { + t.Fatalf("submit: %v", err) + } + if len(router.fulfillLog) != 1 || router.fulfillLog[0].conns[0].ID != "radarr-hd" { + t.Fatalf("fulfill calls = %+v, want only the HD tier to the fallback's server", router.fulfillLog) + } + if targets, _ := store.ListTargets(context.Background(), "r1"); len(targets) != 1 { + t.Fatalf("targets = %+v, want only HD", targets) + } +} + +func TestSubmitRoutedRecordsWhyATierFailed(t *testing.T) { + store := routingStore(capturedFacts(RoutingFacts{})) + store.settings.ForceDualQuality = true + store.routes[0].UHD = RouteDestination{} + router := &fakeRouterProvider{} + svc := newTestService(store) + svc.SetRouterProvider(router) + + if _, err := svc.submitApprovedRequest(context.Background(), *store.requests["r1"], Viewer{}, nil); err != nil { + t.Fatalf("submit: %v", err) + } + targets, _ := store.ListTargets(context.Background(), "r1") + var uhd *Target + for i := range targets { + if targets[i].Quality == Quality2160p { + uhd = &targets[i] + } + } + if uhd == nil || uhd.Status != StatusFailed || uhd.LastError != "no routing rule sends 4K for this title" { + t.Fatalf("4K target = %+v, want failed with the missing route explained", uhd) + } +} + +// A route whose server is disabled or not set up is an admin-fixable problem: +// with nothing sent yet, the submission retries instead of failing. +func TestSubmitRoutedRetriesWhenTheServerIsUnusable(t *testing.T) { + store := routingStore(capturedFacts(RoutingFacts{})) + store.integrations[0].Enabled = false // radarr-hd, the fallback's HD server + router := &fakeRouterProvider{} + svc := newTestService(store) + svc.SetRouterProvider(router) + + req, err := svc.submitApprovedRequest(context.Background(), *store.requests["r1"], Viewer{}, nil) + if err != nil { + t.Fatalf("submit: %v", err) + } + if router.fulfillCalls != 0 || req.Status != StatusApproved || req.NextSubmitAt == nil || + !strings.Contains(req.LastError, `"radarr-hd", which is disabled`) { + t.Fatalf("request = %+v (fulfill calls %d), want a retry scheduled with the disabled server named", req, router.fulfillCalls) + } +} + +func TestSubmitRoutedSkipsFourKEvenWithForceDual(t *testing.T) { + store := routingStore(capturedFacts(RoutingFacts{Anime: true})) + store.settings.ForceDualQuality = true + store.routes[1].SkipUHD = true // the Anime route + router := &fakeRouterProvider{} + svc := newTestService(store) + svc.SetRouterProvider(router) + + if _, err := svc.submitApprovedRequest(context.Background(), *store.requests["r1"], Viewer{}, nil); err != nil { + t.Fatalf("submit: %v", err) + } + targets, _ := store.ListTargets(context.Background(), "r1") + if len(targets) != 1 || targets[0].Quality != Quality1080p || targets[0].Status == StatusFailed { + t.Fatalf("targets = %+v, want only the HD copy: the route skips 4K", targets) + } +} + +// The reconcile service can be wired without a TMDB client; routing a request +// with uncaptured facts must then retry, not crash. +func TestReconcileRoutesWithoutTMDBClient(t *testing.T) { + store := routingStore(RoutingFacts{}) + store.candidates = []*Request{store.requests["r1"]} + svc := NewService(store, nil, &fakePresence{}) + svc.SetRouterProvider(&fakeRouterProvider{}) + + result, err := svc.ReconcileRequests(context.Background(), 10) + if err != nil { + t.Fatalf("ReconcileRequests: %v", err) + } + if result.Deferred != 1 || store.requests["r1"].Status != StatusApproved { + t.Fatalf("result = %+v request = %+v, want the submission deferred", result, store.requests["r1"]) + } +} + +func TestSubmitRoutedFetchesMissingFacts(t *testing.T) { + store := routingStore(RoutingFacts{}) + tmdbClient := &fakeTMDBClient{detail: &tmdb.MediaDetail{ID: 129, KeywordIDs: []int{210024}, Year: 2001}} + router := &fakeRouterProvider{} + svc := newTestServiceWithTMDB(store, tmdbClient) + svc.SetRouterProvider(router) + + if _, err := svc.submitApprovedRequest(context.Background(), *store.requests["r1"], Viewer{}, nil); err != nil { + t.Fatalf("submit: %v", err) + } + if facts := store.factsSet["r1"]; !facts.Captured() || !facts.Anime { + t.Fatalf("stored facts = %+v, want the fetched anime facts", facts) + } + if len(router.fulfillLog) == 0 || router.fulfillLog[0].conns[0].ID != "radarr-anime" { + t.Fatalf("fulfill calls = %+v, want the request routed on the fetched facts", router.fulfillLog) + } +} + +func TestSubmitRoutedWaitsWhenFactsCannotBeRead(t *testing.T) { + store := routingStore(RoutingFacts{}) + router := &fakeRouterProvider{} + svc := newTestServiceWithTMDB(store, &fakeTMDBClient{}) + svc.SetRouterProvider(router) + + req, err := svc.submitApprovedRequest(context.Background(), *store.requests["r1"], Viewer{}, nil) + if err != nil { + t.Fatalf("submit: %v", err) + } + if router.fulfillCalls != 0 || req.Status != StatusApproved || !strings.Contains(req.LastError, "TMDB") || req.NextSubmitAt == nil { + t.Fatalf("request = %+v (fulfill calls %d), want nothing sent and a retry scheduled", req, router.fulfillCalls) + } +} + +func TestReconcileChecksEachTargetThroughItsOwnPlugin(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInstOn("on-one", 1), routerInstOn("on-two", 2)} + store.candidates = []*Request{{ID: "r1", MediaType: MediaTypeMovie, TMDBID: 1, Status: StatusQueued, Outcome: OutcomeActive}} + store.targets = map[string][]Target{"r1": { + {ID: 1, RequestID: "r1", IntegrationID: "on-one", Quality: Quality1080p, Status: StatusQueued, ExternalID: "a"}, + {ID: 2, RequestID: "r1", IntegrationID: "on-two", Quality: Quality2160p, Status: StatusDownloading, ExternalID: "b"}, + }} + router := &fakeRouterProvider{} + svc := newTestService(store) + svc.SetRouterProvider(router) + + if _, err := svc.ReconcileRequests(context.Background(), 10); err != nil { + t.Fatalf("ReconcileRequests: %v", err) + } + installs := map[int][]string{} + for _, call := range router.statusLog { + for _, ref := range call.refs { + installs[call.installationID] = append(installs[call.installationID], ref.ConnectionID) + } + if len(call.conns) != 1 { + t.Fatalf("status call = %+v, want only its own server", call) + } + } + if !slices.Equal(installs[1], []string{"on-one"}) || !slices.Equal(installs[2], []string{"on-two"}) { + t.Fatalf("status checks by installation = %v, want each target through its own", installs) + } +} + +func TestReconcileKeepsStatusesWhenOnePluginFails(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInstOn("on-one", 1), routerInstOn("on-two", 2)} + store.candidates = []*Request{{ID: "r1", MediaType: MediaTypeMovie, TMDBID: 1, Status: StatusQueued, Outcome: OutcomeActive}} + store.targets = map[string][]Target{"r1": { + {ID: 1, RequestID: "r1", IntegrationID: "on-one", Quality: Quality1080p, Status: StatusQueued}, + {ID: 2, RequestID: "r1", IntegrationID: "on-two", Quality: Quality2160p, Status: StatusQueued}, + }} + router := &fakeRouterProvider{ + statuses: []RouterTargetStatus{{Quality: Quality1080p, ConnectionID: "on-one", Status: StatusDownloading}}, + statusErrFor: map[int]error{2: errors.New("sonarr 4K timeout")}, + } + svc := newTestService(store) + svc.SetRouterProvider(router) + + result, err := svc.ReconcileRequests(context.Background(), 10) + if err != nil { + t.Fatalf("ReconcileRequests: %v", err) + } + if result.Errors != 1 { + t.Fatalf("result = %+v, want the failing plugin counted", result) + } + if got := store.targets["r1"][0].Status; got != StatusDownloading { + t.Fatalf("HD target = %s, want the working plugin's status applied", got) + } +} + +// TestRouteSeedingMigrationDatabase runs the request_routes migration over +// Sonarr/Radarr servers configured the old way and checks it produces the +// routes that reproduce the plugin's own routing. +func TestRouteSeedingMigrationDatabase(t *testing.T) { + matches, err := filepath.Glob("../../migrations/sql/*_request_routes.sql") + if err != nil || len(matches) != 1 { + t.Fatalf("find migration: %v %v", matches, err) + } + raw, err := os.ReadFile(matches[0]) + if err != nil { + t.Fatal(err) + } + up := string(raw) + up = up[strings.Index(up, "-- +goose Up"):strings.Index(up, "-- +goose Down")] + up = strings.NewReplacer("-- +goose StatementBegin", "", "-- +goose StatementEnd", "").Replace(up) + + repo, pool := lifecycleTestRepository(t) + ctx := t.Context() + // The schema copy took the migrated columns; start from before them. + if _, err := pool.Exec(ctx, `ALTER TABLE media_request_targets DROP COLUMN IF EXISTS route_id, DROP COLUMN IF EXISTS route_name`); err != nil { + t.Fatal(err) + } + seed := func(id, name string, enabled bool, installation any, key, config string) { + t.Helper() + if _, err := pool.Exec(ctx, `INSERT INTO request_integrations (id, name, enabled, capability_id, installation_id, api_key_ref, plugin_config) + VALUES ($1, $2, $3, 'arr', $4, $5, $6::jsonb)`, id, name, enabled, installation, key, config); err != nil { + t.Fatal(err) + } + } + // Movies: the first usable default by name wins; disabled, unbound and + // keyless defaults are skipped, as the plugin path skipped them. + seed("radarr-a", "A Radarr", false, 1, "k", `{"service_kind":"radarr","is_default":true}`) + seed("radarr-b", "B Radarr", true, nil, "k", `{"service_kind":"radarr","is_default":true}`) + seed("radarr-c", "C Radarr", true, 1, "", `{"service_kind":"radarr","is_default":true}`) + seed("radarr-main", "D Radarr", true, 1, "k", `{"service_kind":"radarr","is_default":true,"anime_enabled":true,"anime_root_folder":"/anime","anime_quality_profile_id":7,"anime_tags":[3]}`) + seed("radarr-later", "E Radarr", true, 1, "k", `{"service_kind":"radarr","is_default":true}`) + seed("radarr-uhd", "Radarr 4K", true, 1, "k", `{"service_kind":"radarr","is_default_4k":true,"is_4k":true}`) + // Series: anime settings on the 4K server only, with empty fields on the + // HD server's anime switch. + seed("sonarr-main", "Sonarr", true, 1, "k", `{"service_kind":"sonarr","is_default":true,"anime_enabled":true,"anime_root_folder":"","anime_tags":[]}`) + seed("sonarr-uhd", "Sonarr 4K", true, 1, "k", `{"service_kind":"sonarr","is_default_4k":true,"anime_enabled":true,"anime_root_folder":"/anime-4k","anime_tags":[9]}`) + seed("seerr", "Seerr", true, 2, "k", `{"requester_mode":"admin"}`) + if _, err := pool.Exec(ctx, up); err != nil { + t.Fatalf("run migration: %v", err) + } + + routes, err := repo.ListRoutes(ctx) + if err != nil { + t.Fatal(err) + } + byID := map[string]Route{} + for _, route := range routes { + byID[route.ID] = route + } + if len(routes) != 4 { + t.Fatalf("routes = %+v, want a fallback and an Anime route per media type", routes) + } + if r := byID["fallback-movie"]; !r.IsFallback || r.HD.IntegrationID != "radarr-main" || r.UHD.IntegrationID != "radarr-uhd" { + t.Fatalf("movie fallback = %+v, want the first usable default by name", r) + } + anime := byID["anime-movie"] + if anime.Conditions.Anime == nil || !*anime.Conditions.Anime || anime.HD.IntegrationID != "radarr-main" || anime.UHD.IntegrationID != "" { + t.Fatalf("movie anime route = %+v", anime) + } + if o := anime.HD.Overrides; o["root_folder"] != "/anime" || o["quality_profile_id"] != float64(7) || + !slices.Equal(anyInts(o["tags"]), []int{3}) || o["series_type"] != nil { + t.Fatalf("movie anime overrides = %v, want the server's anime folder, profile and tags, no series type", o) + } + if r := byID["fallback-series"]; r.HD.IntegrationID != "sonarr-main" || r.UHD.IntegrationID != "sonarr-uhd" { + t.Fatalf("series fallback = %+v", r) + } + seriesAnime := byID["anime-series"] + // The HD server's anime switch with empty fields only makes Sonarr treat + // the series as anime; the 4K server's settings carry over too. + if o := seriesAnime.HD.Overrides; seriesAnime.HD.IntegrationID != "sonarr-main" || len(o) != 1 || o["series_type"] != "anime" { + t.Fatalf("series anime HD = %+v", seriesAnime.HD) + } + if o := seriesAnime.UHD.Overrides; seriesAnime.UHD.IntegrationID != "sonarr-uhd" || o["root_folder"] != "/anime-4k" || + !slices.Equal(anyInts(o["tags"]), []int{9}) || o["series_type"] != "anime" { + t.Fatalf("series anime 4K = %+v", seriesAnime.UHD) + } +} + +func anyInts(v any) []int { + list, _ := v.([]any) + out := make([]int, 0, len(list)) + for _, item := range list { + if n, ok := item.(float64); ok { + out = append(out, int(n)) + } + } + return out +} + +func TestSubmitRoutedRefusesAServerOfTheWrongKind(t *testing.T) { + store := routingStore(capturedFacts(RoutingFacts{})) + store.integrations[0].PluginConfig["service_kind"] = "sonarr" // radarr-hd, switched after the route was saved + router := &fakeRouterProvider{} + svc := newTestService(store) + svc.SetRouterProvider(router) + + req, err := svc.submitApprovedRequest(context.Background(), *store.requests["r1"], Viewer{}, nil) + if err != nil { + t.Fatalf("submit: %v", err) + } + if router.fulfillCalls != 0 || req.NextSubmitAt == nil || !strings.Contains(req.LastError, "a sonarr server") { + t.Fatalf("request = %+v (fulfill calls %d), want nothing sent and a retry with the mismatch named", req, router.fulfillCalls) + } +} + +func TestDeleteIntegrationRefusesARoutedServerDatabase(t *testing.T) { + repo, pool := lifecycleTestRepository(t) + ctx := t.Context() + if _, err := pool.Exec(ctx, `CREATE TABLE request_routes (LIKE public.request_routes INCLUDING ALL)`); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO request_integrations (id, name, enabled, capability_id) VALUES ('radarr', 'Radarr', true, 'arr'), ('spare', 'Spare', true, 'arr'); + INSERT INTO request_routes (id, media_type, position, name, is_fallback, hd_integration_id) VALUES ('fallback-movie', 'movie', 1000, 'Everything else', true, 'radarr')`); err != nil { + t.Fatal(err) + } + err := repo.DeleteIntegration(ctx, "radarr") + var verr *ValidationError + if !errors.As(err, &verr) || !strings.Contains(verr.FormError, "Everything else") { + t.Fatalf("delete a routed server: err = %v, want the routes using it named", err) + } + if err := repo.DeleteIntegration(ctx, "spare"); err != nil { + t.Fatalf("delete an unrouted server: %v", err) + } +} diff --git a/internal/requests/service.go b/internal/requests/service.go index 2706fdfe1e..24e99c8fd7 100644 --- a/internal/requests/service.go +++ b/internal/requests/service.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "log/slog" + "maps" + "slices" "strings" "time" @@ -373,6 +375,19 @@ func (s *Service) allowedQualities(ctx context.Context, req Request, settings Se type fulfillContext struct { integrations []Integration settings Settings + routes []Route +} + +// routesFor returns the media type's routing rules; none means the router +// plugin routes the media type itself. +func (fc *fulfillContext) routesFor(mediaType MediaType) []Route { + var out []Route + for _, route := range fc.routes { + if route.MediaType == mediaType { + out = append(out, route) + } + } + return out } func (s *Service) newFulfillContext(ctx context.Context) (*fulfillContext, error) { @@ -384,7 +399,11 @@ func (s *Service) newFulfillContext(ctx context.Context) (*fulfillContext, error if err != nil { return nil, err } - return &fulfillContext{integrations: integrations, settings: settings}, nil + routes, err := s.store.ListRoutes(ctx) + if err != nil { + return nil, err + } + return &fulfillContext{integrations: integrations, settings: settings, routes: routes}, nil } // resolveRouterConnections turns enabled request_router integrations that serve @@ -1820,6 +1839,9 @@ func (s *Service) submitApprovedRequest(ctx context.Context, req Request, actor // submitClaimed does the submission work for a request whose claim the caller // holds. func (s *Service) submitClaimed(ctx context.Context, req Request, actor Viewer, fc *fulfillContext) (*Request, error) { + if routes := fc.routesFor(req.MediaType); len(routes) > 0 { + return s.submitRouted(ctx, req, actor, fc, routes) + } conns, installationID, capabilityID, err := s.resolveRouterConnections(ctx, fc, req.MediaType) if err != nil { return nil, err @@ -1831,83 +1853,201 @@ func (s *Service) submitClaimed(ctx context.Context, req Request, actor Viewer, // retries with backoff, and goes through once the connection is fixed. return nil, errors.New(unusableRouterMessage(fc, req.MediaType)) } - existing, err := s.store.ListTargets(ctx, req.ID) + allowed, resolved := s.allowedQualities(ctx, req, fc.settings) + if !fc.settings.ForceDualQuality { + allowed = filterUnconfiguredOptionalQualities(allowed, conns) + } + plan, done, err := s.planSubmission(ctx, req, actor, allowed, resolved && !skippedRouterConnection(fc, req.MediaType)) + if done != nil || err != nil { + return done, err + } + s.populateRequesterIdentity(ctx, &req) + targets, msg, err := s.router.Fulfill(ctx, installationID, capabilityID, req, plan.want, conns) if err != nil { return nil, err } - healthy := map[Quality]bool{} - for _, t := range existing { - if t.Status != StatusFailed { - healthy[t.Quality] = true + if len(targets) == 0 { + if msg == "" { + msg = "fulfillment backend created no targets" } + return s.markSubmissionFailed(ctx, req.ID, actor, errors.New(msg)) + } + return s.recordTargets(ctx, req, actor, plan, targets, connectionKindByID(conns), nil, nil) +} + +// submitRouted sends each wanted tier to the server the routing rules chose +// for it, one plugin call per tier with only that server, so the plugin +// cannot pick another. +func (s *Service) submitRouted(ctx context.Context, req Request, actor Viewer, fc *fulfillContext, routes []Route) (*Request, error) { + if err := s.ensureRoutingFacts(ctx, &req); err != nil { + return nil, err } allowed, resolved := s.allowedQualities(ctx, req, fc.settings) - if !fc.settings.ForceDualQuality { - allowed = filterUnconfiguredOptionalQualities(allowed, conns) + decisions := decideRoutes(routes, req, allowed) + allowed = slices.DeleteFunc(allowed, func(q Quality) bool { + decision, routed := decisions[q] + switch { + case routed && decision.Skip: + // A matching route skips the tier: no copy, even with force-dual. + return true + case !routed && q == Quality2160p && !fc.settings.ForceDualQuality: + // No route gives this title a 4K destination: it does not get a + // 4K copy, the same as when no 4K server is configured. + return true + } + return false + }) + plan, done, err := s.planSubmission(ctx, req, actor, allowed, resolved) + if done != nil || err != nil { + return done, err + } + s.populateRequesterIdentity(ctx, &req) + var targets []RouterTarget + connKind := map[string]string{} + failures := map[Quality]string{} + for _, q := range plan.want { + decision, ok := decisions[q] + if !ok { + failures[q] = "no routing rule sends " + qualityLabel(q) + " for this title" + continue + } + conn, installationID, capabilityID, err := routedConnection(fc, decision, req.MediaType, q) + if err != nil { + if len(targets) == 0 { + // The chosen server is gone, disabled or not set up: an + // admin-fixable problem. Nothing reached a server yet, so the + // submission retries with backoff and goes through once the + // server or the route is fixed. + return nil, err + } + failures[q] = err.Error() + continue + } + maps.Copy(connKind, connectionKindByID([]ResolvedRouterConnection{conn})) + got, msg, err := s.router.Fulfill(ctx, installationID, capabilityID, req, []Quality{q}, []ResolvedRouterConnection{conn}) + if err != nil { + if len(targets) == 0 { + // Nothing reached a server yet: retry the whole submission. + return nil, err + } + failures[q] = err.Error() + continue + } + if len(got) == 0 && msg != "" { + failures[q] = msg + } + targets = append(targets, got...) + } + return s.recordTargets(ctx, req, actor, plan, targets, connKind, decisions, failures) +} + +// qualityLabel names a tier in messages. +func qualityLabel(q Quality) string { + if q == Quality2160p { + return "4K" + } + return "HD" +} + +// ensureRoutingFacts fetches the routing facts of a request created before +// they were captured, or while TMDB was unreachable, and stores them. Routing +// without them could send a title to the wrong server, so a TMDB failure is a +// submission error and the submission retries. +func (s *Service) ensureRoutingFacts(ctx context.Context, req *Request) error { + if req.RoutingFacts.Captured() { + return nil + } + detail := s.requestDetail(ctx, req.MediaType, req.TMDBID) + if detail == nil { + return errors.New("could not read the title's details from TMDB to route it") + } + updated, err := s.store.SetRoutingFacts(ctx, req.ID, routingFactsFrom(detail, s.now())) + if err != nil { + return err + } + req.RoutingFacts, req.IsAnime = updated.RoutingFacts, updated.IsAnime + return nil +} + +// submissionPlan is what a submission still has to send. +type submissionPlan struct { + // healthy holds the qualities that already have a live or finished target. + healthy map[Quality]bool + // want is the qualities to send now. + want []Quality +} + +// planSubmission compares the qualities a request should have with its +// targets. It drops failed targets for qualities no longer wanted (4K turned +// off, the requester lost 4K, the 4K destination removed), which would keep the +// request failed forever, but only when allowed is certain: an entitlement +// lookup error or a skipped connection also shrinks it, and a transient error +// must not discard a failure an admin still needs to see. It also drops the +// failed targets of the qualities it is about to resend. done is set when +// nothing is left to send. +func (s *Service) planSubmission(ctx context.Context, req Request, actor Viewer, allowed []Quality, certain bool) (submissionPlan, *Request, error) { + existing, err := s.store.ListTargets(ctx, req.ID) + if err != nil { + return submissionPlan{}, nil, err } - // A failed target for a quality the request no longer wants (4K turned - // off, the requester lost 4K, the 4K server removed) would keep the request - // failed forever; converge to the current quality set instead. Only when - // that set is certain: an entitlement lookup error or a connection skipped - // for a missing key also shrinks it, and a transient error must not - // discard the failed target an admin still needs to see. - if resolved && !skippedRouterConnection(fc, req.MediaType) { - allowedSet := make(map[Quality]bool, len(allowed)) - for _, q := range allowed { - allowedSet[q] = true + plan := submissionPlan{healthy: map[Quality]bool{}} + for _, t := range existing { + if t.Status != StatusFailed { + plan.healthy[t.Quality] = true } + } + if certain { for _, t := range existing { - if t.Status == StatusFailed && !allowedSet[t.Quality] { + if t.Status == StatusFailed && !slices.Contains(allowed, t.Quality) { if err := s.store.DeleteTarget(ctx, t.ID); err != nil && !errors.Is(err, ErrNotFound) { - return nil, err + return submissionPlan{}, nil, err } } } } - var want []Quality for _, q := range allowed { - if !healthy[q] { - want = append(want, q) + if !plan.healthy[q] { + plan.want = append(plan.want, q) } } - if len(want) == 0 { + if len(plan.want) == 0 { // Nothing left to send: let the remaining targets decide the status so // the request does not sit in approved. updated, err := s.store.RecomputeStatus(ctx, req.ID, actor) if errors.Is(err, ErrInvalidState) { - return s.store.GetRequest(ctx, req.ID) + updated, err = s.store.GetRequest(ctx, req.ID) } - return updated, err - } - for _, t := range existing { // drop stale failed targets for the qualities we re-submit - if t.Status == StatusFailed { - for _, q := range want { - if t.Quality == q { - if err := s.store.DeleteTarget(ctx, t.ID); err != nil { - return nil, err - } - } - } + if err != nil { + return submissionPlan{}, nil, err } + return submissionPlan{}, updated, nil } - s.populateRequesterIdentity(ctx, &req) - targets, msg, err := s.router.Fulfill(ctx, installationID, capabilityID, req, want, conns) - if err != nil { - return nil, err - } - if len(targets) == 0 { - if msg == "" { - msg = "fulfillment backend created no targets" + for _, t := range existing { + if t.Status == StatusFailed && slices.Contains(plan.want, t.Quality) { + if err := s.store.DeleteTarget(ctx, t.ID); err != nil { + return submissionPlan{}, nil, err + } } - return s.markSubmissionFailed(ctx, req.ID, actor, errors.New(msg)) } - connKind := connectionKindByID(conns) + return plan, nil, nil +} + +// msgNoTargetForQuality is recorded on a wanted quality the plugin returned no +// target for. +const msgNoTargetForQuality = "fulfillment backend returned no target for this quality" + +// recordTargets stores what the plugin returned for a submission. The plugin +// is an out-of-process trust boundary: every returned target is validated +// against the DB CHECK constraints (quality, status), and a quality duplicated +// in the batch or already holding a healthy target is skipped, so a +// misbehaving plugin can't violate UNIQUE(request_id, quality) and wedge the +// request. Any wanted quality left without a target is recorded as a failed +// target rather than silently dropped, so it stays visible and Retry +// re-attempts it; failures carries the reason when one is known. decisions, +// when routing chose the servers, stamps each target with its route. +func (s *Service) recordTargets(ctx context.Context, req Request, actor Viewer, plan submissionPlan, targets []RouterTarget, + connKind map[string]string, decisions map[Quality]RouteDecision, failures map[Quality]string) (*Request, error) { latest := &req - // The plugin is an out-of-process trust boundary: validate every returned - // target against the DB CHECK constraints (quality, status) and skip any - // quality that is duplicated in the batch or already has a healthy target, so - // a misbehaving plugin can't violate UNIQUE(request_id, quality) and wedge the - // request. validQuality := map[Quality]bool{Quality1080p: true, Quality2160p: true} validStatus := map[Status]bool{StatusQueued: true, StatusDownloading: true, StatusCompleted: true, StatusFailed: true} returned := map[Quality]bool{} @@ -1916,7 +2056,7 @@ func (s *Service) submitClaimed(ctx context.Context, req Request, actor Viewer, slog.WarnContext(ctx, "requests: plugin returned unknown quality; skipping", "component", "requests", "request_id", req.ID, "quality", string(rt.Quality)) continue } - if returned[rt.Quality] || healthy[rt.Quality] { + if returned[rt.Quality] || plan.healthy[rt.Quality] { continue // dup-in-batch, or a healthy target already exists for this quality } if rt.ConnectionID != "" { @@ -1926,9 +2066,11 @@ func (s *Service) submitClaimed(ctx context.Context, req Request, actor Viewer, } } returned[rt.Quality] = true + decision := decisions[rt.Quality] created, err := s.store.CreateTarget(ctx, Target{ RequestID: req.ID, IntegrationID: rt.ConnectionID, IntegrationKind: connKind[rt.ConnectionID], Quality: rt.Quality, IsAnime: req.IsAnime, Status: StatusQueued, + RouteID: decision.RouteID, RouteName: decision.RouteName, }) if err != nil { return nil, err @@ -1945,21 +2087,23 @@ func (s *Service) submitClaimed(ctx context.Context, req Request, actor Viewer, latest = updated } } - // Any wanted quality the plugin did not fulfill is recorded as a failed target - // rather than silently dropped, so it stays visible and Retry re-attempts it - // (a failed target is not "healthy"). - const noTargetMsg = "fulfillment backend returned no target for this quality" - for _, q := range want { + for _, q := range plan.want { if returned[q] { continue } + msg := failures[q] + if msg == "" { + msg = msgNoTargetForQuality + } + decision := decisions[q] created, err := s.store.CreateTarget(ctx, Target{ - RequestID: req.ID, Quality: q, IsAnime: req.IsAnime, Status: StatusFailed, LastError: noTargetMsg, + RequestID: req.ID, IntegrationID: decision.IntegrationID, Quality: q, IsAnime: req.IsAnime, + Status: StatusFailed, LastError: msg, RouteID: decision.RouteID, RouteName: decision.RouteName, }) if err != nil { return nil, err } - updated, err := s.store.UpdateTargetStatus(ctx, created.ID, StatusFailed, "", "", noTargetMsg, actor) + updated, err := s.store.UpdateTargetStatus(ctx, created.ID, StatusFailed, "", "", msg, actor) if err != nil { return nil, err } @@ -2180,29 +2324,7 @@ func (s *Service) reconcileRequest(ctx context.Context, req Request, fc *fulfill if s.router == nil { return reconcileUnchanged, nil } - conns, installationID, capabilityID, err := s.resolveRouterConnections(ctx, fc, req.MediaType) - if err != nil { - return reconcileUnchanged, err - } - if len(conns) == 0 { - return reconcileUnchanged, nil - } - - var refs []RouterTargetRef - for _, t := range targets { - if t.Status == StatusCompleted || t.Status == StatusFailed { - continue - } - refs = append(refs, RouterTargetRef{Quality: t.Quality, ConnectionID: t.IntegrationID, ExternalID: t.ExternalID}) - } - if len(refs) == 0 { - return reconcileUnchanged, nil - } - - statuses, err := s.router.CheckStatus(ctx, installationID, capabilityID, req, refs, conns) - if err != nil { - return reconcileUnchanged, err - } + statuses, checkErr := s.checkTargetStatuses(ctx, req, targets, fc) change := reconcileUnchanged for _, st := range statuses { @@ -2237,7 +2359,74 @@ func (s *Service) reconcileRequest(ctx context.Context, req Request, fc *fulfill } } } - return change, nil + return change, checkErr +} + +// checkTargetStatuses asks each live target's plugin for its status. Targets +// are grouped by the installation and capability that own their server, so a +// target sent through one plugin is never checked through another (routing +// can send a request's tiers through different plugins, and an admin can +// rebind a server). A target whose server is gone, disabled or unusable is +// skipped; the library presence check retires it if the media arrives. +func (s *Service) checkTargetStatuses(ctx context.Context, req Request, targets []Target, fc *fulfillContext) ([]RouterTargetStatus, error) { + type owner struct { + installationID int + capabilityID string + } + type group struct { + refs []RouterTargetRef + conns []ResolvedRouterConnection + seen map[string]bool + } + groups := map[owner]*group{} + var order []owner + for _, t := range targets { + if t.Status != StatusQueued && t.Status != StatusDownloading { + continue + } + in := integrationByID(fc, t.IntegrationID) + if in == nil || !in.Enabled || in.InstallationID == nil || in.CapabilityID == "" || strings.TrimSpace(in.APIKeyRef) == "" { + continue + } + key := owner{*in.InstallationID, in.CapabilityID} + g := groups[key] + if g == nil { + g = &group{seen: map[string]bool{}} + groups[key] = g + order = append(order, key) + } + g.refs = append(g.refs, RouterTargetRef{Quality: t.Quality, ConnectionID: t.IntegrationID, ExternalID: t.ExternalID}) + if !g.seen[in.ID] { + g.seen[in.ID] = true + g.conns = append(g.conns, ResolvedRouterConnection{ID: in.ID, BaseURL: in.BaseURL, APIKey: strings.TrimSpace(in.APIKeyRef), Config: in.PluginConfig}) + } + } + // One plugin being down must not hide the statuses another reported, so + // every group is asked and the errors are returned alongside them. + var out []RouterTargetStatus + var errs []error + for _, key := range order { + g := groups[key] + statuses, err := s.router.CheckStatus(ctx, key.installationID, key.capabilityID, req, g.refs, g.conns) + if err != nil { + errs = append(errs, err) + continue + } + out = append(out, statuses...) + } + return out, errors.Join(errs...) +} + +func integrationByID(fc *fulfillContext, id string) *Integration { + if id == "" { + return nil + } + for i := range fc.integrations { + if fc.integrations[i].ID == id { + return &fc.integrations[i] + } + } + return nil } // liveTargets returns the request's non-terminal (queued or downloading) diff --git a/internal/requests/service_test.go b/internal/requests/service_test.go index 2c211af145..acec98e951 100644 --- a/internal/requests/service_test.go +++ b/internal/requests/service_test.go @@ -1603,6 +1603,8 @@ type fakeStore struct { notified []string reconciled []string follows map[string]Follower // key: media_type/tmdb_id/profile_id + routes []Route + factsSet map[string]RoutingFacts listIntegrationsCalls int getSettingsCalls int @@ -2123,6 +2125,29 @@ func (f *fakeStore) ClearTitleFollowers(_ context.Context, mediaType MediaType, return nil } +func (f *fakeStore) ListRoutes(context.Context) ([]Route, error) { + f.mu.Lock() + defer f.mu.Unlock() + return slices.Clone(f.routes), nil +} + +func (f *fakeStore) SetRoutingFacts(_ context.Context, id string, facts RoutingFacts) (*Request, error) { + f.mu.Lock() + defer f.mu.Unlock() + req := f.lookupLocked(id) + if req == nil { + return nil, ErrNotFound + } + if f.factsSet == nil { + f.factsSet = map[string]RoutingFacts{} + } + f.factsSet[id] = facts + req.RoutingFacts = facts + req.IsAnime = facts.Anime + copy := *req + return ©, nil +} + func (f *fakeStore) ListTargets(_ context.Context, requestID string) ([]Target, error) { f.mu.Lock() defer f.mu.Unlock() @@ -2534,6 +2559,20 @@ func (f ratedCeiling) MaxContentRating(context.Context, int, string) (string, er // fakeRouterProvider is a canned RequestRouterProvider standing in for a // request_router.v1 plugin. Fulfill emits one target per requested quality // (unless noTargets is set), recording the qualities and connections it saw. +// fulfillCall and statusCall record one plugin call each. +type fulfillCall struct { + installationID int + qualities []Quality + conns []ResolvedRouterConnection +} + +type statusCall struct { + installationID int + capabilityID string + refs []RouterTargetRef + conns []ResolvedRouterConnection +} + type fakeRouterProvider struct { mu sync.Mutex @@ -2546,14 +2585,18 @@ type fakeRouterProvider struct { gotConns []ResolvedRouterConnection gotInstallationID int fulfillCalls int + fulfillLog []fulfillCall gotRequesterEmail string gotRequesterUsername string // CheckStatus behavior. - statuses []RouterTargetStatus - statusErr error - statusCalls int + statuses []RouterTargetStatus + statusErr error + // statusErrFor fails CheckStatus for one installation only. + statusErrFor map[int]error + statusCalls int + statusLog []statusCall // ListConfigOptions behavior. options map[string][]RouterOption @@ -2575,6 +2618,7 @@ func (f *fakeRouterProvider) Fulfill(_ context.Context, installationID int, _ st f.gotRequesterEmail = req.RequesterEmail f.gotRequesterUsername = req.RequesterUsername f.fulfillCalls++ + f.fulfillLog = append(f.fulfillLog, fulfillCall{installationID: installationID, qualities: slices.Clone(qualities), conns: slices.Clone(conns)}) f.gotQualities = append(f.gotQualities, qualities...) f.gotConns = conns f.gotInstallationID = installationID @@ -2604,10 +2648,14 @@ func (f *fakeRouterProvider) Fulfill(_ context.Context, installationID int, _ st return out, f.fulfillMsg, nil } -func (f *fakeRouterProvider) CheckStatus(_ context.Context, _ int, _ string, _ Request, _ []RouterTargetRef, _ []ResolvedRouterConnection) ([]RouterTargetStatus, error) { +func (f *fakeRouterProvider) CheckStatus(_ context.Context, installationID int, capabilityID string, _ Request, refs []RouterTargetRef, conns []ResolvedRouterConnection) ([]RouterTargetStatus, error) { f.mu.Lock() defer f.mu.Unlock() f.statusCalls++ + f.statusLog = append(f.statusLog, statusCall{installationID: installationID, capabilityID: capabilityID, refs: slices.Clone(refs), conns: slices.Clone(conns)}) + if err := f.statusErrFor[installationID]; err != nil { + return nil, err + } return f.statuses, f.statusErr } @@ -2654,9 +2702,8 @@ func routerInstOn(id string, installID int) Integration { } } -// autoApproveRouterInst is a router connection that satisfies the auto-approval -// gate (integrationConfigured: an enabled request_router connection bound to an -// installation with a base URL and api key). +// autoApproveRouterInst is an enabled request_router connection bound to an +// installation, with the given api key. func autoApproveRouterInst(id, apiKeyRef string) Integration { in := routerInst(id) in.APIKeyRef = apiKeyRef @@ -2926,7 +2973,7 @@ func TestSubmitApprovedRecordsDroppedQualityAsFailed(t *testing.T) { if failed2160 == nil || failed2160.Status != StatusFailed { t.Fatalf("2160p target = %+v, want a failed target", failed2160) } - if failed2160.LastError != "fulfillment backend returned no target for this quality" { + if failed2160.LastError != msgNoTargetForQuality { t.Fatalf("2160p last error = %q, want the no-target message", failed2160.LastError) } diff --git a/internal/requests/store.go b/internal/requests/store.go index 79ae33a7d3..a1971fc902 100644 --- a/internal/requests/store.go +++ b/internal/requests/store.go @@ -69,6 +69,12 @@ type Store interface { // ForgetTitleFollows removes every follow on a title whose request was // withdrawn. ForgetTitleFollows(ctx context.Context, mediaType MediaType, tmdbID int) error + // ListRoutes returns every routing rule, in no particular order; + // decideRoutes orders them. + ListRoutes(ctx context.Context) ([]Route, error) + // SetRoutingFacts stores facts fetched after the request was created (and + // the anime flag they imply). + SetRoutingFacts(ctx context.Context, id string, facts RoutingFacts) (*Request, error) ListTargets(ctx context.Context, requestID string) ([]Target, error) CreateTarget(ctx context.Context, target Target) (Target, error) DeleteTarget(ctx context.Context, id int64) error diff --git a/internal/requests/targets.go b/internal/requests/targets.go index 242e2dba93..6d1cfaa32b 100644 --- a/internal/requests/targets.go +++ b/internal/requests/targets.go @@ -10,7 +10,7 @@ import ( const targetColumns = `t.id, t.request_id, t.integration_id, t.integration_kind, COALESCE(ri.name, ''), t.quality, t.is_anime, t.external_id, t.external_status, - t.status, t.last_error, t.created_at, t.updated_at` + t.status, t.last_error, t.created_at, t.updated_at, COALESCE(t.route_id, ''), t.route_name` // aggregateStatus derives a request's status/outcome from its targets. func aggregateStatus(targets []Target) (Status, Outcome) { @@ -56,7 +56,7 @@ func scanTarget(row requestScanner) (Target, error) { var integrationID *string if err := row.Scan(&t.ID, &t.RequestID, &integrationID, &t.IntegrationKind, &t.InstanceName, &t.Quality, &t.IsAnime, &t.ExternalID, &t.ExternalStatus, - &t.Status, &t.LastError, &t.CreatedAt, &t.UpdatedAt); err != nil { + &t.Status, &t.LastError, &t.CreatedAt, &t.UpdatedAt, &t.RouteID, &t.RouteName); err != nil { return Target{}, err } if integrationID != nil { @@ -86,18 +86,21 @@ func (r *Repository) ListTargets(ctx context.Context, requestID string) ([]Targe } func (r *Repository) CreateTarget(ctx context.Context, t Target) (Target, error) { - var integrationID any + var integrationID, routeID any if t.IntegrationID != "" { integrationID = t.IntegrationID } + if t.RouteID != "" { + routeID = t.RouteID + } row := r.pool.QueryRow(ctx, ` INSERT INTO media_request_targets (request_id, integration_id, integration_kind, quality, is_anime, - external_id, external_status, status, last_error, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9, now()) + external_id, external_status, status, last_error, updated_at, route_id, route_name) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9, now(), $10, $11) RETURNING id`, t.RequestID, integrationID, t.IntegrationKind, t.Quality, t.IsAnime, - t.ExternalID, t.ExternalStatus, t.Status, t.LastError) + t.ExternalID, t.ExternalStatus, t.Status, t.LastError, routeID, t.RouteName) if err := row.Scan(&t.ID); err != nil { return Target{}, fmt.Errorf("create target: %w", err) } diff --git a/internal/requests/types.go b/internal/requests/types.go index 97bf5dff35..9e0a248df9 100644 --- a/internal/requests/types.go +++ b/internal/requests/types.go @@ -56,6 +56,10 @@ type Target struct { LastError string `json:"last_error,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + // RouteID and RouteName record which routing rule sent the target (v2 + // only); empty when the plugin routed it. + RouteID string `json:"-"` + RouteName string `json:"-"` } type Availability string diff --git a/migrations/sql/20260926155617_request_routes.sql b/migrations/sql/20260926155617_request_routes.sql new file mode 100644 index 0000000000..1443544616 --- /dev/null +++ b/migrations/sql/20260926155617_request_routes.sql @@ -0,0 +1,107 @@ +-- +goose Up +-- Routing rules for requests: which server (and which root folder, quality +-- profile, tags, ...) each quality tier of a request goes to. Silo evaluates +-- them and hands the router plugin only the chosen server, so rules can send +-- anime, a genre, a decade, a language or a requester's titles to their own +-- Sonarr or Radarr. Per tier, the first enabled route whose conditions match +-- and that has a destination for the tier wins; the fallback route (one per +-- media type, no conditions) comes last. +CREATE TABLE request_routes ( + id text PRIMARY KEY, + media_type text NOT NULL, + position integer NOT NULL, + name text NOT NULL, + enabled boolean NOT NULL DEFAULT true, + is_fallback boolean NOT NULL DEFAULT false, + conditions jsonb NOT NULL DEFAULT '{}'::jsonb, + -- RESTRICT: deleting a server a route sends to must not silently + -- reroute its titles; the admin changes the route first. + hd_integration_id text REFERENCES request_integrations (id) ON DELETE RESTRICT, + hd_overrides jsonb NOT NULL DEFAULT '{}'::jsonb, + uhd_integration_id text REFERENCES request_integrations (id) ON DELETE RESTRICT, + uhd_overrides jsonb NOT NULL DEFAULT '{}'::jsonb, + -- skip_uhd stops a matching title from getting a 4K copy at all, rather + -- than letting the 4K tier fall through to a later route. + skip_uhd boolean NOT NULL DEFAULT false, + revision bigint NOT NULL DEFAULT nextval('request_editor_revision_seq'), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT request_routes_media_type_check CHECK (media_type IN ('movie', 'series')) +); + +CREATE UNIQUE INDEX request_routes_one_fallback ON request_routes (media_type) WHERE is_fallback; +CREATE INDEX request_routes_order ON request_routes (media_type, position); +CREATE TRIGGER request_routes_revision BEFORE INSERT OR UPDATE ON request_routes + FOR EACH ROW EXECUTE FUNCTION advance_request_editor_revision(); + +-- Which route sent each target, so an admin can see why a request went where +-- it did. The name is a snapshot: renaming or deleting the route later does not +-- rewrite history. +ALTER TABLE media_request_targets + ADD COLUMN route_id text, + ADD COLUMN route_name text NOT NULL DEFAULT ''; + +-- Carry the Sonarr/Radarr plugin's routing over unchanged. It sent each tier to +-- the first usable server of the media type's kind (by name: enabled, bound to +-- a plugin installation, with an API key) flagged default (HD) or default 4K, +-- and applied that server's anime settings to anime titles. That becomes a +-- fallback route per media type and an Anime route. +-- +goose StatementBegin +WITH kinds (media_type, kind) AS ( + VALUES ('movie', 'radarr'), ('series', 'sonarr') +), +defaults AS ( + SELECT k.media_type, k.kind, + (SELECT i.id FROM request_integrations i + WHERE i.enabled AND i.plugin_config->>'service_kind' = k.kind + AND i.installation_id IS NOT NULL AND i.api_key_ref <> '' + AND coalesce((i.plugin_config->>'is_default')::boolean, false) + ORDER BY i.name, i.id LIMIT 1) AS hd_id, + (SELECT i.id FROM request_integrations i + WHERE i.enabled AND i.plugin_config->>'service_kind' = k.kind + AND i.installation_id IS NOT NULL AND i.api_key_ref <> '' + AND coalesce((i.plugin_config->>'is_default_4k')::boolean, false) + ORDER BY i.name, i.id LIMIT 1) AS uhd_id + FROM kinds k +), +fallbacks AS ( + INSERT INTO request_routes (id, media_type, position, name, is_fallback, hd_integration_id, uhd_integration_id) + SELECT 'fallback-' || d.media_type, d.media_type, 1000, 'Everything else', true, d.hd_id, d.uhd_id + FROM defaults d + WHERE d.hd_id IS NOT NULL OR d.uhd_id IS NOT NULL + RETURNING media_type +), +anime AS ( + SELECT d.media_type, d.kind, + CASE WHEN coalesce((hd.plugin_config->>'anime_enabled')::boolean, false) THEN hd.id END AS hd_id, + hd.plugin_config AS hd_config, + CASE WHEN coalesce((uhd.plugin_config->>'anime_enabled')::boolean, false) THEN uhd.id END AS uhd_id, + uhd.plugin_config AS uhd_config + FROM defaults d + LEFT JOIN request_integrations hd ON hd.id = d.hd_id + LEFT JOIN request_integrations uhd ON uhd.id = d.uhd_id +) +INSERT INTO request_routes (id, media_type, position, name, conditions, + hd_integration_id, hd_overrides, uhd_integration_id, uhd_overrides) +SELECT 'anime-' || a.media_type, a.media_type, 0, 'Anime', '{"anime": true}'::jsonb, + a.hd_id, + CASE WHEN a.hd_id IS NULL THEN '{}'::jsonb ELSE jsonb_strip_nulls(jsonb_build_object( + 'root_folder', nullif(a.hd_config->>'anime_root_folder', ''), + 'quality_profile_id', a.hd_config->'anime_quality_profile_id', + 'tags', CASE WHEN jsonb_typeof(a.hd_config->'anime_tags') = 'array' + AND jsonb_array_length(a.hd_config->'anime_tags') > 0 THEN a.hd_config->'anime_tags' END, + 'series_type', CASE WHEN a.kind = 'sonarr' THEN 'anime' END)) END, + a.uhd_id, + CASE WHEN a.uhd_id IS NULL THEN '{}'::jsonb ELSE jsonb_strip_nulls(jsonb_build_object( + 'root_folder', nullif(a.uhd_config->>'anime_root_folder', ''), + 'quality_profile_id', a.uhd_config->'anime_quality_profile_id', + 'tags', CASE WHEN jsonb_typeof(a.uhd_config->'anime_tags') = 'array' + AND jsonb_array_length(a.uhd_config->'anime_tags') > 0 THEN a.uhd_config->'anime_tags' END, + 'series_type', CASE WHEN a.kind = 'sonarr' THEN 'anime' END)) END +FROM anime a +WHERE a.hd_id IS NOT NULL OR a.uhd_id IS NOT NULL; +-- +goose StatementEnd + +-- +goose Down +ALTER TABLE media_request_targets DROP COLUMN route_name, DROP COLUMN route_id; +DROP TABLE request_routes; diff --git a/web/src/api/v2/schema.ts b/web/src/api/v2/schema.ts index 6ed45baa70..cedc72a135 100644 --- a/web/src/api/v2/schema.ts +++ b/web/src/api/v2/schema.ts @@ -23820,6 +23820,8 @@ export interface components { * @example 1834729 */ request_id: string; + /** @description The routing rule that sent this target to its server, as named when it was sent */ + route_name?: string; /** @example queued */ status: string; /** From 1d7d6954267028dd761d2b357865c551e8e18a01 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:10:16 -0400 Subject: [PATCH 008/103] feat(api): manage request routing rules Adds the v2 administration surface for request routing, which until now could only be seeded by the migration: - GET /admin/request-routes lists every route in evaluation order, always including each media type's fallback ("Everything else"), which reads as revision zero until it is first saved. - GET, PUT and DELETE /admin/request-routes/{id} read, replace and delete a route; replacement and deletion require If-Match on the route's revision. The fallback's first PUT creates it; it cannot be deleted. - POST /admin/request-routes creates a rule after the media type's others, and POST /admin/request-routes/order sets their order. - POST /admin/request-routes/preview shows which server each quality tier of a title would go to, from TMDB's current facts, and why a tier goes nowhere. Validation keeps rules meaningful: a rule needs a condition and an effect, a destination must be a server of the media type's kind, routing-owned config keys cannot be overridden, and no rule can be added before the media type's fallback has an HD server, since the first rule moves the media type to Silo's routing and unmatched titles would otherwise have nowhere to go. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../api/v2/fixtures/get_system_info_ok.json | 2 +- contracts/api/v2/openapi.json | 7413 ++++++---- docs/architecture/api-contract.md | 9 + docs/architecture/media-requests.md | 15 +- internal/api/testdata/media_routes.txt | 14 + internal/apiv2/admin_request_routes.go | 377 + internal/apiv2/admin_request_routes_test.go | 180 + internal/apiv2/document.go | 1 + internal/apiv2/document_test.go | 2 +- internal/contractledger/ledger_test.go | 10 +- internal/metadata/tmdb/client.go | 15 +- internal/metadata/tmdb/client_test.go | 15 + internal/requests/editor_concurrency.go | 3 + internal/requests/routes_admin.go | 630 + internal/requests/routes_admin_test.go | 277 + internal/requests/routing.go | 3 + internal/requests/service.go | 3 + internal/requests/service_test.go | 3 +- web/src/api/v2/operations.ts | 7 + web/src/api/v2/schema.ts | 11498 +++++++++------- 20 files changed, 12506 insertions(+), 7971 deletions(-) create mode 100644 internal/apiv2/admin_request_routes.go create mode 100644 internal/apiv2/admin_request_routes_test.go create mode 100644 internal/requests/routes_admin.go create mode 100644 internal/requests/routes_admin_test.go diff --git a/contracts/api/v2/fixtures/get_system_info_ok.json b/contracts/api/v2/fixtures/get_system_info_ok.json index 2d6100dd3b..9ce9eec7bb 100644 --- a/contracts/api/v2/fixtures/get_system_info_ok.json +++ b/contracts/api/v2/fixtures/get_system_info_ok.json @@ -1,7 +1,7 @@ { "server_version": "unavailable", "api_major": 2, - "contract_digest": "d0807c368f05a3622af3b823cf59ae421dcff955adff51b6d07f627948f4fd1a", + "contract_digest": "60c3d2abbc75811e64f15aa0dfae9f0e4b4e5e1e5e1e7fd3e5da7fe47c7790ba", "links": { "openapi": "/api/v2/openapi.json", "capabilities": "/api/v2/capabilities", diff --git a/contracts/api/v2/openapi.json b/contracts/api/v2/openapi.json index 7c272654bd..ac34112a9a 100644 --- a/contracts/api/v2/openapi.json +++ b/contracts/api/v2/openapi.json @@ -11480,6 +11480,388 @@ }, "type": "object" }, + "AdminRequestRoute": { + "additionalProperties": false, + "properties": { + "conditions": { + "$ref": "#/components/schemas/AdminRequestRouteConditions" + }, + "enabled": { + "type": "boolean" + }, + "hd": { + "$ref": "#/components/schemas/AdminRequestRouteDestination", + "description": "Where the HD (1080p) copy goes" + }, + "id": { + "description": "Opaque route ID; the fallback's is fallback-movie or fallback-series", + "examples": [ + "fallback-movie" + ], + "type": "string" + }, + "is_fallback": { + "description": "The media type's default destination: it has no conditions and cannot be deleted", + "type": "boolean" + }, + "media_type": { + "enum": [ + "movie", + "series" + ], + "type": "string" + }, + "name": { + "examples": [ + "Anime" + ], + "type": "string" + }, + "position": { + "description": "Evaluation order within the media type; the fallback is always last", + "format": "int64", + "type": "integer" + }, + "skip_uhd": { + "description": "Matching titles get no 4K copy at all", + "type": "boolean" + }, + "uhd": { + "$ref": "#/components/schemas/AdminRequestRouteDestination", + "description": "Where the 4K copy goes" + } + }, + "required": [ + "id", + "media_type", + "position", + "name", + "enabled", + "is_fallback", + "conditions", + "hd", + "uhd", + "skip_uhd" + ], + "type": "object" + }, + "AdminRequestRouteBody": { + "additionalProperties": false, + "properties": { + "conditions": { + "$ref": "#/components/schemas/AdminRequestRouteConditions" + }, + "enabled": { + "type": "boolean" + }, + "hd": { + "$ref": "#/components/schemas/AdminRequestRouteDestination" + }, + "media_type": { + "description": "Required on create; ignored on update", + "enum": [ + "movie", + "series" + ], + "type": "string" + }, + "name": { + "maxLength": 100, + "type": "string" + }, + "skip_uhd": { + "type": "boolean" + }, + "uhd": { + "$ref": "#/components/schemas/AdminRequestRouteDestination" + } + }, + "required": [ + "enabled", + "conditions", + "hd", + "uhd", + "skip_uhd" + ], + "type": "object" + }, + "AdminRequestRouteConditions": { + "additionalProperties": false, + "properties": { + "anime": { + "description": "Match titles TMDB tags as anime (true) or not (false)", + "type": "boolean" + }, + "company_ids": { + "description": "TMDB production company IDs (movies)", + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array" + }, + "genre_ids": { + "description": "TMDB genre IDs", + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array" + }, + "keyword_ids": { + "description": "TMDB keyword IDs", + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array" + }, + "network_ids": { + "description": "TMDB network IDs (series)", + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array" + }, + "origin_countries": { + "description": "ISO 3166-1 country codes", + "examples": [ + [ + "JP" + ] + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "original_languages": { + "description": "ISO 639-1 codes of the original language", + "examples": [ + [ + "ja" + ] + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "requester_user_ids": { + "description": "Accounts whose requests the route applies to", + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array" + }, + "year_from": { + "description": "First release (or first-air) year, inclusive", + "examples": [ + 1980 + ], + "format": "int64", + "type": "integer" + }, + "year_to": { + "description": "Last release (or first-air) year, inclusive", + "examples": [ + 1989 + ], + "format": "int64", + "type": "integer" + } + }, + "type": "object" + }, + "AdminRequestRouteDestination": { + "additionalProperties": false, + "properties": { + "integration_id": { + "description": "The request server; empty when the route sends nothing for this tier", + "type": "string" + }, + "overrides": { + "additionalProperties": {}, + "description": "Server settings this route replaces, keyed like the server's plugin config: root_folder, quality_profile_id, tags, series_type, minimum_availability, ...", + "type": "object" + } + }, + "type": "object" + }, + "AdminRequestRouteFacts": { + "additionalProperties": false, + "properties": { + "anime": { + "type": "boolean" + }, + "company_ids": { + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array" + }, + "genre_ids": { + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array" + }, + "keyword_ids": { + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array" + }, + "network_ids": { + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array" + }, + "origin_countries": { + "items": { + "type": "string" + }, + "type": "array" + }, + "original_language": { + "type": "string" + }, + "year": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "genre_ids", + "keyword_ids", + "origin_countries", + "network_ids", + "company_ids", + "anime" + ], + "type": "object" + }, + "AdminRequestRoutePreviewInputBody": { + "additionalProperties": false, + "properties": { + "media_type": { + "enum": [ + "movie", + "series" + ], + "type": "string" + }, + "requester_user_id": { + "description": "Route as this account's request; account conditions are skipped when absent", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + }, + "tmdb_id": { + "description": "TMDB identifier (external, not a Silo ID)", + "examples": [ + 129 + ], + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "media_type", + "tmdb_id" + ], + "type": "object" + }, + "AdminRequestRoutePreviewOutputBody": { + "additionalProperties": false, + "properties": { + "facts": { + "$ref": "#/components/schemas/AdminRequestRouteFacts" + }, + "tiers": { + "items": { + "$ref": "#/components/schemas/AdminRequestRoutePreviewTier" + }, + "type": "array" + } + }, + "required": [ + "facts", + "tiers" + ], + "type": "object" + }, + "AdminRequestRoutePreviewTier": { + "additionalProperties": false, + "properties": { + "integration_id": { + "type": "string" + }, + "integration_name": { + "type": "string" + }, + "note": { + "description": "Why no route sends the tier, or why it would fail", + "type": "string" + }, + "overrides": { + "additionalProperties": {}, + "type": "object" + }, + "quality": { + "enum": [ + "1080p", + "2160p" + ], + "type": "string" + }, + "route_id": { + "type": "string" + }, + "route_name": { + "type": "string" + } + }, + "required": [ + "quality" + ], + "type": "object" + }, + "AdminRequestRouteReorderInputBody": { + "additionalProperties": false, + "properties": { + "ids": { + "description": "Every rule of the media type, fallback excluded, in the new order", + "items": { + "type": "string" + }, + "maxItems": 100, + "type": "array" + }, + "media_type": { + "enum": [ + "movie", + "series" + ], + "type": "string" + } + }, + "required": [ + "media_type", + "ids" + ], + "type": "object" + }, "AdminRequestSettings": { "additionalProperties": false, "properties": { @@ -18915,6 +19297,26 @@ ], "type": "object" }, + "CollectionAdminRequestRoute": { + "additionalProperties": false, + "properties": { + "items": { + "description": "The page's items; empty, never null", + "items": { + "$ref": "#/components/schemas/AdminRequestRoute" + }, + "type": "array" + }, + "page": { + "$ref": "#/components/schemas/PageInfo", + "description": "Cursor state; absent for bounded unpaginated collections" + } + }, + "required": [ + "items" + ], + "type": "object" + }, "CollectionAdminSection": { "additionalProperties": false, "properties": { @@ -73009,9 +73411,1377 @@ "bearerAuth": [] } ], - "summary": "Delegate the current administrator login session to one log stream handshake. Minting is naturally idempotent in effect: extra credentials are unused orphans that expire.", + "summary": "Delegate the current administrator login session to one log stream handshake. Minting is naturally idempotent in effect: extra credentials are unused orphans that expire.", + "tags": [ + "admin-observability" + ], + "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "natural_idempotent", + "x-silo-service-backed": true + } + }, + "/api/v2/admin/logs/ws/capabilities": { + "get": { + "operationId": "getAdminLogsSocketCapabilities", + "parameters": [ + { + "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", + "in": "header", + "name": "If-Match", + "schema": { + "type": "string" + } + }, + { + "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", + "in": "header", + "name": "X-Profile-Id", + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + }, + { + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminLogsSocketCapabilitiesOutputBody" + } + } + }, + "description": "OK", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + } + }, + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "The representation named by If-None-Match is current; no body.", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Discover whether the administrator log stream handshake is served.", + "tags": [ + "admin-observability" + ], + "x-silo-class": "acting_admin", + "x-silo-conditional": true, + "x-silo-service-backed": true + } + }, + "/api/v2/admin/markers/files/{fileId}/history": { + "get": { + "operationId": "listAdminFileMarkerHistory", + "parameters": [ + { + "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", + "in": "header", + "name": "X-Profile-Id", + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + }, + { + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 25, + "format": "int64", + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Opaque identifier", + "in": "path", + "name": "fileId", + "required": true, + "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminMarkerHistory" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Read bounded recent marker edit history.", + "tags": [ + "admin-catalog" + ], + "x-silo-class": "acting_admin", + "x-silo-service-backed": true + } + }, + "/api/v2/admin/markers/history": { + "get": { + "operationId": "listAdminMarkerHistory", + "parameters": [ + { + "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", + "in": "header", + "name": "X-Profile-Id", + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + }, + { + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 25, + "format": "int64", + "maximum": 100, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminMarkerHistory" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Read bounded recent marker edit history.", + "tags": [ + "admin-catalog" + ], + "x-silo-class": "acting_admin", + "x-silo-service-backed": true + } + }, + "/api/v2/admin/markers/items/{id}/history": { + "get": { + "operationId": "listAdminItemMarkerHistory", + "parameters": [ + { + "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", + "in": "header", + "name": "X-Profile-Id", + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + }, + { + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 25, + "format": "int64", + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminMarkerHistory" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Read bounded recent marker edit history.", + "tags": [ + "admin-catalog" + ], + "x-silo-class": "acting_admin", + "x-silo-service-backed": true + } + }, + "/api/v2/admin/markers/providers": { + "get": { + "operationId": "listAdminMarkerProviders", + "parameters": [ + { + "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", + "in": "header", + "name": "X-Profile-Id", + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminMarkerProviders" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Manage registered marker provider configuration.", + "tags": [ + "admin-catalog" + ], + "x-silo-class": "acting_admin", + "x-silo-service-backed": true + } + }, + "/api/v2/admin/markers/providers/{provider}": { + "put": { + "operationId": "updateAdminMarkerProvider", + "parameters": [ + { + "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", + "in": "header", + "name": "X-Profile-Id", + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + }, + { + "in": "path", + "name": "provider", + "required": true, + "schema": { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminMarkerProviderUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminMarkerProvider" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Manage registered marker provider configuration.", + "tags": [ + "admin-catalog" + ], + "x-silo-class": "acting_admin", + "x-silo-retry-safety": "non_retryable", + "x-silo-service-backed": true + } + }, + "/api/v2/admin/markers/providers/{provider}/validate": { + "post": { + "operationId": "validateAdminMarkerProvider", + "parameters": [ + { + "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", + "in": "header", + "name": "X-Profile-Id", + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + }, + { + "in": "path", + "name": "provider", + "required": true, + "schema": { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminMarkerProviderValidation" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Manage registered marker provider configuration.", + "tags": [ + "admin-catalog" + ], + "x-silo-class": "acting_admin", + "x-silo-retry-safety": "non_retryable", + "x-silo-service-backed": true + } + }, + "/api/v2/admin/network-access/{provider}/connect": { + "post": { + "operationId": "connectNetworkAccess", + "parameters": [ + { + "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", + "in": "header", + "name": "X-Profile-Id", + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + }, + { + "in": "path", + "name": "provider", + "required": true, + "schema": { + "maxLength": 64, + "minLength": 1, + "pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NetworkAccessCommand" + } + } + } + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NetworkAccessStatus" + } + } + }, + "description": "Accepted" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Ask the provider on the named hosts (every host when hosts is omitted) to bring its overlay identity up and start proxying. Answers 202 with the state each host reached within ten seconds; enrollment may continue in the background (awaiting_authorization carries the auth_url), so poll status for the final state. Repeating the request converges on one connected instance per host. Hosts not named answer their current status; an unknown host id is 422.", "tags": [ - "admin-observability" + "network-access" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, @@ -73019,18 +74789,10 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/logs/ws/capabilities": { - "get": { - "operationId": "getAdminLogsSocketCapabilities", + "/api/v2/admin/network-access/{provider}/disconnect": { + "post": { + "operationId": "disconnectNetworkAccess", "parameters": [ - { - "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", - "in": "header", - "name": "If-Match", - "schema": { - "type": "string" - } - }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -73054,47 +74816,36 @@ } }, { - "in": "header", - "name": "If-None-Match", + "in": "path", + "name": "provider", + "required": true, "schema": { + "maxLength": 64, + "minLength": 1, + "pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$", "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NetworkAccessCommand" + } + } + } + }, "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminLogsSocketCapabilitiesOutputBody" + "$ref": "#/components/schemas/NetworkAccessStatus" } } }, - "description": "OK", - "headers": { - "Cache-Control": { - "schema": { - "type": "string" - } - }, - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "The representation named by If-None-Match is current; no body.", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } + "description": "Accepted" }, "400": { "content": { @@ -73146,7 +74897,7 @@ }, "description": "Not Acceptable" }, - "412": { + "408": { "content": { "application/problem+json": { "schema": { @@ -73154,15 +74905,27 @@ } } }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "description": "Request Timeout" + }, + "413": { + "content": { + "application/problem+json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" }, "422": { "content": { @@ -73210,18 +74973,19 @@ "bearerAuth": [] } ], - "summary": "Discover whether the administrator log stream handshake is served.", + "summary": "Ask the provider on the named hosts (every host when hosts is omitted) to tear its overlay listener down and clear its desired-connected intent. Answers 202 with the state each host reached within ten seconds. Repeating the request converges on disconnected. Hosts not named answer their current status; an unknown host id is 422.", "tags": [ - "admin-observability" + "network-access" ], "x-silo-class": "acting_admin", - "x-silo-conditional": true, + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true } }, - "/api/v2/admin/markers/files/{fileId}/history": { + "/api/v2/admin/network-access/{provider}/status": { "get": { - "operationId": "listAdminFileMarkerHistory", + "operationId": "getAdminNetworkAccessStatus", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -73246,28 +75010,13 @@ } }, { - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "default": 25, - "format": "int64", - "maximum": 100, - "minimum": 1, - "type": "integer" - } - }, - { - "description": "Opaque identifier", "in": "path", - "name": "fileId", + "name": "provider", "required": true, "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], + "maxLength": 64, "minLength": 1, + "pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$", "type": "string" } } @@ -73277,7 +75026,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminMarkerHistory" + "$ref": "#/components/schemas/NetworkAccessStatus" } } }, @@ -73379,17 +75128,17 @@ "bearerAuth": [] } ], - "summary": "Read bounded recent marker edit history.", + "summary": "Read the provider's live state on every host that runs it, asking each plugin instance directly with a ten-second timeout. A host whose plugin process is not running answers state unavailable with the supervisor's last error. An unknown provider slug is 404.", "tags": [ - "admin-catalog" + "network-access" ], "x-silo-class": "acting_admin", "x-silo-service-backed": true } }, - "/api/v2/admin/markers/history": { + "/api/v2/admin/node-sessions": { "get": { - "operationId": "listAdminMarkerHistory", + "operationId": "listAdminNodeSessions", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -73414,16 +75163,44 @@ } }, { + "description": "Page size; default 50, maximum 200", "explode": false, "in": "query", "name": "limit", "schema": { - "default": 25, + "default": 50, + "description": "Page size; default 50, maximum 200", + "examples": [ + 50 + ], "format": "int64", - "maximum": 100, + "maximum": 200, "minimum": 1, "type": "integer" } + }, + { + "explode": false, + "in": "query", + "name": "cursor", + "schema": { + "maxLength": 8192, + "type": "string" + } + }, + { + "description": "Opaque identifier", + "explode": false, + "in": "query", + "name": "node_id", + "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } } ], "responses": { @@ -73431,7 +75208,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminMarkerHistory" + "$ref": "#/components/schemas/AdminNodeSessionsOutputBody" } } }, @@ -73533,17 +75310,17 @@ "bearerAuth": [] } ], - "summary": "Read bounded recent marker edit history.", + "summary": "Read best-effort Redis observations, not authoritative playback sessions. Each page enumerates current records; expired or unreadable values may be absent.", "tags": [ - "admin-catalog" + "admin" ], "x-silo-class": "acting_admin", "x-silo-service-backed": true } }, - "/api/v2/admin/markers/items/{id}/history": { + "/api/v2/admin/nodes": { "get": { - "operationId": "listAdminItemMarkerHistory", + "operationId": "listAdminNodes", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -73568,24 +75345,28 @@ } }, { + "description": "Page size; default 50, maximum 200", "explode": false, "in": "query", "name": "limit", "schema": { - "default": 25, + "default": 50, + "description": "Page size; default 50, maximum 200", + "examples": [ + 50 + ], "format": "int64", - "maximum": 100, + "maximum": 200, "minimum": 1, "type": "integer" } }, { - "in": "path", - "name": "id", - "required": true, + "explode": false, + "in": "query", + "name": "cursor", "schema": { - "maxLength": 512, - "minLength": 1, + "maxLength": 8192, "type": "string" } } @@ -73595,7 +75376,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminMarkerHistory" + "$ref": "#/components/schemas/CollectionAdminNode" } } }, @@ -73697,17 +75478,15 @@ "bearerAuth": [] } ], - "summary": "Read bounded recent marker edit history.", + "summary": "Page configured nodes and their last stored observations; no worker probe is performed.", "tags": [ - "admin-catalog" + "admin-nodes" ], "x-silo-class": "acting_admin", "x-silo-service-backed": true - } - }, - "/api/v2/admin/markers/providers": { - "get": { - "operationId": "listAdminMarkerProviders", + }, + "post": { + "operationId": "createAdminNode", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -73732,16 +75511,33 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminNodeCreateBody" + } + } + }, + "required": true + }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminMarkerProviders" + "$ref": "#/components/schemas/AdminNode" } } }, - "description": "OK" + "description": "Created", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -73793,6 +75589,46 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -73839,17 +75675,19 @@ "bearerAuth": [] } ], - "summary": "Manage registered marker provider configuration.", + "summary": "Persist node configuration and durable pool invalidation. An identical natural URL configuration resolves a repeated create; conflicting configuration returns 409. No worker probe or replica completion acknowledgement.", "tags": [ - "admin-catalog" + "admin-nodes" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "unique_constraint", "x-silo-service-backed": true } }, - "/api/v2/admin/markers/providers/{provider}": { - "put": { - "operationId": "updateAdminMarkerProvider", + "/api/v2/admin/nodes/force-reload": { + "post": { + "operationId": "forceReloadAdminNodes", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -73872,34 +75710,14 @@ ], "type": "string" } - }, - { - "in": "path", - "name": "provider", - "required": true, - "schema": { - "maxLength": 512, - "minLength": 1, - "type": "string" - } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminMarkerProviderUpdate" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminMarkerProvider" + "$ref": "#/components/schemas/AdminNodeReloadOutputBody" } } }, @@ -73955,36 +75773,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -74031,19 +75819,37 @@ "bearerAuth": [] } ], - "summary": "Manage registered marker provider configuration.", + "summary": "Synchronously request force reload from every currently enumerated enabled node in parallel, with ten-second per-node timeout and no redirects or replay. May tear down sessions. Results are individual acknowledgements, not an atomic or durable completion receipt.", "tags": [ - "admin-catalog" + "admin-nodes" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/markers/providers/{provider}/validate": { - "post": { - "operationId": "validateAdminMarkerProvider", + "/api/v2/admin/nodes/{id}": { + "delete": { + "operationId": "deleteAdminNode", "parameters": [ + { + "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-Match", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Optional second precondition, evaluated after If-Match succeeds: \"*\" or any tag matching the current representation is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -74068,25 +75874,18 @@ }, { "in": "path", - "name": "provider", + "name": "id", "required": true, "schema": { - "maxLength": 512, - "minLength": 1, + "maxLength": 10, + "pattern": "^[1-9][0-9]*$", "type": "string" } } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminMarkerProviderValidation" - } - } - }, - "description": "OK" + "204": { + "description": "No Content" }, "400": { "content": { @@ -74138,6 +75937,24 @@ }, "description": "Not Acceptable" }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, "422": { "content": { "application/problem+json": { @@ -74148,6 +75965,16 @@ }, "description": "Unprocessable Entity" }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Required" + }, "429": { "content": { "application/problem+json": { @@ -74184,19 +76011,36 @@ "bearerAuth": [] } ], - "summary": "Manage registered marker provider configuration.", + "summary": "Delete the original If-Match configuration and atomically persist pool invalidation for replica reconciliation. No worker teardown, session completion or all-replica acknowledgement. A subsequent 404 is not proof of this caller's outcome.", "tags": [ - "admin-catalog" + "admin-nodes" ], "x-silo-class": "acting_admin", - "x-silo-retry-safety": "non_retryable", + "x-silo-demo-restricted": true, + "x-silo-guarded": true, + "x-silo-retry-safety": "durable_dispatch", "x-silo-service-backed": true - } - }, - "/api/v2/admin/network-access/{provider}/connect": { - "post": { - "operationId": "connectNetworkAccess", + }, + "put": { + "operationId": "updateAdminNode", "parameters": [ + { + "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-Match", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Optional second precondition, evaluated after If-Match succeeds: \"*\" or any tag matching the current representation is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -74221,12 +76065,11 @@ }, { "in": "path", - "name": "provider", + "name": "id", "required": true, "schema": { - "maxLength": 64, - "minLength": 1, - "pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$", + "maxLength": 10, + "pattern": "^[1-9][0-9]*$", "type": "string" } } @@ -74235,21 +76078,30 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NetworkAccessCommand" + "$ref": "#/components/schemas/AdminNodeUpdateBody" } } - } + }, + "required": true }, "responses": { - "202": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NetworkAccessStatus" + "$ref": "#/components/schemas/AdminNode" } } }, - "description": "Accepted" + "description": "OK", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -74311,6 +76163,34 @@ }, "description": "Request Timeout" }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, "413": { "content": { "application/problem+json": { @@ -74341,6 +76221,16 @@ }, "description": "Unprocessable Entity" }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Required" + }, "429": { "content": { "application/problem+json": { @@ -74377,19 +76267,20 @@ "bearerAuth": [] } ], - "summary": "Ask the provider on the named hosts (every host when hosts is omitted) to bring its overlay identity up and start proxying. Answers 202 with the state each host reached within ten seconds; enrollment may continue in the background (awaiting_authorization carries the auth_url), so poll status for the final state. Repeating the request converges on one connected instance per host. Hosts not named answer their current status; an unknown host id is 422.", + "summary": "Update stored node configuration under the original If-Match revision. Configuration changes advance ETag and persist pool invalidation; a no-change PUT retains ETag. Disabling alone removes new placement and routine health sampling after reconciliation, preserves the last health sample, leaves existing streams serving and does not contact the worker. The response acknowledges stored configuration, not worker reload, replica completion or session teardown.", "tags": [ - "network-access" + "admin-nodes" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, + "x-silo-guarded": true, "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true } }, - "/api/v2/admin/network-access/{provider}/disconnect": { + "/api/v2/admin/nodes/{id}/check": { "post": { - "operationId": "disconnectNetworkAccess", + "operationId": "checkAdminNode", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -74415,35 +76306,25 @@ }, { "in": "path", - "name": "provider", + "name": "id", "required": true, "schema": { - "maxLength": 64, - "minLength": 1, - "pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$", + "maxLength": 10, + "pattern": "^[1-9][0-9]*$", "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NetworkAccessCommand" - } - } - } - }, "responses": { - "202": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NetworkAccessStatus" + "$ref": "#/components/schemas/AdminNodeHealth" } } }, - "description": "Accepted" + "description": "OK" }, "400": { "content": { @@ -74495,36 +76376,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -74571,9 +76422,9 @@ "bearerAuth": [] } ], - "summary": "Ask the provider on the named hosts (every host when hosts is omitted) to tear its overlay listener down and clear its desired-connected intent. Answers 202 with the state each host reached within ten seconds. Repeating the request converges on disconnected. Hosts not named answer their current status; an unknown host id is 422.", + "summary": "Synchronously observe node health and apply URL-fenced persistence and process pool updates. Unhealthy is a successful observation; no durable job or hardware support guarantee.", "tags": [ - "network-access" + "admin-nodes" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, @@ -74581,9 +76432,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/network-access/{provider}/status": { - "get": { - "operationId": "getAdminNetworkAccessStatus", + "/api/v2/admin/nodes/{id}/force-reload": { + "post": { + "operationId": "forceReloadAdminNode", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -74609,12 +76460,11 @@ }, { "in": "path", - "name": "provider", + "name": "id", "required": true, "schema": { - "maxLength": 64, - "minLength": 1, - "pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$", + "maxLength": 10, + "pattern": "^[1-9][0-9]*$", "type": "string" } } @@ -74624,7 +76474,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NetworkAccessStatus" + "$ref": "#/components/schemas/AdminNodeReloadOutputBody" } } }, @@ -74726,17 +76576,19 @@ "bearerAuth": [] } ], - "summary": "Read the provider's live state on every host that runs it, asking each plugin instance directly with a ten-second timeout. A host whose plugin process is not running answers state unavailable with the supervisor's last error. An unknown provider slug is 404.", + "summary": "Synchronously request force reload from one stored node, including a disabled node. May tear down sessions. Ten-second timeout, no redirects or automatic replay; per-node status is acknowledgement or uncertainty, not a durable receipt.", "tags": [ - "network-access" + "admin-nodes" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/node-sessions": { - "get": { - "operationId": "listAdminNodeSessions", + "/api/v2/admin/nodes/{id}/reprobe": { + "post": { + "operationId": "reprobeAdminNode", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -74761,42 +76613,12 @@ } }, { - "description": "Page size; default 50, maximum 200", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "default": 50, - "description": "Page size; default 50, maximum 200", - "examples": [ - 50 - ], - "format": "int64", - "maximum": 200, - "minimum": 1, - "type": "integer" - } - }, - { - "explode": false, - "in": "query", - "name": "cursor", - "schema": { - "maxLength": 8192, - "type": "string" - } - }, - { - "description": "Opaque identifier", - "explode": false, - "in": "query", - "name": "node_id", + "in": "path", + "name": "id", + "required": true, "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, + "maxLength": 10, + "pattern": "^[1-9][0-9]*$", "type": "string" } } @@ -74806,7 +76628,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminNodeSessionsOutputBody" + "$ref": "#/components/schemas/AdminNodeReprobe" } } }, @@ -74908,17 +76730,19 @@ "bearerAuth": [] } ], - "summary": "Read best-effort Redis observations, not authoritative playback sessions. Each page enumerates current records; expired or unreadable values may be absent.", + "summary": "Synchronously request a node hardware reprobe with existing policy-derived deadlines. The body distinguishes node refusal or uncertainty from success and reports inventory refresh separately. No automatic replay or durable completion receipt.", "tags": [ - "admin" + "admin-nodes" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/nodes": { - "get": { - "operationId": "listAdminNodes", + "/api/v2/admin/notifications/discord/test": { + "post": { + "operationId": "testAdminDiscordNotification", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -74941,32 +76765,6 @@ ], "type": "string" } - }, - { - "description": "Page size; default 50, maximum 200", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "default": 50, - "description": "Page size; default 50, maximum 200", - "examples": [ - 50 - ], - "format": "int64", - "maximum": 200, - "minimum": 1, - "type": "integer" - } - }, - { - "explode": false, - "in": "query", - "name": "cursor", - "schema": { - "maxLength": 8192, - "type": "string" - } } ], "responses": { @@ -74974,7 +76772,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionAdminNode" + "$ref": "#/components/schemas/AdminNotificationDiscordTestResult" } } }, @@ -75076,15 +76874,19 @@ "bearerAuth": [] } ], - "summary": "Page configured nodes and their last stored observations; no worker probe is performed.", + "summary": "Verify the stored Discord bot credential without sending a message.", "tags": [ - "admin-nodes" + "admin" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - }, + } + }, + "/api/v2/admin/notifications/push/apple/test": { "post": { - "operationId": "createAdminNode", + "operationId": "testAdminApplePushNotification", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -75113,29 +76915,22 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminNodeCreateBody" + "$ref": "#/components/schemas/AdminNotificationPushTestInputBody" } } }, "required": true }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminNode" + "$ref": "#/components/schemas/AdminNotificationPushTestResult" } } }, - "description": "Created", - "headers": { - "ETag": { - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "400": { "content": { @@ -75197,16 +76992,6 @@ }, "description": "Request Timeout" }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" - }, "413": { "content": { "application/problem+json": { @@ -75273,19 +77058,19 @@ "bearerAuth": [] } ], - "summary": "Persist node configuration and durable pool invalidation. An identical natural URL configuration resolves a repeated create; conflicting configuration returns 409. No worker probe or replica completion acknowledgement.", + "summary": "Dispatch one test push notification and report its current delivery outcome.", "tags": [ - "admin-nodes" + "admin" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, - "x-silo-retry-safety": "unique_constraint", + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/nodes/force-reload": { + "/api/v2/admin/notifications/push/fcm/test": { "post": { - "operationId": "forceReloadAdminNodes", + "operationId": "testAdminAndroidPushNotification", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -75310,12 +77095,22 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminNotificationPushTestInputBody" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminNodeReloadOutputBody" + "$ref": "#/components/schemas/AdminNotificationPushTestResult" } } }, @@ -75371,6 +77166,36 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -75417,9 +77242,9 @@ "bearerAuth": [] } ], - "summary": "Synchronously request force reload from every currently enumerated enabled node in parallel, with ten-second per-node timeout and no redirects or replay. May tear down sessions. Results are individual acknowledgements, not an atomic or durable completion receipt.", + "summary": "Dispatch one test push notification and report its current delivery outcome.", "tags": [ - "admin-nodes" + "admin" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, @@ -75427,27 +77252,10 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/nodes/{id}": { + "/api/v2/admin/notifications/push/relay": { "delete": { - "operationId": "deleteAdminNode", + "operationId": "clearAdminNotificationRelay", "parameters": [ - { - "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", - "in": "header", - "name": "If-Match", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "Optional second precondition, evaluated after If-Match succeeds: \"*\" or any tag matching the current representation is 412 precondition_failed with the current ETag.", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } - }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -75469,16 +77277,6 @@ ], "type": "string" } - }, - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "maxLength": 10, - "pattern": "^[1-9][0-9]*$", - "type": "string" - } } ], "responses": { @@ -75535,24 +77333,6 @@ }, "description": "Not Acceptable" }, - "412": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, "422": { "content": { "application/problem+json": { @@ -75563,16 +77343,6 @@ }, "description": "Unprocessable Entity" }, - "428": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Required" - }, "429": { "content": { "application/problem+json": { @@ -75609,36 +77379,20 @@ "bearerAuth": [] } ], - "summary": "Delete the original If-Match configuration and atomically persist pool invalidation for replica reconciliation. No worker teardown, session completion or all-replica acknowledgement. A subsequent 404 is not proof of this caller's outcome.", + "summary": "Clear the local push relay credential.", "tags": [ - "admin-nodes" + "admin" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, - "x-silo-guarded": true, - "x-silo-retry-safety": "durable_dispatch", + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - }, - "put": { - "operationId": "updateAdminNode", + } + }, + "/api/v2/admin/notifications/push/relay/register": { + "post": { + "operationId": "registerAdminNotificationRelay", "parameters": [ - { - "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", - "in": "header", - "name": "If-Match", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "Optional second precondition, evaluated after If-Match succeeds: \"*\" or any tag matching the current representation is 412 precondition_failed with the current ETag.", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } - }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -75660,23 +77414,13 @@ ], "type": "string" } - }, - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "maxLength": 10, - "pattern": "^[1-9][0-9]*$", - "type": "string" - } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminNodeUpdateBody" + "$ref": "#/components/schemas/NotificationRelayRegisterInputBody" } } }, @@ -75687,19 +77431,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminNode" + "$ref": "#/components/schemas/NotificationRelayRegistration" } } }, - "description": "OK", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "400": { "content": { @@ -75761,34 +77497,6 @@ }, "description": "Request Timeout" }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" - }, - "412": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, "413": { "content": { "application/problem+json": { @@ -75819,16 +77527,6 @@ }, "description": "Unprocessable Entity" }, - "428": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Required" - }, "429": { "content": { "application/problem+json": { @@ -75865,20 +77563,19 @@ "bearerAuth": [] } ], - "summary": "Update stored node configuration under the original If-Match revision. Configuration changes advance ETag and persist pool invalidation; a no-change PUT retains ETag. Disabling alone removes new placement and routine health sampling after reconciliation, preserves the last health sample, leaves existing streams serving and does not contact the worker. The response acknowledges stored configuration, not worker reload, replica completion or session teardown.", + "summary": "Register or rotate the configured push relay credential.", "tags": [ - "admin-nodes" + "admin" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, - "x-silo-guarded": true, - "x-silo-retry-safety": "natural_idempotent", + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/nodes/{id}/check": { - "post": { - "operationId": "checkAdminNode", + "/api/v2/admin/notifications/server-channels": { + "get": { + "operationId": "listAdminNotificationServerChannels", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -75903,12 +77600,27 @@ } }, { - "in": "path", - "name": "id", - "required": true, + "description": "Page size; default 50, maximum 200", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 50, + "description": "Page size; default 50, maximum 200", + "examples": [ + 50 + ], + "format": "int64", + "maximum": 200, + "minimum": 1, + "type": "integer" + } + }, + { + "explode": false, + "in": "query", + "name": "cursor", "schema": { - "maxLength": 10, - "pattern": "^[1-9][0-9]*$", "type": "string" } } @@ -75918,7 +77630,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminNodeHealth" + "$ref": "#/components/schemas/CollectionNotificationServerChannel" } } }, @@ -76020,19 +77732,15 @@ "bearerAuth": [] } ], - "summary": "Synchronously observe node health and apply URL-fenced persistence and process pool updates. Unhealthy is a successful observation; no durable job or hardware support guarantee.", + "summary": "List server notification channels.", "tags": [ - "admin-nodes" + "admin" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true - } - }, - "/api/v2/admin/nodes/{id}/force-reload": { + }, "post": { - "operationId": "forceReloadAdminNode", + "operationId": "createAdminNotificationServerChannel", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -76055,28 +77763,28 @@ ], "type": "string" } - }, - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "maxLength": 10, - "pattern": "^[1-9][0-9]*$", - "type": "string" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationServerChannelCreateInputBody" + } + } + }, + "required": true + }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminNodeReloadOutputBody" + "$ref": "#/components/schemas/NotificationDestinationCreated" } } }, - "description": "OK" + "description": "Created" }, "400": { "content": { @@ -76128,6 +77836,36 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -76174,9 +77912,9 @@ "bearerAuth": [] } ], - "summary": "Synchronously request force reload from one stored node, including a disabled node. May tear down sessions. Ten-second timeout, no redirects or automatic replay; per-node status is acknowledgement or uncertainty, not a durable receipt.", + "summary": "Create a server notification channel and reveal its signing secret once.", "tags": [ - "admin-nodes" + "admin" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, @@ -76184,9 +77922,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/nodes/{id}/reprobe": { - "post": { - "operationId": "reprobeAdminNode", + "/api/v2/admin/notifications/server-channels/{id}": { + "delete": { + "operationId": "deleteAdminNotificationServerChannel", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -76215,22 +77953,13 @@ "name": "id", "required": true, "schema": { - "maxLength": 10, - "pattern": "^[1-9][0-9]*$", "type": "string" } } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminNodeReprobe" - } - } - }, - "description": "OK" + "204": { + "description": "No Content" }, "400": { "content": { @@ -76328,19 +78057,17 @@ "bearerAuth": [] } ], - "summary": "Synchronously request a node hardware reprobe with existing policy-derived deadlines. The body distinguishes node refusal or uncertainty from success and reports inventory refresh separately. No automatic replay or durable completion receipt.", + "summary": "Delete the exact server notification channel and its row-local delivery bookkeeping. Does not recall already-dispatched provider work.", "tags": [ - "admin-nodes" + "admin" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true - } - }, - "/api/v2/admin/notifications/discord/test": { - "post": { - "operationId": "testAdminDiscordNotification", + }, + "put": { + "operationId": "updateAdminNotificationServerChannel", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -76363,148 +78090,12 @@ ], "type": "string" } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminNotificationDiscordTestResult" - } - } - }, - "description": "OK" - }, - "400": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Bad Request" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unauthorized" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Forbidden" - }, - "404": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Found" }, - "406": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Acceptable" - }, - "422": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unprocessable Entity" - }, - "429": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Too Many Requests" - }, - "500": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Internal Server Error" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Service Unavailable" - } - }, - "security": [ { - "bearerAuth": [] - } - ], - "summary": "Verify the stored Discord bot credential without sending a message.", - "tags": [ - "admin" - ], - "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", - "x-silo-service-backed": true - } - }, - "/api/v2/admin/notifications/push/apple/test": { - "post": { - "operationId": "testAdminApplePushNotification", - "parameters": [ - { - "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", - "in": "header", - "name": "X-Profile-Id", - "schema": { - "examples": [ - "1" - ], - "type": "string" - } - }, - { - "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", - "in": "header", - "name": "X-Profile-Token", + "in": "path", + "name": "id", + "required": true, "schema": { - "examples": [ - "pvt_5f3a9c1e7b2d4e8fa0c6" - ], "type": "string" } } @@ -76513,7 +78104,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminNotificationPushTestInputBody" + "$ref": "#/components/schemas/NotificationServerChannelUpdateInputBody" } } }, @@ -76524,7 +78115,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminNotificationPushTestResult" + "$ref": "#/components/schemas/NotificationServerChannel" } } }, @@ -76656,7 +78247,7 @@ "bearerAuth": [] } ], - "summary": "Dispatch one test push notification and report its current delivery outcome.", + "summary": "Update channel configuration and atomically reset dispatch state on URL replacement or re-enabling. Never replay an uncertain update.", "tags": [ "admin" ], @@ -76666,9 +78257,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/notifications/push/fcm/test": { + "/api/v2/admin/notifications/server-channels/{id}/rotate-secret": { "post": { - "operationId": "testAdminAndroidPushNotification", + "operationId": "rotateAdminNotificationServerChannelSecret", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -76691,28 +78282,33 @@ ], "type": "string" } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminNotificationPushTestInputBody" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminNotificationPushTestResult" + "$ref": "#/components/schemas/NotificationWebhookSecretOutputBody" } } }, - "description": "OK" + "description": "OK", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -76764,36 +78360,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -76840,7 +78406,7 @@ "bearerAuth": [] } ], - "summary": "Dispatch one test push notification and report its current delivery outcome.", + "summary": "Replace the generic server channel signing secret and reveal it once. Never replay an uncertain rotation.", "tags": [ "admin" ], @@ -76850,9 +78416,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/notifications/push/relay": { - "delete": { - "operationId": "clearAdminNotificationRelay", + "/api/v2/admin/notifications/server-channels/{id}/test": { + "post": { + "operationId": "testAdminNotificationServerChannel", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -76875,11 +78441,32 @@ ], "type": "string" } + }, + { + "description": "Opaque identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } } ], "responses": { - "204": { - "description": "No Content" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationDestinationTestResult" + } + } + }, + "description": "OK" }, "400": { "content": { @@ -76977,7 +78564,7 @@ "bearerAuth": [] } ], - "summary": "Clear the local push relay credential.", + "summary": "Send one synchronous sample to a server notification channel.", "tags": [ "admin" ], @@ -76987,9 +78574,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/notifications/push/relay/register": { - "post": { - "operationId": "registerAdminNotificationRelay", + "/api/v2/admin/people/{id}": { + "patch": { + "operationId": "updateAdminPerson", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -77012,13 +78599,27 @@ ], "type": "string" } + }, + { + "description": "Opaque identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationRelayRegisterInputBody" + "$ref": "#/components/schemas/AdminPersonUpdate" } } }, @@ -77029,7 +78630,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationRelayRegistration" + "$ref": "#/components/schemas/Person" } } }, @@ -77161,9 +78762,9 @@ "bearerAuth": [] } ], - "summary": "Register or rotate the configured push relay credential.", + "summary": "Apply a partial person metadata update.", "tags": [ - "admin" + "admin-catalog" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, @@ -77171,9 +78772,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/notifications/server-channels": { - "get": { - "operationId": "listAdminNotificationServerChannels", + "/api/v2/admin/people/{id}/refresh": { + "post": { + "operationId": "refreshAdminPerson", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -77198,27 +78799,16 @@ } }, { - "description": "Page size; default 50, maximum 200", - "explode": false, - "in": "query", - "name": "limit", + "description": "Person identifier", + "in": "path", + "name": "id", + "required": true, "schema": { - "default": 50, - "description": "Page size; default 50, maximum 200", + "description": "Person identifier", "examples": [ - 50 + "7" ], - "format": "int64", - "maximum": 200, - "minimum": 1, - "type": "integer" - } - }, - { - "explode": false, - "in": "query", - "name": "cursor", - "schema": { + "minLength": 1, "type": "string" } } @@ -77228,7 +78818,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionNotificationServerChannel" + "$ref": "#/components/schemas/Person" } } }, @@ -77330,15 +78920,19 @@ "bearerAuth": [] } ], - "summary": "List server notification channels.", + "summary": "Wait for a provider refresh and return the updated person.", "tags": [ - "admin" + "admin-catalog" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - }, - "post": { - "operationId": "createAdminNotificationServerChannel", + } + }, + "/api/v2/admin/playback-history": { + "get": { + "operationId": "listAdminPlaybackHistory", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -77361,28 +78955,98 @@ ], "type": "string" } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotificationServerChannelCreateInputBody" - } + }, + { + "description": "Page size; default 50, maximum 200", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 50, + "description": "Page size; default 50, maximum 200", + "examples": [ + 50 + ], + "format": "int64", + "maximum": 200, + "minimum": 1, + "type": "integer" } }, - "required": true - }, + { + "description": "Opaque cursor from page.next_cursor", + "explode": false, + "in": "query", + "name": "cursor", + "schema": { + "description": "Opaque cursor from page.next_cursor", + "maxLength": 8192, + "type": "string" + } + }, + { + "description": "Only attempts by this login account", + "explode": false, + "in": "query", + "name": "user_id", + "schema": { + "description": "Only attempts by this login account", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } + }, + { + "description": "Only attempts by this household profile", + "explode": false, + "in": "query", + "name": "profile_id", + "schema": { + "description": "Only attempts by this household profile", + "maxLength": 1024, + "type": "string" + } + }, + { + "description": "Only attempts of this catalog item", + "explode": false, + "in": "query", + "name": "media_item_id", + "schema": { + "description": "Only attempts of this catalog item", + "maxLength": 1024, + "type": "string" + } + }, + { + "description": "Completion filter; all returns every finalized attempt", + "explode": false, + "in": "query", + "name": "completed", + "schema": { + "default": "all", + "description": "Completion filter; all returns every finalized attempt", + "enum": [ + "all", + "true", + "false" + ], + "type": "string" + } + } + ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationDestinationCreated" + "$ref": "#/components/schemas/AdminPlaybackHistoryCollection" } } }, - "description": "Created" + "description": "OK" }, "400": { "content": { @@ -77434,36 +79098,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -77510,20 +79144,26 @@ "bearerAuth": [] } ], - "summary": "Create a server notification channel and reveal its signing secret once.", + "summary": "List finalized playback attempts across every account and profile, newest ended first. Each page is one consistent read; later pages read the live log.", "tags": [ "admin" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/notifications/server-channels/{id}": { - "delete": { - "operationId": "deleteAdminNotificationServerChannel", + "/api/v2/admin/playback-routing/capabilities": { + "get": { + "operationId": "getAdminPlaybackRoutingCapabilities", "parameters": [ + { + "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", + "in": "header", + "name": "If-Match", + "schema": { + "type": "string" + } + }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -77547,17 +79187,47 @@ } }, { - "in": "path", - "name": "id", - "required": true, + "in": "header", + "name": "If-None-Match", "schema": { "type": "string" } } ], "responses": { - "204": { - "description": "No Content" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPlaybackRoutingCapabilities" + } + } + }, + "description": "OK", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + } + }, + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "The representation named by If-None-Match is current; no body.", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -77609,6 +79279,24 @@ }, "description": "Not Acceptable" }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, "422": { "content": { "application/problem+json": { @@ -77655,17 +79343,17 @@ "bearerAuth": [] } ], - "summary": "Delete the exact server notification channel and its row-local delivery bookkeeping. Does not recall already-dispatched provider work.", + "summary": "Read the stable routing configuration vocabulary, independently of available worker capacity.", "tags": [ - "admin" + "admin-settings" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "natural_idempotent", - "x-silo-service-backed": true - }, - "put": { - "operationId": "updateAdminNotificationServerChannel", + "x-silo-conditional": true + } + }, + "/api/v2/admin/plugins/catalog": { + "get": { + "operationId": "listAdminPluginCatalog", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -77690,30 +79378,38 @@ } }, { - "in": "path", - "name": "id", - "required": true, + "description": "Page size; default 50, maximum 200", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 50, + "description": "Page size; default 50, maximum 200", + "examples": [ + 50 + ], + "format": "int64", + "maximum": 200, + "minimum": 1, + "type": "integer" + } + }, + { + "explode": false, + "in": "query", + "name": "cursor", "schema": { + "maxLength": 8192, "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotificationServerChannelUpdateInputBody" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationServerChannel" + "$ref": "#/components/schemas/CollectionAdminPluginCatalogEntry" } } }, @@ -77769,36 +79465,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -77845,20 +79511,26 @@ "bearerAuth": [] } ], - "summary": "Update channel configuration and atomically reset dispatch state on URL replacement or re-enabling. Never replay an uncertain update.", + "summary": "Read the discoverable plugin catalog. Each page fetches every enabled repository index live and records the fetch time; continuation enumerates that fetch's result and is not a snapshot.", "tags": [ - "admin" + "admin-plugins" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/notifications/server-channels/{id}/rotate-secret": { - "post": { - "operationId": "rotateAdminNotificationServerChannelSecret", + "/api/v2/admin/plugins/catalog-settings": { + "get": { + "operationId": "getAdminPluginCatalogSettings", "parameters": [ + { + "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", + "in": "header", + "name": "If-Match", + "schema": { + "type": "string" + } + }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -77882,9 +79554,8 @@ } }, { - "in": "path", - "name": "id", - "required": true, + "in": "header", + "name": "If-None-Match", "schema": { "type": "string" } @@ -77895,13 +79566,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationWebhookSecretOutputBody" + "$ref": "#/components/schemas/AdminPluginCatalogSettings" } } }, "description": "OK", "headers": { - "Cache-Control": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "The representation named by If-None-Match is current; no body.", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", "schema": { "type": "string" } @@ -77958,6 +79641,24 @@ }, "description": "Not Acceptable" }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, "422": { "content": { "application/problem+json": { @@ -78004,20 +79705,34 @@ "bearerAuth": [] } ], - "summary": "Replace the generic server channel signing secret and reveal it once. Never replay an uncertain rotation.", + "summary": "Read canonical plugin catalog configuration and its validator.", "tags": [ - "admin" + "admin-plugins" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", + "x-silo-conditional": true, "x-silo-service-backed": true - } - }, - "/api/v2/admin/notifications/server-channels/{id}/test": { - "post": { - "operationId": "testAdminNotificationServerChannel", + }, + "put": { + "operationId": "updateAdminPluginCatalogSettings", "parameters": [ + { + "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-Match", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Optional second precondition, evaluated after If-Match succeeds: \"*\" or any tag matching the current representation is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -78039,32 +79754,36 @@ ], "type": "string" } - }, - { - "description": "Opaque identifier", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPluginCatalogSettings" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationDestinationTestResult" + "$ref": "#/components/schemas/AdminPluginCatalogSettings" } } }, - "description": "OK" + "description": "OK", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -78116,6 +79835,54 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -78126,6 +79893,16 @@ }, "description": "Unprocessable Entity" }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Required" + }, "429": { "content": { "application/problem+json": { @@ -78162,19 +79939,20 @@ "bearerAuth": [] } ], - "summary": "Send one synchronous sample to a server notification channel.", + "summary": "Atomically replace captured catalog configuration and reconcile managed repositories.", "tags": [ - "admin" + "admin-plugins" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", + "x-silo-guarded": true, + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true } }, - "/api/v2/admin/people/{id}": { - "patch": { - "operationId": "updateAdminPerson", + "/api/v2/admin/plugins/catalog-status": { + "get": { + "operationId": "getAdminPluginCatalogStatus", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -78197,38 +79975,14 @@ ], "type": "string" } - }, - { - "description": "Opaque identifier", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminPersonUpdate" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Person" + "$ref": "#/components/schemas/AdminPluginCatalogStatus" } } }, @@ -78284,36 +80038,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -78360,19 +80084,17 @@ "bearerAuth": [] } ], - "summary": "Apply a partial person metadata update.", + "summary": "Read plugin catalog counts and update availability separately from editable configuration.", "tags": [ - "admin-catalog" + "admin-plugins" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/people/{id}/refresh": { - "post": { - "operationId": "refreshAdminPerson", + "/api/v2/admin/plugins/installations": { + "get": { + "operationId": "listAdminPluginInstallations", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -78397,16 +80119,28 @@ } }, { - "description": "Person identifier", - "in": "path", - "name": "id", - "required": true, + "description": "Page size; default 50, maximum 200", + "explode": false, + "in": "query", + "name": "limit", "schema": { - "description": "Person identifier", + "default": 50, + "description": "Page size; default 50, maximum 200", "examples": [ - "7" + 50 ], - "minLength": 1, + "format": "int64", + "maximum": 200, + "minimum": 1, + "type": "integer" + } + }, + { + "explode": false, + "in": "query", + "name": "cursor", + "schema": { + "maxLength": 8192, "type": "string" } } @@ -78416,7 +80150,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Person" + "$ref": "#/components/schemas/CollectionAdminPluginInstallation" } } }, @@ -78518,19 +80252,15 @@ "bearerAuth": [] } ], - "summary": "Wait for a provider refresh and return the updated person.", + "summary": "Read manageable installations with manifest surface, redacted global configuration and bindings. The reserved builtin row is excluded. Every page enumerates the full stored list; continuation is live, not a snapshot.", "tags": [ - "admin-catalog" + "admin-plugins" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - } - }, - "/api/v2/admin/playback-history": { - "get": { - "operationId": "listAdminPlaybackHistory", + }, + "post": { + "operationId": "createAdminPluginInstallation", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -78553,98 +80283,28 @@ ], "type": "string" } - }, - { - "description": "Page size; default 50, maximum 200", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "default": 50, - "description": "Page size; default 50, maximum 200", - "examples": [ - 50 - ], - "format": "int64", - "maximum": 200, - "minimum": 1, - "type": "integer" - } - }, - { - "description": "Opaque cursor from page.next_cursor", - "explode": false, - "in": "query", - "name": "cursor", - "schema": { - "description": "Opaque cursor from page.next_cursor", - "maxLength": 8192, - "type": "string" - } - }, - { - "description": "Only attempts by this login account", - "explode": false, - "in": "query", - "name": "user_id", - "schema": { - "description": "Only attempts by this login account", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - } - }, - { - "description": "Only attempts by this household profile", - "explode": false, - "in": "query", - "name": "profile_id", - "schema": { - "description": "Only attempts by this household profile", - "maxLength": 1024, - "type": "string" - } - }, - { - "description": "Only attempts of this catalog item", - "explode": false, - "in": "query", - "name": "media_item_id", - "schema": { - "description": "Only attempts of this catalog item", - "maxLength": 1024, - "type": "string" - } - }, - { - "description": "Completion filter; all returns every finalized attempt", - "explode": false, - "in": "query", - "name": "completed", - "schema": { - "default": "all", - "description": "Completion filter; all returns every finalized attempt", - "enum": [ - "all", - "true", - "false" - ], - "type": "string" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPluginInstallCreate" + } + } + }, + "required": true + }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPlaybackHistoryCollection" + "$ref": "#/components/schemas/AdminPluginInstallation" } } }, - "description": "OK" + "description": "Created" }, "400": { "content": { @@ -78696,6 +80356,36 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -78742,26 +80432,20 @@ "bearerAuth": [] } ], - "summary": "List finalized playback attempts across every account and profile, newest ended first. Each page is one consistent read; later pages read the live log.", + "summary": "Install a plugin from a catalog repository (repository_id, plugin_id, version) or a direct archive URL, fetching over the network. An installation with the same plugin_id is stopped and replaced rather than duplicated. There is no replay identity: a lost response may follow a committed install; reconcile from the installation list and never automatically retry.", "tags": [ - "admin" + "admin-plugins" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/playback-routing/capabilities": { - "get": { - "operationId": "getAdminPlaybackRoutingCapabilities", + "/api/v2/admin/plugins/installations/{id}": { + "delete": { + "operationId": "deleteAdminPluginInstallation", "parameters": [ - { - "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", - "in": "header", - "name": "If-Match", - "schema": { - "type": "string" - } - }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -78785,47 +80469,24 @@ } }, { - "in": "header", - "name": "If-None-Match", + "description": "Opaque identifier", + "in": "path", + "name": "id", + "required": true, "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "pattern": "^[1-9][0-9]*$", "type": "string" } } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminPlaybackRoutingCapabilities" - } - } - }, - "description": "OK", - "headers": { - "Cache-Control": { - "schema": { - "type": "string" - } - }, - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "The representation named by If-None-Match is current; no body.", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } + "204": { + "description": "No Content" }, "400": { "content": { @@ -78877,7 +80538,7 @@ }, "description": "Not Acceptable" }, - "412": { + "409": { "content": { "application/problem+json": { "schema": { @@ -78885,15 +80546,7 @@ } } }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } + "description": "Conflict" }, "422": { "content": { @@ -78941,17 +80594,17 @@ "bearerAuth": [] } ], - "summary": "Read the stable routing configuration vocabulary, independently of available worker capacity.", + "summary": "Stop the plugin, delete the installation row (configuration, bindings and archives cascade) and remove its files. Row delete and file removal are not one transaction and a repeat finds no row: a later 404 is not this caller's receipt; never automatically retry.", "tags": [ - "admin-settings" + "admin-plugins" ], "x-silo-class": "acting_admin", - "x-silo-conditional": true - } - }, - "/api/v2/admin/plugins/catalog": { - "get": { - "operationId": "listAdminPluginCatalog", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", + "x-silo-service-backed": true + }, + "put": { + "operationId": "updateAdminPluginInstallation", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -78976,38 +80629,37 @@ } }, { - "description": "Page size; default 50, maximum 200", - "explode": false, - "in": "query", - "name": "limit", + "description": "Opaque identifier", + "in": "path", + "name": "id", + "required": true, "schema": { - "default": 50, - "description": "Page size; default 50, maximum 200", + "description": "Opaque identifier", "examples": [ - 50 + "1" ], - "format": "int64", - "maximum": 200, - "minimum": 1, - "type": "integer" - } - }, - { - "explode": false, - "in": "query", - "name": "cursor", - "schema": { - "maxLength": 8192, + "minLength": 1, + "pattern": "^[1-9][0-9]*$", "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPluginInstallationUpdate" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionAdminPluginCatalogEntry" + "$ref": "#/components/schemas/AdminPluginInstallation" } } }, @@ -79063,6 +80715,46 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -79109,26 +80801,20 @@ "bearerAuth": [] } ], - "summary": "Read the discoverable plugin catalog. Each page fetches every enabled repository index live and records the fetch time; continuation enumerates that fetch's result and is not a snapshot.", + "summary": "Assign enabled and/or update_policy on one installation. Disabling stops the running plugin first. Repeating the same assignment converges on one stored row.", "tags": [ "admin-plugins" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true } }, - "/api/v2/admin/plugins/catalog-settings": { - "get": { - "operationId": "getAdminPluginCatalogSettings", + "/api/v2/admin/plugins/installations/{id}/auth-binding": { + "put": { + "operationId": "updateAdminPluginAuthBinding", "parameters": [ - { - "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", - "in": "header", - "name": "If-Match", - "schema": { - "type": "string" - } - }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -79152,38 +80838,38 @@ } }, { - "in": "header", - "name": "If-None-Match", + "description": "Opaque identifier", + "in": "path", + "name": "id", + "required": true, "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "pattern": "^[1-9][0-9]*$", "type": "string" } } ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminPluginCatalogSettings" - } - } - }, - "description": "OK", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPluginAuthBindingWrite" } } }, - "304": { - "description": "The representation named by If-None-Match is current; no body.", + "required": true + }, + "responses": { + "204": { + "description": "No Content", "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "X-Silo-Restart-Required": { "schema": { + "description": "Always true: bindings load at server start", "type": "string" } } @@ -79239,7 +80925,7 @@ }, "description": "Not Acceptable" }, - "412": { + "408": { "content": { "application/problem+json": { "schema": { @@ -79247,15 +80933,37 @@ } } }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "description": "Request Timeout" + }, + "409": { + "content": { + "application/problem+json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" }, "422": { "content": { @@ -79303,34 +81011,20 @@ "bearerAuth": [] } ], - "summary": "Read canonical plugin catalog configuration and its validator.", + "summary": "Replace the auth provider binding for one capability and mark a server restart required. The whole row is assigned, so repeating the request converges on one stored binding.", "tags": [ "admin-plugins" ], "x-silo-class": "acting_admin", - "x-silo-conditional": true, + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true - }, + } + }, + "/api/v2/admin/plugins/installations/{id}/config": { "put": { - "operationId": "updateAdminPluginCatalogSettings", + "operationId": "updateAdminPluginInstallationConfig", "parameters": [ - { - "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", - "in": "header", - "name": "If-Match", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "Optional second precondition, evaluated after If-Match succeeds: \"*\" or any tag matching the current representation is 412 precondition_failed with the current ETag.", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } - }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -79352,36 +81046,36 @@ ], "type": "string" } + }, + { + "description": "Opaque identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "pattern": "^[1-9][0-9]*$", + "type": "string" + } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginCatalogSettings" + "$ref": "#/components/schemas/AdminPluginConfigWrite" } } }, "required": true }, "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminPluginCatalogSettings" - } - } - }, - "description": "OK", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } + "204": { + "description": "No Content" }, "400": { "content": { @@ -79443,7 +81137,7 @@ }, "description": "Request Timeout" }, - "412": { + "409": { "content": { "application/problem+json": { "schema": { @@ -79451,15 +81145,7 @@ } } }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } + "description": "Conflict" }, "413": { "content": { @@ -79491,16 +81177,6 @@ }, "description": "Unprocessable Entity" }, - "428": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Required" - }, "429": { "content": { "application/problem+json": { @@ -79537,20 +81213,19 @@ "bearerAuth": [] } ], - "summary": "Atomically replace captured catalog configuration and reconcile managed repositories.", + "summary": "Replace one global configuration entry after validating it against the plugin manifest, then stop the running plugin so it rebinds. Blank secret fields keep stored secrets; clear_secrets removes them. Repeating the same request converges on one stored entry.", "tags": [ "admin-plugins" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, - "x-silo-guarded": true, "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true } }, - "/api/v2/admin/plugins/catalog-status": { - "get": { - "operationId": "getAdminPluginCatalogStatus", + "/api/v2/admin/plugins/installations/{id}/config/test": { + "post": { + "operationId": "testAdminPluginInstallationConfig", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -79573,14 +81248,39 @@ ], "type": "string" } + }, + { + "description": "Opaque identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "pattern": "^[1-9][0-9]*$", + "type": "string" + } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPluginConfigWrite" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginCatalogStatus" + "$ref": "#/components/schemas/AdminPluginConnectionCheck" } } }, @@ -79636,6 +81336,46 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -79682,17 +81422,19 @@ "bearerAuth": [] } ], - "summary": "Read plugin catalog counts and update availability separately from editable configuration.", + "summary": "Probe one prospective configuration by starting a temporary plugin instance and running its connection check; nothing is stored. The check calls the plugin's provider and is bounded by a server timeout; never automatically retry an uncertain result. A failed check is a 200 result with success false.", "tags": [ "admin-plugins" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/plugins/installations": { - "get": { - "operationId": "listAdminPluginInstallations", + "/api/v2/admin/plugins/installations/{id}/restart": { + "post": { + "operationId": "restartAdminPluginInstallation", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -79717,28 +81459,17 @@ } }, { - "description": "Page size; default 50, maximum 200", - "explode": false, - "in": "query", - "name": "limit", + "description": "Opaque identifier", + "in": "path", + "name": "id", + "required": true, "schema": { - "default": 50, - "description": "Page size; default 50, maximum 200", + "description": "Opaque identifier", "examples": [ - 50 + "1" ], - "format": "int64", - "maximum": 200, - "minimum": 1, - "type": "integer" - } - }, - { - "explode": false, - "in": "query", - "name": "cursor", - "schema": { - "maxLength": 8192, + "minLength": 1, + "pattern": "^[1-9][0-9]*$", "type": "string" } } @@ -79748,7 +81479,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionAdminPluginInstallation" + "$ref": "#/components/schemas/AdminPluginInstallation" } } }, @@ -79804,6 +81535,16 @@ }, "description": "Not Acceptable" }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, "422": { "content": { "application/problem+json": { @@ -79850,15 +81591,19 @@ "bearerAuth": [] } ], - "summary": "Read manageable installations with manifest surface, redacted global configuration and bindings. The reserved builtin row is excluded. Every page enumerates the full stored list; continuation is live, not a snapshot.", + "summary": "Stop the installation's process and, for a resident plugin (one the server supervises, such as a network access provider), start it again with a fresh failure budget; the response's runtime reports the outcome, including a launch that failed. A non-resident plugin is only stopped and launches on its next use. A disabled installation is 409. Repeating the request converges on one running process.", "tags": [ "admin-plugins" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true - }, - "post": { - "operationId": "createAdminPluginInstallation", + } + }, + "/api/v2/admin/plugins/installations/{id}/task-bindings/{capability_id}": { + "put": { + "operationId": "updateAdminPluginTaskBinding", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -79881,28 +81626,53 @@ ], "type": "string" } + }, + { + "description": "Opaque identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "pattern": "^[1-9][0-9]*$", + "type": "string" + } + }, + { + "in": "path", + "name": "capability_id", + "required": true, + "schema": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginInstallCreate" + "$ref": "#/components/schemas/AdminPluginTaskBindingWrite" } } }, "required": true }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginInstallation" + "$ref": "#/components/schemas/AdminPluginTaskBindingResult" } } }, - "description": "Created" + "description": "OK" }, "400": { "content": { @@ -79964,6 +81734,16 @@ }, "description": "Request Timeout" }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, "413": { "content": { "application/problem+json": { @@ -80030,19 +81810,19 @@ "bearerAuth": [] } ], - "summary": "Install a plugin from a catalog repository (repository_id, plugin_id, version) or a direct archive URL, fetching over the network. An installation with the same plugin_id is stopped and replaced rather than duplicated. There is no replay identity: a lost response may follow a committed install; reconcile from the installation list and never automatically retry.", + "summary": "Replace the scheduled task binding for one capability and mark a server restart required. The whole row is assigned, so repeating the request converges on one stored binding.", "tags": [ "admin-plugins" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true } }, - "/api/v2/admin/plugins/installations/{id}": { - "delete": { - "operationId": "deleteAdminPluginInstallation", + "/api/v2/admin/plugins/installations/{id}/update": { + "post": { + "operationId": "applyAdminPluginUpdate", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -80083,8 +81863,15 @@ } ], "responses": { - "204": { - "description": "No Content" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPluginInstallation" + } + } + }, + "description": "OK" }, "400": { "content": { @@ -80192,7 +81979,7 @@ "bearerAuth": [] } ], - "summary": "Stop the plugin, delete the installation row (configuration, bindings and archives cascade) and remove its files. Row delete and file removal are not one transaction and a repeat finds no row: a later 404 is not this caller's receipt; never automatically retry.", + "summary": "Install the recorded available version from the installation's repository, fetching over the network, and clear the marker. No applicable update is 409. There is no replay identity: a lost response may follow a committed update; reconcile from the installation and never automatically retry.", "tags": [ "admin-plugins" ], @@ -80200,9 +81987,11 @@ "x-silo-demo-restricted": true, "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - }, - "put": { - "operationId": "updateAdminPluginInstallation", + } + }, + "/api/v2/admin/plugins/repositories": { + "get": { + "operationId": "listAdminPluginRepositories", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -80227,37 +82016,38 @@ } }, { - "description": "Opaque identifier", - "in": "path", - "name": "id", - "required": true, + "description": "Page size; default 50, maximum 200", + "explode": false, + "in": "query", + "name": "limit", "schema": { - "description": "Opaque identifier", + "default": 50, + "description": "Page size; default 50, maximum 200", "examples": [ - "1" + 50 ], - "minLength": 1, - "pattern": "^[1-9][0-9]*$", + "format": "int64", + "maximum": 200, + "minimum": 1, + "type": "integer" + } + }, + { + "explode": false, + "in": "query", + "name": "cursor", + "schema": { + "maxLength": 8192, "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminPluginInstallationUpdate" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginInstallation" + "$ref": "#/components/schemas/CollectionAdminPluginRepository" } } }, @@ -80313,46 +82103,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -80399,19 +82149,15 @@ "bearerAuth": [] } ], - "summary": "Assign enabled and/or update_policy on one installation. Disabling stops the running plugin first. Repeating the same assignment converges on one stored row.", + "summary": "Read stored repository configuration without remote catalog fetches. Every page enumerates the full stored list; continuation is live, not a snapshot.", "tags": [ "admin-plugins" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true - } - }, - "/api/v2/admin/plugins/installations/{id}/auth-binding": { - "put": { - "operationId": "updateAdminPluginAuthBinding", + }, + "post": { + "operationId": "createAdminPluginRepository", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -80434,44 +82180,28 @@ ], "type": "string" } - }, - { - "description": "Opaque identifier", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "pattern": "^[1-9][0-9]*$", - "type": "string" - } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginAuthBindingWrite" + "$ref": "#/components/schemas/AdminPluginRepositoryCreateBody" } } }, "required": true }, "responses": { - "204": { - "description": "No Content", - "headers": { - "X-Silo-Restart-Required": { + "201": { + "content": { + "application/json": { "schema": { - "description": "Always true: bindings load at server start", - "type": "string" + "$ref": "#/components/schemas/AdminPluginRepository" } } - } + }, + "description": "Created" }, "400": { "content": { @@ -80533,16 +82263,6 @@ }, "description": "Request Timeout" }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" - }, "413": { "content": { "application/problem+json": { @@ -80609,19 +82329,19 @@ "bearerAuth": [] } ], - "summary": "Replace the auth provider binding for one capability and mark a server restart required. The whole row is assigned, so repeating the request converges on one stored binding.", + "summary": "Create one stored repository configuration. No remote fetch, installation, replay identity or automatic retry; uncertain completion must be reconciled explicitly.", "tags": [ "admin-plugins" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, - "x-silo-retry-safety": "natural_idempotent", + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/plugins/installations/{id}/config": { - "put": { - "operationId": "updateAdminPluginInstallationConfig", + "/api/v2/admin/plugins/repositories/{id}": { + "delete": { + "operationId": "deleteAdminPluginRepository", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -80646,31 +82366,16 @@ } }, { - "description": "Opaque identifier", "in": "path", "name": "id", "required": true, "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, + "maxLength": 19, "pattern": "^[1-9][0-9]*$", "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminPluginConfigWrite" - } - } - }, - "required": true - }, "responses": { "204": { "description": "No Content" @@ -80725,46 +82430,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -80811,19 +82476,17 @@ "bearerAuth": [] } ], - "summary": "Replace one global configuration entry after validating it against the plugin manifest, then stop the running plugin so it rebinds. Blank secret fields keep stored secrets; clear_secrets removes them. Repeating the same request converges on one stored entry.", + "summary": "Delete one stored external repository. Managed repositories cannot be deleted. No installation removal, runtime operation or replay identity; reconcile uncertain completion explicitly.", "tags": [ "admin-plugins" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, - "x-silo-retry-safety": "natural_idempotent", + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - } - }, - "/api/v2/admin/plugins/installations/{id}/config/test": { - "post": { - "operationId": "testAdminPluginInstallationConfig", + }, + "put": { + "operationId": "updateAdminPluginRepository", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -80848,16 +82511,11 @@ } }, { - "description": "Opaque identifier", "in": "path", "name": "id", "required": true, "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, + "maxLength": 19, "pattern": "^[1-9][0-9]*$", "type": "string" } @@ -80867,7 +82525,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginConfigWrite" + "$ref": "#/components/schemas/AdminPluginRepositoryUpdateBody" } } }, @@ -80878,7 +82536,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginConnectionCheck" + "$ref": "#/components/schemas/AdminPluginRepository" } } }, @@ -80944,16 +82602,6 @@ }, "description": "Request Timeout" }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" - }, "413": { "content": { "application/problem+json": { @@ -81020,7 +82668,7 @@ "bearerAuth": [] } ], - "summary": "Probe one prospective configuration by starting a temporary plugin instance and running its connection check; nothing is stored. The check calls the plugin's provider and is bounded by a server timeout; never automatically retry an uncertain result. A failed check is a 200 result with success false.", + "summary": "Update stored repository configuration. Blank name/URL are ignored. Managed configuration is read-only. Readback is current state, not a write revision; reconcile uncertain completion without replay.", "tags": [ "admin-plugins" ], @@ -81030,9 +82678,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/plugins/installations/{id}/restart": { + "/api/v2/admin/plugins/uploads": { "post": { - "operationId": "restartAdminPluginInstallation", + "operationId": "uploadAdminPluginInstallation", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -81055,25 +82703,25 @@ ], "type": "string" } - }, - { - "description": "Opaque identifier", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "pattern": "^[1-9][0-9]*$", - "type": "string" - } } ], + "requestBody": { + "content": { + "multipart/form-data": { + "encoding": { + "archive": { + "contentType": "application/zip, application/octet-stream" + } + }, + "schema": { + "$ref": "#/components/schemas/AdminPluginUploadForm" + } + } + }, + "required": true + }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { @@ -81081,7 +82729,7 @@ } } }, - "description": "OK" + "description": "Created" }, "400": { "content": { @@ -81133,7 +82781,7 @@ }, "description": "Not Acceptable" }, - "409": { + "408": { "content": { "application/problem+json": { "schema": { @@ -81141,7 +82789,27 @@ } } }, - "description": "Conflict" + "description": "Request Timeout" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" }, "422": { "content": { @@ -81189,19 +82857,19 @@ "bearerAuth": [] } ], - "summary": "Stop the installation's process and, for a resident plugin (one the server supervises, such as a network access provider), start it again with a fresh failure budget; the response's runtime reports the outcome, including a launch that failed. A non-resident plugin is only stopped and launches on its next use. A disabled installation is 409. Repeating the request converges on one running process.", + "summary": "Install a plugin from one uploaded archive. A zip archive is installed from its manifest; any other file is treated as a plugin binary whose manifest the server obtains by executing it. An installation with the same plugin_id is replaced; there is no replay identity, so a delayed retry can replace a newer installation. Never automatically retry.", "tags": [ "admin-plugins" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, - "x-silo-retry-safety": "natural_idempotent", + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/plugins/installations/{id}/task-bindings/{capability_id}": { - "put": { - "operationId": "updateAdminPluginTaskBinding", + "/api/v2/admin/plugins/uploads/chunked": { + "post": { + "operationId": "createAdminPluginUpload", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -81224,53 +82892,28 @@ ], "type": "string" } - }, - { - "description": "Opaque identifier", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "pattern": "^[1-9][0-9]*$", - "type": "string" - } - }, - { - "in": "path", - "name": "capability_id", - "required": true, - "schema": { - "maxLength": 256, - "minLength": 1, - "type": "string" - } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginTaskBindingWrite" + "$ref": "#/components/schemas/AdminPluginChunkedUploadCreate" } } }, "required": true }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginTaskBindingResult" + "$ref": "#/components/schemas/AdminPluginUploadSession" } } }, - "description": "OK" + "description": "Created" }, "400": { "content": { @@ -81332,7 +82975,7 @@ }, "description": "Request Timeout" }, - "409": { + "413": { "content": { "application/problem+json": { "schema": { @@ -81340,9 +82983,9 @@ } } }, - "description": "Conflict" + "description": "Request Entity Too Large" }, - "413": { + "415": { "content": { "application/problem+json": { "schema": { @@ -81350,9 +82993,9 @@ } } }, - "description": "Request Entity Too Large" + "description": "Unsupported Media Type" }, - "415": { + "422": { "content": { "application/problem+json": { "schema": { @@ -81360,7 +83003,144 @@ } } }, - "description": "Unsupported Media Type" + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Open a process-local chunked upload session for one plugin archive; every call creates a new session on this replica. Sessions expire after inactivity and are unknown to other replicas. Never automatically retry an uncertain create.", + "tags": [ + "admin-plugins" + ], + "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", + "x-silo-service-backed": true + } + }, + "/api/v2/admin/plugins/uploads/chunked/{upload_id}": { + "delete": { + "operationId": "cancelAdminPluginUpload", + "parameters": [ + { + "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", + "in": "header", + "name": "X-Profile-Id", + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + }, + { + "in": "path", + "name": "upload_id", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" }, "422": { "content": { @@ -81408,7 +83188,7 @@ "bearerAuth": [] } ], - "summary": "Replace the scheduled task binding for one capability and mark a server restart required. The whole row is assigned, so repeating the request converges on one stored binding.", + "summary": "Discard a process-local upload session and its spooled bytes; an absent or expired session is already gone and answers 204.", "tags": [ "admin-plugins" ], @@ -81418,9 +83198,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/plugins/installations/{id}/update": { - "post": { - "operationId": "applyAdminPluginUpdate", + "/api/v2/admin/plugins/uploads/chunked/{upload_id}/chunks/{chunk_index}": { + "put": { + "operationId": "putAdminPluginUploadChunk", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -81445,27 +83225,44 @@ } }, { - "description": "Opaque identifier", "in": "path", - "name": "id", + "name": "upload_id", "required": true, "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], + "maxLength": 128, "minLength": 1, - "pattern": "^[1-9][0-9]*$", "type": "string" } + }, + { + "in": "path", + "name": "chunk_index", + "required": true, + "schema": { + "format": "int64", + "minimum": 0, + "type": "integer" + } } ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginInstallation" + "$ref": "#/components/schemas/AdminPluginUploadSession" } } }, @@ -81521,6 +83318,16 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, "409": { "content": { "application/problem+json": { @@ -81531,6 +83338,26 @@ }, "description": "Conflict" }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -81577,19 +83404,19 @@ "bearerAuth": [] } ], - "summary": "Install the recorded available version from the installation's repository, fetching over the network, and clear the marker. No applicable update is 409. There is no replay identity: a lost response may follow a committed update; reconcile from the installation and never automatically retry.", + "summary": "Store one chunk of a process-local session; the body length must equal the session chunk size for that index. A chunk already received is accepted without rewriting, so repeating the same bytes converges.", "tags": [ "admin-plugins" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true } }, - "/api/v2/admin/plugins/repositories": { - "get": { - "operationId": "listAdminPluginRepositories", + "/api/v2/admin/plugins/uploads/chunked/{upload_id}/complete": { + "post": { + "operationId": "completeAdminPluginUpload", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -81614,42 +83441,26 @@ } }, { - "description": "Page size; default 50, maximum 200", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "default": 50, - "description": "Page size; default 50, maximum 200", - "examples": [ - 50 - ], - "format": "int64", - "maximum": 200, - "minimum": 1, - "type": "integer" - } - }, - { - "explode": false, - "in": "query", - "name": "cursor", + "in": "path", + "name": "upload_id", + "required": true, "schema": { - "maxLength": 8192, + "maxLength": 128, + "minLength": 1, "type": "string" } } ], "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionAdminPluginRepository" + "$ref": "#/components/schemas/AdminPluginInstallation" } } }, - "description": "OK" + "description": "Created" }, "400": { "content": { @@ -81701,6 +83512,16 @@ }, "description": "Not Acceptable" }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, "422": { "content": { "application/problem+json": { @@ -81747,15 +83568,19 @@ "bearerAuth": [] } ], - "summary": "Read stored repository configuration without remote catalog fetches. Every page enumerates the full stored list; continuation is live, not a snapshot.", + "summary": "Consume a fully received session on this replica and install the assembled archive as the direct upload does. The session is removed before the install runs: a repeat finds no session and a lost response is not a receipt. Never automatically retry.", "tags": [ "admin-plugins" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - }, - "post": { - "operationId": "createAdminPluginRepository", + } + }, + "/api/v2/admin/policy/decisions": { + "get": { + "operationId": "listAdminPolicyDecisions", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -81778,28 +83603,90 @@ ], "type": "string" } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminPluginRepositoryCreateBody" - } + }, + { + "explode": false, + "in": "query", + "name": "decision_name", + "schema": { + "type": "string" } }, - "required": true - }, + { + "description": "Opaque identifier", + "explode": false, + "in": "query", + "name": "user_id", + "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } + }, + { + "explode": false, + "in": "query", + "name": "allowed", + "schema": { + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + { + "explode": false, + "in": "query", + "name": "from", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "explode": false, + "in": "query", + "name": "to", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 50, + "format": "int64", + "maximum": 200, + "minimum": 1, + "type": "integer" + } + }, + { + "explode": false, + "in": "query", + "name": "cursor", + "schema": { + "type": "string" + } + } + ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginRepository" + "$ref": "#/components/schemas/CollectionAdminPolicyDecision" } } }, - "description": "Created" + "description": "OK" }, "400": { "content": { @@ -81851,36 +83738,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -81927,19 +83784,17 @@ "bearerAuth": [] } ], - "summary": "Create one stored repository configuration. No remote fetch, installation, replay identity or automatic retry; uncertain completion must be reconciled explicitly.", + "summary": "List policy decisions with cursor pagination.", "tags": [ - "admin-plugins" + "admin-policy" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/plugins/repositories/{id}": { - "delete": { - "operationId": "deleteAdminPluginRepository", + "/api/v2/admin/policy/decisions/{id}": { + "get": { + "operationId": "getAdminPolicyDecision", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -81964,19 +83819,44 @@ } }, { + "description": "Opaque identifier", "in": "path", "name": "id", "required": true, "schema": { - "maxLength": 19, - "pattern": "^[1-9][0-9]*$", + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } + }, + { + "in": "header", + "name": "If-Match", + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "If-None-Match", + "schema": { "type": "string" } } ], "responses": { - "204": { - "description": "No Content" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPolicyDecision" + } + } + }, + "description": "OK" }, "400": { "content": { @@ -82074,17 +83954,17 @@ "bearerAuth": [] } ], - "summary": "Delete one stored external repository. Managed repositories cannot be deleted. No installation removal, runtime operation or replay identity; reconcile uncertain completion explicitly.", + "summary": "Read one policy decision and retained samples.", "tags": [ - "admin-plugins" + "admin-policy" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - }, - "put": { - "operationId": "updateAdminPluginRepository", + } + }, + "/api/v2/admin/policy/documents": { + "get": { + "operationId": "listAdminPolicyDocuments", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -82109,32 +83989,32 @@ } }, { - "in": "path", - "name": "id", - "required": true, + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 50, + "format": "int64", + "maximum": 200, + "minimum": 1, + "type": "integer" + } + }, + { + "explode": false, + "in": "query", + "name": "cursor", "schema": { - "maxLength": 19, - "pattern": "^[1-9][0-9]*$", "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminPluginRepositoryUpdateBody" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginRepository" + "$ref": "#/components/schemas/CollectionAdminPolicyDocument" } } }, @@ -82190,36 +84070,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -82266,19 +84116,15 @@ "bearerAuth": [] } ], - "summary": "Update stored repository configuration. Blank name/URL are ignored. Managed configuration is read-only. Readback is current state, not a write revision; reconcile uncertain completion without replay.", + "summary": "List saved policy documents.", "tags": [ - "admin-plugins" + "admin-policy" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - } - }, - "/api/v2/admin/plugins/uploads": { + }, "post": { - "operationId": "uploadAdminPluginInstallation", + "operationId": "createAdminPolicyDocument", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -82305,14 +84151,9 @@ ], "requestBody": { "content": { - "multipart/form-data": { - "encoding": { - "archive": { - "contentType": "application/zip, application/octet-stream" - } - }, + "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginUploadForm" + "$ref": "#/components/schemas/AdminPolicyDocumentCreate" } } }, @@ -82323,11 +84164,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginInstallation" + "$ref": "#/components/schemas/AdminPolicyDocument" } } }, - "description": "Created" + "description": "Created", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + }, + "Location": { + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -82455,9 +84308,9 @@ "bearerAuth": [] } ], - "summary": "Install a plugin from one uploaded archive. A zip archive is installed from its manifest; any other file is treated as a plugin binary whose manifest the server obtains by executing it. An installation with the same plugin_id is replaced; there is no replay identity, so a delayed retry can replace a newer installation. Never automatically retry.", + "summary": "Create a policy document without an active version.", "tags": [ - "admin-plugins" + "admin-policy" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, @@ -82465,10 +84318,27 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/plugins/uploads/chunked": { - "post": { - "operationId": "createAdminPluginUpload", + "/api/v2/admin/policy/documents/{id}": { + "delete": { + "operationId": "deleteAdminPolicyDocument", "parameters": [ + { + "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-Match", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Optional second precondition, evaluated after If-Match succeeds: \"*\" or any tag matching the current representation is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -82490,28 +84360,25 @@ ], "type": "string" } + }, + { + "description": "Opaque identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminPluginChunkedUploadCreate" - } - } - }, - "required": true - }, "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminPluginUploadSession" - } - } - }, - "description": "Created" + "204": { + "description": "No Content" }, "400": { "content": { @@ -82563,7 +84430,7 @@ }, "description": "Not Acceptable" }, - "408": { + "412": { "content": { "application/problem+json": { "schema": { @@ -82571,19 +84438,17 @@ } } }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", "schema": { - "$ref": "#/components/schemas/Problem" + "type": "string" } } - }, - "description": "Request Entity Too Large" + } }, - "415": { + "422": { "content": { "application/problem+json": { "schema": { @@ -82591,9 +84456,9 @@ } } }, - "description": "Unsupported Media Type" + "description": "Unprocessable Entity" }, - "422": { + "428": { "content": { "application/problem+json": { "schema": { @@ -82601,7 +84466,7 @@ } } }, - "description": "Unprocessable Entity" + "description": "Precondition Required" }, "429": { "content": { @@ -82639,19 +84504,18 @@ "bearerAuth": [] } ], - "summary": "Open a process-local chunked upload session for one plugin archive; every call creates a new session on this replica. Sessions expire after inactivity and are unknown to other replicas. Never automatically retry an uncertain create.", + "summary": "Delete an inactive document using its captured validator.", "tags": [ - "admin-plugins" + "admin-policy" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, + "x-silo-guarded": true, "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - } - }, - "/api/v2/admin/plugins/uploads/chunked/{upload_id}": { - "delete": { - "operationId": "cancelAdminPluginUpload", + }, + "get": { + "operationId": "getAdminPolicyDocument", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -82676,19 +84540,51 @@ } }, { + "description": "Opaque identifier", "in": "path", - "name": "upload_id", + "name": "id", "required": true, "schema": { - "maxLength": 128, + "description": "Opaque identifier", + "examples": [ + "1" + ], "minLength": 1, "type": "string" } + }, + { + "in": "header", + "name": "If-Match", + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } } ], "responses": { - "204": { - "description": "No Content" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPolicySnapshot" + } + } + }, + "description": "OK", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -82786,20 +84682,33 @@ "bearerAuth": [] } ], - "summary": "Discard a process-local upload session and its spooled bytes; an absent or expired session is already gone and answers 204.", + "summary": "Read a canonical document, active source, and document validator.", "tags": [ - "admin-plugins" + "admin-policy" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true - } - }, - "/api/v2/admin/plugins/uploads/chunked/{upload_id}/chunks/{chunk_index}": { - "put": { - "operationId": "putAdminPluginUploadChunk", + }, + "patch": { + "operationId": "setAdminPolicyEnabled", "parameters": [ + { + "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-Match", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Optional second precondition, evaluated after If-Match succeeds: \"*\" or any tag matching the current representation is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -82823,33 +84732,25 @@ } }, { + "description": "Opaque identifier", "in": "path", - "name": "upload_id", + "name": "id", "required": true, "schema": { - "maxLength": 128, + "description": "Opaque identifier", + "examples": [ + "1" + ], "minLength": 1, "type": "string" } - }, - { - "in": "path", - "name": "chunk_index", - "required": true, - "schema": { - "format": "int64", - "minimum": 0, - "type": "integer" - } } ], "requestBody": { "content": { - "application/octet-stream": { + "application/json": { "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" + "$ref": "#/components/schemas/AdminPolicyEnabled" } } }, @@ -82860,11 +84761,19 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginUploadSession" + "$ref": "#/components/schemas/AdminPolicyApplyResult" } } }, - "description": "OK" + "description": "OK", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -82926,7 +84835,7 @@ }, "description": "Request Timeout" }, - "409": { + "412": { "content": { "application/problem+json": { "schema": { @@ -82934,7 +84843,15 @@ } } }, - "description": "Conflict" + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } }, "413": { "content": { @@ -82966,6 +84883,16 @@ }, "description": "Unprocessable Entity" }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Required" + }, "429": { "content": { "application/problem+json": { @@ -83002,20 +84929,38 @@ "bearerAuth": [] } ], - "summary": "Store one chunk of a process-local session; the body length must equal the session chunk size for that index. A chunk already received is accepted without rewriting, so repeating the same bytes converges.", + "summary": "Set enabled state and report persisted and local application outcomes.", "tags": [ - "admin-plugins" + "admin-policy" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, - "x-silo-retry-safety": "natural_idempotent", + "x-silo-guarded": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/plugins/uploads/chunked/{upload_id}/complete": { - "post": { - "operationId": "completeAdminPluginUpload", + "/api/v2/admin/policy/documents/{id}/active-version": { + "put": { + "operationId": "activateAdminPolicyVersion", "parameters": [ + { + "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-Match", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Optional second precondition, evaluated after If-Match succeeds: \"*\" or any tag matching the current representation is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -83039,26 +84984,48 @@ } }, { + "description": "Opaque identifier", "in": "path", - "name": "upload_id", + "name": "id", "required": true, "schema": { - "maxLength": 128, + "description": "Opaque identifier", + "examples": [ + "1" + ], "minLength": 1, "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPolicyActivation" + } + } + }, + "required": true + }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginInstallation" + "$ref": "#/components/schemas/AdminPolicyApplyResult" } } }, - "description": "Created" + "description": "OK", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -83110,7 +85077,7 @@ }, "description": "Not Acceptable" }, - "409": { + "408": { "content": { "application/problem+json": { "schema": { @@ -83118,7 +85085,45 @@ } } }, - "description": "Conflict" + "description": "Request Timeout" + }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" }, "422": { "content": { @@ -83130,6 +85135,16 @@ }, "description": "Unprocessable Entity" }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Required" + }, "429": { "content": { "application/problem+json": { @@ -83166,19 +85181,20 @@ "bearerAuth": [] } ], - "summary": "Consume a fully received session on this replica and install the assembled archive as the direct upload does. The session is removed before the install runs: a repeat finds no session and a lost response is not a receipt. Never automatically retry.", + "summary": "Set the active version using the captured document validator.", "tags": [ - "admin-plugins" + "admin-policy" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, + "x-silo-guarded": true, "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/policy/decisions": { + "/api/v2/admin/policy/documents/{id}/versions": { "get": { - "operationId": "listAdminPolicyDecisions", + "operationId": "listAdminPolicyVersions", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -83202,19 +85218,11 @@ "type": "string" } }, - { - "explode": false, - "in": "query", - "name": "decision_name", - "schema": { - "type": "string" - } - }, { "description": "Opaque identifier", - "explode": false, - "in": "query", - "name": "user_id", + "in": "path", + "name": "id", + "required": true, "schema": { "description": "Opaque identifier", "examples": [ @@ -83224,36 +85232,6 @@ "type": "string" } }, - { - "explode": false, - "in": "query", - "name": "allowed", - "schema": { - "enum": [ - "true", - "false" - ], - "type": "string" - } - }, - { - "explode": false, - "in": "query", - "name": "from", - "schema": { - "format": "date-time", - "type": "string" - } - }, - { - "explode": false, - "in": "query", - "name": "to", - "schema": { - "format": "date-time", - "type": "string" - } - }, { "explode": false, "in": "query", @@ -83280,7 +85258,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionAdminPolicyDecision" + "$ref": "#/components/schemas/CollectionAdminPolicyVersion" } } }, @@ -83382,17 +85360,15 @@ "bearerAuth": [] } ], - "summary": "List policy decisions with cursor pagination.", + "summary": "List immutable version metadata.", "tags": [ "admin-policy" ], "x-silo-class": "acting_admin", "x-silo-service-backed": true - } - }, - "/api/v2/admin/policy/decisions/{id}": { - "get": { - "operationId": "getAdminPolicyDecision", + }, + "post": { + "operationId": "createAdminPolicyVersion", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -83429,32 +85405,35 @@ "minLength": 1, "type": "string" } - }, - { - "in": "header", - "name": "If-Match", - "schema": { - "type": "string" - } - }, - { - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPolicyVersionCreate" + } + } + }, + "required": true + }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPolicyDecision" + "$ref": "#/components/schemas/AdminPolicyVersion" } } }, - "description": "OK" + "description": "Created", + "headers": { + "Location": { + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -83506,6 +85485,36 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -83552,17 +85561,19 @@ "bearerAuth": [] } ], - "summary": "Read one policy decision and retained samples.", + "summary": "Save an immutable draft, including drafts that fail compilation.", "tags": [ "admin-policy" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/policy/documents": { + "/api/v2/admin/policy/documents/{id}/versions/{version}": { "get": { - "operationId": "listAdminPolicyDocuments", + "operationId": "getAdminPolicyVersion", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -83587,22 +85598,30 @@ } }, { - "explode": false, - "in": "query", - "name": "limit", + "description": "Opaque identifier", + "in": "path", + "name": "id", + "required": true, "schema": { - "default": 50, - "format": "int64", - "maximum": 200, - "minimum": 1, - "type": "integer" + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" } }, { - "explode": false, - "in": "query", - "name": "cursor", + "description": "Opaque identifier", + "in": "path", + "name": "version", + "required": true, "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, "type": "string" } } @@ -83612,7 +85631,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionAdminPolicyDocument" + "$ref": "#/components/schemas/AdminPolicyVersion" } } }, @@ -83714,15 +85733,17 @@ "bearerAuth": [] } ], - "summary": "List saved policy documents.", + "summary": "Read an immutable version by its opaque ID, including source.", "tags": [ "admin-policy" ], "x-silo-class": "acting_admin", "x-silo-service-backed": true - }, + } + }, + "/api/v2/admin/policy/simulate": { "post": { - "operationId": "createAdminPolicyDocument", + "operationId": "simulateAdminPolicy", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -83751,34 +85772,22 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPolicyDocumentCreate" + "$ref": "#/components/schemas/AdminPolicySimulation" } } }, "required": true }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPolicyDocument" + "$ref": "#/components/schemas/AdminPolicySimulationResult" } } }, - "description": "Created", - "headers": { - "ETag": { - "schema": { - "type": "string" - } - }, - "Location": { - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "400": { "content": { @@ -83906,7 +85915,7 @@ "bearerAuth": [] } ], - "summary": "Create a policy document without an active version.", + "summary": "Evaluate policy input without changing the running policy.", "tags": [ "admin-policy" ], @@ -83916,27 +85925,10 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/policy/documents/{id}": { - "delete": { - "operationId": "deleteAdminPolicyDocument", + "/api/v2/admin/policy/validate": { + "post": { + "operationId": "validateAdminPolicy", "parameters": [ - { - "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", - "in": "header", - "name": "If-Match", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "Optional second precondition, evaluated after If-Match succeeds: \"*\" or any tag matching the current representation is 412 precondition_failed with the current ETag.", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } - }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -83958,25 +85950,28 @@ ], "type": "string" } - }, - { - "description": "Opaque identifier", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPolicySource" + } + } + }, + "required": true + }, "responses": { - "204": { - "description": "No Content" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPolicyValidation" + } + } + }, + "description": "OK" }, "400": { "content": { @@ -84028,7 +86023,7 @@ }, "description": "Not Acceptable" }, - "412": { + "408": { "content": { "application/problem+json": { "schema": { @@ -84036,17 +86031,19 @@ } } }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "description": "Request Timeout" + }, + "413": { + "content": { + "application/problem+json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Request Entity Too Large" }, - "422": { + "415": { "content": { "application/problem+json": { "schema": { @@ -84054,9 +86051,9 @@ } } }, - "description": "Unprocessable Entity" + "description": "Unsupported Media Type" }, - "428": { + "422": { "content": { "application/problem+json": { "schema": { @@ -84064,7 +86061,7 @@ } } }, - "description": "Precondition Required" + "description": "Unprocessable Entity" }, "429": { "content": { @@ -84102,18 +86099,19 @@ "bearerAuth": [] } ], - "summary": "Delete an inactive document using its captured validator.", + "summary": "Compile policy source without persisting it.", "tags": [ "admin-policy" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, - "x-silo-guarded": true, "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - }, + } + }, + "/api/v2/admin/policy/vendor": { "get": { - "operationId": "getAdminPolicyDocument", + "operationId": "listAdminPolicyVendor", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -84136,34 +86134,6 @@ ], "type": "string" } - }, - { - "description": "Opaque identifier", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - } - }, - { - "in": "header", - "name": "If-Match", - "schema": { - "type": "string" - } - }, - { - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } } ], "responses": { @@ -84171,18 +86141,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPolicySnapshot" + "$ref": "#/components/schemas/CollectionAdminPolicyVendor" } } }, - "description": "OK", - "headers": { - "ETag": { - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "400": { "content": { @@ -84280,29 +86243,22 @@ "bearerAuth": [] } ], - "summary": "Read a canonical document, active source, and document validator.", + "summary": "Read embedded vendor policy sources.", "tags": [ "admin-policy" ], "x-silo-class": "acting_admin", "x-silo-service-backed": true - }, - "patch": { - "operationId": "setAdminPolicyEnabled", + } + }, + "/api/v2/admin/rate-limits/config": { + "get": { + "operationId": "getAdminRateLimitConfig", "parameters": [ { - "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", + "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", "in": "header", "name": "If-Match", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "Optional second precondition, evaluated after If-Match succeeds: \"*\" or any tag matching the current representation is 412 precondition_failed with the current ETag.", - "in": "header", - "name": "If-None-Match", "schema": { "type": "string" } @@ -84330,36 +86286,19 @@ } }, { - "description": "Opaque identifier", - "in": "path", - "name": "id", - "required": true, + "in": "header", + "name": "If-None-Match", "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminPolicyEnabled" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPolicyApplyResult" + "$ref": "#/components/schemas/AdminRateLimitConfig" } } }, @@ -84373,6 +86312,17 @@ } } }, + "304": { + "description": "The representation named by If-None-Match is current; no body.", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, "400": { "content": { "application/problem+json": { @@ -84423,16 +86373,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, "412": { "content": { "application/problem+json": { @@ -84451,26 +86391,6 @@ } } }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -84481,16 +86401,6 @@ }, "description": "Unprocessable Entity" }, - "428": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Required" - }, "429": { "content": { "application/problem+json": { @@ -84527,20 +86437,16 @@ "bearerAuth": [] } ], - "summary": "Set enabled state and report persisted and local application outcomes.", + "summary": "Read desired rate-limit settings independently of process runtime observations.", "tags": [ - "admin-policy" + "admin-settings" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-guarded": true, - "x-silo-retry-safety": "non_retryable", + "x-silo-conditional": true, "x-silo-service-backed": true - } - }, - "/api/v2/admin/policy/documents/{id}/active-version": { - "put": { - "operationId": "activateAdminPolicyVersion", + }, + "patch": { + "operationId": "updateAdminRateLimitConfig", "parameters": [ { "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", @@ -84580,27 +86486,13 @@ ], "type": "string" } - }, - { - "description": "Opaque identifier", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPolicyActivation" + "$ref": "#/components/schemas/AdminRateLimitUpdate" } } }, @@ -84611,19 +86503,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPolicyApplyResult" + "$ref": "#/components/schemas/AdminRateLimitUpdateResult" } } }, - "description": "OK", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "400": { "content": { @@ -84779,20 +86663,20 @@ "bearerAuth": [] } ], - "summary": "Set the active version using the captured document validator.", + "summary": "Merge captured rate-limit settings under the settings transaction lock; reloads are process-local and event publication is best effort.", "tags": [ - "admin-policy" + "admin-settings" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, "x-silo-guarded": true, - "x-silo-retry-safety": "non_retryable", + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true } }, - "/api/v2/admin/policy/documents/{id}/versions": { + "/api/v2/admin/rate-limits/status": { "get": { - "operationId": "listAdminPolicyVersions", + "operationId": "getAdminRateLimitStatus", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -84815,40 +86699,6 @@ ], "type": "string" } - }, - { - "description": "Opaque identifier", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - } - }, - { - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "default": 50, - "format": "int64", - "maximum": 200, - "minimum": 1, - "type": "integer" - } - }, - { - "explode": false, - "in": "query", - "name": "cursor", - "schema": { - "type": "string" - } } ], "responses": { @@ -84856,7 +86706,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionAdminPolicyVersion" + "$ref": "#/components/schemas/AdminRateLimitStatus" } } }, @@ -84958,15 +86808,17 @@ "bearerAuth": [] } ], - "summary": "List immutable version metadata.", + "summary": "Read process-local limiter backend and configured Redis availability; no reachability probe.", "tags": [ - "admin-policy" + "admin-settings" ], "x-silo-class": "acting_admin", "x-silo-service-backed": true - }, - "post": { - "operationId": "createAdminPolicyVersion", + } + }, + "/api/v2/admin/recommendations/status": { + "get": { + "operationId": "getAdminRecommendationsStatus", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -84989,49 +86841,18 @@ ], "type": "string" } - }, - { - "description": "Opaque identifier", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminPolicyVersionCreate" - } - } - }, - "required": true - }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPolicyVersion" + "$ref": "#/components/schemas/AdminRecommendationsStatus" } } }, - "description": "Created", - "headers": { - "Location": { - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "400": { "content": { @@ -85083,36 +86904,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -85159,19 +86950,17 @@ "bearerAuth": [] } ], - "summary": "Save an immutable draft, including drafts that fail compilation.", + "summary": "Read persisted counts and this process's running flags.", "tags": [ - "admin-policy" + "admin-recommendations" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/policy/documents/{id}/versions/{version}": { - "get": { - "operationId": "getAdminPolicyVersion", + "/api/v2/admin/recommendations/trigger/cowatch": { + "post": { + "operationId": "triggerAdminRecommendationCowatch", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -85194,34 +86983,6 @@ ], "type": "string" } - }, - { - "description": "Opaque identifier", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - } - }, - { - "description": "Opaque identifier", - "in": "path", - "name": "version", - "required": true, - "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - } } ], "responses": { @@ -85229,7 +86990,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPolicyVersion" + "$ref": "#/components/schemas/AdminRecommendationStarted" } } }, @@ -85331,17 +87092,19 @@ "bearerAuth": [] } ], - "summary": "Read an immutable version by its opaque ID, including source.", + "summary": "Start process-local recommendation work without a durable job receipt.", "tags": [ - "admin-policy" + "admin-recommendations" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/policy/simulate": { + "/api/v2/admin/recommendations/trigger/embeddings": { "post": { - "operationId": "simulateAdminPolicy", + "operationId": "triggerAdminRecommendationEmbeddings", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -85366,22 +87129,12 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminPolicySimulation" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPolicySimulationResult" + "$ref": "#/components/schemas/AdminRecommendationStarted" } } }, @@ -85437,36 +87190,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -85513,9 +87236,9 @@ "bearerAuth": [] } ], - "summary": "Evaluate policy input without changing the running policy.", + "summary": "Start process-local recommendation work without a durable job receipt.", "tags": [ - "admin-policy" + "admin-recommendations" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, @@ -85523,9 +87246,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/policy/validate": { + "/api/v2/admin/recommendations/trigger/recommendations": { "post": { - "operationId": "validateAdminPolicy", + "operationId": "triggerAdminRecommendationRefresh", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -85550,22 +87273,12 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminPolicySource" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPolicyValidation" + "$ref": "#/components/schemas/AdminRecommendationStarted" } } }, @@ -85621,36 +87334,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -85697,9 +87380,9 @@ "bearerAuth": [] } ], - "summary": "Compile policy source without persisting it.", + "summary": "Start process-local recommendation work without a durable job receipt.", "tags": [ - "admin-policy" + "admin-recommendations" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, @@ -85707,9 +87390,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/policy/vendor": { - "get": { - "operationId": "listAdminPolicyVendor", + "/api/v2/admin/recommendations/trigger/taste-profiles": { + "post": { + "operationId": "triggerAdminRecommendationTasteProfiles", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -85739,7 +87422,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionAdminPolicyVendor" + "$ref": "#/components/schemas/AdminRecommendationStarted" } } }, @@ -85841,26 +87524,20 @@ "bearerAuth": [] } ], - "summary": "Read embedded vendor policy sources.", + "summary": "Start process-local recommendation work without a durable job receipt.", "tags": [ - "admin-policy" + "admin-recommendations" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/rate-limits/config": { + "/api/v2/admin/request-integrations": { "get": { - "operationId": "getAdminRateLimitConfig", + "operationId": "listRequestIntegrations", "parameters": [ - { - "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", - "in": "header", - "name": "If-Match", - "schema": { - "type": "string" - } - }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -85884,9 +87561,32 @@ } }, { - "in": "header", - "name": "If-None-Match", + "description": "Page size; default 50, maximum 200", + "explode": false, + "in": "query", + "name": "limit", "schema": { + "default": 50, + "description": "Page size; default 50, maximum 200", + "examples": [ + 50 + ], + "format": "int64", + "maximum": 200, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Opaque cursor from page.next_cursor", + "explode": false, + "in": "query", + "name": "cursor", + "schema": { + "description": "Opaque cursor from page.next_cursor", + "examples": [ + "eyJpIjo1MH0" + ], "type": "string" } } @@ -85896,30 +87596,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRateLimitConfig" + "$ref": "#/components/schemas/CollectionAdminRequestIntegration" } } }, - "description": "OK", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "The representation named by If-None-Match is current; no body.", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "400": { "content": { @@ -85971,24 +87652,6 @@ }, "description": "Not Acceptable" }, - "412": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, "422": { "content": { "application/problem+json": { @@ -86035,34 +87698,16 @@ "bearerAuth": [] } ], - "summary": "Read desired rate-limit settings independently of process runtime observations.", + "summary": "Manage media requests and their configuration.", "tags": [ - "admin-settings" + "admin" ], "x-silo-class": "acting_admin", - "x-silo-conditional": true, "x-silo-service-backed": true }, - "patch": { - "operationId": "updateAdminRateLimitConfig", + "post": { + "operationId": "createRequestIntegration", "parameters": [ - { - "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", - "in": "header", - "name": "If-Match", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "Optional second precondition, evaluated after If-Match succeeds: \"*\" or any tag matching the current representation is 412 precondition_failed with the current ETag.", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } - }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -86090,22 +87735,34 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRateLimitUpdate" + "$ref": "#/components/schemas/AdminRequestIntegrationBody" } } }, "required": true }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRateLimitUpdateResult" + "$ref": "#/components/schemas/AdminRequestIntegration" } } }, - "description": "OK" + "description": "Created", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + }, + "Location": { + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -86167,24 +87824,6 @@ }, "description": "Request Timeout" }, - "412": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, "413": { "content": { "application/problem+json": { @@ -86215,16 +87854,6 @@ }, "description": "Unprocessable Entity" }, - "428": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Required" - }, "429": { "content": { "application/problem+json": { @@ -86261,21 +87890,37 @@ "bearerAuth": [] } ], - "summary": "Merge captured rate-limit settings under the settings transaction lock; reloads are process-local and event publication is best effort.", + "summary": "Manage media requests and their configuration.", "tags": [ - "admin-settings" + "admin" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, - "x-silo-guarded": true, - "x-silo-retry-safety": "natural_idempotent", + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/rate-limits/status": { - "get": { - "operationId": "getAdminRateLimitStatus", + "/api/v2/admin/request-integrations/{id}": { + "delete": { + "operationId": "deleteRequestIntegration", "parameters": [ + { + "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-Match", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Optional second precondition, evaluated after If-Match succeeds: \"*\" or any tag matching the current representation is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -86297,18 +87942,25 @@ ], "type": "string" } + }, + { + "description": "The request", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "The request", + "examples": [ + "1834729" + ], + "minLength": 1, + "type": "string" + } } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminRateLimitStatus" - } - } - }, - "description": "OK" + "204": { + "description": "No Content" }, "400": { "content": { @@ -86360,6 +88012,24 @@ }, "description": "Not Acceptable" }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, "422": { "content": { "application/problem+json": { @@ -86370,6 +88040,16 @@ }, "description": "Unprocessable Entity" }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Required" + }, "429": { "content": { "application/problem+json": { @@ -86406,17 +88086,18 @@ "bearerAuth": [] } ], - "summary": "Read process-local limiter backend and configured Redis availability; no reachability probe.", + "summary": "Manage media requests and their configuration.", "tags": [ - "admin-settings" + "admin" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-guarded": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - } - }, - "/api/v2/admin/recommendations/status": { + }, "get": { - "operationId": "getAdminRecommendationsStatus", + "operationId": "getRequestIntegration", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -86439,6 +88120,20 @@ ], "type": "string" } + }, + { + "description": "The request", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "The request", + "examples": [ + "1834729" + ], + "minLength": 1, + "type": "string" + } } ], "responses": { @@ -86446,11 +88141,18 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRecommendationsStatus" + "$ref": "#/components/schemas/AdminRequestIntegration" } } }, - "description": "OK" + "description": "OK", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -86548,18 +88250,33 @@ "bearerAuth": [] } ], - "summary": "Read persisted counts and this process's running flags.", + "summary": "Manage media requests and their configuration.", "tags": [ - "admin-recommendations" + "admin" ], "x-silo-class": "acting_admin", "x-silo-service-backed": true - } - }, - "/api/v2/admin/recommendations/trigger/cowatch": { - "post": { - "operationId": "triggerAdminRecommendationCowatch", + }, + "put": { + "operationId": "updateRequestIntegration", "parameters": [ + { + "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-Match", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Optional second precondition, evaluated after If-Match succeeds: \"*\" or any tag matching the current representation is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -86581,18 +88298,50 @@ ], "type": "string" } + }, + { + "description": "The request", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "The request", + "examples": [ + "1834729" + ], + "minLength": 1, + "type": "string" + } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminRequestIntegrationBody" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRecommendationStarted" + "$ref": "#/components/schemas/AdminRequestIntegration" } } }, - "description": "OK" + "description": "OK", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -86644,6 +88393,54 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -86654,6 +88451,16 @@ }, "description": "Unprocessable Entity" }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Required" + }, "429": { "content": { "application/problem+json": { @@ -86690,19 +88497,20 @@ "bearerAuth": [] } ], - "summary": "Start process-local recommendation work without a durable job receipt.", + "summary": "Manage media requests and their configuration.", "tags": [ - "admin-recommendations" + "admin" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, + "x-silo-guarded": true, "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/recommendations/trigger/embeddings": { + "/api/v2/admin/request-integrations/{id}/options": { "post": { - "operationId": "triggerAdminRecommendationEmbeddings", + "operationId": "loadRequestIntegrationOptions", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -86725,14 +88533,38 @@ ], "type": "string" } + }, + { + "description": "The request", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "The request", + "examples": [ + "1834729" + ], + "minLength": 1, + "type": "string" + } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminRequestOptionsInputBody" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRecommendationStarted" + "$ref": "#/components/schemas/AdminRequestOptions" } } }, @@ -86788,6 +88620,36 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -86834,9 +88696,9 @@ "bearerAuth": [] } ], - "summary": "Start process-local recommendation work without a durable job receipt.", + "summary": "Manage media requests and their configuration.", "tags": [ - "admin-recommendations" + "admin" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, @@ -86844,9 +88706,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/recommendations/trigger/recommendations": { - "post": { - "operationId": "triggerAdminRecommendationRefresh", + "/api/v2/admin/request-routes": { + "get": { + "operationId": "listRequestRoutes", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -86876,7 +88738,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRecommendationStarted" + "$ref": "#/components/schemas/CollectionAdminRequestRoute" } } }, @@ -86932,6 +88794,16 @@ }, "description": "Not Acceptable" }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, "422": { "content": { "application/problem+json": { @@ -86978,19 +88850,15 @@ "bearerAuth": [] } ], - "summary": "Start process-local recommendation work without a durable job receipt.", + "summary": "List the request routing rules, in evaluation order per media type.", "tags": [ - "admin-recommendations" + "admin" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - } - }, - "/api/v2/admin/recommendations/trigger/taste-profiles": { + }, "post": { - "operationId": "triggerAdminRecommendationTasteProfiles", + "operationId": "createRequestRoute", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -87015,16 +88883,38 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminRequestRouteBody" + } + } + }, + "required": true + }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRecommendationStarted" + "$ref": "#/components/schemas/AdminRequestRoute" } } }, - "description": "OK" + "description": "Created", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + }, + "Location": { + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -87076,6 +88966,46 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -87122,9 +89052,9 @@ "bearerAuth": [] } ], - "summary": "Start process-local recommendation work without a durable job receipt.", + "summary": "Add a request routing rule after the media type's existing rules.", "tags": [ - "admin-recommendations" + "admin" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, @@ -87132,9 +89062,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/request-integrations": { - "get": { - "operationId": "listRequestIntegrations", + "/api/v2/admin/request-routes/order": { + "post": { + "operationId": "reorderRequestRoutes", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -87157,44 +89087,24 @@ ], "type": "string" } - }, - { - "description": "Page size; default 50, maximum 200", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "default": 50, - "description": "Page size; default 50, maximum 200", - "examples": [ - 50 - ], - "format": "int64", - "maximum": 200, - "minimum": 1, - "type": "integer" - } - }, - { - "description": "Opaque cursor from page.next_cursor", - "explode": false, - "in": "query", - "name": "cursor", - "schema": { - "description": "Opaque cursor from page.next_cursor", - "examples": [ - "eyJpIjo1MH0" - ], - "type": "string" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminRequestRouteReorderInputBody" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionAdminRequestIntegration" + "$ref": "#/components/schemas/CollectionAdminRequestRoute" } } }, @@ -87250,6 +89160,46 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -87296,15 +89246,19 @@ "bearerAuth": [] } ], - "summary": "Manage media requests and their configuration.", + "summary": "Set the evaluation order of a media type's routing rules.", "tags": [ "admin" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - }, + } + }, + "/api/v2/admin/request-routes/preview": { "post": { - "operationId": "createRequestIntegration", + "operationId": "previewRequestRoute", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -87333,34 +89287,22 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRequestIntegrationBody" + "$ref": "#/components/schemas/AdminRequestRoutePreviewInputBody" } } }, "required": true }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRequestIntegration" + "$ref": "#/components/schemas/AdminRequestRoutePreviewOutputBody" } } }, - "description": "Created", - "headers": { - "ETag": { - "schema": { - "type": "string" - } - }, - "Location": { - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "400": { "content": { @@ -87422,6 +89364,16 @@ }, "description": "Request Timeout" }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, "413": { "content": { "application/problem+json": { @@ -87488,19 +89440,18 @@ "bearerAuth": [] } ], - "summary": "Manage media requests and their configuration.", + "summary": "Show which server each quality tier of a title would go to.", "tags": [ "admin" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true } }, - "/api/v2/admin/request-integrations/{id}": { + "/api/v2/admin/request-routes/{id}": { "delete": { - "operationId": "deleteRequestIntegration", + "operationId": "deleteRequestRoute", "parameters": [ { "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", @@ -87542,14 +89493,14 @@ } }, { - "description": "The request", + "description": "The route", "in": "path", "name": "id", "required": true, "schema": { - "description": "The request", + "description": "The route", "examples": [ - "1834729" + "fallback-movie" ], "minLength": 1, "type": "string" @@ -87610,6 +89561,16 @@ }, "description": "Not Acceptable" }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, "412": { "content": { "application/problem+json": { @@ -87684,7 +89645,7 @@ "bearerAuth": [] } ], - "summary": "Manage media requests and their configuration.", + "summary": "Delete a request routing rule.", "tags": [ "admin" ], @@ -87695,7 +89656,7 @@ "x-silo-service-backed": true }, "get": { - "operationId": "getRequestIntegration", + "operationId": "getRequestRoute", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -87720,14 +89681,14 @@ } }, { - "description": "The request", + "description": "The route", "in": "path", "name": "id", "required": true, "schema": { - "description": "The request", + "description": "The route", "examples": [ - "1834729" + "fallback-movie" ], "minLength": 1, "type": "string" @@ -87739,7 +89700,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRequestIntegration" + "$ref": "#/components/schemas/AdminRequestRoute" } } }, @@ -87802,6 +89763,16 @@ }, "description": "Not Acceptable" }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, "422": { "content": { "application/problem+json": { @@ -87848,7 +89819,7 @@ "bearerAuth": [] } ], - "summary": "Manage media requests and their configuration.", + "summary": "Get one request routing rule.", "tags": [ "admin" ], @@ -87856,7 +89827,7 @@ "x-silo-service-backed": true }, "put": { - "operationId": "updateRequestIntegration", + "operationId": "updateRequestRoute", "parameters": [ { "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", @@ -87898,14 +89869,14 @@ } }, { - "description": "The request", + "description": "The route", "in": "path", "name": "id", "required": true, "schema": { - "description": "The request", + "description": "The route", "examples": [ - "1834729" + "fallback-movie" ], "minLength": 1, "type": "string" @@ -87916,7 +89887,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRequestIntegrationBody" + "$ref": "#/components/schemas/AdminRequestRouteBody" } } }, @@ -87927,7 +89898,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRequestIntegration" + "$ref": "#/components/schemas/AdminRequestRoute" } } }, @@ -88001,6 +89972,16 @@ }, "description": "Request Timeout" }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, "412": { "content": { "application/problem+json": { @@ -88095,7 +90076,7 @@ "bearerAuth": [] } ], - "summary": "Manage media requests and their configuration.", + "summary": "Replace a request routing rule; saving a media type's fallback creates it.", "tags": [ "admin" ], @@ -88106,204 +90087,6 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/request-integrations/{id}/options": { - "post": { - "operationId": "loadRequestIntegrationOptions", - "parameters": [ - { - "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", - "in": "header", - "name": "X-Profile-Id", - "schema": { - "examples": [ - "1" - ], - "type": "string" - } - }, - { - "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", - "in": "header", - "name": "X-Profile-Token", - "schema": { - "examples": [ - "pvt_5f3a9c1e7b2d4e8fa0c6" - ], - "type": "string" - } - }, - { - "description": "The request", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "The request", - "examples": [ - "1834729" - ], - "minLength": 1, - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminRequestOptionsInputBody" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminRequestOptions" - } - } - }, - "description": "OK" - }, - "400": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Bad Request" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unauthorized" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Forbidden" - }, - "404": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Found" - }, - "406": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Acceptable" - }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, - "422": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unprocessable Entity" - }, - "429": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Too Many Requests" - }, - "500": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Internal Server Error" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Service Unavailable" - } - }, - "security": [ - { - "bearerAuth": [] - } - ], - "summary": "Manage media requests and their configuration.", - "tags": [ - "admin" - ], - "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", - "x-silo-service-backed": true - } - }, "/api/v2/admin/request-settings": { "get": { "operationId": "getAdminRequestSettings", diff --git a/docs/architecture/api-contract.md b/docs/architecture/api-contract.md index 380d65412c..02e665c4a1 100644 --- a/docs/architecture/api-contract.md +++ b/docs/architecture/api-contract.md @@ -2004,6 +2004,15 @@ profile and filters. Integration lists return bounded ID-ordered pages over the configured integrations. The service currently loads that small configuration set before slicing a page; it does not claim database-bounded enumeration. +Request routing rules (`/admin/request-routes`) are v2-only: list (bounded, +unpaginated, in evaluation order, always including each media type's fallback), +read, create, replace and delete by ID, reorder a media type's rules, and a +read-only `preview` that shows which server each quality tier of a title would +go to. Replacement and deletion require `If-Match` on the rule's revision; a +fallback that was never saved reads as revision zero and its first replacement +creates it. The fallback cannot be deleted, and a rule cannot be created until +its media type's fallback has an HD server. + Settings, account limits and integrations require `If-Match` for replacement and integration deletion. A shared PostgreSQL sequence assigns a new revision on every insert or update, including legacy and background writers, and distinguishes a diff --git a/docs/architecture/media-requests.md b/docs/architecture/media-requests.md index ff75d7ddf5..21070300a0 100644 --- a/docs/architecture/media-requests.md +++ b/docs/architecture/media-requests.md @@ -53,14 +53,25 @@ Sonarr/Radarr plugin's terms, with the plugin's own anime overlay off, so the existing plugin follows the route without knowing about routing. Each target records the route that sent it. A server a route sends to cannot be deleted until the route stops using it, so deleting a server never silently reroutes -titles. A chosen server that is disabled, not set up (no installation, no key) -or since switched to the other kind is an admin-fixable problem: when nothing has been +titles, and it cannot be switched to the type the route's media type cannot use +(Sonarr for movies, Radarr for series). A chosen server that is disabled, not +set up (no installation, no key) or of the wrong type anyway is an admin-fixable +problem: when nothing has been sent yet, the submission retries with backoff; a later tier that fails that way becomes a failed target. Status checks go through the plugin installation that owns each target's server, and one plugin failing does not discard the statuses another reported. A media type with no routes keeps the plugin's own routing: every usable connection is handed over and the plugin picks. +Admins manage routes through `/api/v2/admin/request-routes`. Each media type +always has its fallback ("Everything else"); until it is saved it has no servers +and routing leaves the media type to the plugin. Saving it requires an HD server, +since a saved fallback moves the media type to Silo's routing. A rule cannot be added before +the fallback has an HD server, because the first rule switches the media type to +Silo's routing and titles no rule matches would otherwise have nowhere to go. +Rules must narrow (at least one condition) and must do something (a destination, +or skip 4K), and cannot override the config keys routing sets itself. + The migration that introduced routes carried the Sonarr/Radarr plugin's routing over unchanged: each media type's first usable default and default-4K servers (by name) became its fallback route, and a default server's anime settings diff --git a/internal/api/testdata/media_routes.txt b/internal/api/testdata/media_routes.txt index 131881e171..8bafc1fb2f 100644 --- a/internal/api/testdata/media_routes.txt +++ b/internal/api/testdata/media_routes.txt @@ -254,6 +254,13 @@ DELETE /api/v2/admin/request-integrations/{id} non-media GET /api/v2/admin/request-integrations/{id} non-media PUT /api/v2/admin/request-integrations/{id} non-media POST /api/v2/admin/request-integrations/{id}/options non-media +GET /api/v2/admin/request-routes non-media +POST /api/v2/admin/request-routes non-media +POST /api/v2/admin/request-routes/order non-media +POST /api/v2/admin/request-routes/preview non-media +DELETE /api/v2/admin/request-routes/{id} non-media +GET /api/v2/admin/request-routes/{id} non-media +PUT /api/v2/admin/request-routes/{id} non-media GET /api/v2/admin/request-settings non-media PUT /api/v2/admin/request-settings non-media GET /api/v2/admin/request-users/{user_id}/limit non-media @@ -1416,6 +1423,13 @@ DELETE /api/v2/admin/request-integrations/{id} non-media GET /api/v2/admin/request-integrations/{id} non-media PUT /api/v2/admin/request-integrations/{id} non-media POST /api/v2/admin/request-integrations/{id}/options non-media +GET /api/v2/admin/request-routes non-media +POST /api/v2/admin/request-routes non-media +POST /api/v2/admin/request-routes/order non-media +POST /api/v2/admin/request-routes/preview non-media +DELETE /api/v2/admin/request-routes/{id} non-media +GET /api/v2/admin/request-routes/{id} non-media +PUT /api/v2/admin/request-routes/{id} non-media GET /api/v2/admin/request-settings non-media PUT /api/v2/admin/request-settings non-media GET /api/v2/admin/request-users/{user_id}/limit non-media diff --git a/internal/apiv2/admin_request_routes.go b/internal/apiv2/admin_request_routes.go new file mode 100644 index 0000000000..31289cd557 --- /dev/null +++ b/internal/apiv2/admin_request_routes.go @@ -0,0 +1,377 @@ +package apiv2 + +import ( + "context" + "errors" + "net/http" + "strconv" + + mediarequests "github.com/Silo-Server/silo-server/internal/requests" +) + +// Request routing administration: the ordered rules that decide which server +// each quality tier of a request goes to (docs/architecture/media-requests.md, +// "Routing"). + +const ( + opListRequestRoutes = "listRequestRoutes" + opGetRequestRoute = "getRequestRoute" + opCreateRequestRoute = "createRequestRoute" + opUpdateRequestRoute = "updateRequestRoute" + opDeleteRequestRoute = "deleteRequestRoute" + opReorderRequestRoutes = "reorderRequestRoutes" + opPreviewRequestRoute = "previewRequestRoute" +) + +var adminRequestRouteOperationIDs = []string{opListRequestRoutes, opGetRequestRoute, opCreateRequestRoute, + opUpdateRequestRoute, opDeleteRequestRoute, opReorderRequestRoutes, opPreviewRequestRoute} + +// adminRequestRoutes is the route administration slice of the request +// service. +type adminRequestRoutes interface { + ListRoutesAdmin(context.Context, mediarequests.Viewer) ([]mediarequests.Route, error) + GetRoute(context.Context, mediarequests.Viewer, string) (*mediarequests.Route, error) + CreateRoute(context.Context, mediarequests.Viewer, mediarequests.Route) (*mediarequests.Route, error) + UpdateRouteConditional(context.Context, mediarequests.Viewer, mediarequests.Route, int64) (*mediarequests.Route, error) + DeleteRouteConditional(context.Context, mediarequests.Viewer, string, int64) error + ReorderRoutes(context.Context, mediarequests.Viewer, mediarequests.MediaType, []string) ([]mediarequests.Route, error) + PreviewRoute(context.Context, mediarequests.Viewer, mediarequests.MediaType, int, int) (*mediarequests.RoutePreview, error) +} + +// AdminRequestRouteConditions narrow a route. Every set field must match; a +// list matches when the title has any of its values. +type AdminRequestRouteConditions struct { + Anime *bool `json:"anime,omitempty" doc:"Match titles TMDB tags as anime (true) or not (false)"` + GenreIDs []int `json:"genre_ids,omitempty" doc:"TMDB genre IDs"` + KeywordIDs []int `json:"keyword_ids,omitempty" doc:"TMDB keyword IDs"` + OriginalLanguages []string `json:"original_languages,omitempty" doc:"ISO 639-1 codes of the original language" example:"[\"ja\"]"` + OriginCountries []string `json:"origin_countries,omitempty" doc:"ISO 3166-1 country codes" example:"[\"JP\"]"` + YearFrom int `json:"year_from,omitempty" doc:"First release (or first-air) year, inclusive" example:"1980"` + YearTo int `json:"year_to,omitempty" doc:"Last release (or first-air) year, inclusive" example:"1989"` + NetworkIDs []int `json:"network_ids,omitempty" doc:"TMDB network IDs (series)"` + CompanyIDs []int `json:"company_ids,omitempty" doc:"TMDB production company IDs (movies)"` + RequesterUserIDs []int `json:"requester_user_ids,omitempty" doc:"Accounts whose requests the route applies to"` +} + +// AdminRequestRouteDestination is where a route sends one quality tier. +type AdminRequestRouteDestination struct { + IntegrationID string `json:"integration_id,omitempty" doc:"The request server; empty when the route sends nothing for this tier"` + Overrides map[string]any `json:"overrides,omitempty" doc:"Server settings this route replaces, keyed like the server's plugin config: root_folder, quality_profile_id, tags, series_type, minimum_availability, ..."` +} + +// AdminRequestRoute is one routing rule, or a media type's fallback. +type AdminRequestRoute struct { + ID string `json:"id" doc:"Opaque route ID; the fallback's is fallback-movie or fallback-series" example:"fallback-movie"` + MediaType string `json:"media_type" enum:"movie,series"` + Position int `json:"position" doc:"Evaluation order within the media type; the fallback is always last"` + Name string `json:"name" example:"Anime"` + Enabled bool `json:"enabled"` + IsFallback bool `json:"is_fallback" doc:"The media type's default destination: it has no conditions and cannot be deleted"` + Conditions AdminRequestRouteConditions `json:"conditions"` + HD AdminRequestRouteDestination `json:"hd" doc:"Where the HD (1080p) copy goes"` + UHD AdminRequestRouteDestination `json:"uhd" doc:"Where the 4K copy goes"` + SkipUHD bool `json:"skip_uhd" doc:"Matching titles get no 4K copy at all"` +} + +// AdminRequestRouteBody is the editable part of a route. media_type is read +// on create only. +type AdminRequestRouteBody struct { + MediaType string `json:"media_type,omitempty" enum:"movie,series" doc:"Required on create; ignored on update"` + Name string `json:"name,omitempty" maxLength:"100"` + Enabled bool `json:"enabled"` + Conditions AdminRequestRouteConditions `json:"conditions"` + HD AdminRequestRouteDestination `json:"hd"` + UHD AdminRequestRouteDestination `json:"uhd"` + SkipUHD bool `json:"skip_uhd"` +} + +type AdminRequestRouteIDInput struct { + ID string `path:"id" minLength:"1" doc:"The route" example:"fallback-movie"` +} +type AdminRequestRouteOutput struct { + ETag string `header:"ETag"` + Body AdminRequestRoute +} +type AdminRequestRouteCreateInput struct{ Body AdminRequestRouteBody } +type AdminRequestRouteCreateOutput struct { + Location string `header:"Location"` + ETag string `header:"ETag"` + Body AdminRequestRoute +} +type AdminRequestRouteUpdateInput struct { + AdminRequestRouteIDInput + IfMatch string `header:"If-Match"` + IfNoneMatch string `header:"If-None-Match"` + Body AdminRequestRouteBody +} +type AdminRequestRouteDeleteInput struct { + AdminRequestRouteIDInput + IfMatch string `header:"If-Match"` + IfNoneMatch string `header:"If-None-Match"` +} +type AdminRequestRouteCollectionOutput struct { + Body Collection[AdminRequestRoute] +} +type AdminRequestRouteReorderInput struct { + Body struct { + MediaType string `json:"media_type" enum:"movie,series"` + IDs []string `json:"ids" maxItems:"100" doc:"Every rule of the media type, fallback excluded, in the new order"` + } +} + +// AdminRequestRoutePreviewInput asks how a title would be routed. +type AdminRequestRoutePreviewInput struct { + Body struct { + MediaType string `json:"media_type" enum:"movie,series"` + TMDBID int `json:"tmdb_id" minimum:"1" doc:"TMDB identifier (external, not a Silo ID)" example:"129"` + RequesterUserID *ID `json:"requester_user_id,omitempty" doc:"Route as this account's request; account conditions are skipped when absent"` + } +} + +// AdminRequestRouteFacts is what the routes matched on. +type AdminRequestRouteFacts struct { + GenreIDs []int `json:"genre_ids"` + KeywordIDs []int `json:"keyword_ids"` + OriginalLanguage string `json:"original_language,omitempty"` + OriginCountries []string `json:"origin_countries"` + Year int `json:"year,omitempty"` + NetworkIDs []int `json:"network_ids"` + CompanyIDs []int `json:"company_ids"` + Anime bool `json:"anime"` +} + +// AdminRequestRoutePreviewTier is one tier's outcome. +type AdminRequestRoutePreviewTier struct { + Quality string `json:"quality" enum:"1080p,2160p"` + RouteID string `json:"route_id,omitempty"` + RouteName string `json:"route_name,omitempty"` + IntegrationID string `json:"integration_id,omitempty"` + IntegrationName string `json:"integration_name,omitempty"` + Overrides map[string]any `json:"overrides,omitempty"` + Note string `json:"note,omitempty" doc:"Why no route sends the tier, or why it would fail"` +} + +type AdminRequestRoutePreviewOutput struct { + Body struct { + Facts AdminRequestRouteFacts `json:"facts"` + Tiers []AdminRequestRoutePreviewTier `json:"tiers"` + } +} + +func registerAdminRequestRoutes(reg *Registry) { + op := func(method, path, id, summary string, guard bool) Operation { + o := Operation{Operation: humaOp(method, Prefix+path, id, "admin", summary), Class: ClassActingAdmin, DemoRestricted: isMutatingMethod(method), ServiceBacked: true, Guarded: guard} + o.Errors = []int{http.StatusNotFound, http.StatusConflict, http.StatusUnprocessableEntity} + if method != http.MethodGet { + o.RetrySafety = RetrySafetyNonRetryable + } + return o + } + Register(reg, op(http.MethodGet, "/admin/request-routes", opListRequestRoutes, "List the request routing rules, in evaluation order per media type.", false), reg.listAdminRequestRoutes) + Register(reg, op(http.MethodGet, "/admin/request-routes/{id}", opGetRequestRoute, "Get one request routing rule.", false), reg.getAdminRequestRoute) + create := op(http.MethodPost, "/admin/request-routes", opCreateRequestRoute, "Add a request routing rule after the media type's existing rules.", false) + create.DefaultStatus = http.StatusCreated + Register(reg, create, reg.createAdminRequestRoute) + update := op(http.MethodPut, "/admin/request-routes/{id}", opUpdateRequestRoute, "Replace a request routing rule; saving a media type's fallback creates it.", true) + Register(reg, update, reg.updateAdminRequestRoute) + del := op(http.MethodDelete, "/admin/request-routes/{id}", opDeleteRequestRoute, "Delete a request routing rule.", true) + del.DefaultStatus = http.StatusNoContent + Register(reg, del, reg.deleteAdminRequestRoute) + Register(reg, op(http.MethodPost, "/admin/request-routes/order", opReorderRequestRoutes, "Set the evaluation order of a media type's routing rules.", false), reg.reorderAdminRequestRoutes) + preview := op(http.MethodPost, "/admin/request-routes/preview", opPreviewRequestRoute, "Show which server each quality tier of a title would go to.", false) + preview.RetrySafety = RetrySafetyNaturalIdempotent + preview.DemoRestricted = false + Register(reg, preview, reg.previewAdminRequestRoute) +} + +func (reg *Registry) adminRequestRouteService() (adminRequestRoutes, *Problem) { + s, ok := reg.deps.AdminRequests.(adminRequestRoutes) + if !ok { + return nil, unavailable("request routing") + } + return s, nil +} + +func adminRequestRouteOf(r mediarequests.Route) AdminRequestRoute { + c := r.Conditions + return AdminRequestRoute{ + ID: r.ID, MediaType: string(r.MediaType), Position: r.Position, Name: r.Name, Enabled: r.Enabled, + IsFallback: r.IsFallback, SkipUHD: r.SkipUHD, + Conditions: AdminRequestRouteConditions{ + Anime: c.Anime, GenreIDs: c.GenreIDs, KeywordIDs: c.KeywordIDs, OriginalLanguages: c.OriginalLanguages, + OriginCountries: c.OriginCountries, YearFrom: c.YearFrom, YearTo: c.YearTo, NetworkIDs: c.NetworkIDs, + CompanyIDs: c.CompanyIDs, RequesterUserIDs: c.RequesterUserIDs, + }, + HD: AdminRequestRouteDestination{IntegrationID: r.HD.IntegrationID, Overrides: r.HD.Overrides}, + UHD: AdminRequestRouteDestination{IntegrationID: r.UHD.IntegrationID, Overrides: r.UHD.Overrides}, + } +} + +func (b AdminRequestRouteBody) domain(id string) mediarequests.Route { + c := b.Conditions + return mediarequests.Route{ + ID: id, MediaType: mediarequests.MediaType(b.MediaType), Name: b.Name, Enabled: b.Enabled, SkipUHD: b.SkipUHD, + Conditions: mediarequests.RouteConditions{ + Anime: c.Anime, GenreIDs: c.GenreIDs, KeywordIDs: c.KeywordIDs, OriginalLanguages: c.OriginalLanguages, + OriginCountries: c.OriginCountries, YearFrom: c.YearFrom, YearTo: c.YearTo, NetworkIDs: c.NetworkIDs, + CompanyIDs: c.CompanyIDs, RequesterUserIDs: c.RequesterUserIDs, + }, + HD: mediarequests.RouteDestination{IntegrationID: b.HD.IntegrationID, Overrides: b.HD.Overrides}, + UHD: mediarequests.RouteDestination{IntegrationID: b.UHD.IntegrationID, Overrides: b.UHD.Overrides}, + } +} + +func routeTag(ctx context.Context, r mediarequests.Route) EntityTag { + return adminRequestTag(ctx, "route", r.ID, r.Revision) +} + +func (reg *Registry) listAdminRequestRoutes(ctx context.Context, _ *struct{}) (*AdminRequestRouteCollectionOutput, error) { + s, p := reg.adminRequestRouteService() + if p != nil { + return nil, p + } + routes, err := s.ListRoutesAdmin(ctx, adminRequestViewer(ctx)) + if err != nil { + return nil, requestProblem(err) + } + items := make([]AdminRequestRoute, 0, len(routes)) + for _, r := range routes { + items = append(items, adminRequestRouteOf(r)) + } + return &AdminRequestRouteCollectionOutput{Body: NewCollection(items)}, nil +} + +func (reg *Registry) getAdminRequestRoute(ctx context.Context, in *AdminRequestRouteIDInput) (*AdminRequestRouteOutput, error) { + s, p := reg.adminRequestRouteService() + if p != nil { + return nil, p + } + r, err := s.GetRoute(ctx, adminRequestViewer(ctx), in.ID) + if err != nil { + return nil, requestProblem(err) + } + return &AdminRequestRouteOutput{ETag: routeTag(ctx, *r).String(), Body: adminRequestRouteOf(*r)}, nil +} + +func (reg *Registry) createAdminRequestRoute(ctx context.Context, in *AdminRequestRouteCreateInput) (*AdminRequestRouteCreateOutput, error) { + s, p := reg.adminRequestRouteService() + if p != nil { + return nil, p + } + r, err := s.CreateRoute(ctx, adminRequestViewer(ctx), in.Body.domain("")) + if err != nil { + return nil, requestProblem(err) + } + return &AdminRequestRouteCreateOutput{Location: Prefix + "/admin/request-routes/" + r.ID, ETag: routeTag(ctx, *r).String(), Body: adminRequestRouteOf(*r)}, nil +} + +// currentRouteRevision reads the route an editor is replacing. A fallback +// that has never been saved reads as revision zero. +func (reg *Registry) currentRouteRevision(ctx context.Context, s adminRequestRoutes, id string) (EntityTag, int64, *Problem) { + current, err := s.GetRoute(ctx, adminRequestViewer(ctx), id) + if err != nil { + return EntityTag{}, 0, requestProblem(err) + } + return routeTag(ctx, *current), current.Revision, nil +} + +func (reg *Registry) updateAdminRequestRoute(ctx context.Context, in *AdminRequestRouteUpdateInput) (*AdminRequestRouteOutput, error) { + s, p := reg.adminRequestRouteService() + if p != nil { + return nil, p + } + tag, revision, p := reg.currentRouteRevision(ctx, s, in.ID) + if p != nil { + return nil, p + } + rev, p := adminRequestGuard(AdminRequestPreconditions{in.IfMatch, in.IfNoneMatch}, tag, revision) + if p != nil { + return nil, p + } + r, err := s.UpdateRouteConditional(ctx, adminRequestViewer(ctx), in.Body.domain(in.ID), rev) + if errors.Is(err, mediarequests.ErrStaleRevision) { + current, _, p := reg.currentRouteRevision(ctx, s, in.ID) + if p != nil { + return nil, p + } + return nil, NewProblem(TypePreconditionFailed, "The routing rule changed; reload before saving.").WithHeader("ETag", current.String()) + } + if err != nil { + return nil, requestProblem(err) + } + return &AdminRequestRouteOutput{ETag: routeTag(ctx, *r).String(), Body: adminRequestRouteOf(*r)}, nil +} + +func (reg *Registry) deleteAdminRequestRoute(ctx context.Context, in *AdminRequestRouteDeleteInput) (*struct{}, error) { + s, p := reg.adminRequestRouteService() + if p != nil { + return nil, p + } + current, err := s.GetRoute(ctx, adminRequestViewer(ctx), in.ID) + if err != nil { + return nil, requestProblem(err) + } + rev, p := adminRequestGuard(AdminRequestPreconditions{in.IfMatch, in.IfNoneMatch}, routeTag(ctx, *current), current.Revision) + if p != nil { + return nil, p + } + err = s.DeleteRouteConditional(ctx, adminRequestViewer(ctx), in.ID, rev) + if errors.Is(err, mediarequests.ErrStaleRevision) { + return nil, NewProblem(TypePreconditionFailed, "The routing rule changed; reload before deleting.") + } + if err != nil { + return nil, requestProblem(err) + } + return nil, nil +} + +func (reg *Registry) reorderAdminRequestRoutes(ctx context.Context, in *AdminRequestRouteReorderInput) (*AdminRequestRouteCollectionOutput, error) { + s, p := reg.adminRequestRouteService() + if p != nil { + return nil, p + } + routes, err := s.ReorderRoutes(ctx, adminRequestViewer(ctx), mediarequests.MediaType(in.Body.MediaType), in.Body.IDs) + if err != nil { + return nil, requestProblem(err) + } + items := make([]AdminRequestRoute, 0, len(routes)) + for _, r := range routes { + items = append(items, adminRequestRouteOf(r)) + } + return &AdminRequestRouteCollectionOutput{Body: NewCollection(items)}, nil +} + +func (reg *Registry) previewAdminRequestRoute(ctx context.Context, in *AdminRequestRoutePreviewInput) (*AdminRequestRoutePreviewOutput, error) { + s, p := reg.adminRequestRouteService() + if p != nil { + return nil, p + } + requester := 0 + if in.Body.RequesterUserID != nil { + id, err := strconv.Atoi(string(*in.Body.RequesterUserID)) + if err != nil || id <= 0 { + return nil, NewProblem(TypeValidationFailed, "The request did not pass validation; see errors."). + WithErrors(ProblemError{Location: "body.requester_user_id", Code: codeInvalid, Detail: "expected an account ID"}) + } + requester = id + } + preview, err := s.PreviewRoute(ctx, adminRequestViewer(ctx), mediarequests.MediaType(in.Body.MediaType), in.Body.TMDBID, requester) + if err != nil { + return nil, requestProblem(err) + } + out := new(AdminRequestRoutePreviewOutput) + f := preview.Facts + out.Body.Facts = AdminRequestRouteFacts{ + GenreIDs: NonNil(f.GenreIDs), KeywordIDs: NonNil(f.KeywordIDs), OriginalLanguage: f.OriginalLanguage, + OriginCountries: NonNil(f.OriginCountries), Year: f.Year, NetworkIDs: NonNil(f.NetworkIDs), + CompanyIDs: NonNil(f.CompanyIDs), Anime: f.Anime, + } + out.Body.Tiers = make([]AdminRequestRoutePreviewTier, 0, len(preview.Tiers)) + for _, t := range preview.Tiers { + out.Body.Tiers = append(out.Body.Tiers, AdminRequestRoutePreviewTier{ + Quality: string(t.Quality), RouteID: t.RouteID, RouteName: t.RouteName, IntegrationID: t.IntegrationID, + IntegrationName: t.IntegrationName, Overrides: t.Overrides, Note: t.Reason, + }) + } + return out, nil +} diff --git a/internal/apiv2/admin_request_routes_test.go b/internal/apiv2/admin_request_routes_test.go new file mode 100644 index 0000000000..a7c8a063d7 --- /dev/null +++ b/internal/apiv2/admin_request_routes_test.go @@ -0,0 +1,180 @@ +package apiv2 + +import ( + "context" + "net/http" + "testing" + + mediarequests "github.com/Silo-Server/silo-server/internal/requests" +) + +// fakeRouteAdmin adds route administration to the admin request fake. +type fakeRouteAdmin struct { + *fakeAdminRequests + routes map[string]mediarequests.Route + order []string + preview mediarequests.RoutePreview + lastSave mediarequests.Route +} + +func fixtureRouteAdmin() *fakeRouteAdmin { + fallback := mediarequests.Route{ID: "fallback-movie", MediaType: mediarequests.MediaTypeMovie, Position: 1000, Name: "Everything else", + Enabled: true, IsFallback: true, HD: mediarequests.RouteDestination{IntegrationID: "radarr"}, Revision: 4} + anime := mediarequests.Route{ID: "r-anime", MediaType: mediarequests.MediaTypeMovie, Position: 0, Name: "Anime", Enabled: true, + Conditions: mediarequests.RouteConditions{Anime: new(true)}, + HD: mediarequests.RouteDestination{IntegrationID: "radarr-anime", Overrides: map[string]any{"root_folder": "/anime"}}, Revision: 5} + return &fakeRouteAdmin{ + fakeAdminRequests: fixtureAdminRequests(), + routes: map[string]mediarequests.Route{fallback.ID: fallback, anime.ID: anime}, + order: []string{anime.ID, fallback.ID}, + } +} + +func (f *fakeRouteAdmin) ListRoutesAdmin(_ context.Context, v mediarequests.Viewer) ([]mediarequests.Route, error) { + f.viewer = v + out := make([]mediarequests.Route, 0, len(f.order)) + for _, id := range f.order { + out = append(out, f.routes[id]) + } + return out, nil +} + +func (f *fakeRouteAdmin) GetRoute(_ context.Context, _ mediarequests.Viewer, id string) (*mediarequests.Route, error) { + r, ok := f.routes[id] + if !ok { + return nil, mediarequests.ErrNotFound + } + return &r, nil +} + +func (f *fakeRouteAdmin) CreateRoute(_ context.Context, _ mediarequests.Viewer, r mediarequests.Route) (*mediarequests.Route, error) { + f.writes++ + r.ID, r.Revision = "r-new", 1 + f.routes[r.ID] = r + return &r, nil +} + +func (f *fakeRouteAdmin) UpdateRouteConditional(_ context.Context, _ mediarequests.Viewer, r mediarequests.Route, expected int64) (*mediarequests.Route, error) { + current := f.routes[r.ID] + if expected != -1 && expected != current.Revision { + return nil, mediarequests.ErrStaleRevision + } + f.writes++ + f.lastSave = r + r.Revision = current.Revision + 1 + r.IsFallback, r.MediaType, r.Position = current.IsFallback, current.MediaType, current.Position + f.routes[r.ID] = r + return &r, nil +} + +func (f *fakeRouteAdmin) DeleteRouteConditional(_ context.Context, _ mediarequests.Viewer, id string, expected int64) error { + if f.routes[id].IsFallback { + return &mediarequests.ValidationError{FormError: "The default destination cannot be deleted; clear its servers instead."} + } + if expected != f.routes[id].Revision { + return mediarequests.ErrStaleRevision + } + f.writes++ + delete(f.routes, id) + return nil +} + +func (f *fakeRouteAdmin) ReorderRoutes(ctx context.Context, v mediarequests.Viewer, _ mediarequests.MediaType, ids []string) ([]mediarequests.Route, error) { + f.order = append(append([]string(nil), ids...), "fallback-movie") + return f.ListRoutesAdmin(ctx, v) +} + +func (f *fakeRouteAdmin) PreviewRoute(context.Context, mediarequests.Viewer, mediarequests.MediaType, int, int) (*mediarequests.RoutePreview, error) { + return &f.preview, nil +} + +func routeAdminHandler(f *fakeRouteAdmin) http.Handler { + deps := requestDeps(fixtureRequests()) + deps.AdminRequests = f + return NewHandler(deps) +} + +const routeBody = `{"name":"Anime","enabled":true,"conditions":{"anime":true,"original_languages":["ja"]},"hd":{"integration_id":"radarr-anime","overrides":{"root_folder":"/anime"}},"uhd":{},"skip_uhd":true}` + +func TestAdminRequestRoutesListAndGuards(t *testing.T) { + f := fixtureRouteAdmin() + h := routeAdminHandler(f) + base := Prefix + "/admin/request-routes" + + requireProblem(t, do(t, h, http.MethodGet, base, "", requestOwner), TypePermissionDenied) + var list struct { + Items []AdminRequestRoute `json:"items"` + } + rec := do(t, h, http.MethodGet, base, "", actingRequestAdmin) + decodeBody(t, rec.Body, &list) + if rec.Code != 200 || len(list.Items) != 2 || list.Items[0].Name != "Anime" || !list.Items[1].IsFallback || + list.Items[0].HD.Overrides["root_folder"] != "/anime" { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } + + read := do(t, h, http.MethodGet, base+"/r-anime", "", actingRequestAdmin) + tag := read.Header().Get("ETag") + if read.Code != 200 || tag == "" { + t.Fatal(read.Code, read.Body.String()) + } + requireProblem(t, do(t, h, http.MethodPut, base+"/r-anime", routeBody, actingRequestAdmin), TypePreconditionRequired) + requireProblem(t, do(t, h, http.MethodPut, base+"/r-anime", routeBody, with(actingRequestAdmin, "If-Match", `"stale"`)), TypePreconditionFailed) + if f.writes != 0 { + t.Fatal("a guarded write took effect") + } + saved := do(t, h, http.MethodPut, base+"/r-anime", routeBody, with(actingRequestAdmin, "If-Match", tag)) + if saved.Code != 200 || saved.Header().Get("ETag") == tag || !f.lastSave.SkipUHD || + f.lastSave.Conditions.OriginalLanguages[0] != "ja" || f.lastSave.HD.IntegrationID != "radarr-anime" { + t.Fatalf("%d %s saved=%+v", saved.Code, saved.Body.String(), f.lastSave) + } + + fallback := do(t, h, http.MethodGet, base+"/fallback-movie", "", actingRequestAdmin) + requireProblem(t, do(t, h, http.MethodDelete, base+"/fallback-movie", "", with(actingRequestAdmin, "If-Match", fallback.Header().Get("ETag"))), TypeValidationFailed) + current := do(t, h, http.MethodGet, base+"/r-anime", "", actingRequestAdmin) + deleted := do(t, h, http.MethodDelete, base+"/r-anime", "", with(actingRequestAdmin, "If-Match", current.Header().Get("ETag"))) + if deleted.Code != http.StatusNoContent { + t.Fatal(deleted.Code, deleted.Body.String()) + } +} + +func TestAdminRequestRoutesCreateReorderPreview(t *testing.T) { + f := fixtureRouteAdmin() + h := routeAdminHandler(f) + base := Prefix + "/admin/request-routes" + + created := do(t, h, http.MethodPost, base, `{"media_type":"movie","name":"80s","enabled":true,"conditions":{"year_from":1980,"year_to":1989},"hd":{"integration_id":"radarr-retro"},"uhd":{},"skip_uhd":false}`, actingRequestAdmin) + if created.Code != http.StatusCreated || created.Header().Get("Location") != base+"/r-new" || created.Header().Get("ETag") == "" { + t.Fatal(created.Code, created.Body.String()) + } + if r := f.routes["r-new"]; r.MediaType != mediarequests.MediaTypeMovie || r.Conditions.YearFrom != 1980 { + t.Fatalf("created = %+v", r) + } + + var reordered struct { + Items []AdminRequestRoute `json:"items"` + } + rec := do(t, h, http.MethodPost, base+"/order", `{"media_type":"movie","ids":["r-new","r-anime"]}`, actingRequestAdmin) + decodeBody(t, rec.Body, &reordered) + if rec.Code != 200 || len(reordered.Items) != 3 || reordered.Items[0].ID != "r-new" { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } + + f.preview = mediarequests.RoutePreview{ + Facts: mediarequests.RoutingFacts{Anime: true, OriginalLanguage: "ja", Year: 2001}, + Tiers: []mediarequests.RoutePreviewTier{ + {Quality: mediarequests.Quality1080p, RouteID: "r-anime", RouteName: "Anime", IntegrationID: "radarr-anime", IntegrationName: "Radarr Anime"}, + {Quality: mediarequests.Quality2160p, Reason: "No rule sends 4K for this title."}, + }, + } + var preview struct { + Facts AdminRequestRouteFacts `json:"facts"` + Tiers []AdminRequestRoutePreviewTier `json:"tiers"` + } + rec = do(t, h, http.MethodPost, base+"/preview", `{"media_type":"movie","tmdb_id":129,"requester_user_id":"7"}`, actingRequestAdmin) + decodeBody(t, rec.Body, &preview) + if rec.Code != 200 || !preview.Facts.Anime || preview.Facts.GenreIDs == nil || len(preview.Tiers) != 2 || + preview.Tiers[0].IntegrationName != "Radarr Anime" || preview.Tiers[1].Note == "" { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } + requireProblem(t, do(t, h, http.MethodPost, base+"/preview", `{"media_type":"movie","tmdb_id":129,"requester_user_id":"x"}`, actingRequestAdmin), TypeValidationFailed) +} diff --git a/internal/apiv2/document.go b/internal/apiv2/document.go index 52fc4a8bfd..7fb159014f 100644 --- a/internal/apiv2/document.go +++ b/internal/apiv2/document.go @@ -682,6 +682,7 @@ func registerAll(reg *Registry) { registerRecommendations(reg) registerRequests(reg) registerAdminRequests(reg) + registerAdminRequestRoutes(reg) registerRequestLifecycle(reg, reg.deps.RequestLifecycle, reg.deps.WatchProviders) registerHistoryImports(reg) registerAdminHistoryImports(reg) diff --git a/internal/apiv2/document_test.go b/internal/apiv2/document_test.go index f768d65321..8ebcbd93f5 100644 --- a/internal/apiv2/document_test.go +++ b/internal/apiv2/document_test.go @@ -302,7 +302,7 @@ func TestGeneratedDocumentStatuses(t *testing.T) { for _, id := range append(requestOperationIDs, requestLifecycleOperationIDs...) { profileToken[id] = true } - for _, id := range adminRequestOperationIDs { + for _, id := range append(adminRequestOperationIDs, adminRequestRouteOperationIDs...) { profileToken[id] = true } expect[opCreateRequest] = map[int]bool{http.StatusCreated: true, http.StatusConflict: true, http.StatusTooManyRequests: true, http.StatusNotFound: true} diff --git a/internal/contractledger/ledger_test.go b/internal/contractledger/ledger_test.go index ca7835d0d4..51f70c401f 100644 --- a/internal/contractledger/ledger_test.go +++ b/internal/contractledger/ledger_test.go @@ -878,7 +878,10 @@ func TestConcurrencyMarkingIsRestricted(t *testing.T) { // legacy route and so have no ledger row to mark, each with the reason. It // is empty today; the reconcile test refuses an unmapped guarded operation // that is not listed here. -var guardedWithoutLegacyRow = map[string]string{} +var guardedWithoutLegacyRow = map[string]string{ + "updateRequestRoute": "V2-only request routing rule: the rule's revision from request_editor_revision_seq is its ETag.", + "deleteRequestRoute": "V2-only request routing rule: deletion is guarded by the rule's revision.", +} // TestGuardedOperationsAreMarkedIfMatch reconciles the v2 registry with the // ledger: every operation registered Guarded must have each legacy row that @@ -1208,6 +1211,11 @@ func TestRetrySafetyMismatchesFire(t *testing.T) { // applies to concurrency. var mutationWithoutLegacyRow = map[string]string{ "transferAdminUserOwnership": "V2-only server ownership transfer (issue #1382): v1 had no Owner. Replaying a completed transfer is refused because the caller is no longer the Owner, so it cannot move ownership twice.", + "createRequestRoute": "V2-only request routing rule (routing replaced the router plugin's per-connection default switches). Creating a rule is non-retryable: a replay adds a second rule.", + "updateRequestRoute": "V2-only request routing rule replacement, guarded by If-Match on the rule's revision; a replay after success answers 412.", + "deleteRequestRoute": "V2-only request routing rule deletion, guarded by If-Match on the rule's revision; a replay finds no rule.", + "reorderRequestRoutes": "V2-only reorder of a media type's routing rules. The body names the full order, so a replay sets the same positions; it is non-retryable because it moves every rule to a new revision.", + "previewRequestRoute": "V2-only read-only route preview (POST for the request body). It reads TMDB and the rules and writes nothing, so a replay returns the same answer.", "followRequestMedia": "V2-only title follow (Requests acceptance AC1/AC5): v1 had no way to follow a title someone else requested. The follow row is keyed by title and profile, so a replay converges on the same follow.", "unfollowRequestMedia": "V2-only title unfollow, the inverse of followRequestMedia. Deleting an absent follow is a no-op, so a replay converges on no follow.", "createAdminUserPasswordReset": "V2-only password reset link issue (issue #1442): v1 had no reset links. Each call replaces the account's single live link, so a replay only supersedes the previous link; it is non-retryable because an emailed link may already have been delivered.", diff --git a/internal/metadata/tmdb/client.go b/internal/metadata/tmdb/client.go index a2977dee71..b8e648aa28 100644 --- a/internal/metadata/tmdb/client.go +++ b/internal/metadata/tmdb/client.go @@ -3,6 +3,7 @@ package tmdb import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -51,6 +52,10 @@ type Client struct { // NewClient creates a TMDB API client with the given API key and rate limit // (requests per second). If apiKey is empty, Silo's public project API key is // used. + +// ErrNotFound is wrapped by errors for titles TMDB does not have (HTTP 404). +var ErrNotFound = errors.New("tmdb: not found") + func NewClient(apiKey string, rateLimit int) *Client { apiKey = strings.TrimSpace(apiKey) if apiKey == "" { @@ -149,11 +154,17 @@ func (c *Client) doGet(ctx context.Context, path string, dest any) error { if resp.StatusCode >= 400 { body, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody)) resp.Body.Close() + // A 404 wraps ErrNotFound so callers can tell a missing title + // from TMDB being unreachable. + var notFound error + if resp.StatusCode == http.StatusNotFound { + notFound = ErrNotFound + } var apiErr apiError if err := json.Unmarshal(body, &apiErr); err == nil && apiErr.StatusMessage != "" { - return fmt.Errorf("tmdb: HTTP %d: %s", resp.StatusCode, apiErr.StatusMessage) + return errors.Join(notFound, fmt.Errorf("tmdb: HTTP %d: %s", resp.StatusCode, apiErr.StatusMessage)) } - return fmt.Errorf("tmdb: HTTP %d", resp.StatusCode) + return errors.Join(notFound, fmt.Errorf("tmdb: HTTP %d", resp.StatusCode)) } decodeErr := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(dest) diff --git a/internal/metadata/tmdb/client_test.go b/internal/metadata/tmdb/client_test.go index a69e6625f0..af58fafdab 100644 --- a/internal/metadata/tmdb/client_test.go +++ b/internal/metadata/tmdb/client_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "slices" + "strings" "sync/atomic" "testing" "time" @@ -1173,3 +1174,17 @@ func TestDetailCarriesRoutingIdentifiers(t *testing.T) { t.Fatalf("series = %+v", s) } } + +func TestNotFoundWrapsErrNotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"status_message":"The resource you requested could not be found."}`)) + })) + defer srv.Close() + client := NewClient("key", 40) + client.baseURL = srv.URL + _, err := client.GetMediaDetail(context.Background(), "movie", 1) + if !errors.Is(err, ErrNotFound) || !strings.Contains(err.Error(), "HTTP 404") { + t.Fatalf("err = %v, want ErrNotFound with the HTTP detail", err) + } +} diff --git a/internal/requests/editor_concurrency.go b/internal/requests/editor_concurrency.go index d3fe74f233..6ce58a64bf 100644 --- a/internal/requests/editor_concurrency.go +++ b/internal/requests/editor_concurrency.go @@ -193,6 +193,9 @@ func (s *Service) UpdateIntegrationConditional(ctx context.Context, v Viewer, in if err = validateInstance(&in); err != nil { return nil, err } + if err = s.ensureRoutesKeepServerKind(ctx, in); err != nil { + return nil, err + } if err = s.validateViaPlugin(ctx, in); err != nil { return nil, err } diff --git a/internal/requests/routes_admin.go b/internal/requests/routes_admin.go new file mode 100644 index 0000000000..6d144ff11e --- /dev/null +++ b/internal/requests/routes_admin.go @@ -0,0 +1,630 @@ +package requests + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "regexp" + "slices" + "strings" + "unicode/utf8" + + "github.com/jackc/pgx/v5" + + "github.com/Silo-Server/silo-server/internal/idgen" + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" +) + +// Route administration. Every media type has one fallback route, with the +// fixed ID FallbackRouteID(mediaType): saving it creates it, it takes no +// conditions, and it cannot be deleted. The other routes are ordered rules +// that must narrow (at least one condition) and must do something (a +// destination, or skip 4K). + +// maxRoutesPerMediaType bounds the rules one media type can have, keeping the +// route list a small bounded collection. +const maxRoutesPerMediaType = 100 + +// FallbackRouteID is the ID of a media type's fallback route. +func FallbackRouteID(mediaType MediaType) string { return "fallback-" + string(mediaType) } + +// routingOwnedConfigKeys are the plugin config keys routing sets itself; a +// route cannot override them. +var routingOwnedConfigKeys = []string{ + configServiceKind, configIsDefault, configIsDefault4K, configIs4K, + configAnimeEnabled, "anime_root_folder", "anime_quality_profile_id", "anime_tags", +} + +var ( + languageCode = regexp.MustCompile(`^[a-z]{2,3}$`) + countryCode = regexp.MustCompile(`^[A-Z]{2}$`) +) + +// RoutePreview is how the routes would send one title right now. +type RoutePreview struct { + Facts RoutingFacts + Tiers []RoutePreviewTier +} + +// RoutePreviewTier is one quality tier's outcome. RouteID is empty when no +// route sends the tier anywhere; Reason then says why. +type RoutePreviewTier struct { + Quality Quality + RouteID string + RouteName string + IntegrationID string + IntegrationName string + Overrides map[string]any + Reason string +} + +func (s *Service) routeStore() (RouteStore, error) { + store, ok := s.store.(RouteStore) + if !ok { + return nil, fmt.Errorf("request store does not support route administration") + } + return store, nil +} + +// RouteStore is implemented by the PostgreSQL repository. +type RouteStore interface { + GetRoute(ctx context.Context, id string) (*Route, error) + SaveRouteConditional(ctx context.Context, route Route, expected int64) (*Route, error) + DeleteRouteConditional(ctx context.Context, id string, expected int64) error + ReorderRoutes(ctx context.Context, mediaType MediaType, ids []string) error +} + +// unsavedFallback is a media type's fallback before its first save: revision +// zero, no destinations. Administration shows it so it can be edited; routing +// never sees it. +func unsavedFallback(mediaType MediaType) Route { + return Route{ID: FallbackRouteID(mediaType), MediaType: mediaType, Position: 1000, Name: fallbackRouteName, Enabled: true, IsFallback: true} +} + +// ListRoutesAdmin returns every route, in evaluation order per media type, +// with each media type's fallback (unsaved if it never was). +func (s *Service) ListRoutesAdmin(ctx context.Context, viewer Viewer) ([]Route, error) { + if !viewer.IsAdmin { + return nil, ErrForbidden + } + routes, err := s.store.ListRoutes(ctx) + if err != nil { + return nil, err + } + var out []Route + for _, mediaType := range []MediaType{MediaTypeMovie, MediaTypeSeries} { + var ofType []Route + hasFallback := false + for _, route := range routes { + if route.MediaType == mediaType { + ofType = append(ofType, route) + hasFallback = hasFallback || route.IsFallback + } + } + if !hasFallback { + ofType = append(ofType, unsavedFallback(mediaType)) + } + out = append(out, orderRoutes(ofType)...) + } + return out, nil +} + +// GetRoute returns one route; a fallback that was never saved comes back +// unsaved (revision zero). +func (s *Service) GetRoute(ctx context.Context, viewer Viewer, id string) (*Route, error) { + if !viewer.IsAdmin { + return nil, ErrForbidden + } + store, err := s.routeStore() + if err != nil { + return nil, err + } + id = strings.TrimSpace(id) + route, err := store.GetRoute(ctx, id) + if errors.Is(err, ErrNotFound) { + for _, mediaType := range []MediaType{MediaTypeMovie, MediaTypeSeries} { + if id == FallbackRouteID(mediaType) { + fallback := unsavedFallback(mediaType) + return &fallback, nil + } + } + } + return route, err +} + +// CreateRoute adds a rule after the media type's existing rules. +func (s *Service) CreateRoute(ctx context.Context, viewer Viewer, route Route) (*Route, error) { + if !viewer.IsAdmin { + return nil, ErrForbidden + } + store, err := s.routeStore() + if err != nil { + return nil, err + } + id, err := idgen.NextID() + if err != nil { + return nil, err + } + route.ID = id + route.IsFallback = false + if err := s.validateRoute(ctx, &route); err != nil { + return nil, err + } + routes, err := s.store.ListRoutes(ctx) + if err != nil { + return nil, err + } + // The first rule switches the media type from the plugin's routing to + // Silo's, where a title no rule matches goes to the fallback. Without a + // fallback HD server those titles would have nowhere to go. + fallbackReady := false + rules := 0 + for _, existing := range routes { + if existing.MediaType != route.MediaType { + continue + } + if existing.IsFallback { + fallbackReady = existing.HD.IntegrationID != "" + continue + } + rules++ + if existing.Position >= route.Position { + route.Position = existing.Position + 1 + } + } + if rules >= maxRoutesPerMediaType { + return nil, &ValidationError{FormError: fmt.Sprintf("A media type can have at most %d rules.", maxRoutesPerMediaType)} + } + if !fallbackReady { + return nil, &ValidationError{FormError: "Choose the default server for " + mediaTypePlural(route.MediaType) + " before adding rules; titles no rule matches go there."} + } + return store.SaveRouteConditional(ctx, route, 0) +} + +// UpdateRouteConditional replaces a route. The fallback route is created on +// its first save (expected 0). Position is kept; ReorderRoutes changes it. +func (s *Service) UpdateRouteConditional(ctx context.Context, viewer Viewer, route Route, expected int64) (*Route, error) { + if !viewer.IsAdmin { + return nil, ErrForbidden + } + store, err := s.routeStore() + if err != nil { + return nil, err + } + route.IsFallback = route.ID == FallbackRouteID(MediaTypeMovie) || route.ID == FallbackRouteID(MediaTypeSeries) + current, err := store.GetRoute(ctx, route.ID) + switch { + case errors.Is(err, ErrNotFound) && route.IsFallback: + route.MediaType = MediaType(strings.TrimPrefix(route.ID, "fallback-")) + route.Position = 1000 + case err != nil: + return nil, err + default: + route.MediaType = current.MediaType + route.Position = current.Position + } + if err := s.validateRoute(ctx, &route); err != nil { + return nil, err + } + return store.SaveRouteConditional(ctx, route, expected) +} + +func (s *Service) DeleteRouteConditional(ctx context.Context, viewer Viewer, id string, expected int64) error { + if !viewer.IsAdmin { + return ErrForbidden + } + store, err := s.routeStore() + if err != nil { + return err + } + current, err := store.GetRoute(ctx, strings.TrimSpace(id)) + if err != nil { + return err + } + if current.IsFallback { + return &ValidationError{FormError: "The default destination cannot be deleted; change its servers instead."} + } + return store.DeleteRouteConditional(ctx, current.ID, expected) +} + +// ReorderRoutes sets the evaluation order of a media type's rules and returns +// its routes in the new order. ids must list every rule of the media type +// exactly once; the fallback always stays last and is not listed. +func (s *Service) ReorderRoutes(ctx context.Context, viewer Viewer, mediaType MediaType, ids []string) ([]Route, error) { + if !viewer.IsAdmin { + return nil, ErrForbidden + } + store, err := s.routeStore() + if err != nil { + return nil, err + } + mediaType, err = normalizeMediaType(mediaType) + if err != nil { + return nil, err + } + routes, err := s.store.ListRoutes(ctx) + if err != nil { + return nil, err + } + var want []string + for _, route := range routes { + if route.MediaType == mediaType && !route.IsFallback { + want = append(want, route.ID) + } + } + got := slices.Clone(ids) + slices.Sort(want) + slices.Sort(got) + if !slices.Equal(want, got) { + return nil, &ValidationError{FormError: "The order must list every rule for this media type exactly once; reload and try again."} + } + if err := store.ReorderRoutes(ctx, mediaType, ids); err != nil { + return nil, err + } + all, err := s.ListRoutesAdmin(ctx, viewer) + if err != nil { + return nil, err + } + return slices.DeleteFunc(all, func(r Route) bool { return r.MediaType != mediaType }), nil +} + +// PreviewRoute shows how the routes would send a title for a requester now, +// from TMDB's current facts. Both tiers are shown regardless of the +// requester's 4K entitlement. +func (s *Service) PreviewRoute(ctx context.Context, viewer Viewer, mediaType MediaType, tmdbID, requesterUserID int) (*RoutePreview, error) { + if !viewer.IsAdmin { + return nil, ErrForbidden + } + mediaType, err := normalizeMediaType(mediaType) + if err != nil { + return nil, err + } + if tmdbID <= 0 { + return nil, fmt.Errorf("%w: tmdb id is required", ErrInvalidInput) + } + if s.tmdb == nil { + return nil, ErrIntegrationUnreachable + } + detail, err := s.tmdb.GetMediaDetail(ctx, tmdbMediaType(mediaType), tmdbID) + switch { + case errors.Is(err, tmdb.ErrNotFound) || (err == nil && detail == nil): + return nil, ErrNotFound + case err != nil: + // TMDB is down or refusing: a dependency failure, worth retrying. + return nil, fmt.Errorf("%w: %w", ErrIntegrationUnreachable, err) + } + fc, err := s.newFulfillContext(ctx) + if err != nil { + return nil, err + } + req := Request{MediaType: mediaType, TMDBID: tmdbID, RequestedByUserID: requesterUserID, RoutingFacts: routingFactsFrom(detail, s.now())} + routes := fc.routesFor(mediaType) + qualities := []Quality{Quality1080p, Quality2160p} + decisions := decideRoutes(routes, req, qualities) + preview := &RoutePreview{Facts: req.RoutingFacts} + for _, q := range qualities { + tier := RoutePreviewTier{Quality: q} + decision, ok := decisions[q] + switch { + case len(routes) == 0: + tier.Reason = "No routes are set up for this media type, so the request plugin picks the server." + case !ok: + tier.Reason = "No rule sends " + qualityLabel(q) + " for this title." + case decision.Skip: + tier.RouteID, tier.RouteName = decision.RouteID, decision.RouteName + tier.Reason = decision.RouteName + " skips 4K for this title." + default: + tier.RouteID, tier.RouteName = decision.RouteID, decision.RouteName + tier.IntegrationID, tier.Overrides = decision.IntegrationID, decision.Overrides + if in := integrationByID(fc, decision.IntegrationID); in != nil { + tier.IntegrationName = in.Name + if !in.Enabled { + tier.Reason = in.Name + " is disabled, so this tier would fail." + } else if kind := serverKindMismatch(*in, mediaType); kind != "" { + tier.Reason = fmt.Sprintf("%s is a %s server, so this tier would fail.", in.Name, kind) + } + } + } + preview.Tiers = append(preview.Tiers, tier) + } + return preview, nil +} + +// serverKindMismatch returns a server's type when it cannot take the media +// type (a Sonarr server for movies, a Radarr server for series), and "" when +// it can or does not say. +func serverKindMismatch(in Integration, mediaType MediaType) string { + kind, _ := in.PluginConfig[configServiceKind].(string) + want := map[MediaType]string{MediaTypeMovie: kindRadarr, MediaTypeSeries: kindSonarr}[mediaType] + if kind == "" || want == "" || kind == want { + return "" + } + return kind +} + +// ensureRoutesKeepServerKind refuses to switch a server to a type the routes +// sending to it cannot use: their requests would fail when sent. +func (s *Service) ensureRoutesKeepServerKind(ctx context.Context, in Integration) error { + routes, err := s.store.ListRoutes(ctx) + if err != nil { + return err + } + var names []string + for _, r := range routes { + if r.HD.IntegrationID != in.ID && r.UHD.IntegrationID != in.ID { + continue + } + if serverKindMismatch(in, r.MediaType) != "" { + names = append(names, r.Name) + } + } + if len(names) == 0 { + return nil + } + return &ValidationError{FieldErrors: map[string]string{ + "plugin_config." + configServiceKind: "Routing sends requests of the other media type to this server (" + + strings.Join(names, ", ") + "); change those routes first.", + }} +} + +// validateRoute normalizes a route and checks it against the configured +// servers, answering a ValidationError keyed by field. +func (s *Service) validateRoute(ctx context.Context, route *Route) error { + fields := map[string]string{} + route.Name = strings.TrimSpace(route.Name) + if route.IsFallback && route.Name == "" { + route.Name = fallbackRouteName + } + switch { + case route.Name == "": + fields["name"] = "Give the rule a name." + case utf8.RuneCountInString(route.Name) > 100: + fields["name"] = "Keep the name under 100 characters." + } + mediaType, err := normalizeMediaType(route.MediaType) + if err != nil { + fields["media_type"] = "Choose movies or series." + } + route.MediaType = mediaType + route.Conditions = normalizeConditions(route.Conditions) + validateConditions(route.Conditions, fields) + + integrations, err := s.store.ListIntegrations(ctx) + if err != nil { + return err + } + for field, dest := range map[string]*RouteDestination{"hd": &route.HD, "uhd": &route.UHD} { + validateDestination(field, dest, route.MediaType, integrations, fields) + } + if route.IsFallback { + route.Enabled = true + route.SkipUHD = false + if !conditionsEmpty(route.Conditions) { + fields["conditions"] = "The default destination applies to everything; it takes no conditions." + } + // A saved fallback moves the media type to Silo's routing; without an + // HD server, every title no rule matches would fail. + if route.HD.IntegrationID == "" && fields["hd.integration_id"] == "" { + fields["hd.integration_id"] = "Choose the server titles no rule matches go to." + } + } else { + if conditionsEmpty(route.Conditions) { + fields["conditions"] = "Add at least one condition; the default destination handles everything else." + } + if route.HD.IntegrationID == "" && route.UHD.IntegrationID == "" && !route.SkipUHD { + fields["hd"] = "Choose a server for HD or 4K, or skip 4K." + } + if route.SkipUHD && route.UHD.IntegrationID != "" { + fields["uhd"] = "A rule that skips 4K cannot also send 4K somewhere." + } + } + if len(fields) > 0 { + return &ValidationError{FieldErrors: fields} + } + return nil +} + +func validateDestination(field string, dest *RouteDestination, mediaType MediaType, integrations []Integration, fields map[string]string) { + dest.IntegrationID = strings.TrimSpace(dest.IntegrationID) + if dest.IntegrationID == "" { + dest.Overrides = nil + return + } + var in *Integration + for i := range integrations { + if integrations[i].ID == dest.IntegrationID { + in = &integrations[i] + } + } + if in == nil { + fields[field+".integration_id"] = "That server no longer exists." + return + } + if kind, _ := in.PluginConfig[configServiceKind].(string); kind != "" { + wantKind := map[MediaType]string{MediaTypeMovie: kindRadarr, MediaTypeSeries: kindSonarr}[mediaType] + if wantKind != "" && kind != wantKind { + fields[field+".integration_id"] = fmt.Sprintf("%s is a %s server; %s go to %s.", in.Name, kind, mediaTypePlural(mediaType), wantKind) + } + } + for _, key := range routingOwnedConfigKeys { + if _, ok := dest.Overrides[key]; ok { + fields[field+".overrides."+key] = "Routing sets " + key + " itself." + } + } + if len(dest.Overrides) == 0 { + dest.Overrides = nil + } +} + +func mediaTypePlural(mediaType MediaType) string { + if mediaType == MediaTypeSeries { + return string(MediaTypeSeries) + } + return "movies" +} + +func normalizeConditions(c RouteConditions) RouteConditions { + lower := func(values []string) []string { + var out []string + for _, v := range values { + if v = strings.ToLower(strings.TrimSpace(v)); v != "" && !slices.Contains(out, v) { + out = append(out, v) + } + } + return out + } + upper := func(values []string) []string { + var out []string + for _, v := range values { + if v = strings.ToUpper(strings.TrimSpace(v)); v != "" && !slices.Contains(out, v) { + out = append(out, v) + } + } + return out + } + ids := func(values []int) []int { + out := slices.Clone(values) + slices.Sort(out) + return slices.Compact(out) + } + c.GenreIDs, c.KeywordIDs = ids(c.GenreIDs), ids(c.KeywordIDs) + c.NetworkIDs, c.CompanyIDs = ids(c.NetworkIDs), ids(c.CompanyIDs) + c.RequesterUserIDs = ids(c.RequesterUserIDs) + c.OriginalLanguages, c.OriginCountries = lower(c.OriginalLanguages), upper(c.OriginCountries) + return c +} + +func validateConditions(c RouteConditions, fields map[string]string) { + for field, values := range map[string][]int{ + "genre_ids": c.GenreIDs, "keyword_ids": c.KeywordIDs, "network_ids": c.NetworkIDs, + "company_ids": c.CompanyIDs, "requester_user_ids": c.RequesterUserIDs, + } { + for _, v := range values { + if v <= 0 { + fields["conditions."+field] = "IDs must be positive." + break + } + } + } + for _, v := range c.OriginalLanguages { + if !languageCode.MatchString(v) { + fields["conditions.original_languages"] = "Use ISO 639-1 language codes such as ja or en." + break + } + } + for _, v := range c.OriginCountries { + if !countryCode.MatchString(v) { + fields["conditions.origin_countries"] = "Use ISO 3166-1 country codes such as JP or US." + break + } + } + for field, year := range map[string]int{"year_from": c.YearFrom, "year_to": c.YearTo} { + if year != 0 && (year < 1870 || year > 2200) { + fields["conditions."+field] = "Use a year between 1870 and 2200." + } + } + if c.YearFrom != 0 && c.YearTo != 0 && c.YearFrom > c.YearTo { + fields["conditions.year_to"] = "The end year comes before the start year." + } +} + +func conditionsEmpty(c RouteConditions) bool { + return c.Anime == nil && len(c.GenreIDs) == 0 && len(c.KeywordIDs) == 0 && len(c.OriginalLanguages) == 0 && + len(c.OriginCountries) == 0 && c.YearFrom == 0 && c.YearTo == 0 && len(c.NetworkIDs) == 0 && + len(c.CompanyIDs) == 0 && len(c.RequesterUserIDs) == 0 +} + +func (r *Repository) GetRoute(ctx context.Context, id string) (*Route, error) { + route, err := scanRoute(r.pool.QueryRow(ctx, `SELECT `+routeColumns+` FROM request_routes WHERE id = $1`, id)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return &route, nil +} + +// SaveRouteConditional inserts or replaces a route. expected is the revision +// the editor read: zero creates, -1 overwrites whatever is there. +func (r *Repository) SaveRouteConditional(ctx context.Context, route Route, expected int64) (*Route, error) { + conditions, err := json.Marshal(route.Conditions) + if err != nil { + return nil, err + } + hdOverrides, err := json.Marshal(nonNilOverrides(route.HD.Overrides)) + if err != nil { + return nil, err + } + uhdOverrides, err := json.Marshal(nonNilOverrides(route.UHD.Overrides)) + if err != nil { + return nil, err + } + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.ReadCommitted}) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback(ctx) }() + // Only a create (expected 0) or the fallback's first save may find no + // row; a rule deleted under an editor must not come back. + allowMissing := expected == 0 || route.IsFallback + if err := lockRevision(ctx, tx, `SELECT revision FROM request_routes WHERE id = $1 FOR UPDATE`, []any{route.ID}, expected, allowMissing); err != nil { + return nil, err + } + // A missing row cannot be locked, so two first saves of the fallback can + // both get here; the revision predicate lets only one of them win. + saved, err := scanRoute(tx.QueryRow(ctx, ` + INSERT INTO request_routes (id, media_type, position, name, enabled, is_fallback, conditions, + hd_integration_id, hd_overrides, uhd_integration_id, uhd_overrides, skip_uhd) + VALUES ($1, $2, $3, $4, $5, $6, $7, nullif($8, ''), $9, nullif($10, ''), $11, $12) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, enabled = EXCLUDED.enabled, conditions = EXCLUDED.conditions, + hd_integration_id = EXCLUDED.hd_integration_id, hd_overrides = EXCLUDED.hd_overrides, + uhd_integration_id = EXCLUDED.uhd_integration_id, uhd_overrides = EXCLUDED.uhd_overrides, + skip_uhd = EXCLUDED.skip_uhd, updated_at = now() + WHERE $13::bigint = -1 OR request_routes.revision = $13::bigint + RETURNING `+routeColumns, + route.ID, route.MediaType, route.Position, route.Name, route.Enabled, route.IsFallback, conditions, + route.HD.IntegrationID, hdOverrides, route.UHD.IntegrationID, uhdOverrides, route.SkipUHD, expected)) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrStaleRevision + } + if err != nil { + return nil, fmt.Errorf("save request route: %w", err) + } + return &saved, tx.Commit(ctx) +} + +func nonNilOverrides(overrides map[string]any) map[string]any { + if overrides == nil { + return map[string]any{} + } + return overrides +} + +func (r *Repository) DeleteRouteConditional(ctx context.Context, id string, expected int64) error { + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.ReadCommitted}) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + if err := lockRevision(ctx, tx, `SELECT revision FROM request_routes WHERE id = $1 FOR UPDATE`, []any{id}, expected, false); err != nil { + return err + } + if _, err := tx.Exec(ctx, `DELETE FROM request_routes WHERE id = $1 AND NOT is_fallback`, id); err != nil { + return fmt.Errorf("delete request route: %w", err) + } + return tx.Commit(ctx) +} + +func (r *Repository) ReorderRoutes(ctx context.Context, mediaType MediaType, ids []string) error { + if _, err := r.pool.Exec(ctx, ` + UPDATE request_routes r SET position = o.position - 1, updated_at = now() + FROM unnest($2::text[]) WITH ORDINALITY AS o(id, position) + WHERE r.id = o.id AND r.media_type = $1 AND NOT r.is_fallback + `, mediaType, ids); err != nil { + return fmt.Errorf("reorder request routes: %w", err) + } + return nil +} diff --git a/internal/requests/routes_admin_test.go b/internal/requests/routes_admin_test.go new file mode 100644 index 0000000000..b9710cd21c --- /dev/null +++ b/internal/requests/routes_admin_test.go @@ -0,0 +1,277 @@ +package requests + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + "testing" + + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" +) + +// routeAdminService wires a Service over the lifecycle test schema, with the +// route table copied in and Radarr/Sonarr servers seeded. +func routeAdminService(t *testing.T) (*Service, *Repository) { + t.Helper() + repo, pool := lifecycleTestRepository(t) + ctx := t.Context() + if _, err := pool.Exec(ctx, `CREATE TABLE request_routes (LIKE public.request_routes INCLUDING ALL)`); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `CREATE TRIGGER route_revision BEFORE INSERT OR UPDATE ON request_routes + FOR EACH ROW EXECUTE FUNCTION public.advance_request_editor_revision()`); err != nil { + t.Fatal(err) + } + for _, seed := range [][2]string{ + {"radarr", `{"service_kind":"radarr"}`}, {"radarr-anime", `{"service_kind":"radarr"}`}, {"sonarr", `{"service_kind":"sonarr"}`}, + } { + if _, err := pool.Exec(ctx, `INSERT INTO request_integrations (id, name, enabled, capability_id, plugin_config) VALUES ($1, $1, true, 'arr', $2::jsonb)`, seed[0], seed[1]); err != nil { + t.Fatal(err) + } + } + return NewService(repo, &fakeTMDBClient{}, &fakePresence{}), repo +} + +var routeAdmin = Viewer{UserID: 1, IsAdmin: true} + +func fieldErrors(t *testing.T, err error) map[string]string { + t.Helper() + var verr *ValidationError + if !errors.As(err, &verr) { + t.Fatalf("err = %v, want a ValidationError", err) + } + return verr.FieldErrors +} + +func TestRouteAdministrationDatabase(t *testing.T) { + svc, repo := routeAdminService(t) + ctx := t.Context() + + // Until the fallback has an HD server there is nowhere for titles no rule + // matches to go, so rules wait for it. + _, err := svc.CreateRoute(ctx, routeAdmin, Route{ + MediaType: MediaTypeMovie, Name: "Anime", Enabled: true, + Conditions: RouteConditions{Anime: boolPtr(true)}, HD: RouteDestination{IntegrationID: "radarr-anime"}, + }) + var verr *ValidationError + if !errors.As(err, &verr) || !strings.Contains(verr.FormError, "default server for movies") { + t.Fatalf("rule before the fallback: err = %v, want the default server asked for", err) + } + unsaved, err := svc.GetRoute(ctx, routeAdmin, FallbackRouteID(MediaTypeMovie)) + if err != nil || !unsaved.IsFallback || unsaved.Revision != 0 { + t.Fatalf("unsaved fallback = %+v, %v; want a revision-zero fallback", unsaved, err) + } + + // The fallback is created on its first save and takes no conditions. + fallback, err := svc.UpdateRouteConditional(ctx, routeAdmin, Route{ + ID: FallbackRouteID(MediaTypeMovie), HD: RouteDestination{IntegrationID: "radarr"}, + }, 0) + if err != nil { + t.Fatalf("save fallback: %v", err) + } + if !fallback.IsFallback || fallback.MediaType != MediaTypeMovie || fallback.Name != "Everything else" || !fallback.Enabled { + t.Fatalf("fallback = %+v", fallback) + } + + anime, err := svc.CreateRoute(ctx, routeAdmin, Route{ + MediaType: MediaTypeMovie, Name: " Anime ", Enabled: true, + Conditions: RouteConditions{Anime: boolPtr(true), OriginalLanguages: []string{"JA", "ja"}}, + HD: RouteDestination{IntegrationID: "radarr-anime", Overrides: map[string]any{"root_folder": "/anime"}}, + }) + if err != nil { + t.Fatalf("create anime rule: %v", err) + } + eighties, err := svc.CreateRoute(ctx, routeAdmin, Route{ + MediaType: MediaTypeMovie, Name: "80s", Enabled: true, + Conditions: RouteConditions{YearFrom: 1980, YearTo: 1989}, SkipUHD: true, + }) + if err != nil { + t.Fatalf("create 80s rule: %v", err) + } + if anime.Name != "Anime" || !slices.Equal(anime.Conditions.OriginalLanguages, []string{"ja"}) || eighties.Position <= anime.Position { + t.Fatalf("anime = %+v 80s = %+v, want normalized and appended in order", anime, eighties) + } + + // A stale editor loses. + stale := *anime + stale.Name = "Anime (old tab)" + if _, err := svc.UpdateRouteConditional(ctx, routeAdmin, stale, anime.Revision-1); !errors.Is(err, ErrStaleRevision) { + t.Fatalf("stale update: err = %v, want ErrStaleRevision", err) + } + renamed := *anime + renamed.Name = "Anime and donghua" + updated, err := svc.UpdateRouteConditional(ctx, routeAdmin, renamed, anime.Revision) + if err != nil || updated.Name != "Anime and donghua" || updated.Position != anime.Position { + t.Fatalf("update = %+v, %v; want renamed in place", updated, err) + } + + ordered, err := svc.ReorderRoutes(ctx, routeAdmin, MediaTypeMovie, []string{eighties.ID, anime.ID}) + if err != nil { + t.Fatalf("reorder: %v", err) + } + var names []string + for _, route := range ordered { + names = append(names, route.Name) + } + if !slices.Equal(names, []string{"80s", "Anime and donghua", "Everything else"}) { + t.Fatalf("order = %v, want the new order with the fallback last", names) + } + if _, err := svc.ReorderRoutes(ctx, routeAdmin, MediaTypeMovie, []string{anime.ID}); err == nil { + t.Fatal("a reorder missing a rule was accepted") + } + + if err := svc.DeleteRouteConditional(ctx, routeAdmin, fallback.ID, fallback.Revision); err == nil { + t.Fatal("deleting the fallback was accepted") + } + // Reordering is an edit: it moves the rules to new revisions. + if err := svc.DeleteRouteConditional(ctx, routeAdmin, eighties.ID, eighties.Revision); !errors.Is(err, ErrStaleRevision) { + t.Fatalf("delete with the pre-reorder revision: err = %v, want ErrStaleRevision", err) + } + if err := svc.DeleteRouteConditional(ctx, routeAdmin, eighties.ID, ordered[0].Revision); err != nil { + t.Fatalf("delete rule: %v", err) + } + if _, err := repo.GetRoute(ctx, eighties.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("deleted rule: err = %v, want ErrNotFound", err) + } +} + +func TestRouteValidationDatabase(t *testing.T) { + svc, _ := routeAdminService(t) + ctx := t.Context() + for _, tc := range []struct { + name string + route Route + field string + }{ + {"no name", Route{MediaType: MediaTypeMovie, Conditions: RouteConditions{Anime: boolPtr(true)}, HD: RouteDestination{IntegrationID: "radarr"}}, "name"}, + {"no condition", Route{MediaType: MediaTypeMovie, Name: "All", HD: RouteDestination{IntegrationID: "radarr"}}, "conditions"}, + {"no effect", Route{MediaType: MediaTypeMovie, Name: "Nothing", Conditions: RouteConditions{Anime: boolPtr(true)}}, "hd"}, + {"wrong kind", Route{MediaType: MediaTypeMovie, Name: "Wrong", Conditions: RouteConditions{Anime: boolPtr(true)}, HD: RouteDestination{IntegrationID: "sonarr"}}, "hd.integration_id"}, + {"unknown server", Route{MediaType: MediaTypeMovie, Name: "Gone", Conditions: RouteConditions{Anime: boolPtr(true)}, HD: RouteDestination{IntegrationID: "nope"}}, "hd.integration_id"}, + {"routing-owned key", Route{MediaType: MediaTypeMovie, Name: "Sneaky", Conditions: RouteConditions{Anime: boolPtr(true)}, + HD: RouteDestination{IntegrationID: "radarr", Overrides: map[string]any{"is_default": true}}}, "hd.overrides.is_default"}, + {"backwards years", Route{MediaType: MediaTypeMovie, Name: "Years", Conditions: RouteConditions{YearFrom: 1990, YearTo: 1980}, HD: RouteDestination{IntegrationID: "radarr"}}, "conditions.year_to"}, + {"bad language", Route{MediaType: MediaTypeMovie, Name: "Lang", Conditions: RouteConditions{OriginalLanguages: []string{"japanese"}}, HD: RouteDestination{IntegrationID: "radarr"}}, "conditions.original_languages"}, + {"skip and send 4K", Route{MediaType: MediaTypeMovie, Name: "Both", Conditions: RouteConditions{Anime: boolPtr(true)}, SkipUHD: true, UHD: RouteDestination{IntegrationID: "radarr"}}, "uhd"}, + } { + _, err := svc.CreateRoute(ctx, routeAdmin, tc.route) + if fields := fieldErrors(t, err); fields[tc.field] == "" { + t.Errorf("%s: field errors = %v, want %s", tc.name, fields, tc.field) + } + } + _, err := svc.UpdateRouteConditional(ctx, routeAdmin, Route{ + ID: FallbackRouteID(MediaTypeSeries), Conditions: RouteConditions{Anime: boolPtr(true)}, HD: RouteDestination{IntegrationID: "sonarr"}, + }, 0) + if fields := fieldErrors(t, err); fields["conditions"] == "" { + t.Fatalf("fallback with conditions: field errors = %v", fields) + } +} + +func TestRouteAdministrationRequiresAdmin(t *testing.T) { + svc := newTestService(newFakeStore()) + member := Viewer{UserID: 2} + if _, err := svc.ListRoutesAdmin(context.Background(), member); !errors.Is(err, ErrForbidden) { + t.Fatalf("list: err = %v", err) + } + if _, err := svc.CreateRoute(context.Background(), member, Route{}); !errors.Is(err, ErrForbidden) { + t.Fatalf("create: err = %v", err) + } + if _, err := svc.PreviewRoute(context.Background(), member, MediaTypeMovie, 1, 0); !errors.Is(err, ErrForbidden) { + t.Fatalf("preview: err = %v", err) + } +} + +func TestPreviewRoute(t *testing.T) { + store := routingStore(RoutingFacts{}) + svc := newTestServiceWithTMDB(store, &fakeTMDBClient{detail: &tmdb.MediaDetail{ID: 129, KeywordIDs: []int{210024}}}) + + preview, err := svc.PreviewRoute(context.Background(), routeAdmin, MediaTypeMovie, 129, 7) + if err != nil { + t.Fatalf("preview: %v", err) + } + if !preview.Facts.Anime || len(preview.Tiers) != 2 { + t.Fatalf("preview = %+v", preview) + } + hd, uhd := preview.Tiers[0], preview.Tiers[1] + if hd.RouteName != "Anime" || hd.IntegrationName != "radarr-anime" || uhd.RouteName != "Everything else" { + t.Fatalf("tiers = %+v", preview.Tiers) + } +} + +func TestRouteAdministrationGuardsDatabase(t *testing.T) { + svc, repo := routeAdminService(t) + ctx := t.Context() + + // A fallback without an HD server would fail every unmatched title. + _, err := svc.UpdateRouteConditional(ctx, routeAdmin, Route{ID: FallbackRouteID(MediaTypeMovie), UHD: RouteDestination{IntegrationID: "radarr"}}, 0) + if fields := fieldErrors(t, err); fields["hd.integration_id"] == "" { + t.Fatalf("fallback without HD: field errors = %v", fields) + } + + // Two first saves of the fallback: the second editor read revision zero + // too, and must lose instead of overwriting. + first, err := repo.SaveRouteConditional(ctx, Route{ID: FallbackRouteID(MediaTypeMovie), MediaType: MediaTypeMovie, Position: 1000, + Name: "Everything else", Enabled: true, IsFallback: true, HD: RouteDestination{IntegrationID: "radarr"}}, 0) + if err != nil { + t.Fatal(err) + } + if _, err := repo.SaveRouteConditional(ctx, Route{ID: first.ID, MediaType: MediaTypeMovie, Position: 1000, + Name: "Other editor", Enabled: true, IsFallback: true, HD: RouteDestination{IntegrationID: "radarr-anime"}}, 0); !errors.Is(err, ErrStaleRevision) { + t.Fatalf("second first save: err = %v, want ErrStaleRevision", err) + } + + // A rule deleted under an editor does not come back on save. + rule, err := svc.CreateRoute(ctx, routeAdmin, Route{MediaType: MediaTypeMovie, Name: "アニメとドンファのための特別なルール、とても長い名前でも大丈夫", Enabled: true, + Conditions: RouteConditions{Anime: boolPtr(true)}, HD: RouteDestination{IntegrationID: "radarr-anime"}}) + if err != nil { + t.Fatalf("create a rule with a long non-Latin name: %v", err) + } + if err := svc.DeleteRouteConditional(ctx, routeAdmin, rule.ID, rule.Revision); err != nil { + t.Fatal(err) + } + if _, err := repo.SaveRouteConditional(ctx, *rule, rule.Revision); !errors.Is(err, ErrStaleRevision) && !errors.Is(err, ErrNotFound) { + t.Fatalf("save a deleted rule: err = %v, want it refused", err) + } + if _, err := repo.GetRoute(ctx, rule.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("deleted rule came back: %v", err) + } +} + +func TestPreviewRouteTellsMissingFromUnreachable(t *testing.T) { + store := routingStore(RoutingFacts{}) + missing := newTestServiceWithTMDB(store, &fakeTMDBClient{detailErr: fmt.Errorf("lookup: %w", tmdb.ErrNotFound)}) + if _, err := missing.PreviewRoute(context.Background(), routeAdmin, MediaTypeMovie, 1, 0); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing title: err = %v, want ErrNotFound", err) + } + down := newTestServiceWithTMDB(store, &fakeTMDBClient{detailErr: errors.New("tmdb: server error 503 after 3 retries")}) + if _, err := down.PreviewRoute(context.Background(), routeAdmin, MediaTypeMovie, 1, 0); !errors.Is(err, ErrIntegrationUnreachable) { + t.Fatalf("TMDB down: err = %v, want ErrIntegrationUnreachable", err) + } +} + +// Switching a server to the other type would fail every request its routes +// send it, so the switch is refused while a route uses the server, and the +// preview flags a server already switched. +func TestServerTypeSwitchKeepsRoutesWorking(t *testing.T) { + store := routingStore(RoutingFacts{}) + svc := newTestServiceWithTMDB(store, &fakeTMDBClient{detail: &tmdb.MediaDetail{ID: 129, KeywordIDs: []int{210024}}}) + + switched := store.integrations[2] // radarr-anime, which the movie rule "Anime" sends to + switched.PluginConfig = map[string]any{"service_kind": "sonarr", "root_folder": "/tv"} + _, err := svc.UpdateIntegration(context.Background(), routeAdmin, switched) + var verr *ValidationError + if !errors.As(err, &verr) || !strings.Contains(verr.FieldErrors["plugin_config.service_kind"], "Anime") { + t.Fatalf("switch: err = %v, want a service_kind field error naming the route", err) + } + + store.integrations[2] = switched + preview, err := svc.PreviewRoute(context.Background(), routeAdmin, MediaTypeMovie, 129, 7) + if err != nil { + t.Fatal(err) + } + if hd := preview.Tiers[0]; !strings.Contains(hd.Reason, "a sonarr server") { + t.Fatalf("hd tier = %+v, want the type mismatch noted", hd) + } +} diff --git a/internal/requests/routing.go b/internal/requests/routing.go index 43914a1e0b..efab689b15 100644 --- a/internal/requests/routing.go +++ b/internal/requests/routing.go @@ -26,6 +26,9 @@ const ( kindSonarr = "sonarr" ) +// fallbackRouteName names a media type's fallback until an admin renames it. +const fallbackRouteName = "Everything else" + // Route sends the requests it matches to a server per quality tier. type Route struct { ID string diff --git a/internal/requests/service.go b/internal/requests/service.go index 24e99c8fd7..7d6c8e9719 100644 --- a/internal/requests/service.go +++ b/internal/requests/service.go @@ -1257,6 +1257,9 @@ func (s *Service) UpdateIntegration(ctx context.Context, viewer Viewer, in Integ if err := validateInstance(&in); err != nil { return nil, err } + if err := s.ensureRoutesKeepServerKind(ctx, in); err != nil { + return nil, err + } if err := s.validateViaPlugin(ctx, in); err != nil { return nil, err } diff --git a/internal/requests/service_test.go b/internal/requests/service_test.go index acec98e951..72fac76902 100644 --- a/internal/requests/service_test.go +++ b/internal/requests/service_test.go @@ -2467,6 +2467,7 @@ type fakeTMDBClient struct { externalIDsByID map[int]*tmdb.ExternalIDs externalIDCalls []int detail *tmdb.MediaDetail + detailErr error discoverPage *tmdb.MediaPage discoverErr error searchMediaType string @@ -2506,7 +2507,7 @@ func (f *fakeTMDBClient) GetExternalIDs(_ context.Context, _ string, id int) (*t } func (f *fakeTMDBClient) GetMediaDetail(context.Context, string, int) (*tmdb.MediaDetail, error) { - return f.detail, nil + return f.detail, f.detailErr } // certTMDBClient layers GetCertification onto fakeTMDBClient so a service diff --git a/web/src/api/v2/operations.ts b/web/src/api/v2/operations.ts index 1f92f47d17..820fc9aa87 100644 --- a/web/src/api/v2/operations.ts +++ b/web/src/api/v2/operations.ts @@ -31,6 +31,7 @@ export const v2Operations = { "DELETE /api/v2/admin/plugins/uploads/chunked/{upload_id}": "cancelAdminPluginUpload", "DELETE /api/v2/admin/policy/documents/{id}": "deleteAdminPolicyDocument", "DELETE /api/v2/admin/request-integrations/{id}": "deleteRequestIntegration", + "DELETE /api/v2/admin/request-routes/{id}": "deleteRequestRoute", "DELETE /api/v2/admin/sections/{id}": "deleteAdminSection", "DELETE /api/v2/admin/subtitles/{id}": "deleteAdminStoredSubtitle", "DELETE /api/v2/admin/users/{id}": "deleteAdminUser", @@ -179,6 +180,8 @@ export const v2Operations = { "GET /api/v2/admin/recommendations/status": "getAdminRecommendationsStatus", "GET /api/v2/admin/request-integrations": "listRequestIntegrations", "GET /api/v2/admin/request-integrations/{id}": "getRequestIntegration", + "GET /api/v2/admin/request-routes": "listRequestRoutes", + "GET /api/v2/admin/request-routes/{id}": "getRequestRoute", "GET /api/v2/admin/request-settings": "getAdminRequestSettings", "GET /api/v2/admin/request-users/{user_id}/limit": "getAdminRequestUserLimit", "GET /api/v2/admin/requests": "listAdminRequests", @@ -576,6 +579,9 @@ export const v2Operations = { "triggerAdminRecommendationTasteProfiles", "POST /api/v2/admin/request-integrations": "createRequestIntegration", "POST /api/v2/admin/request-integrations/{id}/options": "loadRequestIntegrationOptions", + "POST /api/v2/admin/request-routes": "createRequestRoute", + "POST /api/v2/admin/request-routes/order": "reorderRequestRoutes", + "POST /api/v2/admin/request-routes/preview": "previewRequestRoute", "POST /api/v2/admin/requests/{id}/approve": "adminApproveRequest", "POST /api/v2/admin/requests/{id}/cancel": "adminCancelRequest", "POST /api/v2/admin/requests/{id}/decline": "adminDeclineRequest", @@ -737,6 +743,7 @@ export const v2Operations = { "putAdminPluginUploadChunk", "PUT /api/v2/admin/policy/documents/{id}/active-version": "activateAdminPolicyVersion", "PUT /api/v2/admin/request-integrations/{id}": "updateRequestIntegration", + "PUT /api/v2/admin/request-routes/{id}": "updateRequestRoute", "PUT /api/v2/admin/request-settings": "updateAdminRequestSettings", "PUT /api/v2/admin/request-users/{user_id}/limit": "updateAdminRequestUserLimit", "PUT /api/v2/admin/sections/defaults": "restoreAdminSections", diff --git a/web/src/api/v2/schema.ts b/web/src/api/v2/schema.ts index cedc72a135..bec4ae2665 100644 --- a/web/src/api/v2/schema.ts +++ b/web/src/api/v2/schema.ts @@ -3283,6 +3283,77 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v2/admin/request-routes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List the request routing rules, in evaluation order per media type. */ + get: operations["listRequestRoutes"]; + put?: never; + /** Add a request routing rule after the media type's existing rules. */ + post: operations["createRequestRoute"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v2/admin/request-routes/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get one request routing rule. */ + get: operations["getRequestRoute"]; + /** Replace a request routing rule; saving a media type's fallback creates it. */ + put: operations["updateRequestRoute"]; + post?: never; + /** Delete a request routing rule. */ + delete: operations["deleteRequestRoute"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v2/admin/request-routes/order": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Set the evaluation order of a media type's routing rules. */ + post: operations["reorderRequestRoutes"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v2/admin/request-routes/preview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Show which server each quality tier of a title would go to. */ + post: operations["previewRequestRoute"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v2/admin/request-settings": { parameters: { query?: never; @@ -14436,6 +14507,142 @@ export interface components { [key: string]: unknown; }; }; + AdminRequestRoute: { + conditions: components["schemas"]["AdminRequestRouteConditions"]; + enabled: boolean; + /** @description Where the HD (1080p) copy goes */ + hd: components["schemas"]["AdminRequestRouteDestination"]; + /** + * @description Opaque route ID; the fallback's is fallback-movie or fallback-series + * @example fallback-movie + */ + id: string; + /** @description The media type's default destination: it has no conditions and cannot be deleted */ + is_fallback: boolean; + /** @enum {string} */ + media_type: "movie" | "series"; + /** @example Anime */ + name: string; + /** + * Format: int64 + * @description Evaluation order within the media type; the fallback is always last + */ + position: number; + /** @description Matching titles get no 4K copy at all */ + skip_uhd: boolean; + /** @description Where the 4K copy goes */ + uhd: components["schemas"]["AdminRequestRouteDestination"]; + }; + AdminRequestRouteBody: { + conditions: components["schemas"]["AdminRequestRouteConditions"]; + enabled: boolean; + hd: components["schemas"]["AdminRequestRouteDestination"]; + /** + * @description Required on create; ignored on update + * @enum {string} + */ + media_type?: "movie" | "series"; + name?: string; + skip_uhd: boolean; + uhd: components["schemas"]["AdminRequestRouteDestination"]; + }; + AdminRequestRouteConditions: { + /** @description Match titles TMDB tags as anime (true) or not (false) */ + anime?: boolean; + /** @description TMDB production company IDs (movies) */ + company_ids?: number[]; + /** @description TMDB genre IDs */ + genre_ids?: number[]; + /** @description TMDB keyword IDs */ + keyword_ids?: number[]; + /** @description TMDB network IDs (series) */ + network_ids?: number[]; + /** + * @description ISO 3166-1 country codes + * @example [ + * "JP" + * ] + */ + origin_countries?: string[]; + /** + * @description ISO 639-1 codes of the original language + * @example [ + * "ja" + * ] + */ + original_languages?: string[]; + /** @description Accounts whose requests the route applies to */ + requester_user_ids?: number[]; + /** + * Format: int64 + * @description First release (or first-air) year, inclusive + * @example 1980 + */ + year_from?: number; + /** + * Format: int64 + * @description Last release (or first-air) year, inclusive + * @example 1989 + */ + year_to?: number; + }; + AdminRequestRouteDestination: { + /** @description The request server; empty when the route sends nothing for this tier */ + integration_id?: string; + /** @description Server settings this route replaces, keyed like the server's plugin config: root_folder, quality_profile_id, tags, series_type, minimum_availability, ... */ + overrides?: { + [key: string]: unknown; + }; + }; + AdminRequestRouteFacts: { + anime: boolean; + company_ids: number[]; + genre_ids: number[]; + keyword_ids: number[]; + network_ids: number[]; + origin_countries: string[]; + original_language?: string; + /** Format: int64 */ + year?: number; + }; + AdminRequestRoutePreviewInputBody: { + /** @enum {string} */ + media_type: "movie" | "series"; + /** + * @description Route as this account's request; account conditions are skipped when absent + * @example 1 + */ + requester_user_id?: string; + /** + * Format: int64 + * @description TMDB identifier (external, not a Silo ID) + * @example 129 + */ + tmdb_id: number; + }; + AdminRequestRoutePreviewOutputBody: { + facts: components["schemas"]["AdminRequestRouteFacts"]; + tiers: components["schemas"]["AdminRequestRoutePreviewTier"][]; + }; + AdminRequestRoutePreviewTier: { + integration_id?: string; + integration_name?: string; + /** @description Why no route sends the tier, or why it would fail */ + note?: string; + overrides?: { + [key: string]: unknown; + }; + /** @enum {string} */ + quality: "1080p" | "2160p"; + route_id?: string; + route_name?: string; + }; + AdminRequestRouteReorderInputBody: { + /** @description Every rule of the media type, fallback excluded, in the new order */ + ids: string[]; + /** @enum {string} */ + media_type: "movie" | "series"; + }; AdminRequestSettings: { force_dual_quality: boolean; global_auto_approval_enabled: boolean; @@ -17078,6 +17285,12 @@ export interface components { /** @description Cursor state; absent for bounded unpaginated collections */ page?: components["schemas"]["PageInfo"]; }; + CollectionAdminRequestRoute: { + /** @description The page's items; empty, never null */ + items: components["schemas"]["AdminRequestRoute"][]; + /** @description Cursor state; absent for bounded unpaginated collections */ + page?: components["schemas"]["PageInfo"]; + }; CollectionAdminSection: { /** @description The page's items; empty, never null */ items: components["schemas"]["AdminSection"][]; @@ -27154,36 +27367,1231 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + getAccountPasswordCapability: { + parameters: { + query?: never; + header?: { + /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ + "If-Match"?: string; + "If-None-Match"?: string; + /** @description Optional. When present, it must name a profile of the authenticated account. */ + "X-Profile-Id"?: string; + /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ + "X-Profile-Token"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "Cache-Control"?: string; + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AccountPasswordCapability"]; + }; + }; + /** @description The representation named by If-None-Match is current; no body. */ + 304: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Acceptable */ + 406: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Precondition Failed */ + 412: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + listAdminAccessGroups: { + parameters: { + query?: { + /** @description Opaque cursor from page.next_cursor */ + cursor?: string; + /** @description Page size; default 50, maximum 200 */ + limit?: number; + }; + header?: { + /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ + "X-Profile-Id"?: string; + /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ + "X-Profile-Token"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CollectionAdminAccessGroupListItem"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Acceptable */ + 406: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + createAdminAccessGroup: { + parameters: { + query?: never; + header?: { + /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ + "X-Profile-Id"?: string; + /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ + "X-Profile-Token"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminAccessGroupBody"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + Location?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminAccessGroup"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Acceptable */ + 406: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + getAdminAccessGroup: { + parameters: { + query?: never; + header?: { + /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ + "If-Match"?: string; + "If-None-Match"?: string; + /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ + "X-Profile-Id"?: string; + /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ + "X-Profile-Token"?: string; + }; + path: { + /** @description Opaque identifier */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminAccessGroup"]; + }; + }; + /** @description The representation named by If-None-Match is current; no body. */ + 304: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Acceptable */ + 406: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Precondition Failed */ + 412: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + updateAdminAccessGroup: { + parameters: { + query?: never; + header: { + /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ + "If-Match": string; + /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + "If-None-Match"?: string; + /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ + "X-Profile-Id"?: string; + /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ + "X-Profile-Token"?: string; + }; + path: { + /** @description Opaque identifier */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminAccessGroupBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminAccessGroup"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Acceptable */ + 406: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Precondition Failed */ + 412: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Precondition Required */ + 428: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + deleteAdminAccessGroup: { + parameters: { + query?: never; + header: { + /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ + "If-Match": string; + /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + "If-None-Match"?: string; + /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ + "X-Profile-Id"?: string; + /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ + "X-Profile-Token"?: string; + }; + path: { + /** @description Opaque identifier */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Acceptable */ + 406: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Precondition Failed */ + 412: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Precondition Required */ + 428: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + listAdminAPIKeys: { + parameters: { + query?: { + /** @description Opaque cursor from page.next_cursor */ + cursor?: string; + /** @description Page size; default 50, maximum 200 */ + limit?: number; + }; + header?: { + /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ + "X-Profile-Id"?: string; + /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ + "X-Profile-Token"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CollectionAdminAPIKeyListItem"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Acceptable */ + 406: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + createAdminAPIKey: { + parameters: { + query?: never; + header?: { + /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ + "X-Profile-Id"?: string; + /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ + "X-Profile-Token"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminAPIKeyCreateInputBody"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + Location?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminAPIKeyCreated"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Acceptable */ + 406: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + getAdminAPIKey: { + parameters: { + query?: never; + header?: { + /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ + "If-Match"?: string; + "If-None-Match"?: string; + /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ + "X-Profile-Id"?: string; + /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ + "X-Profile-Token"?: string; + }; + path: { + /** @description Opaque identifier */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminAPIKey"]; + }; + }; + /** @description The representation named by If-None-Match is current; no body. */ + 304: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Acceptable */ + 406: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Precondition Failed */ + 412: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { @@ -27228,40 +28636,30 @@ export interface operations { }; }; }; - getAccountPasswordCapability: { + deleteAdminAPIKey: { parameters: { query?: never; - header?: { - /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ - "If-Match"?: string; + header: { + /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ + "If-Match": string; + /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ "If-None-Match"?: string; - /** @description Optional. When present, it must name a profile of the authenticated account. */ + /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description Opaque identifier */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ - 200: { - headers: { - "Cache-Control"?: string; - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["AccountPasswordCapability"]; - }; - }; - /** @description The representation named by If-None-Match is current; no body. */ - 304: { + /** @description No Content */ + 204: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content?: never; @@ -27331,6 +28729,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Required */ + 428: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Too Many Requests */ 429: { headers: { @@ -27360,32 +28767,40 @@ export interface operations { }; }; }; - listAdminAccessGroups: { + updateAdminAPIKeyTier: { parameters: { - query?: { - /** @description Opaque cursor from page.next_cursor */ - cursor?: string; - /** @description Page size; default 50, maximum 200 */ - limit?: number; - }; - header?: { + query?: never; + header: { + /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ + "If-Match": string; + /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description Opaque identifier */ + id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminAPIKeyTierInputBody"]; + }; + }; responses: { /** @description OK */ 200: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminAccessGroupListItem"]; + "application/json": components["schemas"]["AdminAPIKey"]; }; }; /** @description Bad Request */ @@ -27433,6 +28848,44 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Precondition Failed */ + 412: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -27442,6 +28895,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Required */ + 428: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Too Many Requests */ 429: { headers: { @@ -27471,10 +28933,13 @@ export interface operations { }; }; }; - createAdminAccessGroup: { + getAdminAPIKeyCapabilities: { parameters: { query?: never; header?: { + /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ + "If-Match"?: string; + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ @@ -27483,22 +28948,29 @@ export interface operations { path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminAccessGroupBody"]; - }; - }; + requestBody?: never; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { - Location?: string; + "Cache-Control"?: string; + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminAccessGroup"]; + "application/json": components["schemas"]["AdminAPIKeyCapabilitiesOutputBody"]; }; }; + /** @description The representation named by If-None-Match is current; no body. */ + 304: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content?: never; + }; /** @description Bad Request */ 400: { headers: { @@ -27544,27 +29016,11 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { + /** @description Precondition Failed */ + 412: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { @@ -27609,22 +29065,20 @@ export interface operations { }; }; }; - getAdminAccessGroup: { + listAdminAutoscanConnections: { parameters: { - query?: never; + query?: { + cursor?: string; + /** @description Page size; default 50, maximum 200 */ + limit?: number; + }; header?: { - /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ - "If-Match"?: string; - "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -27632,22 +29086,11 @@ export interface operations { /** @description OK */ 200: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminAccessGroup"]; - }; - }; - /** @description The representation named by If-None-Match is current; no body. */ - 304: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; + "application/json": components["schemas"]["CollectionAdminAutoscanConnection"]; }; - content?: never; }; /** @description Bad Request */ 400: { @@ -27694,17 +29137,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -27743,40 +29175,31 @@ export interface operations { }; }; }; - updateAdminAccessGroup: { + createAdminAutoscanConnection: { parameters: { query?: never; - header: { - /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ - "If-Match": string; - /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ - "If-None-Match"?: string; + header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminAccessGroupBody"]; + "application/json": components["schemas"]["AdminAutoscanConnectionCreateBody"]; }; }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminAccessGroup"]; + "application/json": components["schemas"]["AdminAutoscanConnection"]; }; }; /** @description Bad Request */ @@ -27833,17 +29256,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Request Entity Too Large */ 413: { headers: { @@ -27871,15 +29283,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Required */ - 428: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Too Many Requests */ 429: { headers: { @@ -27909,33 +29312,34 @@ export interface operations { }; }; }; - deleteAdminAccessGroup: { + updateAdminAutoscanConnection: { parameters: { query?: never; - header: { - /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ - "If-Match": string; - /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ - "If-None-Match"?: string; + header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; path: { - /** @description Opaque identifier */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminAutoscanConnectionCreateBody"]; + }; + }; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["AdminAutoscanConnection"]; + }; }; /** @description Bad Request */ 400: { @@ -27982,19 +29386,17 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { + /** @description Request Timeout */ + 408: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + /** @description Request Entity Too Large */ + 413: { headers: { [name: string]: unknown; }; @@ -28002,8 +29404,17 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Required */ - 428: { + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -28040,33 +29451,28 @@ export interface operations { }; }; }; - listAdminAPIKeys: { + deleteAdminAutoscanConnection: { parameters: { - query?: { - /** @description Opaque cursor from page.next_cursor */ - cursor?: string; - /** @description Page size; default 50, maximum 200 */ - limit?: number; - }; + query?: never; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["CollectionAdminAPIKeyListItem"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -28151,7 +29557,7 @@ export interface operations { }; }; }; - createAdminAPIKey: { + testAdminAutoscanConnection: { parameters: { query?: never; header?: { @@ -28165,18 +29571,17 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AdminAPIKeyCreateInputBody"]; + "application/json": components["schemas"]["AdminAutoscanConnectionTestBody"]; }; }; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { - Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminAPIKeyCreated"]; + "application/json": components["schemas"]["AdminAutoscanConnectionTestResult"]; }; }; /** @description Bad Request */ @@ -28289,22 +29694,22 @@ export interface operations { }; }; }; - getAdminAPIKey: { + listAdminAutoscanEvents: { parameters: { - query?: never; + query?: { + cursor?: string; + limit?: number; + q?: string; + source_id?: string; + status?: "" | "running" | "success" | "error" | "unresolved"; + }; header?: { - /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ - "If-Match"?: string; - "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -28312,22 +29717,11 @@ export interface operations { /** @description OK */ 200: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminAPIKey"]; - }; - }; - /** @description The representation named by If-None-Match is current; no body. */ - 304: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; + "application/json": components["schemas"]["AdminAutoscanEventsPage"]; }; - content?: never; }; /** @description Bad Request */ 400: { @@ -28374,11 +29768,110 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + listAdminAutoscanAvailableSources: { + parameters: { + query?: { + cursor?: string; + /** @description Page size; default 50, maximum 200 */ + limit?: number; + }; + header?: { + /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ + "X-Profile-Id"?: string; + /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ + "X-Profile-Token"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CollectionAdminAutoscanAvailableSource"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Acceptable */ + 406: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { @@ -28423,33 +29916,33 @@ export interface operations { }; }; }; - deleteAdminAPIKey: { + listAdminAutoscanScans: { parameters: { - query?: never; - header: { - /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ - "If-Match": string; - /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ - "If-None-Match"?: string; + query?: { + cursor?: string; + limit?: number; + q?: string; + status?: "" | "accepted" | "running" | "completed" | "failed" | "cancelled"; + }; + header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["AdminAutoscanScansPage"]; + }; }; /** @description Bad Request */ 400: { @@ -28496,17 +29989,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -28516,15 +29998,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Required */ - 428: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Too Many Requests */ 429: { headers: { @@ -28554,30 +30027,22 @@ export interface operations { }; }; }; - updateAdminAPIKeyTier: { + getAdminAutoscanSettings: { parameters: { query?: never; - header: { - /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ - "If-Match": string; - /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + header?: { + /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ + "If-Match"?: string; "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminAPIKeyTierInputBody"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -28587,9 +30052,18 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminAPIKey"]; + "application/json": components["schemas"]["AdminAutoscanSettings"]; }; }; + /** @description The representation named by If-None-Match is current; no body. */ + 304: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content?: never; + }; /** @description Bad Request */ 400: { headers: { @@ -28635,15 +30109,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Precondition Failed */ 412: { headers: { @@ -28655,24 +30120,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -28682,15 +30129,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Required */ - 428: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Too Many Requests */ 429: { headers: { @@ -28720,13 +30158,10 @@ export interface operations { }; }; }; - getAdminAPIKeyCapabilities: { + updateAdminAutoscanSettings: { parameters: { query?: never; header?: { - /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ - "If-Match"?: string; - "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ @@ -28735,28 +30170,20 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminAutoscanSettingsUpdateBody"]; + }; + }; responses: { /** @description OK */ 200: { headers: { - "Cache-Control"?: string; - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminAPIKeyCapabilitiesOutputBody"]; - }; - }; - /** @description The representation named by If-None-Match is current; no body. */ - 304: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; + "application/json": components["schemas"]["AdminAutoscanSettingsUpdateResult"]; }; - content?: never; }; /** @description Bad Request */ 400: { @@ -28803,11 +30230,27 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { @@ -28852,7 +30295,7 @@ export interface operations { }; }; }; - listAdminAutoscanConnections: { + listAdminAutoscanSources: { parameters: { query?: { cursor?: string; @@ -28876,7 +30319,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminAutoscanConnection"]; + "application/json": components["schemas"]["CollectionAdminAutoscanSource"]; }; }; /** @description Bad Request */ @@ -28962,7 +30405,7 @@ export interface operations { }; }; }; - createAdminAutoscanConnection: { + createAdminAutoscanSource: { parameters: { query?: never; header?: { @@ -28976,7 +30419,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AdminAutoscanConnectionCreateBody"]; + "application/json": components["schemas"]["AdminAutoscanSourceCreateBody"]; }; }; responses: { @@ -28986,7 +30429,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminAutoscanConnection"]; + "application/json": components["schemas"]["AdminAutoscanSource"]; }; }; /** @description Bad Request */ @@ -29099,7 +30542,7 @@ export interface operations { }; }; }; - updateAdminAutoscanConnection: { + updateAdminAutoscanSource: { parameters: { query?: never; header?: { @@ -29115,7 +30558,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AdminAutoscanConnectionCreateBody"]; + "application/json": components["schemas"]["AdminAutoscanSourceWriteBody"]; }; }; responses: { @@ -29125,7 +30568,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminAutoscanConnection"]; + "application/json": components["schemas"]["AdminAutoscanSource"]; }; }; /** @description Bad Request */ @@ -29238,7 +30681,7 @@ export interface operations { }; }; }; - deleteAdminAutoscanConnection: { + deleteAdminAutoscanSource: { parameters: { query?: never; header?: { @@ -29344,7 +30787,115 @@ export interface operations { }; }; }; - testAdminAutoscanConnection: { + getAdminAutoscanRewriteSuggestions: { + parameters: { + query?: never; + header?: { + /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ + "X-Profile-Id"?: string; + /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ + "X-Profile-Token"?: string; + }; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminAutoscanRewriteSuggestions"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Acceptable */ + 406: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + createAdminAutoscanSourceWebhook: { parameters: { query?: never; header?: { @@ -29353,14 +30904,12 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminAutoscanConnectionTestBody"]; + path: { + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -29368,7 +30917,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminAutoscanConnectionTestResult"]; + "application/json": components["schemas"]["AdminAutoscanSource"]; }; }; /** @description Bad Request */ @@ -29416,33 +30965,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -29481,34 +31003,28 @@ export interface operations { }; }; }; - listAdminAutoscanEvents: { + deleteAdminAutoscanSourceWebhook: { parameters: { - query?: { - cursor?: string; - limit?: number; - q?: string; - source_id?: string; - status?: "" | "running" | "success" | "error" | "unresolved"; - }; + query?: never; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["AdminAutoscanEventsPage"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -29593,20 +31109,18 @@ export interface operations { }; }; }; - listAdminAutoscanAvailableSources: { + rotateAdminAutoscanSourceWebhook: { parameters: { - query?: { - cursor?: string; - /** @description Page size; default 50, maximum 200 */ - limit?: number; - }; + query?: never; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody?: never; @@ -29617,7 +31131,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminAutoscanAvailableSource"]; + "application/json": components["schemas"]["AdminAutoscanSource"]; }; }; /** @description Bad Request */ @@ -29703,14 +31217,9 @@ export interface operations { }; }; }; - listAdminAutoscanScans: { + getAdminAutoscanStatus: { parameters: { - query?: { - cursor?: string; - limit?: number; - q?: string; - status?: "" | "accepted" | "running" | "completed" | "failed" | "cancelled"; - }; + query?: never; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; @@ -29728,7 +31237,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminAutoscanScansPage"]; + "application/json": components["schemas"]["AdminAutoscanStatus"]; }; }; /** @description Bad Request */ @@ -29814,13 +31323,10 @@ export interface operations { }; }; }; - getAdminAutoscanSettings: { + triggerAdminAutoscan: { parameters: { query?: never; header?: { - /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ - "If-Match"?: string; - "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ @@ -29834,22 +31340,11 @@ export interface operations { /** @description OK */ 200: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminAutoscanSettings"]; - }; - }; - /** @description The representation named by If-None-Match is current; no body. */ - 304: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; + "application/json": components["schemas"]["AdminTask"]; }; - content?: never; }; /** @description Bad Request */ 400: { @@ -29896,11 +31391,9 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { + /** @description Conflict */ + 409: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { @@ -29945,7 +31438,7 @@ export interface operations { }; }; }; - updateAdminAutoscanSettings: { + uploadAdminBrandingAsset: { parameters: { query?: never; header?: { @@ -29954,12 +31447,15 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description Branding asset slot */ + kind: "wordmark" | "mark" | "favicon" | "login_bg"; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminAutoscanSettingsUpdateBody"]; + "multipart/form-data": components["schemas"]["AdminBrandingAssetForm"]; }; }; responses: { @@ -29969,7 +31465,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminAutoscanSettingsUpdateResult"]; + "application/json": components["schemas"]["AdminBrandingAsset"]; }; }; /** @description Bad Request */ @@ -30082,32 +31578,29 @@ export interface operations { }; }; }; - listAdminAutoscanSources: { + deleteAdminBrandingAsset: { parameters: { - query?: { - cursor?: string; - /** @description Page size; default 50, maximum 200 */ - limit?: number; - }; + query?: never; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description Branding asset slot */ + kind: "wordmark" | "mark" | "favicon" | "login_bg"; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["CollectionAdminAutoscanSource"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -30192,7 +31685,7 @@ export interface operations { }; }; }; - createAdminAutoscanSource: { + exportAdminCatalog: { parameters: { query?: never; header?: { @@ -30206,17 +31699,20 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AdminAutoscanSourceCreateBody"]; + "application/json": components["schemas"]["AdminCatalogExportRequest"]; }; }; responses: { - /** @description Created */ - 201: { + /** @description Compressed catalog seed */ + 200: { headers: { + "Content-Disposition"?: string; + "Content-Length"?: string; + "Content-Type"?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminAutoscanSource"]; + "application/gzip": string; }; }; /** @description Bad Request */ @@ -30329,7 +31825,7 @@ export interface operations { }; }; }; - updateAdminAutoscanSource: { + createCatalogExportJob: { parameters: { query?: never; header?: { @@ -30338,24 +31834,24 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminAutoscanSourceWriteBody"]; + "application/json": components["schemas"]["AdminCatalogExportRequest"]; }; }; responses: { - /** @description OK */ - 200: { + /** @description Accepted */ + 202: { headers: { + Location?: string; + "Retry-After"?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminAutoscanSource"]; + "application/json": components["schemas"]["AdminTaskJob"]; }; }; /** @description Bad Request */ @@ -30412,6 +31908,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Request Entity Too Large */ 413: { headers: { @@ -30468,7 +31973,7 @@ export interface operations { }; }; }; - deleteAdminAutoscanSource: { + publishCatalogExportJob: { parameters: { query?: never; header?: { @@ -30484,12 +31989,14 @@ export interface operations { }; requestBody?: never; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["AdminCatalogPublished"]; + }; }; /** @description Bad Request */ 400: { @@ -30536,6 +32043,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -30574,7 +32090,7 @@ export interface operations { }; }; }; - getAdminAutoscanRewriteSuggestions: { + importAdminCatalog: { parameters: { query?: never; header?: { @@ -30583,12 +32099,14 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminCatalogImportRequest"]; + }; + }; responses: { /** @description OK */ 200: { @@ -30596,7 +32114,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminAutoscanRewriteSuggestions"]; + "application/json": components["schemas"]["ImportResult"]; }; }; /** @description Bad Request */ @@ -30644,6 +32162,33 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -30682,7 +32227,7 @@ export interface operations { }; }; }; - createAdminAutoscanSourceWebhook: { + createCatalogImportJob: { parameters: { query?: never; header?: { @@ -30691,20 +32236,24 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminCatalogImportRequest"]; + }; + }; responses: { - /** @description OK */ - 200: { + /** @description Accepted */ + 202: { headers: { + Location?: string; + "Retry-After"?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminAutoscanSource"]; + "application/json": components["schemas"]["AdminTaskJob"]; }; }; /** @description Bad Request */ @@ -30752,6 +32301,42 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -30790,28 +32375,31 @@ export interface operations { }; }; }; - deleteAdminAutoscanSourceWebhook: { + listCatalogImportSources: { parameters: { - query?: never; + query?: { + cursor?: string; + limit?: number; + }; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["CollectionAdminCatalogSource"]; + }; }; /** @description Bad Request */ 400: { @@ -30896,18 +32484,19 @@ export interface operations { }; }; }; - rotateAdminAutoscanSourceWebhook: { + listLocalCatalogImportSources: { parameters: { - query?: never; + query?: { + cursor?: string; + limit?: number; + }; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -30918,7 +32507,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminAutoscanSource"]; + "application/json": components["schemas"]["CollectionAdminCatalogSource"]; }; }; /** @description Bad Request */ @@ -31004,7 +32593,7 @@ export interface operations { }; }; }; - getAdminAutoscanStatus: { + getAdminCatalogSearchStatus: { parameters: { query?: never; header?: { @@ -31024,7 +32613,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminAutoscanStatus"]; + "application/json": components["schemas"]["AdminCatalogSearchRuntimeStatus"]; }; }; /** @description Bad Request */ @@ -31110,16 +32699,22 @@ export interface operations { }; }; }; - triggerAdminAutoscan: { + getAdminGroupCollectionOrder: { parameters: { - query?: never; + query?: { + /** @description Opaque identifier */ + library_id?: string; + }; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description Opaque identifier */ + group_id: string; + }; cookie?: never; }; requestBody?: never; @@ -31127,10 +32722,11 @@ export interface operations { /** @description OK */ 200: { headers: { + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminTask"]; + "application/json": components["schemas"]["AdminCollectionOrder"]; }; }; /** @description Bad Request */ @@ -31178,15 +32774,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -31225,34 +32812,44 @@ export interface operations { }; }; }; - uploadAdminBrandingAsset: { + moveAndReorderAdminGroupCollections: { parameters: { - query?: never; - header?: { + query?: { + /** @description Opaque identifier */ + library_id?: string; + move_omitted?: string; + }; + header: { + /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ + "If-Match": string; + /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; path: { - /** @description Branding asset slot */ - kind: "wordmark" | "mark" | "favicon" | "login_bg"; + /** @description Opaque identifier */ + group_id: string; }; cookie?: never; }; requestBody: { content: { - "multipart/form-data": components["schemas"]["AdminBrandingAssetForm"]; + "application/json": components["schemas"]["AdminCollectionIDsOrder"]; }; }; responses: { /** @description OK */ 200: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminBrandingAsset"]; + "application/json": components["schemas"]["AdminCollectionOrder"]; }; }; /** @description Bad Request */ @@ -31309,6 +32906,17 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Failed */ + 412: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Request Entity Too Large */ 413: { headers: { @@ -31336,6 +32944,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Required */ + 428: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Too Many Requests */ 429: { headers: { @@ -31365,18 +32982,134 @@ export interface operations { }; }; }; - deleteAdminBrandingAsset: { + getAdminCollectionGroup: { parameters: { query?: never; header?: { + "If-Match"?: string; + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; path: { - /** @description Branding asset slot */ - kind: "wordmark" | "mark" | "favicon" | "login_bg"; + /** @description Opaque identifier */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminCollectionGroup"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Acceptable */ + 406: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + deleteAdminCollectionGroup: { + parameters: { + query?: never; + header: { + /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ + "If-Match": string; + /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + "If-None-Match"?: string; + /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ + "X-Profile-Id"?: string; + /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ + "X-Profile-Token"?: string; + }; + path: { + /** @description Opaque identifier */ + id: string; }; cookie?: never; }; @@ -31434,139 +33167,19 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Too Many Requests */ - 429: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Internal Server Error */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Service Unavailable */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - }; - }; - exportAdminCatalog: { - parameters: { - query?: never; - header?: { - /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ - "X-Profile-Id"?: string; - /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ - "X-Profile-Token"?: string; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminCatalogExportRequest"]; - }; - }; - responses: { - /** @description Compressed catalog seed */ - 200: { - headers: { - "Content-Disposition"?: string; - "Content-Length"?: string; - "Content-Type"?: string; - [name: string]: unknown; - }; - content: { - "application/gzip": string; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Forbidden */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Not Acceptable */ - 406: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { + /** @description Precondition Failed */ + 412: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unsupported Media Type */ - 415: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -31574,8 +33187,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + /** @description Precondition Required */ + 428: { headers: { [name: string]: unknown; }; @@ -31612,33 +33225,40 @@ export interface operations { }; }; }; - createCatalogExportJob: { + updateAdminCollectionGroup: { parameters: { query?: never; - header?: { + header: { + /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ + "If-Match": string; + /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description Opaque identifier */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminCatalogExportRequest"]; + "application/json": components["schemas"]["CollectionGroupUpdate"]; }; }; responses: { - /** @description Accepted */ - 202: { + /** @description OK */ + 200: { headers: { - Location?: string; - "Retry-After"?: string; + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminTaskJob"]; + "application/json": components["schemas"]["AdminCollectionGroup"]; }; }; /** @description Bad Request */ @@ -31695,9 +33315,11 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Conflict */ - 409: { + /** @description Precondition Failed */ + 412: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { @@ -31731,6 +33353,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Required */ + 428: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Too Many Requests */ 429: { headers: { @@ -31760,17 +33391,20 @@ export interface operations { }; }; }; - publishCatalogExportJob: { + getAdminCollectionJob: { parameters: { query?: never; header?: { + /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ + "If-Match"?: string; + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; path: { - id: string; + job_id: string; }; cookie?: never; }; @@ -31779,11 +33413,24 @@ export interface operations { /** @description OK */ 200: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + Location?: string; + "Retry-After"?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminCatalogPublished"]; + "application/json": components["schemas"]["AdminJob"]; + }; + }; + /** @description The representation named by If-None-Match is current; no body. */ + 304: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; }; + content?: never; }; /** @description Bad Request */ 400: { @@ -31830,9 +33477,11 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Conflict */ - 409: { + /** @description Precondition Failed */ + 412: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { @@ -31877,9 +33526,12 @@ export interface operations { }; }; }; - importAdminCatalog: { + listAdminCollections: { parameters: { - query?: never; + query?: { + /** @description Opaque identifier */ + library_id?: string; + }; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; @@ -31889,11 +33541,7 @@ export interface operations { path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminCatalogImportRequest"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -31901,7 +33549,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ImportResult"]; + "application/json": components["schemas"]["AdminCollectionList"]; }; }; /** @description Bad Request */ @@ -31949,33 +33597,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -32014,7 +33635,7 @@ export interface operations { }; }; }; - createCatalogImportJob: { + createAdminCollection: { parameters: { query?: never; header?: { @@ -32028,19 +33649,18 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AdminCatalogImportRequest"]; + "application/json": components["schemas"]["AdminCollectionCreate"]; }; }; responses: { - /** @description Accepted */ - 202: { + /** @description Created */ + 201: { headers: { Location?: string; - "Retry-After"?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminTaskJob"]; + "application/json": components["schemas"]["AdminCollection"]; }; }; /** @description Bad Request */ @@ -32097,15 +33717,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Request Entity Too Large */ 413: { headers: { @@ -32162,19 +33773,21 @@ export interface operations { }; }; }; - listCatalogImportSources: { + getAdminCollection: { parameters: { - query?: { - cursor?: string; - limit?: number; - }; + query?: never; header?: { + "If-Match"?: string; + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description Opaque identifier */ + id: string; + }; cookie?: never; }; requestBody?: never; @@ -32182,10 +33795,11 @@ export interface operations { /** @description OK */ 200: { headers: { + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminCatalogSource"]; + "application/json": components["schemas"]["AdminCollection"]; }; }; /** @description Bad Request */ @@ -32271,31 +33885,33 @@ export interface operations { }; }; }; - listLocalCatalogImportSources: { + deleteAdminCollection: { parameters: { - query?: { - cursor?: string; - limit?: number; - }; - header?: { + query?: never; + header: { + /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ + "If-Match": string; + /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description Opaque identifier */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["CollectionAdminCatalogSource"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -32342,6 +33958,17 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Failed */ + 412: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -32351,6 +33978,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Required */ + 428: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Too Many Requests */ 429: { headers: { @@ -32380,27 +34016,40 @@ export interface operations { }; }; }; - getAdminCatalogSearchStatus: { + updateAdminCollection: { parameters: { query?: never; - header?: { + header: { + /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ + "If-Match": string; + /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description Opaque identifier */ + id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminCollectionUpdate"]; + }; + }; responses: { /** @description OK */ 200: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminCatalogSearchRuntimeStatus"]; + "application/json": components["schemas"]["AdminCollection"]; }; }; /** @description Bad Request */ @@ -32448,6 +34097,44 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Precondition Failed */ + 412: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -32457,6 +34144,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Required */ + 428: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Too Many Requests */ 429: { headers: { @@ -32486,12 +34182,9 @@ export interface operations { }; }; }; - getAdminGroupCollectionOrder: { + uploadAdminCollectionBackdrop: { parameters: { - query?: { - /** @description Opaque identifier */ - library_id?: string; - }; + query?: never; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; @@ -32500,11 +34193,15 @@ export interface operations { }; path: { /** @description Opaque identifier */ - group_id: string; + id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["AdminCollectionBackdropForm"]; + }; + }; responses: { /** @description OK */ 200: { @@ -32513,7 +34210,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminCollectionOrder"]; + "application/json": components["schemas"]["AdminCollection"]; }; }; /** @description Bad Request */ @@ -32561,6 +34258,33 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -32599,18 +34323,12 @@ export interface operations { }; }; }; - moveAndReorderAdminGroupCollections: { + deleteAdminCollectionImage: { parameters: { - query?: { - /** @description Opaque identifier */ - library_id?: string; - move_omitted?: string; + query: { + type: "poster" | "backdrop"; }; - header: { - /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ - "If-Match": string; - /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ - "If-None-Match"?: string; + header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ @@ -32618,26 +34336,18 @@ export interface operations { }; path: { /** @description Opaque identifier */ - group_id: string; + id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminCollectionIDsOrder"]; - }; - }; + requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["AdminCollectionOrder"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -32684,44 +34394,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Precondition Failed */ - 412: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -32731,15 +34403,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Required */ - 428: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Too Many Requests */ 429: { headers: { @@ -32769,12 +34432,14 @@ export interface operations { }; }; }; - getAdminCollectionGroup: { + getAdminCollectionItems: { parameters: { - query?: never; + query?: { + cursor?: string; + /** @description Page size; default 50, maximum 200 */ + limit?: number; + }; header?: { - "If-Match"?: string; - "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ @@ -32791,11 +34456,10 @@ export interface operations { /** @description OK */ 200: { headers: { - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminCollectionGroup"]; + "application/json": components["schemas"]["CollectionAdminCollectionMember"]; }; }; /** @description Bad Request */ @@ -32881,14 +34545,10 @@ export interface operations { }; }; }; - deleteAdminCollectionGroup: { + addAdminCollectionItem: { parameters: { query?: never; - header: { - /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ - "If-Match": string; - /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ - "If-None-Match"?: string; + header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ @@ -32897,156 +34557,23 @@ export interface operations { path: { /** @description Opaque identifier */ id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description No Content */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Forbidden */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Not Acceptable */ - 406: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Precondition Failed */ - 412: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unprocessable Entity */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Precondition Required */ - 428: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Too Many Requests */ - 429: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Internal Server Error */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Service Unavailable */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - }; - }; - updateAdminCollectionGroup: { - parameters: { - query?: never; - header: { - /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ - "If-Match": string; - /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ - "If-None-Match"?: string; - /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ - "X-Profile-Id"?: string; - /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ - "X-Profile-Token"?: string; - }; - path: { /** @description Opaque identifier */ - id: string; + item_id: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CollectionGroupUpdate"]; + "application/json": components["schemas"]["AdminCollectionMemberInputBody"]; }; }; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["AdminCollectionGroup"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -33102,17 +34629,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Request Entity Too Large */ 413: { headers: { @@ -33140,15 +34656,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Required */ - 428: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Too Many Requests */ 429: { headers: { @@ -33178,43 +34685,28 @@ export interface operations { }; }; }; - getAdminCollectionJob: { + removeAdminCollectionItem: { parameters: { query?: never; header?: { - /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ - "If-Match"?: string; - "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; path: { - job_id: string; + /** @description Opaque identifier */ + id: string; + /** @description Opaque identifier */ + item_id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ - 200: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - Location?: string; - "Retry-After"?: string; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["AdminJob"]; - }; - }; - /** @description The representation named by If-None-Match is current; no body. */ - 304: { + /** @description No Content */ + 204: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content?: never; @@ -33264,17 +34756,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -33313,19 +34794,21 @@ export interface operations { }; }; }; - listAdminCollections: { + getAdminCollectionItemsOrder: { parameters: { - query?: { - /** @description Opaque identifier */ - library_id?: string; - }; + query?: never; header?: { + "If-Match"?: string; + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description Opaque identifier */ + id: string; + }; cookie?: never; }; requestBody?: never; @@ -33333,10 +34816,11 @@ export interface operations { /** @description OK */ 200: { headers: { + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminCollectionList"]; + "application/json": components["schemas"]["AdminCollectionOrder"]; }; }; /** @description Bad Request */ @@ -33422,32 +34906,40 @@ export interface operations { }; }; }; - createAdminCollection: { + reorderAdminCollectionItems: { parameters: { query?: never; - header?: { + header: { + /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ + "If-Match": string; + /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description Opaque identifier */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminCollectionCreate"]; + "application/json": components["schemas"]["AdminCollectionIDsOrder"]; }; }; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { - Location?: string; + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminCollection"]; + "application/json": components["schemas"]["AdminCollectionOrder"]; }; }; /** @description Bad Request */ @@ -33504,6 +34996,17 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Failed */ + 412: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Request Entity Too Large */ 413: { headers: { @@ -33531,6 +35034,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Required */ + 428: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Too Many Requests */ 429: { headers: { @@ -33560,12 +35072,10 @@ export interface operations { }; }; }; - getAdminCollection: { + uploadAdminCollectionPoster: { parameters: { query?: never; header?: { - "If-Match"?: string; - "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ @@ -33577,7 +35087,11 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["AdminCollectionArtworkForm"]; + }; + }; responses: { /** @description OK */ 200: { @@ -33634,6 +35148,33 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -33672,13 +35213,11 @@ export interface operations { }; }; }; - deleteAdminCollection: { + syncAdminCollection: { parameters: { query?: never; - header: { - /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ - "If-Match": string; - /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + header?: { + "If-Match"?: string; "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; @@ -33693,12 +35232,14 @@ export interface operations { }; requestBody?: never; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["AdminCollectionSyncRun"]; + }; }; /** @description Bad Request */ 400: { @@ -33745,19 +35286,93 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + getAdminCollectionCapabilities: { + parameters: { + query?: never; + header?: { + /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ + "If-Match"?: string; + "If-None-Match"?: string; + /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ + "X-Profile-Id"?: string; + /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ + "X-Profile-Token"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { headers: { + "Cache-Control"?: string; /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ ETag?: string; [name: string]: unknown; }; + content: { + "application/json": components["schemas"]["AdminCollectionCapabilityOutputBody"]; + }; + }; + /** @description The representation named by If-None-Match is current; no body. */ + 304: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; content: { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + /** @description Unauthorized */ + 401: { headers: { [name: string]: unknown; }; @@ -33765,8 +35380,46 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Required */ - 428: { + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Acceptable */ + 406: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Precondition Failed */ + 412: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -33803,40 +35456,32 @@ export interface operations { }; }; }; - updateAdminCollection: { + importAdminMDBList: { parameters: { query?: never; - header: { - /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ - "If-Match": string; - /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ - "If-None-Match"?: string; + header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminCollectionUpdate"]; + "application/json": components["schemas"]["AdminMDBListImport"]; }; }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; + Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminCollection"]; + "application/json": components["schemas"]["AdminCollectionImportResult"]; }; }; /** @description Bad Request */ @@ -33893,17 +35538,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Request Entity Too Large */ 413: { headers: { @@ -33931,15 +35565,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Required */ - 428: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Too Many Requests */ 429: { headers: { @@ -33969,7 +35594,7 @@ export interface operations { }; }; }; - uploadAdminCollectionBackdrop: { + importAdminTMDB: { parameters: { query?: never; header?: { @@ -33978,26 +35603,23 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "multipart/form-data": components["schemas"]["AdminCollectionBackdropForm"]; + "application/json": components["schemas"]["AdminTMDBImport"]; }; }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { - ETag?: string; + Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminCollection"]; + "application/json": components["schemas"]["AdminCollectionImportResult"]; }; }; /** @description Bad Request */ @@ -34110,31 +35732,33 @@ export interface operations { }; }; }; - deleteAdminCollectionImage: { + importAdminTrakt: { parameters: { - query: { - type: "poster" | "backdrop"; - }; + query?: never; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminTraktImport"]; + }; + }; responses: { - /** @description No Content */ - 204: { + /** @description Created */ + 201: { headers: { + Location?: string; [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["AdminCollectionImportResult"]; + }; }; /** @description Bad Request */ 400: { @@ -34181,6 +35805,33 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -34219,12 +35870,13 @@ export interface operations { }; }; }; - getAdminCollectionItems: { + getAdminCollectionOrder: { parameters: { - query?: { - cursor?: string; - /** @description Page size; default 50, maximum 200 */ - limit?: number; + query: { + /** @description Opaque identifier */ + group_id?: string; + /** @description Opaque identifier */ + library_id: string; }; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ @@ -34232,10 +35884,7 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -34243,10 +35892,11 @@ export interface operations { /** @description OK */ 200: { headers: { + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminCollectionMember"]; + "application/json": components["schemas"]["AdminCollectionOrder"]; }; }; /** @description Bad Request */ @@ -34332,35 +35982,38 @@ export interface operations { }; }; }; - addAdminCollectionItem: { + reorderAdminCollections: { parameters: { query?: never; - header?: { + header: { + /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ + "If-Match": string; + /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - id: string; - /** @description Opaque identifier */ - item_id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminCollectionMemberInputBody"]; + "application/json": components["schemas"]["AdminCollectionOrderBody"]; }; }; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["AdminCollectionOrder"]; + }; }; /** @description Bad Request */ 400: { @@ -34416,6 +36069,17 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Failed */ + 412: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Request Entity Too Large */ 413: { headers: { @@ -34443,6 +36107,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Required */ + 428: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Too Many Requests */ 429: { headers: { @@ -34472,7 +36145,7 @@ export interface operations { }; }; }; - removeAdminCollectionItem: { + previewAdminCollection: { parameters: { query?: never; header?: { @@ -34481,22 +36154,23 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - id: string; - /** @description Opaque identifier */ - item_id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["PersonalCollectionPreviewInputBody"]; + }; + }; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["AdminCollectionPreviewOutputBody"]; + }; }; /** @description Bad Request */ 400: { @@ -34543,6 +36217,33 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -34581,21 +36282,16 @@ export interface operations { }; }; }; - getAdminCollectionItemsOrder: { + listAdminCollectionTemplateBundles: { parameters: { query?: never; header?: { - "If-Match"?: string; - "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -34603,11 +36299,10 @@ export interface operations { /** @description OK */ 200: { headers: { - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminCollectionOrder"]; + "application/json": components["schemas"]["BundleCatalog"]; }; }; /** @description Bad Request */ @@ -34693,40 +36388,33 @@ export interface operations { }; }; }; - reorderAdminCollectionItems: { + applyAdminCollectionTemplateBundle: { parameters: { query?: never; - header: { - /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ - "If-Match": string; - /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ - "If-None-Match"?: string; + header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; path: { - /** @description Opaque identifier */ - id: string; + bundle_id: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminCollectionIDsOrder"]; + "application/json": components["schemas"]["AdminTemplateApply"]; }; }; responses: { /** @description OK */ 200: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminCollectionOrder"]; + "application/json": components["schemas"]["AdminTemplateResult"]; }; }; /** @description Bad Request */ @@ -34783,17 +36471,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Request Entity Too Large */ 413: { headers: { @@ -34821,15 +36498,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Required */ - 428: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Too Many Requests */ 429: { headers: { @@ -34859,7 +36527,7 @@ export interface operations { }; }; }; - uploadAdminCollectionPoster: { + startAdminCollectionTemplateBundleJob: { parameters: { query?: never; header?: { @@ -34869,25 +36537,25 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - /** @description Opaque identifier */ - id: string; + bundle_id: string; }; cookie?: never; }; requestBody: { content: { - "multipart/form-data": components["schemas"]["AdminCollectionArtworkForm"]; + "application/json": components["schemas"]["AdminTemplateApply"]; }; }; responses: { - /** @description OK */ - 200: { + /** @description Accepted */ + 202: { headers: { - ETag?: string; + Location?: string; + "Retry-After"?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminCollection"]; + "application/json": components["schemas"]["AdminJob"]; }; }; /** @description Bad Request */ @@ -35000,21 +36668,16 @@ export interface operations { }; }; }; - syncAdminCollection: { + listAdminCollectionTemplates: { parameters: { query?: never; header?: { - "If-Match"?: string; - "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -35025,7 +36688,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminCollectionSyncRun"]; + "application/json": components["schemas"]["CollectionTemplateCatalog"]; }; }; /** @description Bad Request */ @@ -35111,7 +36774,7 @@ export interface operations { }; }; }; - getAdminCollectionCapabilities: { + getAdminDashboardCapabilities: { parameters: { query?: never; header?: { @@ -35137,7 +36800,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminCollectionCapabilityOutputBody"]; + "application/json": components["schemas"]["AdminDashboardCapabilities"]; }; }; /** @description The representation named by If-None-Match is current; no body. */ @@ -35243,10 +36906,13 @@ export interface operations { }; }; }; - importAdminMDBList: { + getAdminDashboardLayout: { parameters: { query?: never; header?: { + /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ + "If-Match"?: string; + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ @@ -35255,22 +36921,28 @@ export interface operations { path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminMDBListImport"]; - }; - }; + requestBody?: never; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { - Location?: string; + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminCollectionImportResult"]; + "application/json": components["schemas"]["AdminDashboardLayout"]; }; }; + /** @description The representation named by If-None-Match is current; no body. */ + 304: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content?: never; + }; /** @description Bad Request */ 400: { headers: { @@ -35316,27 +36988,11 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { + /** @description Precondition Failed */ + 412: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { @@ -35381,10 +37037,14 @@ export interface operations { }; }; }; - importAdminTMDB: { + saveAdminDashboardLayout: { parameters: { query?: never; - header?: { + header: { + /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ + "If-Match": string; + /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ @@ -35395,19 +37055,18 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AdminTMDBImport"]; + "application/json": components["schemas"]["AdminDashboardLayoutSaveBody"]; }; }; responses: { - /** @description Created */ - 201: { + /** @description No Content */ + 204: { headers: { - Location?: string; + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["AdminCollectionImportResult"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -35463,6 +37122,17 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Failed */ + 412: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Request Entity Too Large */ 413: { headers: { @@ -35490,6 +37160,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Required */ + 428: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Too Many Requests */ 429: { headers: { @@ -35519,7 +37198,7 @@ export interface operations { }; }; }; - importAdminTrakt: { + resetAdminDashboardLayout: { parameters: { query?: never; header?: { @@ -35531,21 +37210,14 @@ export interface operations { path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminTraktImport"]; - }; - }; + requestBody?: never; responses: { - /** @description Created */ - 201: { + /** @description No Content */ + 204: { headers: { - Location?: string; [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["AdminCollectionImportResult"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -35592,33 +37264,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -35657,13 +37302,12 @@ export interface operations { }; }; }; - getAdminCollectionOrder: { + listAdminDevices: { parameters: { - query: { - /** @description Opaque identifier */ - group_id?: string; - /** @description Opaque identifier */ - library_id: string; + query?: { + cursor?: string; + /** @description Page size; default 50, maximum 200 */ + limit?: number; }; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ @@ -35679,11 +37323,10 @@ export interface operations { /** @description OK */ 200: { headers: { - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminCollectionOrder"]; + "application/json": components["schemas"]["CollectionAdminDeviceMetadata"]; }; }; /** @description Bad Request */ @@ -35769,37 +37412,31 @@ export interface operations { }; }; }; - reorderAdminCollections: { + getAdminDevice: { parameters: { query?: never; - header: { - /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ - "If-Match": string; - /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ - "If-None-Match"?: string; + header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminCollectionOrderBody"]; + path: { + device_id: string; + /** @description Opaque identifier */ + user_id: string; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description OK */ 200: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminCollectionOrder"]; + "application/json": components["schemas"]["AdminDeviceDetail"]; }; }; /** @description Bad Request */ @@ -35847,44 +37484,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Precondition Failed */ - 412: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -35894,15 +37493,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Required */ - 428: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Too Many Requests */ 429: { headers: { @@ -35932,10 +37522,13 @@ export interface operations { }; }; }; - previewAdminCollection: { + getAdminDeviceCapabilities: { parameters: { query?: never; header?: { + /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ + "If-Match"?: string; + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ @@ -35944,21 +37537,29 @@ export interface operations { path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["PersonalCollectionPreviewInputBody"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { headers: { + "Cache-Control"?: string; + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminCollectionPreviewOutputBody"]; + "application/json": components["schemas"]["AdminDeviceCapabilitiesOutputBody"]; }; }; + /** @description The representation named by If-None-Match is current; no body. */ + 304: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content?: never; + }; /** @description Bad Request */ 400: { headers: { @@ -36004,27 +37605,11 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { + /** @description Precondition Failed */ + 412: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { @@ -36069,9 +37654,19 @@ export interface operations { }; }; }; - listAdminCollectionTemplateBundles: { + listAdminDiagnosticReports: { parameters: { - query?: never; + query?: { + cursor?: string; + from?: string; + /** @description Page size; default 50, maximum 200 */ + limit?: number; + platform?: string; + report_type?: string; + short_id?: string; + to?: string; + user_id?: string; + }; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; @@ -36089,7 +37684,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["BundleCatalog"]; + "application/json": components["schemas"]["CollectionAdminDiagnosticSummary"]; }; }; /** @description Bad Request */ @@ -36175,7 +37770,7 @@ export interface operations { }; }; }; - applyAdminCollectionTemplateBundle: { + getAdminDiagnosticReport: { parameters: { query?: never; header?: { @@ -36185,15 +37780,11 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - bundle_id: string; + id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminTemplateApply"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -36201,7 +37792,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminTemplateResult"]; + "application/json": components["schemas"]["AdminDiagnosticDetail"]; }; }; /** @description Bad Request */ @@ -36249,33 +37840,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -36314,7 +37878,7 @@ export interface operations { }; }; }; - startAdminCollectionTemplateBundleJob: { + deleteAdminDiagnosticReport: { parameters: { query?: never; header?: { @@ -36324,26 +37888,18 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - bundle_id: string; + id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminTemplateApply"]; - }; - }; + requestBody?: never; responses: { - /** @description Accepted */ - 202: { + /** @description No Content */ + 204: { headers: { - Location?: string; - "Retry-After"?: string; [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["AdminJob"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -36390,33 +37946,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -36455,7 +37984,7 @@ export interface operations { }; }; }; - listAdminCollectionTemplates: { + downloadAdminDiagnosticReport: { parameters: { query?: never; header?: { @@ -36464,18 +37993,23 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ + /** @description Complete gzip-compressed diagnostic bundle */ 200: { headers: { + "Accept-Ranges"?: string; + "Content-Disposition"?: string; + "Content-Length"?: number; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionTemplateCatalog"]; + "application/gzip": string; }; }; /** @description Bad Request */ @@ -36514,8 +38048,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Acceptable */ - 406: { + /** @description Conflict */ + 409: { headers: { [name: string]: unknown; }; @@ -36561,13 +38095,10 @@ export interface operations { }; }; }; - getAdminDashboardCapabilities: { + sendAdminTestEmail: { parameters: { query?: never; header?: { - /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ - "If-Match"?: string; - "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ @@ -36576,28 +38107,20 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminEmailTestInputBody"]; + }; + }; responses: { /** @description OK */ 200: { headers: { - "Cache-Control"?: string; - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminDashboardCapabilities"]; - }; - }; - /** @description The representation named by If-None-Match is current; no body. */ - 304: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; + "application/json": components["schemas"]["AdminEmailTestResponse"]; }; - content?: never; }; /** @description Bad Request */ 400: { @@ -36644,11 +38167,27 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { @@ -36693,42 +38232,35 @@ export interface operations { }; }; }; - getAdminDashboardLayout: { + contributeAdminFileMarkers: { parameters: { query?: never; header?: { - /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ - "If-Match"?: string; - "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description Opaque identifier */ + fileId: string; + }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["AdminMarkerContributionRequest"]; + }; + }; responses: { /** @description OK */ 200: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminDashboardLayout"]; - }; - }; - /** @description The representation named by If-None-Match is current; no body. */ - 304: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; + "application/json": components["schemas"]["AdminMarkerContributionOutcomes"]; }; - content?: never; }; /** @description Bad Request */ 400: { @@ -36775,11 +38307,27 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { @@ -36824,36 +38372,35 @@ export interface operations { }; }; }; - saveAdminDashboardLayout: { + listAdminFileMarkerContributions: { parameters: { - query?: never; - header: { - /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ - "If-Match": string; - /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ - "If-None-Match"?: string; + query?: { + cursor?: string; + /** @description Page size; default 50, maximum 200 */ + limit?: number; + }; + header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminDashboardLayoutSaveBody"]; + path: { + /** @description Opaque identifier */ + fileId: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["CollectionAdminMarkerContribution"]; + }; }; /** @description Bad Request */ 400: { @@ -36900,44 +38447,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Precondition Failed */ - 412: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -36947,15 +38456,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Required */ - 428: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Too Many Requests */ 429: { headers: { @@ -36985,9 +38485,14 @@ export interface operations { }; }; }; - resetAdminDashboardLayout: { + browseAdminFilesystem: { parameters: { - query?: never; + query?: { + cursor?: string; + limit?: number; + name_prefix?: string; + path?: string; + }; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; @@ -36999,12 +38504,14 @@ export interface operations { }; requestBody?: never; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["AdminFilesystemPage"]; + }; }; /** @description Bad Request */ 400: { @@ -37089,9 +38596,10 @@ export interface operations { }; }; }; - listAdminDevices: { + listAdminHistoryImportSources: { parameters: { query?: { + /** @description Opaque cursor from page.next_cursor */ cursor?: string; /** @description Page size; default 50, maximum 200 */ limit?: number; @@ -37113,7 +38621,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminDeviceMetadata"]; + "application/json": components["schemas"]["CollectionAdminHistoryImportSource"]; }; }; /** @description Bad Request */ @@ -37199,7 +38707,7 @@ export interface operations { }; }; }; - getAdminDevice: { + createAdminHistoryImportSource: { parameters: { query?: never; header?: { @@ -37208,22 +38716,24 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - device_id: string; - /** @description Opaque identifier */ - user_id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminHistoryImportSourceCreateInputBody"]; + }; + }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { + ETag?: string; + Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminDeviceDetail"]; + "application/json": components["schemas"]["AdminHistoryImportSource"]; }; }; /** @description Bad Request */ @@ -37271,6 +38781,33 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -37309,19 +38846,19 @@ export interface operations { }; }; }; - getAdminDeviceCapabilities: { + getAdminHistoryImportSource: { parameters: { query?: never; header?: { - /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ - "If-Match"?: string; - "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description Opaque identifier */ + id: string; + }; cookie?: never; }; requestBody?: never; @@ -37329,23 +38866,13 @@ export interface operations { /** @description OK */ 200: { headers: { - "Cache-Control"?: string; - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ ETag?: string; + Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminDeviceCapabilitiesOutputBody"]; - }; - }; - /** @description The representation named by If-None-Match is current; no body. */ - 304: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; + "application/json": components["schemas"]["AdminHistoryImportSource"]; }; - content?: never; }; /** @description Bad Request */ 400: { @@ -37392,17 +38919,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -37441,37 +38957,41 @@ export interface operations { }; }; }; - listAdminDiagnosticReports: { + updateAdminHistoryImportSource: { parameters: { - query?: { - cursor?: string; - from?: string; - /** @description Page size; default 50, maximum 200 */ - limit?: number; - platform?: string; - report_type?: string; - short_id?: string; - to?: string; - user_id?: string; - }; - header?: { + query?: never; + header: { + /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ + "If-Match": string; + /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description Opaque identifier */ + id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminHistoryImportSourceUpdateInputBody"]; + }; + }; responses: { /** @description OK */ 200: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminDiagnosticSummary"]; + "application/json": components["schemas"]["AdminHistoryImportSource"]; }; }; /** @description Bad Request */ @@ -37519,71 +39039,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Too Many Requests */ - 429: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Internal Server Error */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Service Unavailable */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - }; - }; - getAdminDiagnosticReport: { - parameters: { - query?: never; - header?: { - /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ - "X-Profile-Id"?: string; - /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ - "X-Profile-Token"?: string; - }; - path: { - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["AdminDiagnosticDetail"]; - }; - }; - /** @description Bad Request */ - 400: { + /** @description Request Timeout */ + 408: { headers: { [name: string]: unknown; }; @@ -37591,17 +39048,19 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized */ - 401: { + /** @description Precondition Failed */ + 412: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden */ - 403: { + /** @description Request Entity Too Large */ + 413: { headers: { [name: string]: unknown; }; @@ -37609,8 +39068,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Found */ - 404: { + /** @description Unsupported Media Type */ + 415: { headers: { [name: string]: unknown; }; @@ -37618,8 +39077,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Acceptable */ - 406: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -37627,8 +39086,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + /** @description Precondition Required */ + 428: { headers: { [name: string]: unknown; }; @@ -37665,16 +39124,21 @@ export interface operations { }; }; }; - deleteAdminDiagnosticReport: { + deleteAdminHistoryImportSource: { parameters: { query?: never; - header?: { + header: { + /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ + "If-Match": string; + /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; path: { + /** @description Opaque identifier */ id: string; }; cookie?: never; @@ -37733,6 +39197,17 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Failed */ + 412: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -37742,6 +39217,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Required */ + 428: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Too Many Requests */ 429: { headers: { @@ -37771,33 +39255,43 @@ export interface operations { }; }; }; - downloadAdminDiagnosticReport: { + getAdminHistoryImportCapabilities: { parameters: { query?: never; header?: { + /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ + "If-Match"?: string; + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Complete gzip-compressed diagnostic bundle */ + /** @description OK */ 200: { headers: { - "Accept-Ranges"?: string; - "Content-Disposition"?: string; - "Content-Length"?: number; + "Cache-Control"?: string; + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { - "application/gzip": string; + "application/json": components["schemas"]["AdminHistoryImportCapabilitiesOutputBody"]; + }; + }; + /** @description The representation named by If-None-Match is current; no body. */ + 304: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; }; + content?: never; }; /** @description Bad Request */ 400: { @@ -37835,8 +39329,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Conflict */ - 409: { + /** @description Not Acceptable */ + 406: { headers: { [name: string]: unknown; }; @@ -37844,6 +39338,17 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Failed */ + 412: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -37882,9 +39387,15 @@ export interface operations { }; }; }; - sendAdminTestEmail: { + listAdminHistoryImportMappings: { parameters: { - query?: never; + query?: { + cursor?: string; + /** @description Page size; default 50, maximum 200 */ + limit?: number; + /** @description Opaque identifier */ + source_id?: string; + }; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; @@ -37894,11 +39405,7 @@ export interface operations { path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminEmailTestInputBody"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -37906,7 +39413,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminEmailTestResponse"]; + "application/json": components["schemas"]["CollectionAdminHistoryImportMapping"]; }; }; /** @description Bad Request */ @@ -37954,33 +39461,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -38019,7 +39499,7 @@ export interface operations { }; }; }; - contributeAdminFileMarkers: { + createAdminHistoryImportMapping: { parameters: { query?: never; header?: { @@ -38028,25 +39508,24 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - fileId: string; - }; + path?: never; cookie?: never; }; - requestBody?: { + requestBody: { content: { - "application/json": components["schemas"]["AdminMarkerContributionRequest"]; + "application/json": components["schemas"]["AdminHistoryImportMappingCreateInputBody"]; }; }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { + ETag?: string; + Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminMarkerContributionOutcomes"]; + "application/json": components["schemas"]["AdminHistoryImportMapping"]; }; }; /** @description Bad Request */ @@ -38159,13 +39638,9 @@ export interface operations { }; }; }; - listAdminFileMarkerContributions: { + getAdminHistoryImportMapping: { parameters: { - query?: { - cursor?: string; - /** @description Page size; default 50, maximum 200 */ - limit?: number; - }; + query?: never; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; @@ -38174,7 +39649,7 @@ export interface operations { }; path: { /** @description Opaque identifier */ - fileId: string; + id: string; }; cookie?: never; }; @@ -38183,10 +39658,12 @@ export interface operations { /** @description OK */ 200: { headers: { + ETag?: string; + Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminMarkerContribution"]; + "application/json": components["schemas"]["AdminHistoryImportMapping"]; }; }; /** @description Bad Request */ @@ -38272,143 +39749,41 @@ export interface operations { }; }; }; - browseAdminFilesystem: { + updateAdminHistoryImportMapping: { parameters: { - query?: { - cursor?: string; - limit?: number; - name_prefix?: string; - path?: string; - }; - header?: { + query?: never; + header: { + /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ + "If-Match": string; + /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["AdminFilesystemPage"]; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Forbidden */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Not Acceptable */ - 406: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unprocessable Entity */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Too Many Requests */ - 429: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Internal Server Error */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Service Unavailable */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; + path: { + /** @description Opaque identifier */ + id: string; }; + cookie?: never; }; - }; - listAdminHistoryImportSources: { - parameters: { - query?: { - /** @description Opaque cursor from page.next_cursor */ - cursor?: string; - /** @description Page size; default 50, maximum 200 */ - limit?: number; - }; - header?: { - /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ - "X-Profile-Id"?: string; - /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ - "X-Profile-Token"?: string; + requestBody: { + content: { + "application/json": components["schemas"]["AdminHistoryImportMappingUpdateInputBody"]; }; - path?: never; - cookie?: never; }; - requestBody?: never; responses: { /** @description OK */ 200: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminHistoryImportSource"]; + "application/json": components["schemas"]["AdminHistoryImportMapping"]; }; }; /** @description Bad Request */ @@ -38456,6 +39831,44 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Precondition Failed */ + 412: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -38465,6 +39878,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Required */ + 428: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Too Many Requests */ 429: { headers: { @@ -38494,34 +39916,33 @@ export interface operations { }; }; }; - createAdminHistoryImportSource: { + deleteAdminHistoryImportMapping: { parameters: { query?: never; - header?: { + header: { + /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ + "If-Match": string; + /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminHistoryImportSourceCreateInputBody"]; + path: { + /** @description Opaque identifier */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Created */ - 201: { + /** @description No Content */ + 204: { headers: { - ETag?: string; - Location?: string; [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["AdminHistoryImportSource"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -38568,26 +39989,19 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { + /** @description Precondition Failed */ + 412: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unsupported Media Type */ - 415: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -38595,8 +40009,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + /** @description Precondition Required */ + 428: { headers: { [name: string]: unknown; }; @@ -38633,7 +40047,7 @@ export interface operations { }; }; }; - getAdminHistoryImportSource: { + createAdminHistoryImportRun: { parameters: { query?: never; header?: { @@ -38650,15 +40064,16 @@ export interface operations { }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description Accepted */ + 202: { headers: { ETag?: string; Location?: string; + "Retry-After"?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminHistoryImportSource"]; + "application/json": components["schemas"]["AdminHistoryImportRun"]; }; }; /** @description Bad Request */ @@ -38744,41 +40159,31 @@ export interface operations { }; }; }; - updateAdminHistoryImportSource: { + loginAdminHistoryImportPlex: { parameters: { query?: never; - header: { - /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ - "If-Match": string; - /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ - "If-None-Match"?: string; + header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminHistoryImportSourceUpdateInputBody"]; + "application/json": components["schemas"]["AdminHistoryImportPlexLoginInputBody"]; }; }; responses: { /** @description OK */ 200: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminHistoryImportSource"]; + "application/json": components["schemas"]["AdminHistoryImportPlexLoginOutputBody"]; }; }; /** @description Bad Request */ @@ -38835,17 +40240,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Request Entity Too Large */ 413: { headers: { @@ -38873,15 +40267,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Required */ - 428: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Too Many Requests */ 429: { headers: { @@ -38911,33 +40296,34 @@ export interface operations { }; }; }; - deleteAdminHistoryImportSource: { + listAdminHistoryImportRuns: { parameters: { - query?: never; - header: { - /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ - "If-Match": string; - /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ - "If-None-Match"?: string; + query?: { + cursor?: string; + /** @description Page size; default 50, maximum 200 */ + limit?: number; + /** @description Opaque identifier */ + source_id?: string; + }; + header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["CollectionAdminHistoryImportRun"]; + }; }; /** @description Bad Request */ 400: { @@ -38984,17 +40370,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -39004,15 +40379,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Required */ - 428: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Too Many Requests */ 429: { headers: { @@ -39042,7 +40408,7 @@ export interface operations { }; }; }; - getAdminHistoryImportCapabilities: { + getAdminHistoryImportRun: { parameters: { query?: never; header?: { @@ -39054,7 +40420,10 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description The run */ + id: string; + }; cookie?: never; }; requestBody?: never; @@ -39062,13 +40431,14 @@ export interface operations { /** @description OK */ 200: { headers: { - "Cache-Control"?: string; /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ ETag?: string; + Location?: string; + "Retry-After"?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminHistoryImportCapabilitiesOutputBody"]; + "application/json": components["schemas"]["AdminHistoryImportRun"]; }; }; /** @description The representation named by If-None-Match is current; no body. */ @@ -39174,33 +40544,42 @@ export interface operations { }; }; }; - listAdminHistoryImportMappings: { + cancelAdminHistoryImportRun: { parameters: { - query?: { - cursor?: string; - /** @description Page size; default 50, maximum 200 */ - limit?: number; - /** @description Opaque identifier */ - source_id?: string; - }; + query?: never; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description The run */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ + /** @description The run is already canceled. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminHistoryImportMapping"]; + "application/json": components["schemas"]["AdminHistoryImportRun"]; + }; + }; + /** @description Accepted */ + 202: { + headers: { + ETag?: string; + Location?: string; + "Retry-After"?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminHistoryImportRun"]; }; }; /** @description Bad Request */ @@ -39286,7 +40665,7 @@ export interface operations { }; }; }; - createAdminHistoryImportMapping: { + bulkCreateAdminHistoryImportRuns: { parameters: { query?: never; header?: { @@ -39295,24 +40674,21 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminHistoryImportMappingCreateInputBody"]; + path: { + /** @description Opaque identifier */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { - ETag?: string; - Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminHistoryImportMapping"]; + "application/json": components["schemas"]["AdminHistoryImportBulkOutputBody"]; }; }; /** @description Bad Request */ @@ -39360,33 +40736,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -39425,10 +40774,14 @@ export interface operations { }; }; }; - getAdminHistoryImportMapping: { + setAdminHistoryImportToken: { parameters: { query?: never; - header?: { + header: { + /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ + "If-Match": string; + /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ @@ -39440,17 +40793,22 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminHistoryImportTokenInputBody"]; + }; + }; responses: { /** @description OK */ 200: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ ETag?: string; Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminHistoryImportMapping"]; + "application/json": components["schemas"]["AdminHistoryImportSource"]; }; }; /** @description Bad Request */ @@ -39498,6 +40856,44 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Precondition Failed */ + 412: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -39507,6 +40903,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Required */ + 428: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Too Many Requests */ 429: { headers: { @@ -39536,7 +40941,7 @@ export interface operations { }; }; }; - updateAdminHistoryImportMapping: { + clearAdminHistoryImportToken: { parameters: { query?: never; header: { @@ -39555,23 +40960,14 @@ export interface operations { }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminHistoryImportMappingUpdateInputBody"]; - }; - }; + requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - Location?: string; [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["AdminHistoryImportMapping"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -39618,15 +41014,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Precondition Failed */ 412: { headers: { @@ -39638,24 +41025,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -39703,14 +41072,14 @@ export interface operations { }; }; }; - deleteAdminHistoryImportMapping: { + listAdminHistoryImportExternalUsers: { parameters: { - query?: never; - header: { - /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ - "If-Match": string; - /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ - "If-None-Match"?: string; + query?: { + cursor?: string; + /** @description Page size; default 50, maximum 200 */ + limit?: number; + }; + header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ @@ -39724,12 +41093,14 @@ export interface operations { }; requestBody?: never; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["CollectionExternalUser"]; + }; }; /** @description Bad Request */ 400: { @@ -39776,11 +41147,9 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { + /** @description Conflict */ + 409: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { @@ -39796,15 +41165,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Required */ - 428: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Too Many Requests */ 429: { headers: { @@ -39834,33 +41194,32 @@ export interface operations { }; }; }; - createAdminHistoryImportRun: { + listAdminInvitations: { parameters: { - query?: never; + query?: { + /** @description Opaque cursor from page.next_cursor */ + cursor?: string; + /** @description Page size; default 50, maximum 200 */ + limit?: number; + }; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Accepted */ - 202: { + /** @description OK */ + 200: { headers: { - ETag?: string; - Location?: string; - "Retry-After"?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminHistoryImportRun"]; + "application/json": components["schemas"]["CollectionAdminInvitation"]; }; }; /** @description Bad Request */ @@ -39908,6 +41267,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -39946,7 +41314,7 @@ export interface operations { }; }; }; - loginAdminHistoryImportPlex: { + createAdminInvitation: { parameters: { query?: never; header?: { @@ -39960,17 +41328,18 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AdminHistoryImportPlexLoginInputBody"]; + "application/json": components["schemas"]["AdminInvitationCreateInputBody"]; }; }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { + Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminHistoryImportPlexLoginOutputBody"]; + "application/json": components["schemas"]["InvitationDelivery"]; }; }; /** @description Bad Request */ @@ -40027,6 +41396,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Request Entity Too Large */ 413: { headers: { @@ -40072,6 +41450,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Service Unavailable */ 503: { headers: { @@ -40083,22 +41470,19 @@ export interface operations { }; }; }; - listAdminHistoryImportRuns: { + getAdminInvitation: { parameters: { - query?: { - cursor?: string; - /** @description Page size; default 50, maximum 200 */ - limit?: number; - /** @description Opaque identifier */ - source_id?: string; - }; + query?: never; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description Opaque identifier */ + id: string; + }; cookie?: never; }; requestBody?: never; @@ -40109,7 +41493,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminHistoryImportRun"]; + "application/json": components["schemas"]["AdminInvitation"]; }; }; /** @description Bad Request */ @@ -40157,6 +41541,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -40195,47 +41588,148 @@ export interface operations { }; }; }; - getAdminHistoryImportRun: { + revokeAdminInvitation: { parameters: { query?: never; header?: { - /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ - "If-Match"?: string; - "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; path: { - /** @description The run */ + /** @description Opaque identifier */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - Location?: string; - "Retry-After"?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminHistoryImportRun"]; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description The representation named by If-None-Match is current; no body. */ - 304: { + /** @description Unauthorized */ + 401: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; - content?: never; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Acceptable */ + 406: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + resendAdminInvitation: { + parameters: { + query?: never; + header?: { + /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ + "X-Profile-Id"?: string; + /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ + "X-Profile-Token"?: string; + }; + path: { + /** @description Opaque identifier */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Created */ + 201: { + headers: { + Location?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InvitationDelivery"]; + }; }; /** @description Bad Request */ 400: { @@ -40282,11 +41776,9 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { + /** @description Conflict */ + 409: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { @@ -40320,6 +41812,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Service Unavailable */ 503: { headers: { @@ -40331,43 +41832,43 @@ export interface operations { }; }; }; - cancelAdminHistoryImportRun: { + getAdminInvitationCapabilities: { parameters: { query?: never; header?: { + /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ + "If-Match"?: string; + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description The run */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description The run is already canceled. */ + /** @description OK */ 200: { headers: { + "Cache-Control"?: string; + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminHistoryImportRun"]; + "application/json": components["schemas"]["InvitationCapabilities"]; }; }; - /** @description Accepted */ - 202: { + /** @description The representation named by If-None-Match is current; no body. */ + 304: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ ETag?: string; - Location?: string; - "Retry-After"?: string; [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["AdminHistoryImportRun"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -40414,6 +41915,26 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Precondition Failed */ + 412: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -40452,19 +41973,21 @@ export interface operations { }; }; }; - bulkCreateAdminHistoryImportRuns: { + listAdminInviteCodes: { parameters: { - query?: never; + query?: { + /** @description Opaque cursor from page.next_cursor */ + cursor?: string; + /** @description Page size; default 50, maximum 200 */ + limit?: number; + }; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -40475,7 +41998,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminHistoryImportBulkOutputBody"]; + "application/json": components["schemas"]["CollectionAdminInviteCode"]; }; }; /** @description Bad Request */ @@ -40561,41 +42084,31 @@ export interface operations { }; }; }; - setAdminHistoryImportToken: { + createAdminInviteCode: { parameters: { query?: never; - header: { - /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ - "If-Match": string; - /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ - "If-None-Match"?: string; + header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminHistoryImportTokenInputBody"]; + "application/json": components["schemas"]["AdminInviteCodeCreateInputBody"]; }; }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminHistoryImportSource"]; + "application/json": components["schemas"]["AdminInviteCode"]; }; }; /** @description Bad Request */ @@ -40652,17 +42165,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Request Entity Too Large */ 413: { headers: { @@ -40690,15 +42192,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Required */ - 428: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Too Many Requests */ 429: { headers: { @@ -40728,14 +42221,10 @@ export interface operations { }; }; }; - clearAdminHistoryImportToken: { + updateAdminInviteCode: { parameters: { query?: never; - header: { - /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ - "If-Match": string; - /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ - "If-None-Match"?: string; + header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ @@ -40747,7 +42236,11 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminInviteCodeUpdateInputBody"]; + }; + }; responses: { /** @description No Content */ 204: { @@ -40801,19 +42294,17 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { + /** @description Request Timeout */ + 408: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + /** @description Request Entity Too Large */ + 413: { headers: { [name: string]: unknown; }; @@ -40821,8 +42312,17 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Required */ - 428: { + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -40859,13 +42359,9 @@ export interface operations { }; }; }; - listAdminHistoryImportExternalUsers: { + deleteAdminInviteCode: { parameters: { - query?: { - cursor?: string; - /** @description Page size; default 50, maximum 200 */ - limit?: number; - }; + query?: never; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; @@ -40880,14 +42376,12 @@ export interface operations { }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["CollectionExternalUser"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -40934,15 +42428,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -40981,24 +42466,26 @@ export interface operations { }; }; }; - listAdminInvitations: { + topUpAdminInviteCode: { parameters: { - query?: { - /** @description Opaque cursor from page.next_cursor */ - cursor?: string; - /** @description Page size; default 50, maximum 200 */ - limit?: number; - }; + query?: never; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description Opaque identifier */ + id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminInviteCodeTopUpInputBody"]; + }; + }; responses: { /** @description OK */ 200: { @@ -41006,7 +42493,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminInvitation"]; + "application/json": components["schemas"]["AdminInviteCode"]; }; }; /** @description Bad Request */ @@ -41054,8 +42541,26 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Conflict */ - 409: { + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { headers: { [name: string]: unknown; }; @@ -41101,10 +42606,13 @@ export interface operations { }; }; }; - createAdminInvitation: { + getAdminInviteCodeCapabilities: { parameters: { query?: never; header?: { + /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ + "If-Match"?: string; + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ @@ -41113,21 +42621,28 @@ export interface operations { path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminInvitationCreateInputBody"]; - }; - }; + requestBody?: never; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { - Location?: string; + "Cache-Control"?: string; + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["InvitationDelivery"]; + "application/json": components["schemas"]["AdminInviteCodeCapabilitiesOutputBody"]; + }; + }; + /** @description The representation named by If-None-Match is current; no body. */ + 304: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; }; + content?: never; }; /** @description Bad Request */ 400: { @@ -41174,17 +42689,19 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { + /** @description Precondition Failed */ + 412: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Conflict */ - 409: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -41192,8 +42709,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Entity Too Large */ - 413: { + /** @description Too Many Requests */ + 429: { headers: { [name: string]: unknown; }; @@ -41201,8 +42718,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unsupported Media Type */ - 415: { + /** @description Internal Server Error */ + 500: { headers: { [name: string]: unknown; }; @@ -41210,8 +42727,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + /** @description Service Unavailable */ + 503: { headers: { [name: string]: unknown; }; @@ -41219,8 +42736,39 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Too Many Requests */ - 429: { + }; + }; + listAdminIPUsers: { + parameters: { + query: { + cursor?: string; + days?: number; + ip: string; + /** @description Page size; default 50, maximum 200 */ + limit?: number; + }; + header?: { + /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ + "X-Profile-Id"?: string; + /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ + "X-Profile-Token"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CollectionAdminIPUser"]; + }; + }; + /** @description Bad Request */ + 400: { headers: { [name: string]: unknown; }; @@ -41228,8 +42776,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal Server Error */ - 500: { + /** @description Unauthorized */ + 401: { headers: { [name: string]: unknown; }; @@ -41237,8 +42785,53 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Implemented */ - 501: { + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Acceptable */ + 406: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Internal Server Error */ + 500: { headers: { [name: string]: unknown; }; @@ -41257,9 +42850,13 @@ export interface operations { }; }; }; - getAdminInvitation: { + listAdminItemFiles: { parameters: { - query?: never; + query?: { + cursor?: string; + /** @description Page size; default 50, maximum 200 */ + limit?: number; + }; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; @@ -41267,7 +42864,6 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - /** @description Opaque identifier */ id: string; }; cookie?: never; @@ -41280,7 +42876,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminInvitation"]; + "application/json": components["schemas"]["CollectionAdminItemFile"]; }; }; /** @description Bad Request */ @@ -41328,15 +42924,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -41375,9 +42962,13 @@ export interface operations { }; }; }; - revokeAdminInvitation: { + listAdminItemImages: { parameters: { - query?: never; + query?: { + cursor?: string; + /** @description Page size; default 50, maximum 200 */ + limit?: number; + }; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; @@ -41385,19 +42976,20 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - /** @description Opaque identifier */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["AdminImagesPage"]; + }; }; /** @description Bad Request */ 400: { @@ -41444,15 +43036,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -41491,7 +43074,7 @@ export interface operations { }; }; }; - resendAdminInvitation: { + applyAdminItemImage: { parameters: { query?: never; header?: { @@ -41501,21 +43084,23 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - /** @description Opaque identifier */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminImageApplyInputBody"]; + }; + }; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { - Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["InvitationDelivery"]; + "application/json": components["schemas"]["AdminImageApplied"]; }; }; /** @description Bad Request */ @@ -41563,8 +43148,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Conflict */ - 409: { + /** @description Request Timeout */ + 408: { headers: { [name: string]: unknown; }; @@ -41572,8 +43157,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + /** @description Request Entity Too Large */ + 413: { headers: { [name: string]: unknown; }; @@ -41581,8 +43166,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Too Many Requests */ - 429: { + /** @description Unsupported Media Type */ + 415: { headers: { [name: string]: unknown; }; @@ -41590,8 +43175,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal Server Error */ - 500: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -41599,8 +43184,17 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Implemented */ - 501: { + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Internal Server Error */ + 500: { headers: { [name: string]: unknown; }; @@ -41619,43 +43213,34 @@ export interface operations { }; }; }; - getAdminInvitationCapabilities: { + applyAdminItemMatch: { parameters: { query?: never; header?: { - /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ - "If-Match"?: string; - "If-None-Match"?: string; - /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ + /** @description Optional. When present, it must name a profile of the authenticated account. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminMatchApplyInputBody"]; + }; + }; responses: { /** @description OK */ 200: { headers: { - "Cache-Control"?: string; - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["InvitationCapabilities"]; - }; - }; - /** @description The representation named by If-None-Match is current; no body. */ - 304: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; + "application/json": components["schemas"]["AdminMatchApplied"]; }; - content?: never; }; /** @description Bad Request */ 400: { @@ -41702,8 +43287,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Conflict */ - 409: { + /** @description Request Timeout */ + 408: { headers: { [name: string]: unknown; }; @@ -41711,11 +43296,18 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { @@ -41760,24 +43352,25 @@ export interface operations { }; }; }; - listAdminInviteCodes: { + searchAdminItemMatches: { parameters: { - query?: { - /** @description Opaque cursor from page.next_cursor */ - cursor?: string; - /** @description Page size; default 50, maximum 200 */ - limit?: number; - }; + query?: never; header?: { - /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ + /** @description Optional. When present, it must name a profile of the authenticated account. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminMatchSearchBody"]; + }; + }; responses: { /** @description OK */ 200: { @@ -41785,7 +43378,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminInviteCode"]; + "application/json": components["schemas"]["AdminMatchCandidates"]; }; }; /** @description Bad Request */ @@ -41833,6 +43426,33 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -41871,7 +43491,7 @@ export interface operations { }; }; }; - createAdminInviteCode: { + mergeAdminItem: { parameters: { query?: never; header?: { @@ -41880,22 +43500,24 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminInviteCodeCreateInputBody"]; + "application/json": components["schemas"]["AdminMergeInputBody"]; }; }; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminInviteCode"]; + "application/json": components["schemas"]["AdminMergeResult"]; }; }; /** @description Bad Request */ @@ -42008,33 +43630,34 @@ export interface operations { }; }; }; - updateAdminInviteCode: { + updateAdminItemMetadata: { parameters: { query?: never; header?: { - /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ + /** @description Optional. When present, it must name a profile of the authenticated account. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; path: { - /** @description Opaque identifier */ id: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminInviteCodeUpdateInputBody"]; + "application/json": components["schemas"]["AdminItemMetadataUpdate"]; }; }; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["CatalogItemDetail"]; + }; }; /** @description Bad Request */ 400: { @@ -42146,29 +43769,35 @@ export interface operations { }; }; }; - deleteAdminInviteCode: { + translateAdminItemMetadata: { parameters: { query?: never; header?: { - /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ + /** @description Optional. When present, it must name a profile of the authenticated account. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; path: { - /** @description Opaque identifier */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminTranslateMetadataInputBody"]; + }; + }; responses: { - /** @description No Content */ - 204: { + /** @description Accepted */ + 202: { headers: { + Location?: string; [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["MetadataTranslationJob"]; + }; }; /** @description Bad Request */ 400: { @@ -42215,6 +43844,33 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -42253,26 +43909,21 @@ export interface operations { }; }; }; - topUpAdminInviteCode: { + listAdminMetadataTranslationJobs: { parameters: { query?: never; header?: { - /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ + /** @description Optional. When present, it must name a profile of the authenticated account. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; path: { - /** @description Opaque identifier */ id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminInviteCodeTopUpInputBody"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -42280,7 +43931,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminInviteCode"]; + "application/json": components["schemas"]["AdminMetadataTranslationJobs"]; }; }; /** @description Bad Request */ @@ -42328,33 +43979,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -42393,40 +44017,27 @@ export interface operations { }; }; }; - getAdminInviteCodeCapabilities: { + cancelAdminMetadataTranslation: { parameters: { query?: never; header?: { - /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ - "If-Match"?: string; - "If-None-Match"?: string; - /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ + /** @description Optional. When present, it must name a profile of the authenticated account. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + id: string; + /** @description Opaque identifier */ + job_id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ - 200: { - headers: { - "Cache-Control"?: string; - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["AdminInviteCodeCapabilitiesOutputBody"]; - }; - }; - /** @description The representation named by If-None-Match is current; no body. */ - 304: { + /** @description No Content */ + 204: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content?: never; @@ -42476,17 +44087,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -42525,33 +44125,29 @@ export interface operations { }; }; }; - listAdminIPUsers: { + redetectAdminEpisodeIntro: { parameters: { - query: { - cursor?: string; - days?: number; - ip: string; - /** @description Page size; default 50, maximum 200 */ - limit?: number; - }; + query?: never; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description Accepted */ + 202: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminIPUser"]; + "application/json": components["schemas"]["AdminEpisodeMarkersStatus"]; }; }; /** @description Bad Request */ @@ -42637,13 +44233,9 @@ export interface operations { }; }; }; - listAdminItemFiles: { + refreshAdminEpisodeMarkers: { parameters: { - query?: { - cursor?: string; - /** @description Page size; default 50, maximum 200 */ - limit?: number; - }; + query?: never; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; @@ -42657,13 +44249,13 @@ export interface operations { }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description Accepted */ + 202: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminItemFile"]; + "application/json": components["schemas"]["AdminEpisodeMarkersStatus"]; }; }; /** @description Bad Request */ @@ -42749,15 +44341,11 @@ export interface operations { }; }; }; - listAdminItemImages: { + refreshAdminItemMetadata: { parameters: { - query?: { - cursor?: string; - /** @description Page size; default 50, maximum 200 */ - limit?: number; - }; + query?: never; header?: { - /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ + /** @description Optional. When present, it must name a profile of the authenticated account. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; @@ -42767,15 +44355,21 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminItemMetadataRefreshInputBody"]; + }; + }; responses: { - /** @description OK */ - 200: { + /** @description Accepted */ + 202: { headers: { + Location?: string; + "Retry-After"?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminImagesPage"]; + "application/json": components["schemas"]["AdminTaskJob"]; }; }; /** @description Bad Request */ @@ -42823,6 +44417,33 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -42861,7 +44482,7 @@ export interface operations { }; }; }; - applyAdminItemImage: { + splitAdminItem: { parameters: { query?: never; header?: { @@ -42877,7 +44498,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AdminImageApplyInputBody"]; + "application/json": components["schemas"]["AdminSplitRequest"]; }; }; responses: { @@ -42887,7 +44508,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminImageApplied"]; + "application/json": components["schemas"]["AdminSplitResult"]; }; }; /** @description Bad Request */ @@ -43000,33 +44621,37 @@ export interface operations { }; }; }; - applyAdminItemMatch: { + updateAdminJellyfinCompatSettings: { parameters: { query?: never; - header?: { - /** @description Optional. When present, it must name a profile of the authenticated account. */ + header: { + /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ + "If-Match": string; + /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + "If-None-Match"?: string; + /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminMatchApplyInputBody"]; + "application/json": components["schemas"]["AdminJellyfinCompatSettingsPatch"]; }; }; responses: { /** @description OK */ 200: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminMatchApplied"]; + "application/json": components["schemas"]["AdminJellyfinCompatStatus"]; }; }; /** @description Bad Request */ @@ -43083,6 +44708,17 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Failed */ + 412: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Request Entity Too Large */ 413: { headers: { @@ -43110,6 +44746,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Required */ + 428: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Too Many Requests */ 429: { headers: { @@ -43139,25 +44784,19 @@ export interface operations { }; }; }; - searchAdminItemMatches: { + getAdminJellyfinCompatStatus: { parameters: { query?: never; header?: { - /** @description Optional. When present, it must name a profile of the authenticated account. */ + /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminMatchSearchBody"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -43165,7 +44804,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminMatchCandidates"]; + "application/json": components["schemas"]["AdminJellyfinCompatStatus"]; }; }; /** @description Bad Request */ @@ -43213,33 +44852,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -43278,7 +44890,7 @@ export interface operations { }; }; }; - mergeAdminItem: { + installAdminJellyfinCompatWeb: { parameters: { query?: never; header?: { @@ -43287,24 +44899,22 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminMergeInputBody"]; + "application/json": components["schemas"]["AdminJellyfinWebInstallBody"]; }; }; responses: { - /** @description OK */ - 200: { + /** @description Accepted */ + 202: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminMergeResult"]; + "application/json": components["schemas"]["AdminJellyfinCompatStatus"]; }; }; /** @description Bad Request */ @@ -43361,6 +44971,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Request Entity Too Large */ 413: { headers: { @@ -43417,33 +45036,27 @@ export interface operations { }; }; }; - updateAdminItemMetadata: { + removeAdminJellyfinCompatWeb: { parameters: { query?: never; header?: { - /** @description Optional. When present, it must name a profile of the authenticated account. */ + /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminItemMetadataUpdate"]; - }; - }; + requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description Accepted */ + 202: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CatalogItemDetail"]; + "application/json": components["schemas"]["AdminJellyfinCompatStatus"]; }; }; /** @description Bad Request */ @@ -43491,26 +45104,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { + /** @description Conflict */ + 409: { headers: { [name: string]: unknown; }; @@ -43556,34 +45151,31 @@ export interface operations { }; }; }; - translateAdminItemMetadata: { + listAdminJobs: { parameters: { - query?: never; + query?: { + cursor?: string; + kind?: string; + limit?: number; + }; header?: { - /** @description Optional. When present, it must name a profile of the authenticated account. */ + /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminTranslateMetadataInputBody"]; - }; - }; + requestBody?: never; responses: { - /** @description Accepted */ - 202: { + /** @description OK */ + 200: { headers: { - Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["MetadataTranslationJob"]; + "application/json": components["schemas"]["CollectionAdminTaskJob"]; }; }; /** @description Bad Request */ @@ -43631,8 +45223,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -43640,8 +45232,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Entity Too Large */ - 413: { + /** @description Too Many Requests */ + 429: { headers: { [name: string]: unknown; }; @@ -43649,8 +45241,76 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unsupported Media Type */ - 415: { + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + getAdminJob: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "Retry-After"?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminTaskJob"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Acceptable */ + 406: { headers: { [name: string]: unknown; }; @@ -43696,11 +45356,52 @@ export interface operations { }; }; }; - listAdminMetadataTranslationJobs: { + downloadAdminJobArtifact: { + parameters: { + query: { + exp: number; + sig: string; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Gzip-compressed job artifact */ + 200: { + headers: { + "Content-Disposition"?: string; + "Content-Length"?: number; + [name: string]: unknown; + }; + content: { + "application/gzip": string; + }; + }; + /** @description Artifact not found, or the capability is invalid or expired */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Artifact storage unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + cancelAdminJob: { parameters: { query?: never; header?: { - /** @description Optional. When present, it must name a profile of the authenticated account. */ + /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; @@ -43712,13 +45413,23 @@ export interface operations { }; requestBody?: never; responses: { - /** @description OK */ + /** @description The job was already canceled. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminMetadataTranslationJobs"]; + "application/json": components["schemas"]["AdminTaskJob"]; + }; + }; + /** @description Accepted */ + 202: { + headers: { + "Retry-After"?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminTaskJob"]; }; }; /** @description Bad Request */ @@ -43766,6 +45477,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -43804,27 +45524,40 @@ export interface operations { }; }; }; - cancelAdminMetadataTranslation: { + getAdminJobCapabilities: { parameters: { query?: never; header?: { - /** @description Optional. When present, it must name a profile of the authenticated account. */ + /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ + "If-Match"?: string; + "If-None-Match"?: string; + /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - id: string; - /** @description Opaque identifier */ - job_id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { + "Cache-Control"?: string; + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminJobArtifactCapabilities"]; + }; + }; + /** @description The representation named by If-None-Match is current; no body. */ + 304: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content?: never; @@ -43874,6 +45607,17 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Failed */ + 412: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -43912,7 +45656,7 @@ export interface operations { }; }; }; - redetectAdminEpisodeIntro: { + listAdminCollectionGroups: { parameters: { query?: never; header?: { @@ -43922,19 +45666,20 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - id: string; + /** @description Opaque identifier */ + library_id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Accepted */ - 202: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminEpisodeMarkersStatus"]; + "application/json": components["schemas"]["AdminGroups"]; }; }; /** @description Bad Request */ @@ -44020,7 +45765,7 @@ export interface operations { }; }; }; - refreshAdminEpisodeMarkers: { + createAdminCollectionGroup: { parameters: { query?: never; header?: { @@ -44030,19 +45775,25 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - id: string; + /** @description Opaque identifier */ + library_id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CollectionGroupCreate"]; + }; + }; responses: { - /** @description Accepted */ - 202: { + /** @description Created */ + 201: { headers: { + Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminEpisodeMarkersStatus"]; + "application/json": components["schemas"]["AdminCollectionGroup"]; }; }; /** @description Bad Request */ @@ -44090,6 +45841,33 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -44128,35 +45906,31 @@ export interface operations { }; }; }; - refreshAdminItemMetadata: { + getAdminCollectionGroupOrder: { parameters: { query?: never; header?: { - /** @description Optional. When present, it must name a profile of the authenticated account. */ + /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; path: { - id: string; + /** @description Opaque identifier */ + library_id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminItemMetadataRefreshInputBody"]; - }; - }; + requestBody?: never; responses: { - /** @description Accepted */ - 202: { + /** @description OK */ + 200: { headers: { - Location?: string; - "Retry-After"?: string; + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminTaskJob"]; + "application/json": components["schemas"]["AdminCollectionOrder"]; }; }; /** @description Bad Request */ @@ -44204,33 +45978,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -44269,33 +46016,40 @@ export interface operations { }; }; }; - splitAdminItem: { + reorderAdminCollectionGroups: { parameters: { query?: never; - header?: { + header: { + /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ + "If-Match": string; + /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; path: { - id: string; + /** @description Opaque identifier */ + library_id: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminSplitRequest"]; + "application/json": components["schemas"]["AdminCollectionIDsOrder"]; }; }; responses: { /** @description OK */ 200: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminSplitResult"]; + "application/json": components["schemas"]["AdminCollectionOrder"]; }; }; /** @description Bad Request */ @@ -44352,6 +46106,17 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Failed */ + 412: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Request Entity Too Large */ 413: { headers: { @@ -44379,6 +46144,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Required */ + 428: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Too Many Requests */ 429: { headers: { @@ -44408,38 +46182,29 @@ export interface operations { }; }; }; - updateAdminJellyfinCompatSettings: { + unlinkAdminLiteraryItem: { parameters: { query?: never; - header: { - /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ - "If-Match": string; - /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ - "If-None-Match"?: string; + header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminJellyfinCompatSettingsPatch"]; + path: { + content_id: string; + work_id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["AdminJellyfinCompatStatus"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -44486,8 +46251,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -44495,19 +46260,17 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { + /** @description Too Many Requests */ + 429: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Entity Too Large */ - 413: { + /** @description Internal Server Error */ + 500: { headers: { [name: string]: unknown; }; @@ -44515,8 +46278,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unsupported Media Type */ - 415: { + /** @description Service Unavailable */ + 503: { headers: { [name: string]: unknown; }; @@ -44524,8 +46287,37 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + }; + }; + listAdminLiteraryCandidates: { + parameters: { + query?: { + limit?: number; + }; + header?: { + /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ + "X-Profile-Id"?: string; + /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ + "X-Profile-Token"?: string; + }; + path: { + content_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminLiteraryCandidates"]; + }; + }; + /** @description Bad Request */ + 400: { headers: { [name: string]: unknown; }; @@ -44533,8 +46325,44 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Required */ - 428: { + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Acceptable */ + 406: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -44571,7 +46399,7 @@ export interface operations { }; }; }; - getAdminJellyfinCompatStatus: { + linkAdminLiteraryItems: { parameters: { query?: never; header?: { @@ -44583,7 +46411,11 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminLiteraryLinkInputBody"]; + }; + }; responses: { /** @description OK */ 200: { @@ -44591,7 +46423,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminJellyfinCompatStatus"]; + "application/json": components["schemas"]["AdminLiteraryLink"]; }; }; /** @description Bad Request */ @@ -44639,6 +46471,33 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -44677,7 +46536,7 @@ export interface operations { }; }; }; - installAdminJellyfinCompatWeb: { + confirmAdminLiteraryMatch: { parameters: { query?: never; header?: { @@ -44691,17 +46550,17 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AdminJellyfinWebInstallBody"]; + "application/json": components["schemas"]["AdminLiteraryDecisionInputBody"]; }; }; responses: { - /** @description Accepted */ - 202: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminJellyfinCompatStatus"]; + "application/json": components["schemas"]["AdminLiteraryDecision"]; }; }; /** @description Bad Request */ @@ -44758,15 +46617,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Request Entity Too Large */ 413: { headers: { @@ -44823,7 +46673,7 @@ export interface operations { }; }; }; - removeAdminJellyfinCompatWeb: { + ignoreAdminLiteraryMatch: { parameters: { query?: never; header?: { @@ -44835,15 +46685,19 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminLiteraryDecisionInputBody"]; + }; + }; responses: { - /** @description Accepted */ - 202: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminJellyfinCompatStatus"]; + "application/json": components["schemas"]["AdminLiteraryDecision"]; }; }; /** @description Bad Request */ @@ -44891,8 +46745,26 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Conflict */ - 409: { + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { headers: { [name: string]: unknown; }; @@ -44938,12 +46810,22 @@ export interface operations { }; }; }; - listAdminJobs: { + listAdminOperationalLogs: { parameters: { query?: { + component?: string; cursor?: string; - kind?: string; + from?: string; + level?: string; + /** @description Page size; default 50, maximum 200 */ limit?: number; + node_id?: string; + playback_session_id?: string; + q?: string; + request_id?: string; + session_id?: string; + to?: string; + user_id?: string; }; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ @@ -44962,7 +46844,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminTaskJob"]; + "application/json": components["schemas"]["CollectionAdminOperationalLog"]; }; }; /** @description Bad Request */ @@ -45048,13 +46930,30 @@ export interface operations { }; }; }; - getAdminJob: { + listAdminAuditLogs: { parameters: { - query?: never; - header?: never; - path: { - id: string; + query?: { + client_ip?: string; + cursor?: string; + from?: string; + /** @description Page size; default 50, maximum 200 */ + limit?: number; + method?: string; + path_prefix?: string; + playback_session_id?: string; + request_id?: string; + session_id?: string; + status_code?: string; + to?: string; + user_id?: string; }; + header?: { + /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ + "X-Profile-Id"?: string; + /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ + "X-Profile-Token"?: string; + }; + path?: never; cookie?: never; }; requestBody?: never; @@ -45062,11 +46961,10 @@ export interface operations { /** @description OK */ 200: { headers: { - "Retry-After"?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminTaskJob"]; + "application/json": components["schemas"]["CollectionAdminAuditLog"]; }; }; /** @description Bad Request */ @@ -45087,6 +46985,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Not Found */ 404: { headers: { @@ -45143,48 +47050,109 @@ export interface operations { }; }; }; - downloadAdminJobArtifact: { + connectAdminLogsSocket: { parameters: { query: { - exp: number; - sig: string; + /** @description audit: client address or prefix filter. */ + client_ip?: string; + /** @description app: component filter. */ + component?: string; + /** @description Snapshot continuation from the matching list route. */ + cursor?: string; + /** @description Inclusive lower time bound. */ + from?: string; + /** @description app: comma-separated levels. */ + level?: string; + /** @description Snapshot size, 1-200; the list route default applies when omitted. */ + limit?: number; + /** @description audit: HTTP method filter. */ + method?: string; + /** @description app: node filter. */ + node_id?: string; + /** @description audit: path prefix filter. */ + path_prefix?: string; + /** @description Playback session filter. */ + playback_session_id?: string; + /** @description app: message text search. */ + q?: string; + /** @description Request identifier filter. */ + request_id?: string; + /** @description Login session filter. */ + session_id?: string; + /** @description audit: status filter. */ + status_code?: number; + /** @description Which log stream to snapshot and follow. */ + stream: "app" | "audit"; + /** @description Inclusive upper time bound. */ + to?: string; + /** @description Account filter. */ + user_id?: number; }; - header?: never; - path: { - id: string; + header: { + /** @description Browser origin must match the configured public origin, the request origin, or a connected network access overlay origin. */ + Origin?: string; + /** @description Offer silo.admin-logs.v2 followed by silo.ticket.. */ + "Sec-WebSocket-Protocol": string; }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Gzip-compressed job artifact */ - 200: { + /** @description Administrator log stream established. */ + 101: { + headers: { + /** @description WebSocket handshake header. */ + Connection?: string; + /** @description WebSocket handshake header. */ + "Sec-WebSocket-Accept"?: string; + /** @description WebSocket handshake header. */ + "Sec-WebSocket-Protocol"?: string; + /** @description WebSocket handshake header. */ + Upgrade?: string; + [name: string]: unknown; + }; + content?: never; + }; + /** @description Handshake refused. */ + 400: { headers: { - "Content-Disposition"?: string; - "Content-Length"?: number; [name: string]: unknown; }; content: { - "application/gzip": string; + "text/plain": string; }; }; - /** @description Artifact not found, or the capability is invalid or expired */ - 404: { + /** @description Handshake refused. */ + 401: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "text/plain": string; + }; }; - /** @description Artifact storage unavailable */ + /** @description Handshake refused. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + /** @description Handshake refused. */ 503: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "text/plain": string; + }; }; }; }; - cancelAdminJob: { + createAdminLogsSocketTicket: { parameters: { query?: never; header?: { @@ -45193,30 +47161,19 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description The job was already canceled. */ + /** @description OK */ 200: { headers: { + "Cache-Control"?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminTaskJob"]; - }; - }; - /** @description Accepted */ - 202: { - headers: { - "Retry-After"?: string; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["AdminTaskJob"]; + "application/json": components["schemas"]["EventsSocketTicket"]; }; }; /** @description Bad Request */ @@ -45264,15 +47221,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -45311,7 +47259,7 @@ export interface operations { }; }; }; - getAdminJobCapabilities: { + getAdminLogsSocketCapabilities: { parameters: { query?: never; header?: { @@ -45337,7 +47285,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminJobArtifactCapabilities"]; + "application/json": components["schemas"]["AdminLogsSocketCapabilitiesOutputBody"]; }; }; /** @description The representation named by If-None-Match is current; no body. */ @@ -45443,9 +47391,11 @@ export interface operations { }; }; }; - listAdminCollectionGroups: { + listAdminFileMarkerHistory: { parameters: { - query?: never; + query?: { + limit?: number; + }; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; @@ -45454,7 +47404,7 @@ export interface operations { }; path: { /** @description Opaque identifier */ - library_id: string; + fileId: string; }; cookie?: never; }; @@ -45466,7 +47416,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminGroups"]; + "application/json": components["schemas"]["AdminMarkerHistory"]; }; }; /** @description Bad Request */ @@ -45552,160 +47502,18 @@ export interface operations { }; }; }; - createAdminCollectionGroup: { + listAdminMarkerHistory: { parameters: { - query?: never; - header?: { - /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ - "X-Profile-Id"?: string; - /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ - "X-Profile-Token"?: string; - }; - path: { - /** @description Opaque identifier */ - library_id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CollectionGroupCreate"]; - }; - }; - responses: { - /** @description Created */ - 201: { - headers: { - Location?: string; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["AdminCollectionGroup"]; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Forbidden */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Not Acceptable */ - 406: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unprocessable Entity */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Too Many Requests */ - 429: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Internal Server Error */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Service Unavailable */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; + query?: { + limit?: number; }; - }; - }; - getAdminCollectionGroupOrder: { - parameters: { - query?: never; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - library_id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -45713,11 +47521,10 @@ export interface operations { /** @description OK */ 200: { headers: { - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminCollectionOrder"]; + "application/json": components["schemas"]["AdminMarkerHistory"]; }; }; /** @description Bad Request */ @@ -45803,40 +47610,31 @@ export interface operations { }; }; }; - reorderAdminCollectionGroups: { + listAdminItemMarkerHistory: { parameters: { - query?: never; - header: { - /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ - "If-Match": string; - /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ - "If-None-Match"?: string; + query?: { + limit?: number; + }; + header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; path: { - /** @description Opaque identifier */ - library_id: string; + id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminCollectionIDsOrder"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminCollectionOrder"]; + "application/json": components["schemas"]["AdminMarkerHistory"]; }; }; /** @description Bad Request */ @@ -45884,44 +47682,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Precondition Failed */ - 412: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -45931,15 +47691,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Required */ - 428: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Too Many Requests */ 429: { headers: { @@ -45969,7 +47720,7 @@ export interface operations { }; }; }; - unlinkAdminLiteraryItem: { + listAdminMarkerProviders: { parameters: { query?: never; header?: { @@ -45978,20 +47729,19 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - content_id: string; - work_id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["AdminMarkerProviders"]; + }; }; /** @description Bad Request */ 400: { @@ -46076,11 +47826,9 @@ export interface operations { }; }; }; - listAdminLiteraryCandidates: { + updateAdminMarkerProvider: { parameters: { - query?: { - limit?: number; - }; + query?: never; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; @@ -46088,11 +47836,15 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - content_id: string; + provider: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminMarkerProviderUpdate"]; + }; + }; responses: { /** @description OK */ 200: { @@ -46100,7 +47852,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminLiteraryCandidates"]; + "application/json": components["schemas"]["AdminMarkerProvider"]; }; }; /** @description Bad Request */ @@ -46148,6 +47900,33 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -46186,7 +47965,7 @@ export interface operations { }; }; }; - linkAdminLiteraryItems: { + validateAdminMarkerProvider: { parameters: { query?: never; header?: { @@ -46195,14 +47974,12 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminLiteraryLinkInputBody"]; + path: { + provider: string; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -46210,7 +47987,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminLiteraryLink"]; + "application/json": components["schemas"]["AdminMarkerProviderValidation"]; }; }; /** @description Bad Request */ @@ -46258,33 +48035,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -46323,7 +48073,7 @@ export interface operations { }; }; }; - confirmAdminLiteraryMatch: { + connectNetworkAccess: { parameters: { query?: never; header?: { @@ -46332,22 +48082,24 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + provider: string; + }; cookie?: never; }; - requestBody: { + requestBody?: { content: { - "application/json": components["schemas"]["AdminLiteraryDecisionInputBody"]; + "application/json": components["schemas"]["NetworkAccessCommand"]; }; }; responses: { - /** @description OK */ - 200: { + /** @description Accepted */ + 202: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminLiteraryDecision"]; + "application/json": components["schemas"]["NetworkAccessStatus"]; }; }; /** @description Bad Request */ @@ -46460,7 +48212,7 @@ export interface operations { }; }; }; - ignoreAdminLiteraryMatch: { + disconnectNetworkAccess: { parameters: { query?: never; header?: { @@ -46469,22 +48221,24 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + provider: string; + }; cookie?: never; }; - requestBody: { + requestBody?: { content: { - "application/json": components["schemas"]["AdminLiteraryDecisionInputBody"]; + "application/json": components["schemas"]["NetworkAccessCommand"]; }; }; responses: { - /** @description OK */ - 200: { + /** @description Accepted */ + 202: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminLiteraryDecision"]; + "application/json": components["schemas"]["NetworkAccessStatus"]; }; }; /** @description Bad Request */ @@ -46597,150 +48351,18 @@ export interface operations { }; }; }; - listAdminOperationalLogs: { + getAdminNetworkAccessStatus: { parameters: { - query?: { - component?: string; - cursor?: string; - from?: string; - level?: string; - /** @description Page size; default 50, maximum 200 */ - limit?: number; - node_id?: string; - playback_session_id?: string; - q?: string; - request_id?: string; - session_id?: string; - to?: string; - user_id?: string; - }; + query?: never; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["CollectionAdminOperationalLog"]; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Forbidden */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Not Acceptable */ - 406: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unprocessable Entity */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Too Many Requests */ - 429: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Internal Server Error */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Service Unavailable */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - }; - }; - listAdminAuditLogs: { - parameters: { - query?: { - client_ip?: string; - cursor?: string; - from?: string; - /** @description Page size; default 50, maximum 200 */ - limit?: number; - method?: string; - path_prefix?: string; - playback_session_id?: string; - request_id?: string; - session_id?: string; - status_code?: string; - to?: string; - user_id?: string; - }; - header?: { - /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ - "X-Profile-Id"?: string; - /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ - "X-Profile-Token"?: string; + path: { + provider: string; }; - path?: never; cookie?: never; }; requestBody?: never; @@ -46751,7 +48373,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminAuditLog"]; + "application/json": components["schemas"]["NetworkAccessStatus"]; }; }; /** @description Bad Request */ @@ -46837,111 +48459,15 @@ export interface operations { }; }; }; - connectAdminLogsSocket: { + listAdminNodeSessions: { parameters: { - query: { - /** @description audit: client address or prefix filter. */ - client_ip?: string; - /** @description app: component filter. */ - component?: string; - /** @description Snapshot continuation from the matching list route. */ + query?: { cursor?: string; - /** @description Inclusive lower time bound. */ - from?: string; - /** @description app: comma-separated levels. */ - level?: string; - /** @description Snapshot size, 1-200; the list route default applies when omitted. */ + /** @description Page size; default 50, maximum 200 */ limit?: number; - /** @description audit: HTTP method filter. */ - method?: string; - /** @description app: node filter. */ + /** @description Opaque identifier */ node_id?: string; - /** @description audit: path prefix filter. */ - path_prefix?: string; - /** @description Playback session filter. */ - playback_session_id?: string; - /** @description app: message text search. */ - q?: string; - /** @description Request identifier filter. */ - request_id?: string; - /** @description Login session filter. */ - session_id?: string; - /** @description audit: status filter. */ - status_code?: number; - /** @description Which log stream to snapshot and follow. */ - stream: "app" | "audit"; - /** @description Inclusive upper time bound. */ - to?: string; - /** @description Account filter. */ - user_id?: number; }; - header: { - /** @description Browser origin must match the configured public origin, the request origin, or a connected network access overlay origin. */ - Origin?: string; - /** @description Offer silo.admin-logs.v2 followed by silo.ticket.. */ - "Sec-WebSocket-Protocol": string; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Administrator log stream established. */ - 101: { - headers: { - /** @description WebSocket handshake header. */ - Connection?: string; - /** @description WebSocket handshake header. */ - "Sec-WebSocket-Accept"?: string; - /** @description WebSocket handshake header. */ - "Sec-WebSocket-Protocol"?: string; - /** @description WebSocket handshake header. */ - Upgrade?: string; - [name: string]: unknown; - }; - content?: never; - }; - /** @description Handshake refused. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "text/plain": string; - }; - }; - /** @description Handshake refused. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "text/plain": string; - }; - }; - /** @description Handshake refused. */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "text/plain": string; - }; - }; - /** @description Handshake refused. */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "text/plain": string; - }; - }; - }; - }; - createAdminLogsSocketTicket: { - parameters: { - query?: never; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; @@ -46956,11 +48482,10 @@ export interface operations { /** @description OK */ 200: { headers: { - "Cache-Control"?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventsSocketTicket"]; + "application/json": components["schemas"]["AdminNodeSessionsOutputBody"]; }; }; /** @description Bad Request */ @@ -47046,13 +48571,14 @@ export interface operations { }; }; }; - getAdminLogsSocketCapabilities: { + listAdminNodes: { parameters: { - query?: never; + query?: { + cursor?: string; + /** @description Page size; default 50, maximum 200 */ + limit?: number; + }; header?: { - /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ - "If-Match"?: string; - "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ @@ -47066,23 +48592,11 @@ export interface operations { /** @description OK */ 200: { headers: { - "Cache-Control"?: string; - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminLogsSocketCapabilitiesOutputBody"]; - }; - }; - /** @description The representation named by If-None-Match is current; no body. */ - 304: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; + "application/json": components["schemas"]["CollectionAdminNode"]; }; - content?: never; }; /** @description Bad Request */ 400: { @@ -47129,17 +48643,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -47178,32 +48681,32 @@ export interface operations { }; }; }; - listAdminFileMarkerHistory: { + createAdminNode: { parameters: { - query?: { - limit?: number; - }; + query?: never; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - fileId: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminNodeCreateBody"]; + }; + }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminMarkerHistory"]; + "application/json": components["schemas"]["AdminNode"]; }; }; /** @description Bad Request */ @@ -47251,6 +48754,42 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -47289,29 +48828,39 @@ export interface operations { }; }; }; - listAdminMarkerHistory: { + updateAdminNode: { parameters: { - query?: { - limit?: number; - }; - header?: { + query?: never; + header: { + /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ + "If-Match": string; + /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminNodeUpdateBody"]; + }; + }; responses: { /** @description OK */ 200: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminMarkerHistory"]; + "application/json": components["schemas"]["AdminNode"]; }; }; /** @description Bad Request */ @@ -47359,6 +48908,53 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Precondition Failed */ + 412: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -47368,6 +48964,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Required */ + 428: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Too Many Requests */ 429: { headers: { @@ -47397,12 +49002,14 @@ export interface operations { }; }; }; - listAdminItemMarkerHistory: { + deleteAdminNode: { parameters: { - query?: { - limit?: number; - }; - header?: { + query?: never; + header: { + /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ + "If-Match": string; + /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ @@ -47415,14 +49022,12 @@ export interface operations { }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["AdminMarkerHistory"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -47469,6 +49074,17 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Failed */ + 412: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -47478,6 +49094,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Required */ + 428: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Too Many Requests */ 429: { headers: { @@ -47507,7 +49132,7 @@ export interface operations { }; }; }; - listAdminMarkerProviders: { + checkAdminNode: { parameters: { query?: never; header?: { @@ -47516,7 +49141,9 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody?: never; @@ -47527,7 +49154,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminMarkerProviders"]; + "application/json": components["schemas"]["AdminNodeHealth"]; }; }; /** @description Bad Request */ @@ -47613,7 +49240,7 @@ export interface operations { }; }; }; - updateAdminMarkerProvider: { + forceReloadAdminNode: { parameters: { query?: never; header?: { @@ -47623,15 +49250,11 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - provider: string; + id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminMarkerProviderUpdate"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -47639,7 +49262,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminMarkerProvider"]; + "application/json": components["schemas"]["AdminNodeReloadOutputBody"]; }; }; /** @description Bad Request */ @@ -47687,33 +49310,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -47752,7 +49348,7 @@ export interface operations { }; }; }; - validateAdminMarkerProvider: { + reprobeAdminNode: { parameters: { query?: never; header?: { @@ -47762,7 +49358,7 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - provider: string; + id: string; }; cookie?: never; }; @@ -47774,7 +49370,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminMarkerProviderValidation"]; + "application/json": components["schemas"]["AdminNodeReprobe"]; }; }; /** @description Bad Request */ @@ -47860,7 +49456,7 @@ export interface operations { }; }; }; - connectNetworkAccess: { + forceReloadAdminNodes: { parameters: { query?: never; header?: { @@ -47869,24 +49465,18 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - provider: string; - }; + path?: never; cookie?: never; }; - requestBody?: { - content: { - "application/json": components["schemas"]["NetworkAccessCommand"]; - }; - }; + requestBody?: never; responses: { - /** @description Accepted */ - 202: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["NetworkAccessStatus"]; + "application/json": components["schemas"]["AdminNodeReloadOutputBody"]; }; }; /** @description Bad Request */ @@ -47934,33 +49524,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -47999,7 +49562,7 @@ export interface operations { }; }; }; - disconnectNetworkAccess: { + testAdminDiscordNotification: { parameters: { query?: never; header?: { @@ -48008,24 +49571,18 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - provider: string; - }; + path?: never; cookie?: never; }; - requestBody?: { - content: { - "application/json": components["schemas"]["NetworkAccessCommand"]; - }; - }; + requestBody?: never; responses: { - /** @description Accepted */ - 202: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["NetworkAccessStatus"]; + "application/json": components["schemas"]["AdminNotificationDiscordTestResult"]; }; }; /** @description Bad Request */ @@ -48073,33 +49630,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -48138,7 +49668,7 @@ export interface operations { }; }; }; - getAdminNetworkAccessStatus: { + testAdminApplePushNotification: { parameters: { query?: never; header?: { @@ -48147,12 +49677,14 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - provider: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminNotificationPushTestInputBody"]; + }; + }; responses: { /** @description OK */ 200: { @@ -48160,7 +49692,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["NetworkAccessStatus"]; + "application/json": components["schemas"]["AdminNotificationPushTestResult"]; }; }; /** @description Bad Request */ @@ -48208,93 +49740,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Too Many Requests */ - 429: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Internal Server Error */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Service Unavailable */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - }; - }; - listAdminNodeSessions: { - parameters: { - query?: { - cursor?: string; - /** @description Page size; default 50, maximum 200 */ - limit?: number; - /** @description Opaque identifier */ - node_id?: string; - }; - header?: { - /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ - "X-Profile-Id"?: string; - /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ - "X-Profile-Token"?: string; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["AdminNodeSessionsOutputBody"]; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Forbidden */ - 403: { + /** @description Request Timeout */ + 408: { headers: { [name: string]: unknown; }; @@ -48302,8 +49749,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Found */ - 404: { + /** @description Request Entity Too Large */ + 413: { headers: { [name: string]: unknown; }; @@ -48311,8 +49758,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Acceptable */ - 406: { + /** @description Unsupported Media Type */ + 415: { headers: { [name: string]: unknown; }; @@ -48358,13 +49805,9 @@ export interface operations { }; }; }; - listAdminNodes: { + testAdminAndroidPushNotification: { parameters: { - query?: { - cursor?: string; - /** @description Page size; default 50, maximum 200 */ - limit?: number; - }; + query?: never; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; @@ -48374,7 +49817,11 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminNotificationPushTestInputBody"]; + }; + }; responses: { /** @description OK */ 200: { @@ -48382,7 +49829,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminNode"]; + "application/json": components["schemas"]["AdminNotificationPushTestResult"]; }; }; /** @description Bad Request */ @@ -48430,6 +49877,33 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -48468,7 +49942,7 @@ export interface operations { }; }; }; - createAdminNode: { + clearAdminNotificationRelay: { parameters: { query?: never; header?: { @@ -48480,21 +49954,14 @@ export interface operations { path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminNodeCreateBody"]; - }; - }; + requestBody?: never; responses: { - /** @description Created */ - 201: { + /** @description No Content */ + 204: { headers: { - ETag?: string; [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["AdminNode"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -48541,42 +50008,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -48615,39 +50046,31 @@ export interface operations { }; }; }; - updateAdminNode: { + registerAdminNotificationRelay: { parameters: { query?: never; - header: { - /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ - "If-Match": string; - /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ - "If-None-Match"?: string; + header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminNodeUpdateBody"]; + "application/json": components["schemas"]["NotificationRelayRegisterInputBody"]; }; }; responses: { /** @description OK */ 200: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminNode"]; + "application/json": components["schemas"]["NotificationRelayRegistration"]; }; }; /** @description Bad Request */ @@ -48704,26 +50127,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Precondition Failed */ - 412: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Request Entity Too Large */ 413: { headers: { @@ -48751,15 +50154,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Required */ - 428: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Too Many Requests */ 429: { headers: { @@ -48789,32 +50183,32 @@ export interface operations { }; }; }; - deleteAdminNode: { + listAdminNotificationServerChannels: { parameters: { - query?: never; - header: { - /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ - "If-Match": string; - /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ - "If-None-Match"?: string; + query?: { + cursor?: string; + /** @description Page size; default 50, maximum 200 */ + limit?: number; + }; + header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["CollectionNotificationServerChannel"]; + }; }; /** @description Bad Request */ 400: { @@ -48861,17 +50255,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -48881,15 +50264,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Required */ - 428: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Too Many Requests */ 429: { headers: { @@ -48919,7 +50293,7 @@ export interface operations { }; }; }; - checkAdminNode: { + createAdminNotificationServerChannel: { parameters: { query?: never; header?: { @@ -48928,20 +50302,22 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["NotificationServerChannelCreateInputBody"]; + }; + }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminNodeHealth"]; + "application/json": components["schemas"]["NotificationDestinationCreated"]; }; }; /** @description Bad Request */ @@ -48989,6 +50365,33 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -49027,7 +50430,7 @@ export interface operations { }; }; }; - forceReloadAdminNode: { + updateAdminNotificationServerChannel: { parameters: { query?: never; header?: { @@ -49041,7 +50444,11 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["NotificationServerChannelUpdateInputBody"]; + }; + }; responses: { /** @description OK */ 200: { @@ -49049,7 +50456,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminNodeReloadOutputBody"]; + "application/json": components["schemas"]["NotificationServerChannel"]; }; }; /** @description Bad Request */ @@ -49097,6 +50504,33 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -49135,7 +50569,7 @@ export interface operations { }; }; }; - reprobeAdminNode: { + deleteAdminNotificationServerChannel: { parameters: { query?: never; header?: { @@ -49151,14 +50585,12 @@ export interface operations { }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["AdminNodeReprobe"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -49243,7 +50675,7 @@ export interface operations { }; }; }; - forceReloadAdminNodes: { + rotateAdminNotificationServerChannelSecret: { parameters: { query?: never; header?: { @@ -49252,7 +50684,9 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + id: string; + }; cookie?: never; }; requestBody?: never; @@ -49260,10 +50694,11 @@ export interface operations { /** @description OK */ 200: { headers: { + "Cache-Control"?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminNodeReloadOutputBody"]; + "application/json": components["schemas"]["NotificationWebhookSecretOutputBody"]; }; }; /** @description Bad Request */ @@ -49349,7 +50784,7 @@ export interface operations { }; }; }; - testAdminDiscordNotification: { + testAdminNotificationServerChannel: { parameters: { query?: never; header?: { @@ -49358,7 +50793,10 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description Opaque identifier */ + id: string; + }; cookie?: never; }; requestBody?: never; @@ -49369,7 +50807,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminNotificationDiscordTestResult"]; + "application/json": components["schemas"]["NotificationDestinationTestResult"]; }; }; /** @description Bad Request */ @@ -49455,7 +50893,7 @@ export interface operations { }; }; }; - testAdminApplePushNotification: { + updateAdminPerson: { parameters: { query?: never; header?: { @@ -49464,12 +50902,15 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description Opaque identifier */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminNotificationPushTestInputBody"]; + "application/json": components["schemas"]["AdminPersonUpdate"]; }; }; responses: { @@ -49479,7 +50920,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminNotificationPushTestResult"]; + "application/json": components["schemas"]["Person"]; }; }; /** @description Bad Request */ @@ -49592,7 +51033,7 @@ export interface operations { }; }; }; - testAdminAndroidPushNotification: { + refreshAdminPerson: { parameters: { query?: never; header?: { @@ -49601,14 +51042,13 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminNotificationPushTestInputBody"]; + path: { + /** @description Person identifier */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -49616,7 +51056,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminNotificationPushTestResult"]; + "application/json": components["schemas"]["Person"]; }; }; /** @description Bad Request */ @@ -49664,33 +51104,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -49729,9 +51142,22 @@ export interface operations { }; }; }; - clearAdminNotificationRelay: { + listAdminPlaybackHistory: { parameters: { - query?: never; + query?: { + /** @description Completion filter; all returns every finalized attempt */ + completed?: "all" | "true" | "false"; + /** @description Opaque cursor from page.next_cursor */ + cursor?: string; + /** @description Page size; default 50, maximum 200 */ + limit?: number; + /** @description Only attempts of this catalog item */ + media_item_id?: string; + /** @description Only attempts by this household profile */ + profile_id?: string; + /** @description Only attempts by this login account */ + user_id?: string; + }; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; @@ -49743,12 +51169,14 @@ export interface operations { }; requestBody?: never; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["AdminPlaybackHistoryCollection"]; + }; }; /** @description Bad Request */ 400: { @@ -49833,10 +51261,13 @@ export interface operations { }; }; }; - registerAdminNotificationRelay: { + getAdminPlaybackRoutingCapabilities: { parameters: { query?: never; header?: { + /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ + "If-Match"?: string; + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ @@ -49845,20 +51276,28 @@ export interface operations { path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["NotificationRelayRegisterInputBody"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { headers: { + "Cache-Control"?: string; + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["NotificationRelayRegistration"]; + "application/json": components["schemas"]["AdminPlaybackRoutingCapabilities"]; + }; + }; + /** @description The representation named by If-None-Match is current; no body. */ + 304: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; }; + content?: never; }; /** @description Bad Request */ 400: { @@ -49905,27 +51344,11 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { + /** @description Precondition Failed */ + 412: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { @@ -49970,7 +51393,7 @@ export interface operations { }; }; }; - listAdminNotificationServerChannels: { + listAdminPluginCatalog: { parameters: { query?: { cursor?: string; @@ -49994,7 +51417,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionNotificationServerChannel"]; + "application/json": components["schemas"]["CollectionAdminPluginCatalogEntry"]; }; }; /** @description Bad Request */ @@ -50080,10 +51503,13 @@ export interface operations { }; }; }; - createAdminNotificationServerChannel: { + getAdminPluginCatalogSettings: { parameters: { query?: never; header?: { + /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ + "If-Match"?: string; + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ @@ -50092,21 +51518,28 @@ export interface operations { path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["NotificationServerChannelCreateInputBody"]; - }; - }; + requestBody?: never; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["NotificationDestinationCreated"]; + "application/json": components["schemas"]["AdminPluginCatalogSettings"]; }; }; + /** @description The representation named by If-None-Match is current; no body. */ + 304: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content?: never; + }; /** @description Bad Request */ 400: { headers: { @@ -50152,27 +51585,11 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { + /** @description Precondition Failed */ + 412: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { @@ -50217,33 +51634,37 @@ export interface operations { }; }; }; - updateAdminNotificationServerChannel: { + updateAdminPluginCatalogSettings: { parameters: { query?: never; - header?: { + header: { + /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ + "If-Match": string; + /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["NotificationServerChannelUpdateInputBody"]; + "application/json": components["schemas"]["AdminPluginCatalogSettings"]; }; }; responses: { /** @description OK */ 200: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["NotificationServerChannel"]; + "application/json": components["schemas"]["AdminPluginCatalogSettings"]; }; }; /** @description Bad Request */ @@ -50300,6 +51721,17 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Failed */ + 412: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Request Entity Too Large */ 413: { headers: { @@ -50327,6 +51759,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Required */ + 428: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Too Many Requests */ 429: { headers: { @@ -50356,7 +51797,7 @@ export interface operations { }; }; }; - deleteAdminNotificationServerChannel: { + getAdminPluginCatalogStatus: { parameters: { query?: never; header?: { @@ -50365,19 +51806,19 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["AdminPluginCatalogStatus"]; + }; }; /** @description Bad Request */ 400: { @@ -50462,18 +51903,20 @@ export interface operations { }; }; }; - rotateAdminNotificationServerChannelSecret: { + listAdminPluginInstallations: { parameters: { - query?: never; + query?: { + cursor?: string; + /** @description Page size; default 50, maximum 200 */ + limit?: number; + }; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -50481,11 +51924,10 @@ export interface operations { /** @description OK */ 200: { headers: { - "Cache-Control"?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["NotificationWebhookSecretOutputBody"]; + "application/json": components["schemas"]["CollectionAdminPluginInstallation"]; }; }; /** @description Bad Request */ @@ -50571,7 +52013,7 @@ export interface operations { }; }; }; - testAdminNotificationServerChannel: { + createAdminPluginInstallation: { parameters: { query?: never; header?: { @@ -50580,21 +52022,22 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminPluginInstallCreate"]; + }; + }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["NotificationDestinationTestResult"]; + "application/json": components["schemas"]["AdminPluginInstallation"]; }; }; /** @description Bad Request */ @@ -50642,6 +52085,33 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -50680,7 +52150,7 @@ export interface operations { }; }; }; - updateAdminPerson: { + updateAdminPluginInstallation: { parameters: { query?: never; header?: { @@ -50697,7 +52167,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AdminPersonUpdate"]; + "application/json": components["schemas"]["AdminPluginInstallationUpdate"]; }; }; responses: { @@ -50707,7 +52177,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Person"]; + "application/json": components["schemas"]["AdminPluginInstallation"]; }; }; /** @description Bad Request */ @@ -50764,6 +52234,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Request Entity Too Large */ 413: { headers: { @@ -50820,7 +52299,7 @@ export interface operations { }; }; }; - refreshAdminPerson: { + deleteAdminPluginInstallation: { parameters: { query?: never; header?: { @@ -50830,21 +52309,19 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - /** @description Person identifier */ + /** @description Opaque identifier */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["Person"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -50891,6 +52368,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -50929,41 +52415,34 @@ export interface operations { }; }; }; - listAdminPlaybackHistory: { + updateAdminPluginAuthBinding: { parameters: { - query?: { - /** @description Completion filter; all returns every finalized attempt */ - completed?: "all" | "true" | "false"; - /** @description Opaque cursor from page.next_cursor */ - cursor?: string; - /** @description Page size; default 50, maximum 200 */ - limit?: number; - /** @description Only attempts of this catalog item */ - media_item_id?: string; - /** @description Only attempts by this household profile */ - profile_id?: string; - /** @description Only attempts by this login account */ - user_id?: string; - }; + query?: never; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description Opaque identifier */ + id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminPluginAuthBindingWrite"]; + }; + }; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { + "X-Silo-Restart-Required"?: string; [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["AdminPlaybackHistoryCollection"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -51010,6 +52489,42 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -51048,40 +52563,30 @@ export interface operations { }; }; }; - getAdminPlaybackRoutingCapabilities: { + updateAdminPluginInstallationConfig: { parameters: { query?: never; header?: { - /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ - "If-Match"?: string; - "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description Opaque identifier */ + id: string; + }; cookie?: never; }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - "Cache-Control"?: string; - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["AdminPlaybackRoutingCapabilities"]; - }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminPluginConfigWrite"]; }; - /** @description The representation named by If-None-Match is current; no body. */ - 304: { + }; + responses: { + /** @description No Content */ + 204: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content?: never; @@ -51131,11 +52636,36 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { @@ -51180,23 +52710,26 @@ export interface operations { }; }; }; - listAdminPluginCatalog: { + testAdminPluginInstallationConfig: { parameters: { - query?: { - cursor?: string; - /** @description Page size; default 50, maximum 200 */ - limit?: number; - }; + query?: never; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description Opaque identifier */ + id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminPluginConfigWrite"]; + }; + }; responses: { /** @description OK */ 200: { @@ -51204,7 +52737,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminPluginCatalogEntry"]; + "application/json": components["schemas"]["AdminPluginConnectionCheck"]; }; }; /** @description Bad Request */ @@ -51252,6 +52785,42 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -51290,19 +52859,19 @@ export interface operations { }; }; }; - getAdminPluginCatalogSettings: { + restartAdminPluginInstallation: { parameters: { query?: never; header?: { - /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ - "If-Match"?: string; - "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description Opaque identifier */ + id: string; + }; cookie?: never; }; requestBody?: never; @@ -51310,22 +52879,11 @@ export interface operations { /** @description OK */ 200: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPluginCatalogSettings"]; - }; - }; - /** @description The representation named by If-None-Match is current; no body. */ - 304: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; + "application/json": components["schemas"]["AdminPluginInstallation"]; }; - content?: never; }; /** @description Bad Request */ 400: { @@ -51372,11 +52930,9 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { + /** @description Conflict */ + 409: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { @@ -51421,37 +52977,35 @@ export interface operations { }; }; }; - updateAdminPluginCatalogSettings: { + updateAdminPluginTaskBinding: { parameters: { query?: never; - header: { - /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ - "If-Match": string; - /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ - "If-None-Match"?: string; + header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + capability_id: string; + /** @description Opaque identifier */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminPluginCatalogSettings"]; + "application/json": components["schemas"]["AdminPluginTaskBindingWrite"]; }; }; responses: { /** @description OK */ 200: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPluginCatalogSettings"]; + "application/json": components["schemas"]["AdminPluginTaskBindingResult"]; }; }; /** @description Bad Request */ @@ -51508,11 +53062,9 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { + /** @description Conflict */ + 409: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { @@ -51546,15 +53098,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Required */ - 428: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Too Many Requests */ 429: { headers: { @@ -51584,7 +53127,7 @@ export interface operations { }; }; }; - getAdminPluginCatalogStatus: { + applyAdminPluginUpdate: { parameters: { query?: never; header?: { @@ -51593,7 +53136,10 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description Opaque identifier */ + id: string; + }; cookie?: never; }; requestBody?: never; @@ -51604,7 +53150,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPluginCatalogStatus"]; + "application/json": components["schemas"]["AdminPluginInstallation"]; }; }; /** @description Bad Request */ @@ -51652,6 +53198,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -51690,7 +53245,7 @@ export interface operations { }; }; }; - listAdminPluginInstallations: { + listAdminPluginRepositories: { parameters: { query?: { cursor?: string; @@ -51714,7 +53269,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminPluginInstallation"]; + "application/json": components["schemas"]["CollectionAdminPluginRepository"]; }; }; /** @description Bad Request */ @@ -51800,7 +53355,7 @@ export interface operations { }; }; }; - createAdminPluginInstallation: { + createAdminPluginRepository: { parameters: { query?: never; header?: { @@ -51814,7 +53369,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AdminPluginInstallCreate"]; + "application/json": components["schemas"]["AdminPluginRepositoryCreateBody"]; }; }; responses: { @@ -51824,7 +53379,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPluginInstallation"]; + "application/json": components["schemas"]["AdminPluginRepository"]; }; }; /** @description Bad Request */ @@ -51937,7 +53492,7 @@ export interface operations { }; }; }; - updateAdminPluginInstallation: { + updateAdminPluginRepository: { parameters: { query?: never; header?: { @@ -51947,14 +53502,13 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - /** @description Opaque identifier */ id: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminPluginInstallationUpdate"]; + "application/json": components["schemas"]["AdminPluginRepositoryUpdateBody"]; }; }; responses: { @@ -51964,7 +53518,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPluginInstallation"]; + "application/json": components["schemas"]["AdminPluginRepository"]; }; }; /** @description Bad Request */ @@ -52021,15 +53575,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Request Entity Too Large */ 413: { headers: { @@ -52086,123 +53631,7 @@ export interface operations { }; }; }; - deleteAdminPluginInstallation: { - parameters: { - query?: never; - header?: { - /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ - "X-Profile-Id"?: string; - /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ - "X-Profile-Token"?: string; - }; - path: { - /** @description Opaque identifier */ - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description No Content */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Forbidden */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Not Acceptable */ - 406: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unprocessable Entity */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Too Many Requests */ - 429: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Internal Server Error */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Service Unavailable */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - }; - }; - updateAdminPluginAuthBinding: { + deleteAdminPluginRepository: { parameters: { query?: never; header?: { @@ -52212,21 +53641,15 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - /** @description Opaque identifier */ id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminPluginAuthBindingWrite"]; - }; - }; + requestBody?: never; responses: { /** @description No Content */ 204: { headers: { - "X-Silo-Restart-Required"?: string; [name: string]: unknown; }; content?: never; @@ -52276,42 +53699,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -52350,7 +53737,7 @@ export interface operations { }; }; }; - updateAdminPluginInstallationConfig: { + uploadAdminPluginInstallation: { parameters: { query?: never; header?: { @@ -52359,24 +53746,23 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminPluginConfigWrite"]; + "multipart/form-data": components["schemas"]["AdminPluginUploadForm"]; }; }; responses: { - /** @description No Content */ - 204: { + /** @description Created */ + 201: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["AdminPluginInstallation"]; + }; }; /** @description Bad Request */ 400: { @@ -52432,15 +53818,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Request Entity Too Large */ 413: { headers: { @@ -52497,7 +53874,7 @@ export interface operations { }; }; }; - testAdminPluginInstallationConfig: { + createAdminPluginUpload: { parameters: { query?: never; header?: { @@ -52506,25 +53883,22 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminPluginConfigWrite"]; + "application/json": components["schemas"]["AdminPluginChunkedUploadCreate"]; }; }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPluginConnectionCheck"]; + "application/json": components["schemas"]["AdminPluginUploadSession"]; }; }; /** @description Bad Request */ @@ -52581,15 +53955,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Request Entity Too Large */ 413: { headers: { @@ -52646,7 +54011,7 @@ export interface operations { }; }; }; - restartAdminPluginInstallation: { + cancelAdminPluginUpload: { parameters: { query?: never; header?: { @@ -52656,21 +54021,18 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - /** @description Opaque identifier */ - id: string; + upload_id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["AdminPluginInstallation"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -52717,15 +54079,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -52764,7 +54117,7 @@ export interface operations { }; }; }; - updateAdminPluginTaskBinding: { + putAdminPluginUploadChunk: { parameters: { query?: never; header?: { @@ -52774,15 +54127,14 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - capability_id: string; - /** @description Opaque identifier */ - id: string; + chunk_index: number; + upload_id: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminPluginTaskBindingWrite"]; + "application/octet-stream": string; }; }; responses: { @@ -52792,7 +54144,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPluginTaskBindingResult"]; + "application/json": components["schemas"]["AdminPluginUploadSession"]; }; }; /** @description Bad Request */ @@ -52914,7 +54266,7 @@ export interface operations { }; }; }; - applyAdminPluginUpdate: { + completeAdminPluginUpload: { parameters: { query?: never; header?: { @@ -52924,15 +54276,14 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - /** @description Opaque identifier */ - id: string; + upload_id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { [name: string]: unknown; }; @@ -53032,12 +54383,17 @@ export interface operations { }; }; }; - listAdminPluginRepositories: { + listAdminPolicyDecisions: { parameters: { query?: { + allowed?: "true" | "false"; cursor?: string; - /** @description Page size; default 50, maximum 200 */ + decision_name?: string; + from?: string; limit?: number; + to?: string; + /** @description Opaque identifier */ + user_id?: string; }; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ @@ -53056,7 +54412,118 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminPluginRepository"]; + "application/json": components["schemas"]["CollectionAdminPolicyDecision"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Acceptable */ + 406: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + getAdminPolicyDecision: { + parameters: { + query?: never; + header?: { + "If-Match"?: string; + "If-None-Match"?: string; + /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ + "X-Profile-Id"?: string; + /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ + "X-Profile-Token"?: string; + }; + path: { + /** @description Opaque identifier */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminPolicyDecision"]; }; }; /** @description Bad Request */ @@ -53142,9 +54609,12 @@ export interface operations { }; }; }; - createAdminPluginRepository: { + listAdminPolicyDocuments: { parameters: { - query?: never; + query?: { + cursor?: string; + limit?: number; + }; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; @@ -53154,19 +54624,15 @@ export interface operations { path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminPluginRepositoryCreateBody"]; - }; - }; + requestBody?: never; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPluginRepository"]; + "application/json": components["schemas"]["CollectionAdminPolicyDocument"]; }; }; /** @description Bad Request */ @@ -53214,33 +54680,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -53279,7 +54718,7 @@ export interface operations { }; }; }; - updateAdminPluginRepository: { + createAdminPolicyDocument: { parameters: { query?: never; header?: { @@ -53288,24 +54727,24 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminPluginRepositoryUpdateBody"]; + "application/json": components["schemas"]["AdminPolicyDocumentCreate"]; }; }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { + ETag?: string; + Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPluginRepository"]; + "application/json": components["schemas"]["AdminPolicyDocument"]; }; }; /** @description Bad Request */ @@ -53418,28 +54857,34 @@ export interface operations { }; }; }; - deleteAdminPluginRepository: { + getAdminPolicyDocument: { parameters: { query?: never; header?: { + "If-Match"?: string; + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; path: { + /** @description Opaque identifier */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { + ETag?: string; [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["AdminPolicySnapshot"]; + }; }; /** @description Bad Request */ 400: { @@ -53524,32 +54969,33 @@ export interface operations { }; }; }; - uploadAdminPluginInstallation: { + deleteAdminPolicyDocument: { parameters: { query?: never; - header?: { + header: { + /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ + "If-Match": string; + /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "multipart/form-data": components["schemas"]["AdminPluginUploadForm"]; + path: { + /** @description Opaque identifier */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Created */ - 201: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["AdminPluginInstallation"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -53596,26 +55042,19 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { + /** @description Precondition Failed */ + 412: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unsupported Media Type */ - 415: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -53623,8 +55062,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + /** @description Precondition Required */ + 428: { headers: { [name: string]: unknown; }; @@ -53661,31 +55100,40 @@ export interface operations { }; }; }; - createAdminPluginUpload: { + setAdminPolicyEnabled: { parameters: { query?: never; - header?: { + header: { + /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ + "If-Match": string; + /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description Opaque identifier */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminPluginChunkedUploadCreate"]; + "application/json": components["schemas"]["AdminPolicyEnabled"]; }; }; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPluginUploadSession"]; + "application/json": components["schemas"]["AdminPolicyApplyResult"]; }; }; /** @description Bad Request */ @@ -53742,6 +55190,17 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Failed */ + 412: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Request Entity Too Large */ 413: { headers: { @@ -53769,6 +55228,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Required */ + 428: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Too Many Requests */ 429: { headers: { @@ -53798,28 +55266,41 @@ export interface operations { }; }; }; - cancelAdminPluginUpload: { + activateAdminPolicyVersion: { parameters: { query?: never; - header?: { + header: { + /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ + "If-Match": string; + /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; path: { - upload_id: string; + /** @description Opaque identifier */ + id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminPolicyActivation"]; + }; + }; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["AdminPolicyApplyResult"]; + }; }; /** @description Bad Request */ 400: { @@ -53866,6 +55347,44 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Precondition Failed */ + 412: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -53875,6 +55394,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Required */ + 428: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Too Many Requests */ 429: { headers: { @@ -53904,9 +55432,12 @@ export interface operations { }; }; }; - putAdminPluginUploadChunk: { + listAdminPolicyVersions: { parameters: { - query?: never; + query?: { + cursor?: string; + limit?: number; + }; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; @@ -53914,16 +55445,12 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - chunk_index: number; - upload_id: string; + /** @description Opaque identifier */ + id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/octet-stream": string; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -53931,7 +55458,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPluginUploadSession"]; + "application/json": components["schemas"]["CollectionAdminPolicyVersion"]; }; }; /** @description Bad Request */ @@ -53979,42 +55506,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -54053,7 +55544,7 @@ export interface operations { }; }; }; - completeAdminPluginUpload: { + createAdminPolicyVersion: { parameters: { query?: never; header?: { @@ -54063,19 +55554,25 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - upload_id: string; + /** @description Opaque identifier */ + id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminPolicyVersionCreate"]; + }; + }; responses: { /** @description Created */ 201: { headers: { + Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPluginInstallation"]; + "application/json": components["schemas"]["AdminPolicyVersion"]; }; }; /** @description Bad Request */ @@ -54123,8 +55620,26 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Conflict */ - 409: { + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { headers: { [name: string]: unknown; }; @@ -54170,25 +55685,21 @@ export interface operations { }; }; }; - listAdminPolicyDecisions: { + getAdminPolicyVersion: { parameters: { - query?: { - allowed?: "true" | "false"; - cursor?: string; - decision_name?: string; - from?: string; - limit?: number; - to?: string; - /** @description Opaque identifier */ - user_id?: string; - }; + query?: never; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description Opaque identifier */ + id: string; + /** @description Opaque identifier */ + version: string; + }; cookie?: never; }; requestBody?: never; @@ -54199,7 +55710,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminPolicyDecision"]; + "application/json": components["schemas"]["AdminPolicyVersion"]; }; }; /** @description Bad Request */ @@ -54285,24 +55796,23 @@ export interface operations { }; }; }; - getAdminPolicyDecision: { + simulateAdminPolicy: { parameters: { query?: never; header?: { - "If-Match"?: string; - "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminPolicySimulation"]; + }; + }; responses: { /** @description OK */ 200: { @@ -54310,7 +55820,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPolicyDecision"]; + "application/json": components["schemas"]["AdminPolicySimulationResult"]; }; }; /** @description Bad Request */ @@ -54358,90 +55868,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Too Many Requests */ - 429: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Internal Server Error */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Service Unavailable */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - }; - }; - listAdminPolicyDocuments: { - parameters: { - query?: { - cursor?: string; - limit?: number; - }; - header?: { - /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ - "X-Profile-Id"?: string; - /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ - "X-Profile-Token"?: string; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["CollectionAdminPolicyDocument"]; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Forbidden */ - 403: { + /** @description Request Timeout */ + 408: { headers: { [name: string]: unknown; }; @@ -54449,8 +55877,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Found */ - 404: { + /** @description Request Entity Too Large */ + 413: { headers: { [name: string]: unknown; }; @@ -54458,8 +55886,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Acceptable */ - 406: { + /** @description Unsupported Media Type */ + 415: { headers: { [name: string]: unknown; }; @@ -54505,7 +55933,7 @@ export interface operations { }; }; }; - createAdminPolicyDocument: { + validateAdminPolicy: { parameters: { query?: never; header?: { @@ -54519,19 +55947,17 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AdminPolicyDocumentCreate"]; + "application/json": components["schemas"]["AdminPolicySource"]; }; }; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { - ETag?: string; - Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPolicyDocument"]; + "application/json": components["schemas"]["AdminPolicyValidation"]; }; }; /** @description Bad Request */ @@ -54644,21 +56070,16 @@ export interface operations { }; }; }; - getAdminPolicyDocument: { + listAdminPolicyVendor: { parameters: { query?: never; header?: { - "If-Match"?: string; - "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -54666,11 +56087,10 @@ export interface operations { /** @description OK */ 200: { headers: { - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPolicySnapshot"]; + "application/json": components["schemas"]["CollectionAdminPolicyVendor"]; }; }; /** @description Bad Request */ @@ -54756,30 +56176,39 @@ export interface operations { }; }; }; - deleteAdminPolicyDocument: { + getAdminRateLimitConfig: { parameters: { query?: never; - header: { - /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ - "If-Match": string; - /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + header?: { + /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ + "If-Match"?: string; "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminRateLimitConfig"]; + }; + }; + /** @description The representation named by If-None-Match is current; no body. */ + 304: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content?: never; @@ -54849,15 +56278,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Required */ - 428: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Too Many Requests */ 429: { headers: { @@ -54887,7 +56307,7 @@ export interface operations { }; }; }; - setAdminPolicyEnabled: { + updateAdminRateLimitConfig: { parameters: { query?: never; header: { @@ -54900,27 +56320,22 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminPolicyEnabled"]; + "application/json": components["schemas"]["AdminRateLimitUpdate"]; }; }; responses: { /** @description OK */ 200: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPolicyApplyResult"]; + "application/json": components["schemas"]["AdminRateLimitUpdateResult"]; }; }; /** @description Bad Request */ @@ -55053,188 +56468,16 @@ export interface operations { }; }; }; - activateAdminPolicyVersion: { + getAdminRateLimitStatus: { parameters: { query?: never; - header: { - /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ - "If-Match": string; - /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ - "If-None-Match"?: string; - /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ - "X-Profile-Id"?: string; - /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ - "X-Profile-Token"?: string; - }; - path: { - /** @description Opaque identifier */ - id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminPolicyActivation"]; - }; - }; - responses: { - /** @description OK */ - 200: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["AdminPolicyApplyResult"]; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Forbidden */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Not Acceptable */ - 406: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Precondition Failed */ - 412: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unprocessable Entity */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Precondition Required */ - 428: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Too Many Requests */ - 429: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Internal Server Error */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Service Unavailable */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - }; - }; - listAdminPolicyVersions: { - parameters: { - query?: { - cursor?: string; - limit?: number; - }; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -55245,7 +56488,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminPolicyVersion"]; + "application/json": components["schemas"]["AdminRateLimitStatus"]; }; }; /** @description Bad Request */ @@ -55331,7 +56574,7 @@ export interface operations { }; }; }; - createAdminPolicyVersion: { + getAdminRecommendationsStatus: { parameters: { query?: never; header?: { @@ -55340,26 +56583,18 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminPolicyVersionCreate"]; - }; - }; + requestBody?: never; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { - Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPolicyVersion"]; + "application/json": components["schemas"]["AdminRecommendationsStatus"]; }; }; /** @description Bad Request */ @@ -55407,33 +56642,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -55472,7 +56680,7 @@ export interface operations { }; }; }; - getAdminPolicyVersion: { + triggerAdminRecommendationCowatch: { parameters: { query?: never; header?: { @@ -55481,12 +56689,7 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - id: string; - /** @description Opaque identifier */ - version: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -55497,7 +56700,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPolicyVersion"]; + "application/json": components["schemas"]["AdminRecommendationStarted"]; }; }; /** @description Bad Request */ @@ -55583,7 +56786,7 @@ export interface operations { }; }; }; - simulateAdminPolicy: { + triggerAdminRecommendationEmbeddings: { parameters: { query?: never; header?: { @@ -55595,11 +56798,7 @@ export interface operations { path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminPolicySimulation"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -55607,7 +56806,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPolicySimulationResult"]; + "application/json": components["schemas"]["AdminRecommendationStarted"]; }; }; /** @description Bad Request */ @@ -55655,33 +56854,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -55720,7 +56892,7 @@ export interface operations { }; }; }; - validateAdminPolicy: { + triggerAdminRecommendationRefresh: { parameters: { query?: never; header?: { @@ -55732,11 +56904,7 @@ export interface operations { path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminPolicySource"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -55744,7 +56912,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPolicyValidation"]; + "application/json": components["schemas"]["AdminRecommendationStarted"]; }; }; /** @description Bad Request */ @@ -55792,33 +56960,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -55857,7 +56998,7 @@ export interface operations { }; }; }; - listAdminPolicyVendor: { + triggerAdminRecommendationTasteProfiles: { parameters: { query?: never; header?: { @@ -55877,7 +57018,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminPolicyVendor"]; + "application/json": components["schemas"]["AdminRecommendationStarted"]; }; }; /** @description Bad Request */ @@ -55963,13 +57104,15 @@ export interface operations { }; }; }; - getAdminRateLimitConfig: { + listRequestIntegrations: { parameters: { - query?: never; + query?: { + /** @description Opaque cursor from page.next_cursor */ + cursor?: string; + /** @description Page size; default 50, maximum 200 */ + limit?: number; + }; header?: { - /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ - "If-Match"?: string; - "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ @@ -55983,22 +57126,11 @@ export interface operations { /** @description OK */ 200: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminRateLimitConfig"]; - }; - }; - /** @description The representation named by If-None-Match is current; no body. */ - 304: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; + "application/json": components["schemas"]["CollectionAdminRequestIntegration"]; }; - content?: never; }; /** @description Bad Request */ 400: { @@ -56045,17 +57177,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -56094,14 +57215,10 @@ export interface operations { }; }; }; - updateAdminRateLimitConfig: { + createRequestIntegration: { parameters: { query?: never; - header: { - /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ - "If-Match": string; - /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ - "If-None-Match"?: string; + header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ @@ -56112,17 +57229,19 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AdminRateLimitUpdate"]; + "application/json": components["schemas"]["AdminRequestIntegrationBody"]; }; }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { + ETag?: string; + Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminRateLimitUpdateResult"]; + "application/json": components["schemas"]["AdminRequestIntegration"]; }; }; /** @description Bad Request */ @@ -56179,19 +57298,100 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + getRequestIntegration: { + parameters: { + query?: never; + header?: { + /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ + "X-Profile-Id"?: string; + /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ + "X-Profile-Token"?: string; + }; + path: { + /** @description The request */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ ETag?: string; [name: string]: unknown; }; + content: { + "application/json": components["schemas"]["AdminRequestIntegration"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; content: { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Entity Too Large */ - 413: { + /** @description Unauthorized */ + 401: { headers: { [name: string]: unknown; }; @@ -56199,8 +57399,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unsupported Media Type */ - 415: { + /** @description Forbidden */ + 403: { headers: { [name: string]: unknown; }; @@ -56208,8 +57408,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + /** @description Not Found */ + 404: { headers: { [name: string]: unknown; }; @@ -56217,8 +57417,17 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Required */ - 428: { + /** @description Not Acceptable */ + 406: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -56255,27 +57464,40 @@ export interface operations { }; }; }; - getAdminRateLimitStatus: { + updateRequestIntegration: { parameters: { query?: never; - header?: { + header: { + /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ + "If-Match": string; + /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description The request */ + id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminRequestIntegrationBody"]; + }; + }; responses: { /** @description OK */ 200: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminRateLimitStatus"]; + "application/json": components["schemas"]["AdminRequestIntegration"]; }; }; /** @description Bad Request */ @@ -56323,69 +57545,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Too Many Requests */ - 429: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Internal Server Error */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Service Unavailable */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - }; - }; - getAdminRecommendationsStatus: { - parameters: { - query?: never; - header?: { - /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ - "X-Profile-Id"?: string; - /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ - "X-Profile-Token"?: string; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["AdminRecommendationsStatus"]; - }; - }; - /** @description Bad Request */ - 400: { + /** @description Request Timeout */ + 408: { headers: { [name: string]: unknown; }; @@ -56393,17 +57554,19 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unauthorized */ - 401: { + /** @description Precondition Failed */ + 412: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; [name: string]: unknown; }; content: { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden */ - 403: { + /** @description Request Entity Too Large */ + 413: { headers: { [name: string]: unknown; }; @@ -56411,8 +57574,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Found */ - 404: { + /** @description Unsupported Media Type */ + 415: { headers: { [name: string]: unknown; }; @@ -56420,8 +57583,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Acceptable */ - 406: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -56429,8 +57592,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + /** @description Precondition Required */ + 428: { headers: { [name: string]: unknown; }; @@ -56467,28 +57630,33 @@ export interface operations { }; }; }; - triggerAdminRecommendationCowatch: { + deleteRequestIntegration: { parameters: { query?: never; - header?: { + header: { + /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ + "If-Match": string; + /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description The request */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["AdminRecommendationStarted"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -56535,6 +57703,17 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Failed */ + 412: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -56544,6 +57723,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Required */ + 428: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Too Many Requests */ 429: { headers: { @@ -56573,7 +57761,7 @@ export interface operations { }; }; }; - triggerAdminRecommendationEmbeddings: { + loadRequestIntegrationOptions: { parameters: { query?: never; header?: { @@ -56582,10 +57770,17 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; + path: { + /** @description The request */ + id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminRequestOptionsInputBody"]; + }; + }; responses: { /** @description OK */ 200: { @@ -56593,7 +57788,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminRecommendationStarted"]; + "application/json": components["schemas"]["AdminRequestOptions"]; }; }; /** @description Bad Request */ @@ -56641,87 +57836,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Too Many Requests */ - 429: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Internal Server Error */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Service Unavailable */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - }; - }; - triggerAdminRecommendationRefresh: { - parameters: { - query?: never; - header?: { - /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ - "X-Profile-Id"?: string; - /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ - "X-Profile-Token"?: string; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["AdminRecommendationStarted"]; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Forbidden */ - 403: { + /** @description Request Timeout */ + 408: { headers: { [name: string]: unknown; }; @@ -56729,8 +57845,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Found */ - 404: { + /** @description Request Entity Too Large */ + 413: { headers: { [name: string]: unknown; }; @@ -56738,8 +57854,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Acceptable */ - 406: { + /** @description Unsupported Media Type */ + 415: { headers: { [name: string]: unknown; }; @@ -56785,7 +57901,7 @@ export interface operations { }; }; }; - triggerAdminRecommendationTasteProfiles: { + listRequestRoutes: { parameters: { query?: never; header?: { @@ -56805,7 +57921,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminRecommendationStarted"]; + "application/json": components["schemas"]["CollectionAdminRequestRoute"]; }; }; /** @description Bad Request */ @@ -56853,6 +57969,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -56891,14 +58016,9 @@ export interface operations { }; }; }; - listRequestIntegrations: { + createRequestRoute: { parameters: { - query?: { - /** @description Opaque cursor from page.next_cursor */ - cursor?: string; - /** @description Page size; default 50, maximum 200 */ - limit?: number; - }; + query?: never; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; @@ -56908,15 +58028,21 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminRequestRouteBody"]; + }; + }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { + ETag?: string; + Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminRequestIntegration"]; + "application/json": components["schemas"]["AdminRequestRoute"]; }; }; /** @description Bad Request */ @@ -56964,6 +58090,42 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -57002,7 +58164,7 @@ export interface operations { }; }; }; - createRequestIntegration: { + getRequestRoute: { parameters: { query?: never; header?: { @@ -57011,24 +58173,22 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminRequestIntegrationBody"]; + path: { + /** @description The route */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { ETag?: string; - Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminRequestIntegration"]; + "application/json": components["schemas"]["AdminRequestRoute"]; }; }; /** @description Bad Request */ @@ -57076,26 +58236,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { + /** @description Conflict */ + 409: { headers: { [name: string]: unknown; }; @@ -57141,31 +58283,40 @@ export interface operations { }; }; }; - getRequestIntegration: { + updateRequestRoute: { parameters: { query?: never; - header?: { + header: { + /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ + "If-Match": string; + /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ + "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; path: { - /** @description The request */ + /** @description The route */ id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminRequestRouteBody"]; + }; + }; responses: { /** @description OK */ 200: { headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminRequestIntegration"]; + "application/json": components["schemas"]["AdminRequestRoute"]; }; }; /** @description Bad Request */ @@ -57213,6 +58364,53 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Request Timeout */ + 408: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Precondition Failed */ + 412: { + headers: { + /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ + ETag?: string; + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Unprocessable Entity */ 422: { headers: { @@ -57222,6 +58420,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Precondition Required */ + 428: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Too Many Requests */ 429: { headers: { @@ -57251,7 +58458,7 @@ export interface operations { }; }; }; - updateRequestIntegration: { + deleteRequestRoute: { parameters: { query?: never; header: { @@ -57265,27 +58472,19 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - /** @description The request */ + /** @description The route */ id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminRequestIntegrationBody"]; - }; - }; + requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["AdminRequestIntegration"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -57332,8 +58531,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { + /** @description Conflict */ + 409: { headers: { [name: string]: unknown; }; @@ -57352,24 +58551,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -57417,33 +58598,32 @@ export interface operations { }; }; }; - deleteRequestIntegration: { + reorderRequestRoutes: { parameters: { query?: never; - header: { - /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ - "If-Match": string; - /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ - "If-None-Match"?: string; + header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description The request */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminRequestRouteReorderInputBody"]; + }; + }; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["CollectionAdminRequestRoute"]; + }; }; /** @description Bad Request */ 400: { @@ -57490,19 +58670,17 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { + /** @description Request Timeout */ + 408: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + /** @description Conflict */ + 409: { headers: { [name: string]: unknown; }; @@ -57510,8 +58688,26 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Required */ - 428: { + /** @description Request Entity Too Large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unsupported Media Type */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -57548,7 +58744,7 @@ export interface operations { }; }; }; - loadRequestIntegrationOptions: { + previewRequestRoute: { parameters: { query?: never; header?: { @@ -57557,15 +58753,12 @@ export interface operations { /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ "X-Profile-Token"?: string; }; - path: { - /** @description The request */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminRequestOptionsInputBody"]; + "application/json": components["schemas"]["AdminRequestRoutePreviewInputBody"]; }; }; responses: { @@ -57575,7 +58768,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminRequestOptions"]; + "application/json": components["schemas"]["AdminRequestRoutePreviewOutputBody"]; }; }; /** @description Bad Request */ @@ -57632,6 +58825,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Request Entity Too Large */ 413: { headers: { From 949ef360b57fcd38840e25dd82cfa8a89aa676e3 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:11:13 -0400 Subject: [PATCH 009/103] feat(admin): manage requests, servers and routing in Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Request configuration moves out of the Requests queue page into Settings › Requests, built on the settings shell: one save bar with an unsaved-changes guard, If-Match on every save, and a conflict prompt that keeps the admin's edits when someone else saved first. - General: allow requests, approval (automatic or by an admin), the request limit and its window, and whether to also request a 4K copy. - Servers: one tile per Sonarr or Radarr server showing where routing uses it. The editor puts the type, name, URL and API key first, then the plugin's settings without the switches routing now owns. Test reports the quality profiles and root folders it found. A server routing uses cannot be deleted or switched to the other type, and the editor says why. - Routing, per media type: the default destination (HD and 4K server with per-tier overrides from the plugin's own options), ordered rules with an editor for their conditions (anime, genres, keywords, networks or studios, languages, countries, years) and destinations, and "Test a title", which shows the facts and the server each tier would go to. With requests off, a title can be tested by its TMDB ID. The Requests page keeps the queue and user overrides and links to the new page; its old ?tab=settings and ?tab=integrations URLs redirect there. ⌘K and the settings overview find the page. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/architecture/admin-settings-ux.md | 15 +- docs/architecture/media-requests.md | 4 +- web/src/api/v2/adminRequests.ts | 91 ++ web/src/components/admin/EditorConflict.tsx | 36 + .../admin/plugins/SchemaForm.test.tsx | 127 ++ .../components/admin/plugins/SchemaForm.tsx | 96 +- web/src/components/settings/SecretField.tsx | 6 +- .../hooks/admin/useSettingsOverview.test.ts | 3 +- web/src/hooks/admin/useSettingsOverview.ts | 1 + web/src/hooks/queries/keys.ts | 8 + web/src/hooks/queries/useRequests.ts | 188 ++- web/src/lib/adminNavigation.test.ts | 24 + web/src/lib/adminNavigation.ts | 6 +- web/src/lib/adminSettingsSearch.ts | 47 + web/src/lib/requestRoutingOptions.ts | 114 ++ web/src/lib/tmdbGenres.ts | 69 + web/src/pages/AdminRequests.tsx | 957 +------------ web/src/pages/AdminRequests.v2.test.tsx | 199 +-- .../admin-settings/AdminSettingsLayout.tsx | 2 + .../admin-settings/RequestRouteFields.tsx | 450 ++++++ .../admin-settings/RequestRoutePreview.tsx | 253 ++++ .../pages/admin-settings/RequestRouting.tsx | 407 ++++++ .../admin-settings/RequestRuleEditor.tsx | 645 +++++++++ .../pages/admin-settings/RequestServers.tsx | 824 +++++++++++ .../admin-settings/RequestsSettings.test.tsx | 1256 +++++++++++++++++ .../pages/admin-settings/RequestsSettings.tsx | 439 ++++++ web/src/pages/admin-settings/SaveBar.tsx | 8 +- .../requestIntegrationMediaTypes.test.ts | 0 .../requestIntegrationMediaTypes.ts | 0 .../requestRoutingModel.test.ts | 178 +++ .../admin-settings/requestRoutingModel.ts | 304 ++++ .../admin-settings/requestServerModel.ts | 163 +++ .../pages/admin-settings/useStagedDraft.ts | 65 + .../pages/admin/autoscan/ConnectionsPanel.tsx | 16 +- web/src/pages/requestExclusivity.test.ts | 59 - web/src/pages/requestExclusivity.ts | 52 - 36 files changed, 5856 insertions(+), 1256 deletions(-) create mode 100644 web/src/components/admin/EditorConflict.tsx create mode 100644 web/src/lib/adminNavigation.test.ts create mode 100644 web/src/lib/requestRoutingOptions.ts create mode 100644 web/src/lib/tmdbGenres.ts create mode 100644 web/src/pages/admin-settings/RequestRouteFields.tsx create mode 100644 web/src/pages/admin-settings/RequestRoutePreview.tsx create mode 100644 web/src/pages/admin-settings/RequestRouting.tsx create mode 100644 web/src/pages/admin-settings/RequestRuleEditor.tsx create mode 100644 web/src/pages/admin-settings/RequestServers.tsx create mode 100644 web/src/pages/admin-settings/RequestsSettings.test.tsx create mode 100644 web/src/pages/admin-settings/RequestsSettings.tsx rename web/src/pages/{ => admin-settings}/requestIntegrationMediaTypes.test.ts (100%) rename web/src/pages/{ => admin-settings}/requestIntegrationMediaTypes.ts (100%) create mode 100644 web/src/pages/admin-settings/requestRoutingModel.test.ts create mode 100644 web/src/pages/admin-settings/requestRoutingModel.ts create mode 100644 web/src/pages/admin-settings/requestServerModel.ts create mode 100644 web/src/pages/admin-settings/useStagedDraft.ts delete mode 100644 web/src/pages/requestExclusivity.test.ts delete mode 100644 web/src/pages/requestExclusivity.ts diff --git a/docs/architecture/admin-settings-ux.md b/docs/architecture/admin-settings-ux.md index 947628cc75..9a7f5dbdcb 100644 --- a/docs/architecture/admin-settings-ux.md +++ b/docs/architecture/admin-settings-ux.md @@ -2,10 +2,11 @@ Admin settings are organized by admin intent ("I want subtitles to download automatically"), not by subsystem. `/admin/settings` is the **Overview**: -server health across the top and one live card per settings group. Twelve +server health across the top and one live card per settings group. Fourteen standalone pages hang off it: General, Storage & Database, Appearance, -Security & Access, Library & Metadata, Playback, Downloads, Subtitles & -Metadata, Watch Providers, AI Services, Notifications, and Compatibility. The +Security & Access, Library & Metadata, Playback, Downloads, Requests, +Subtitles & Metadata, Watch Providers, AI Services, Notifications, +Compatibility, and Network Access. The global admin sidebar has one Settings destination; the Overview owns the settings information architecture. Old `?tab=` URLs and retired page ids from earlier layouts (including `integrations`, now split into Subtitles & Metadata, @@ -56,7 +57,13 @@ credits markers, while *which provider answers, in what order, and on what terms* is a tile beside the subtitle and metadata providers, with a cross-link each way. A tile only reads "Connected" when the provider could actually serve a request — its configuration saved and the provider switched on — so an -installed plugin whose API key was never entered reads "Needs setup". Staged edits raise +installed plugin whose API key was never entered reads "Needs setup". Request +servers (Sonarr, Radarr) are the exception to the provider-page rule: they are +destinations that request routing chooses between, not interchangeable +providers, so the Requests page holds the servers, the routing rules that pick +one, and the request limits together. Routing rules are ordered records with +their own validator, so each one saves from its own editor; the save pill +covers the request settings and each media type's default destination. Staged edits raise one floating save pill (`SaveBar`) and arm the shell's unsaved-changes prompt; the restart prompt is a single `RestartBanner` (`web/src/components/admin/`) rendered by the admin shell (`AdminLayout`), never per page. A restart is owed diff --git a/docs/architecture/media-requests.md b/docs/architecture/media-requests.md index 21070300a0..65929c8424 100644 --- a/docs/architecture/media-requests.md +++ b/docs/architecture/media-requests.md @@ -63,7 +63,9 @@ owns each target's server, and one plugin failing does not discard the statuses another reported. A media type with no routes keeps the plugin's own routing: every usable connection is handed over and the plugin picks. -Admins manage routes through `/api/v2/admin/request-routes`. Each media type +Admins manage routes through `/api/v2/admin/request-routes` (in the web admin, +Settings › Requests, which also hides the server switches routing now owns). +Each media type always has its fallback ("Everything else"); until it is saved it has no servers and routing leaves the media type to the plugin. Saving it requires an HD server, since a saved fallback moves the media type to Silo's routing. A rule cannot be added before diff --git a/web/src/api/v2/adminRequests.ts b/web/src/api/v2/adminRequests.ts index d98338b1f2..5d038e5107 100644 --- a/web/src/api/v2/adminRequests.ts +++ b/web/src/api/v2/adminRequests.ts @@ -13,6 +13,24 @@ import type { } from "@/api/types"; import { v2, type V2Body, type V2Result, V2ProblemError } from "./request"; import { mediaRequestFromV2 } from "./requests"; +import type { components } from "./schema"; + +type Schemas = components["schemas"]; + +export type RequestRouteMediaType = Schemas["AdminRequestRoute"]["media_type"]; +export type RequestRouteConditions = Schemas["AdminRequestRouteConditions"]; +export type RequestRouteDestination = Schemas["AdminRequestRouteDestination"]; +/** The editable part of a routing rule; `media_type` is read on create only. */ +export type RequestRouteBody = Schemas["AdminRequestRouteBody"]; +export type RequestRoutePreview = Schemas["AdminRequestRoutePreviewOutputBody"]; +export type RequestRoutePreviewTier = Schemas["AdminRequestRoutePreviewTier"]; + +/** + * A routing rule, or a media type's fallback, with the validator of the read + * it came from. A fallback that was never saved still has one: the server + * answers it at revision zero, and its first save sends that tag. + */ +export type RequestRoute = Schemas["AdminRequestRoute"] & { etag: string }; function requireETag(etag?: string) { if (!etag) throw new Error("Reload this editor before saving."); @@ -228,6 +246,79 @@ export function declineAdminRequestV2(id: string, reason?: string) { mediaRequestFromV2, ); } +export async function getAdminRequestRouteV2( + id: string, + profileContext?: ProfileRequestContextSnapshot, +): Promise { + let etag = ""; + const body = await v2("GET /api/v2/admin/request-routes/{id}", { + profileContext, + path: { id }, + onResponse: (r) => { + etag = r.headers.get("ETag") ?? ""; + }, + }); + return { ...body, etag: requireETag(etag) }; +} +/** + * Every route, in evaluation order per media type, each from its own read so + * an editor always starts from a row and the validator that read returned. + */ +export async function listAdminRequestRoutesV2(): Promise { + const profileContext = captureProfileRequestContext(); + if (!profileContext) throw new StaleApiRequestContextError(); + const list = await v2("GET /api/v2/admin/request-routes", { profileContext }); + const rows = await Promise.all( + list.items.map((route) => getAdminRequestRouteV2(route.id, profileContext)), + ); + if (!isProfileRequestContextCurrent(profileContext)) throw new StaleApiRequestContextError(); + return rows; +} +export async function createAdminRequestRouteV2(body: RequestRouteBody): Promise { + let etag = ""; + const saved = await v2("POST /api/v2/admin/request-routes", { + body, + onResponse: (r) => { + etag = r.headers.get("ETag") ?? ""; + }, + }); + return { ...saved, etag: requireETag(etag) }; +} +export async function updateAdminRequestRouteV2( + route: Pick, + body: RequestRouteBody, +): Promise { + let etag = ""; + const saved = await v2("PUT /api/v2/admin/request-routes/{id}", { + path: { id: route.id }, + headers: { "If-Match": requireETag(route.etag) }, + body, + onResponse: (r) => { + etag = r.headers.get("ETag") ?? ""; + }, + }); + return { ...saved, etag: requireETag(etag) }; +} +export function deleteAdminRequestRouteV2(route: Pick) { + return v2("DELETE /api/v2/admin/request-routes/{id}", { + path: { id: route.id }, + headers: { "If-Match": requireETag(route.etag) }, + }); +} +/** Sets the order of a media type's rules; `ids` lists every rule, fallback excluded. */ +export function reorderAdminRequestRoutesV2(mediaType: RequestRouteMediaType, ids: string[]) { + return v2("POST /api/v2/admin/request-routes/order", { + body: { media_type: mediaType, ids }, + }).then((result) => result.items); +} +export function previewAdminRequestRouteV2( + mediaType: RequestRouteMediaType, + tmdbId: number, +): Promise { + return v2("POST /api/v2/admin/request-routes/preview", { + body: { media_type: mediaType, tmdb_id: tmdbId }, + }); +} export function loadAdminRequestIntegrationOptionsV2( id: string, body: LoadRequestIntegrationOptionsRequest, diff --git a/web/src/components/admin/EditorConflict.tsx b/web/src/components/admin/EditorConflict.tsx new file mode 100644 index 0000000000..95d4728c8b --- /dev/null +++ b/web/src/components/admin/EditorConflict.tsx @@ -0,0 +1,36 @@ +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; + +/** + * Shown when a save answered 412: someone else changed the record since this + * editor read it. The admin's edits stay on screen; only an explicit reload + * replaces them with the latest version and its validator. + */ +export function EditorConflict({ onReload }: { onReload: () => Promise }) { + const [loading, setLoading] = useState(false); + return ( +
+

+ This was changed by another administrator. Your edits have been kept. Reload to review the + latest version before saving again. +

+ +
+ ); +} diff --git a/web/src/components/admin/plugins/SchemaForm.test.tsx b/web/src/components/admin/plugins/SchemaForm.test.tsx index f2383248cf..368bf535f2 100644 --- a/web/src/components/admin/plugins/SchemaForm.test.tsx +++ b/web/src/components/admin/plugins/SchemaForm.test.tsx @@ -287,3 +287,130 @@ it("marks a show_when-gated field as nested when it is revealed", () => { ); expect(container.querySelector('[data-nested="true"]')).not.toBeNull(); }); + +describe("SchemaForm host-owned fields", () => { + const hostDescriptor: PluginAdminForm = { + fields: [ + { + key: "quality_profile_id", + label: "Quality profile", + control: "SELECT", + required: true, + secret: false, + multiline: false, + options: [{ value: "1", label: "HD-1080p" }], + }, + { + key: "is_default", + label: "Default (HD/1080p)", + control: "SWITCH", + required: false, + secret: false, + multiline: false, + }, + { + key: "anime_enabled", + label: "Enable anime overrides", + control: "SWITCH", + required: false, + secret: false, + multiline: false, + }, + { + key: "anime_root_folder", + label: "Anime root folder", + control: "TEXT", + required: true, + secret: false, + multiline: false, + }, + ], + sections: [ + { + key: "library", + title: "Library", + collapsible: true, + collapsed_default: true, + field_keys: ["quality_profile_id", "is_default"], + }, + { + key: "anime", + title: "Anime overrides", + collapsible: false, + collapsed_default: false, + field_keys: ["anime_enabled", "anime_root_folder"], + }, + ], + }; + const hidden = ["is_default", "anime_enabled", "anime_root_folder"]; + + it("hides host-owned keys and drops a section they leave empty", () => { + render( + , + ); + expect(screen.queryByText("Default (HD/1080p)")).toBeNull(); + expect(screen.queryByText("Anime overrides")).toBeNull(); + expect(screen.queryByText("Anime root folder")).toBeNull(); + expect(screen.getByText("Quality profile")).toBeTruthy(); + }); + + it("keeps hidden values when a visible field changes", () => { + const onChange = vi.fn(); + render( + , + ); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "b" } }); + expect(onChange).toHaveBeenCalledWith({ name: "b", is_default: true }); + }); + + it("leaves hidden required fields out of validation", () => { + const onValidityChange = vi.fn(); + render( + , + ); + expect(onValidityChange).toHaveBeenLastCalledWith(true); + }); + + it("renders collapsible sections open and without a toggle when expanded", () => { + render( + , + ); + expect(screen.queryByText("Show")).toBeNull(); + expect(screen.getByText("Quality profile")).toBeTruthy(); + }); +}); diff --git a/web/src/components/admin/plugins/SchemaForm.tsx b/web/src/components/admin/plugins/SchemaForm.tsx index 590209c3e9..9bc5bc7ac3 100644 --- a/web/src/components/admin/plugins/SchemaForm.tsx +++ b/web/src/components/admin/plugins/SchemaForm.tsx @@ -39,8 +39,24 @@ type Props = { optionsLoading?: boolean; idPrefix?: string; onValidityChange?: (valid: boolean) => void; + /** + * Fields the host owns for this form and does not show. Their values pass + * through untouched, they are left out of validation (an admin could not fix + * a field they cannot see), and a section left with no fields is dropped. + */ + hiddenKeys?: readonly string[]; + /** Render every section open and without its Show/Hide toggle. */ + expandSections?: boolean; + /** + * Fields shown but not editable right now, each with the reason, which is + * shown under the control (e.g. why a server's type cannot change). + */ + lockedKeys?: Readonly>; }; +const NO_HIDDEN_KEYS: readonly string[] = []; +const NO_LOCKED_KEYS: Readonly> = {}; + function optionsFor( field: PluginAdminFormField, dynamicOptions: Record | undefined, @@ -99,12 +115,14 @@ function SchemaFormSection({ values, fields, forceOpen, + expanded, renderFields, }: { section: PluginAdminFormSection; values: Record; fields: PluginAdminFormField[]; forceOpen: boolean; + expanded: boolean; renderFields: (keys: string[]) => React.ReactNode; }) { // null = operator hasn't toggled; fall back to collapsed_default. forceOpen @@ -115,8 +133,9 @@ function SchemaFormSection({ return null; } + const collapsible = section.collapsible && !expanded; const open = forceOpen || (userOpen ?? !section.collapsed_default); - const showFields = section.collapsible ? open : true; + const showFields = collapsible ? open : true; return (
@@ -125,7 +144,7 @@ function SchemaFormSection({
- {section.collapsible ? ( + {collapsible ? ( @@ -145,19 +164,24 @@ export function SchemaForm({ optionsLoading, idPrefix = "schema", onValidityChange, + hiddenKeys = NO_HIDDEN_KEYS, + expandSections = false, + lockedKeys = NO_LOCKED_KEYS, }: Props) { + const hidden = useMemo(() => new Set(hiddenKeys), [hiddenKeys]); const byKey = useMemo(() => { const map = new Map(); for (const field of descriptor.fields) { - map.set(field.key, field); + if (!hidden.has(field.key)) map.set(field.key, field); } return map; - }, [descriptor.fields]); + }, [descriptor.fields, hidden]); - const clientErrors = useMemo( - () => validateSchemaValues(descriptor, values), - [descriptor, values], - ); + const clientErrors = useMemo(() => { + const all = validateSchemaValues(descriptor, values); + if (hidden.size === 0) return all; + return Object.fromEntries(Object.entries(all).filter(([key]) => !hidden.has(key))); + }, [descriptor, values, hidden]); const mergedErrors = useMemo(() => { return { ...clientErrors, ...(errors ?? {}) }; @@ -185,8 +209,24 @@ export function SchemaForm({ onChange({ ...values, [key]: value }); } + function lockNoteId(field: PluginAdminFormField): string { + return `${idPrefix}-${field.key}-locked`; + } + + // The reason a locked field cannot change, tied to its control. + function renderLockNote(field: PluginAdminFormField): React.ReactNode { + const reason = lockedKeys[field.key]; + return reason ? ( +

+ {reason} +

+ ) : null; + } + function renderControl(field: PluginAdminFormField): React.ReactNode { const id = `${idPrefix}-${field.key}`; + const locked = lockedKeys[field.key] !== undefined; + const describedBy = locked ? lockNoteId(field) : undefined; if (field.control === "SELECT") { const options = optionsFor(field, dynamicOptions); @@ -197,8 +237,9 @@ export function SchemaForm({
{renderControl(field)} + {renderLockNote(field)} {err ?

{err}

: null}
); @@ -316,12 +364,15 @@ export function SchemaForm({ className="mt-0.5 shrink-0" checked={Boolean(effectiveValue(field, values))} onCheckedChange={(checked) => setField(field.key, checked)} + disabled={lockedKeys[field.key] !== undefined} + aria-describedby={lockedKeys[field.key] !== undefined ? lockNoteId(field) : undefined} />
+ {renderLockNote(field)}
{err ?

{err}

: null} @@ -378,7 +429,9 @@ export function SchemaForm({ groupedKeys.add(key); } } - const ungroupedFields = descriptor.fields.filter((field) => !groupedKeys.has(field.key)); + const ungroupedFields = descriptor.fields.filter( + (field) => !groupedKeys.has(field.key) && !hidden.has(field.key), + ); const resolveKeys = (keys: string[]): PluginAdminFormField[] => keys @@ -388,16 +441,19 @@ export function SchemaForm({ return (
{ungroupedFields.length > 0 ? renderFieldList(ungroupedFields) : null} - {sections.map((section) => ( - mergedErrors[key] != null)} - renderFields={(keys) => renderFieldList(resolveKeys(keys))} - /> - ))} + {sections + .filter((section) => resolveKeys(section.field_keys).length > 0) + .map((section) => ( + mergedErrors[key] != null)} + expanded={expandSections} + renderFields={(keys) => renderFieldList(resolveKeys(keys))} + /> + ))}
); } diff --git a/web/src/components/settings/SecretField.tsx b/web/src/components/settings/SecretField.tsx index 890b0a982f..ca2dc9682f 100644 --- a/web/src/components/settings/SecretField.tsx +++ b/web/src/components/settings/SecretField.tsx @@ -1,4 +1,4 @@ -import { useId } from "react"; +import { useId, type ReactNode } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -33,6 +33,8 @@ export interface SecretFieldProps { */ cleared?: boolean; hint?: string; + /** Extra line under the description, e.g. a save error for this field. */ + status?: ReactNode; disabled?: boolean; restartRequired?: boolean; } @@ -61,6 +63,7 @@ export function SecretField({ onClear, cleared = false, hint, + status, disabled = false, restartRequired = false, }: SecretFieldProps) { @@ -97,6 +100,7 @@ export function SecretField({ htmlFor={controlId} description={description} descriptionId={hintId} + status={status} restartRequired={restartRequired} > {/* The action sits ahead of the input, as in LimitField: the row's unit diff --git a/web/src/hooks/admin/useSettingsOverview.test.ts b/web/src/hooks/admin/useSettingsOverview.test.ts index 6d30764a2d..0bb33c6397 100644 --- a/web/src/hooks/admin/useSettingsOverview.test.ts +++ b/web/src/hooks/admin/useSettingsOverview.test.ts @@ -25,7 +25,7 @@ describe("buildSettingsOverview health tiles", () => { const model = buildSettingsOverview({}); expect(model.tiles).toHaveLength(5); - expect(model.cards).toHaveLength(13); + expect(model.cards).toHaveLength(14); expect(tile({}, "storage").stateText).toBe("Not set up"); expect(card({}, "general")).toEqual({ id: "general" }); }); @@ -291,6 +291,7 @@ describe("buildSettingsOverview groups", () => { "library", "playback", "downloads", + "requests", "providers", "watch-sync", "ai", diff --git a/web/src/hooks/admin/useSettingsOverview.ts b/web/src/hooks/admin/useSettingsOverview.ts index 659626459c..043721e117 100644 --- a/web/src/hooks/admin/useSettingsOverview.ts +++ b/web/src/hooks/admin/useSettingsOverview.ts @@ -24,6 +24,7 @@ export const ADMIN_SETTINGS_PAGE_IDS = [ "library", "playback", "downloads", + "requests", "providers", "watch-sync", "ai", diff --git a/web/src/hooks/queries/keys.ts b/web/src/hooks/queries/keys.ts index 290da539d3..38ec00bcb5 100644 --- a/web/src/hooks/queries/keys.ts +++ b/web/src/hooks/queries/keys.ts @@ -402,6 +402,14 @@ export const adminKeys = { requests: (params: Record) => ["admin", "requests", params] as const, requestSettings: () => ["admin", "requests", "settings"] as const, requestIntegrations: () => ["admin", "requests", "integrations"] as const, + // The next three sit outside requestsRoot on purpose: every request, server, + // and settings write invalidates that root. Nothing those writes change shows + // up in a route, and reading the routes is one GET per route; the options + // each call out to the server's Sonarr or Radarr. + requestRoutes: () => ["admin", "requestRoutes"] as const, + requestIntegrationOptionsRoot: () => ["admin", "requestIntegrationOptions"] as const, + requestIntegrationOptions: (integrationId: string) => + ["admin", "requestIntegrationOptions", integrationId] as const, requestUserLimit: (userId: number) => ["admin", "requests", "users", userId, "limit"] as const, recommendationsStatus: () => ["admin", "recommendationsStatus"] as const, inviteCodes: () => ["admin", "inviteCodes"] as const, diff --git a/web/src/hooks/queries/useRequests.ts b/web/src/hooks/queries/useRequests.ts index cc6081d805..11f8e58268 100644 --- a/web/src/hooks/queries/useRequests.ts +++ b/web/src/hooks/queries/useRequests.ts @@ -14,6 +14,15 @@ import { declineAdminRequestV2, retryAdminRequestV2, loadAdminRequestIntegrationOptionsV2, + listAdminRequestRoutesV2, + createAdminRequestRouteV2, + updateAdminRequestRouteV2, + deleteAdminRequestRouteV2, + reorderAdminRequestRoutesV2, + previewAdminRequestRouteV2, + type RequestRoute, + type RequestRouteBody, + type RequestRouteMediaType, } from "@/api/v2/adminRequests"; import { v2 } from "@/api/v2/request"; import { @@ -94,19 +103,30 @@ export function useRequestDiscoverySection(section: string, page = 1) { }); } -export function useDiscoverStudios() { +export interface DiscoverBrandQueryOptions { + /** When false, the list is not read. Default: true. */ + enabled?: boolean; + /** Retry policy; the admin routing editor passes false so a refusal is not repeated. */ + retry?: boolean; +} + +export function useDiscoverStudios(options: DiscoverBrandQueryOptions = {}) { return useQuery({ queryKey: requestKeys.discoverStudios(), queryFn: listDiscoverStudiosV2, staleTime: DISCOVER_BRAND_STALE_TIME, + enabled: options.enabled ?? true, + ...(options.retry !== undefined ? { retry: options.retry } : {}), }); } -export function useDiscoverNetworks() { +export function useDiscoverNetworks(options: DiscoverBrandQueryOptions = {}) { return useQuery({ queryKey: requestKeys.discoverNetworks(), queryFn: listDiscoverNetworksV2, staleTime: DISCOVER_BRAND_STALE_TIME, + enabled: options.enabled ?? true, + ...(options.retry !== undefined ? { retry: options.retry } : {}), }); } @@ -322,14 +342,19 @@ export function useUpdateRequestSettings() { return useMutation({ retry: false, mutationFn: putAdminRequestSettingsV2, - onSuccess: () => { + onSuccess: (saved) => { toast.success("Request settings saved"); - queryClient.invalidateQueries({ queryKey: adminKeys.requestSettings() }); + // The editor adopts the saved record; the cache has to hold it first, + // or a clean editor would follow the query back to the replaced one. + queryClient.setQueryData(adminKeys.requestSettings(), saved); queryClient.invalidateQueries({ queryKey: requestKeys.status() }); invalidateRequestSurfaces(queryClient); }, onError: (err) => { toast.error(err instanceof Error ? err.message : "Failed to save request settings"); + // A refused save (412) means someone else saved; read their version so + // a discarded draft starts from it. + queryClient.invalidateQueries({ queryKey: adminKeys.requestSettings() }); }, }); } @@ -342,6 +367,14 @@ export function useRequestIntegrations() { }); } +// A saved server's connection may have changed, so the root folders, quality +// profiles, and tags read from it are stale too. +function invalidateRequestServers(queryClient: ReturnType) { + queryClient.invalidateQueries({ queryKey: adminKeys.requestIntegrations() }); + queryClient.invalidateQueries({ queryKey: adminKeys.requestIntegrationOptionsRoot() }); + invalidateRequestSurfaces(queryClient); +} + export function useCreateRequestIntegration() { const queryClient = useQueryClient(); return useMutation({ @@ -349,13 +382,12 @@ export function useCreateRequestIntegration() { mutationFn: (integration: RequestIntegration) => saveAdminRequestIntegrationV2(integration, true), onSuccess: () => { - toast.success("Integration created"); - queryClient.invalidateQueries({ queryKey: adminKeys.requestIntegrations() }); - invalidateRequestSurfaces(queryClient); + toast.success("Server added"); + invalidateRequestServers(queryClient); }, onError: (err) => { if (isValidationFailure(err)) return; - toast.error(err instanceof Error ? err.message : "Failed to create integration"); + toast.error(err instanceof Error ? err.message : "Failed to add server"); }, }); } @@ -366,13 +398,12 @@ export function useUpdateRequestIntegration() { retry: false, mutationFn: (integration: RequestIntegration) => saveAdminRequestIntegrationV2(integration), onSuccess: () => { - toast.success("Integration saved"); - queryClient.invalidateQueries({ queryKey: adminKeys.requestIntegrations() }); - invalidateRequestSurfaces(queryClient); + toast.success("Server saved"); + invalidateRequestServers(queryClient); }, onError: (err) => { if (isValidationFailure(err)) return; - toast.error(err instanceof Error ? err.message : "Failed to save integration"); + toast.error(err instanceof Error ? err.message : "Failed to save server"); }, }); } @@ -383,16 +414,141 @@ export function useDeleteRequestIntegration() { retry: false, mutationFn: deleteAdminRequestIntegrationV2, onSuccess: () => { - toast.success("Integration deleted"); - queryClient.invalidateQueries({ queryKey: adminKeys.requestIntegrations() }); - invalidateRequestSurfaces(queryClient); + toast.success("Server deleted"); + invalidateRequestServers(queryClient); }, onError: (err) => { - toast.error(err instanceof Error ? err.message : "Failed to delete integration"); + toast.error(err instanceof Error ? err.message : "Failed to delete server"); }, }); } +/** + * The root folders, quality profiles, and tags a saved server offers, read + * through the server's own stored connection. A routing destination picks its + * overrides from these. + */ +export function useRequestIntegrationOptions(integrationId: string | undefined) { + return useQuery({ + queryKey: adminKeys.requestIntegrationOptions(integrationId ?? ""), + queryFn: () => loadAdminRequestIntegrationOptionsV2(integrationId!, { base_url: "" }), + enabled: Boolean(integrationId), + staleTime: 5 * 60 * 1000, + retry: false, + }); +} + +export function useRequestRoutes() { + return useQuery({ + queryKey: adminKeys.requestRoutes(), + queryFn: listAdminRequestRoutesV2, + staleTime: REQUESTS_STALE_TIME, + }); +} + +// Reordering, like any save, advances the revision of every route it touches, +// so the whole list is read again rather than patched in place. The promise +// is returned so a mutation stays pending until the list is fresh: a second +// reorder or toggle computed from the old list would undo the first or carry +// a replaced validator. +function invalidateRequestRoutes(queryClient: ReturnType) { + return queryClient.invalidateQueries({ queryKey: adminKeys.requestRoutes() }); +} + +/** Writes a saved route into the list, so an editor adopting it is not pulled back. */ +function storeRequestRoute(queryClient: ReturnType, saved: RequestRoute) { + queryClient.setQueryData(adminKeys.requestRoutes(), (routes) => + routes?.map((route) => (route.id === saved.id ? saved : route)), + ); +} + +/** A problem in one line: its detail and every field error it carries. */ +function problemMessage(err: unknown, fallback: string): string { + if (!(err instanceof Error)) return fallback; + if (!(err instanceof V2ProblemError)) return err.message; + const details = [err.message, ...(err.problem.errors ?? []).map((e) => e.detail)]; + return [...new Set(details.filter(Boolean))].join(" "); +} + +/** + * Toasts a failed route write. A caller that shows validation errors beside + * its fields passes `inline`; anyone else (a toggle, a reorder) would + * otherwise fail without a word. + */ +function routeMutationError(err: unknown, fallback: string, inline = false) { + if (inline && isValidationFailure(err)) return; + toast.error(problemMessage(err, fallback)); +} + +/** Adds a rule from the rule editor, which shows validation errors itself. */ +export function useCreateRequestRoute() { + const queryClient = useQueryClient(); + return useMutation({ + retry: false, + mutationFn: (body: RequestRouteBody) => createAdminRequestRouteV2(body), + onSuccess: () => { + toast.success("Rule added"); + return invalidateRequestRoutes(queryClient); + }, + onError: (err) => routeMutationError(err, "Failed to add rule", true), + }); +} + +/** `inlineErrors`: the caller shows validation errors beside its fields. */ +export function useUpdateRequestRoute({ inlineErrors = false }: { inlineErrors?: boolean } = {}) { + const queryClient = useQueryClient(); + return useMutation({ + retry: false, + mutationFn: ({ + route, + body, + }: { + route: Pick; + body: RequestRouteBody; + }) => updateAdminRequestRouteV2(route, body), + onSuccess: (saved) => { + toast.success(saved.is_fallback ? "Default destination saved" : "Rule saved"); + storeRequestRoute(queryClient, saved); + }, + onError: (err) => routeMutationError(err, "Failed to save routing", inlineErrors), + // After a refused save too: the list then carries the other admin's version. + onSettled: () => invalidateRequestRoutes(queryClient), + }); +} + +export function useDeleteRequestRoute() { + const queryClient = useQueryClient(); + return useMutation({ + retry: false, + mutationFn: (route: Pick) => deleteAdminRequestRouteV2(route), + onSuccess: () => { + toast.success("Rule deleted"); + }, + onError: (err) => routeMutationError(err, "Failed to delete rule"), + onSettled: () => invalidateRequestRoutes(queryClient), + }); +} + +export function useReorderRequestRoutes() { + const queryClient = useQueryClient(); + return useMutation({ + retry: false, + mutationFn: ({ mediaType, ids }: { mediaType: RequestRouteMediaType; ids: string[] }) => + reorderAdminRequestRoutesV2(mediaType, ids), + onError: (err) => routeMutationError(err, "Failed to reorder rules"), + onSettled: () => invalidateRequestRoutes(queryClient), + }); +} + +/** Asks where each quality tier of a title would go; failures are shown inline. */ +export function usePreviewRequestRoute() { + return useMutation({ + retry: false, + mutationFn: ({ mediaType, tmdbId }: { mediaType: RequestRouteMediaType; tmdbId: number }) => + previewAdminRequestRouteV2(mediaType, tmdbId), + }); +} + export function useLoadRequestIntegrationOptions() { return useMutation({ retry: false, diff --git a/web/src/lib/adminNavigation.test.ts b/web/src/lib/adminNavigation.test.ts new file mode 100644 index 0000000000..ba965e276c --- /dev/null +++ b/web/src/lib/adminNavigation.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { filterSettingsSearchGroups } from "@/components/settings/settingsSearch"; + +import { buildAdminCommandNavSections } from "./adminNavigation"; + +function hrefsFor(query: string): string[] { + return filterSettingsSearchGroups(buildAdminCommandNavSections(undefined), query).flatMap( + (group) => group.items.map((item) => item.href), + ); +} + +describe("admin command palette", () => { + it.each(["sonarr", "radarr", "request servers", "routing", "anime", "quota", "request limit"])( + "finds the Requests settings page for %s", + (query) => { + expect(hrefsFor(query)).toContain("/admin/settings/requests"); + }, + ); + + it("still finds the request queue", () => { + expect(hrefsFor("approvals")).toContain("/admin/requests"); + }); +}); diff --git a/web/src/lib/adminNavigation.ts b/web/src/lib/adminNavigation.ts index 1e8c604523..4255823086 100644 --- a/web/src/lib/adminNavigation.ts +++ b/web/src/lib/adminNavigation.ts @@ -116,8 +116,10 @@ export const ADMIN_NAV_SECTIONS: AdminNavGroup[] = [ }, { label: "Requests", - description: "User media requests and request handling.", - keywords: ["requested media", "approvals", "overseerr"], + description: "Review, approve, and decline media requests; per-account request limits.", + // Servers, routing, and the request limit itself live on the Requests + // settings page, which the command palette lists with its own keywords. + keywords: ["requested media", "approvals", "request queue", "decline", "user overrides"], icon: Send, href: "/admin/requests", }, diff --git a/web/src/lib/adminSettingsSearch.ts b/web/src/lib/adminSettingsSearch.ts index 5540e4a8e8..5f8d250bd1 100644 --- a/web/src/lib/adminSettingsSearch.ts +++ b/web/src/lib/adminSettingsSearch.ts @@ -9,6 +9,7 @@ import { PlayCircle, Plug, RefreshCw, + Send, Settings2, Sparkles, Users, @@ -348,6 +349,52 @@ export const ADMIN_SETTINGS_GROUPS: AdminSettingsSearchGroup[] = [ ), icon: Download, }, + { + id: "requests", + label: "Requests", + description: + "Request limits and approval, the Sonarr and Radarr servers, and routing rules.", + groups: ["General", "Servers", "Movie routing", "Series routing"], + keywords: [ + "requests", + "media requests", + "sonarr", + "radarr", + "arr", + "request servers", + "routing", + "routes", + "rules", + "anime", + "quota", + "request limit", + "approval", + "auto approve", + "4k", + "root folder", + "quality profile", + "overseerr", + "jellyseerr", + ], + settings: settingIndex( + "General", + "Allow requests", + "Approval", + "Request limit", + "Limit window", + "Also request a 4K copy of every title", + "Servers", + "Add server", + "API key", + "Default destination", + "HD server", + "4K server", + "Rules", + "Add rule", + "Test a title", + ), + icon: Send, + }, { id: "providers", label: "Subtitles & Metadata", diff --git a/web/src/lib/requestRoutingOptions.ts b/web/src/lib/requestRoutingOptions.ts new file mode 100644 index 0000000000..f70191f4c9 --- /dev/null +++ b/web/src/lib/requestRoutingOptions.ts @@ -0,0 +1,114 @@ +/** + * Choices for request routing conditions that TMDB describes with standard + * codes: a title's original language (ISO 639-1) and its origin countries + * (ISO 3166-1). These are the common ones, not the full standards; a rule + * saved through the API with another code still shows it by code. + */ +export interface CodeOption { + code: string; + name: string; +} + +export const ROUTING_LANGUAGES: readonly CodeOption[] = [ + { code: "en", name: "English" }, + { code: "ja", name: "Japanese" }, + { code: "ko", name: "Korean" }, + { code: "zh", name: "Chinese" }, + // TMDB's own code for Cantonese; it is not ISO 639-1, but it is what TMDB + // reports as the original language of most Hong Kong films. + { code: "cn", name: "Cantonese" }, + { code: "fr", name: "French" }, + { code: "de", name: "German" }, + { code: "es", name: "Spanish" }, + { code: "it", name: "Italian" }, + { code: "pt", name: "Portuguese" }, + { code: "ru", name: "Russian" }, + { code: "hi", name: "Hindi" }, + { code: "ta", name: "Tamil" }, + { code: "te", name: "Telugu" }, + { code: "ml", name: "Malayalam" }, + { code: "th", name: "Thai" }, + { code: "id", name: "Indonesian" }, + { code: "tl", name: "Tagalog" }, + { code: "vi", name: "Vietnamese" }, + { code: "tr", name: "Turkish" }, + { code: "ar", name: "Arabic" }, + { code: "he", name: "Hebrew" }, + { code: "fa", name: "Persian" }, + { code: "nl", name: "Dutch" }, + { code: "sv", name: "Swedish" }, + { code: "da", name: "Danish" }, + { code: "no", name: "Norwegian" }, + { code: "fi", name: "Finnish" }, + { code: "is", name: "Icelandic" }, + { code: "pl", name: "Polish" }, + { code: "cs", name: "Czech" }, + { code: "hu", name: "Hungarian" }, + { code: "ro", name: "Romanian" }, + { code: "el", name: "Greek" }, + { code: "uk", name: "Ukrainian" }, +]; + +export const ROUTING_COUNTRIES: readonly CodeOption[] = [ + { code: "US", name: "United States" }, + { code: "GB", name: "United Kingdom" }, + { code: "CA", name: "Canada" }, + { code: "AU", name: "Australia" }, + { code: "NZ", name: "New Zealand" }, + { code: "IE", name: "Ireland" }, + { code: "JP", name: "Japan" }, + { code: "KR", name: "South Korea" }, + { code: "CN", name: "China" }, + { code: "HK", name: "Hong Kong" }, + { code: "TW", name: "Taiwan" }, + { code: "IN", name: "India" }, + { code: "TH", name: "Thailand" }, + { code: "ID", name: "Indonesia" }, + { code: "PH", name: "Philippines" }, + { code: "FR", name: "France" }, + { code: "DE", name: "Germany" }, + { code: "ES", name: "Spain" }, + { code: "IT", name: "Italy" }, + { code: "PT", name: "Portugal" }, + { code: "NL", name: "Netherlands" }, + { code: "BE", name: "Belgium" }, + { code: "CH", name: "Switzerland" }, + { code: "AT", name: "Austria" }, + { code: "SE", name: "Sweden" }, + { code: "NO", name: "Norway" }, + { code: "DK", name: "Denmark" }, + { code: "FI", name: "Finland" }, + { code: "IS", name: "Iceland" }, + { code: "PL", name: "Poland" }, + { code: "CZ", name: "Czechia" }, + { code: "HU", name: "Hungary" }, + { code: "GR", name: "Greece" }, + { code: "RU", name: "Russia" }, + { code: "UA", name: "Ukraine" }, + { code: "TR", name: "Turkey" }, + { code: "IL", name: "Israel" }, + { code: "IR", name: "Iran" }, + { code: "EG", name: "Egypt" }, + { code: "NG", name: "Nigeria" }, + { code: "ZA", name: "South Africa" }, + { code: "BR", name: "Brazil" }, + { code: "MX", name: "Mexico" }, + { code: "AR", name: "Argentina" }, +]; + +/** Decade shortcuts for a rule's release-year range. */ +export const ROUTING_DECADES: readonly { label: string; from: number; to: number }[] = [ + 1970, 1980, 1990, 2000, 2010, 2020, +].map((from) => ({ label: `${from}s`, from, to: from + 9 })); + +function nameOf(options: readonly CodeOption[], code: string): string { + return options.find((option) => option.code === code)?.name ?? code; +} + +export function routingLanguageName(code: string): string { + return nameOf(ROUTING_LANGUAGES, code.toLowerCase()); +} + +export function routingCountryName(code: string): string { + return nameOf(ROUTING_COUNTRIES, code.toUpperCase()); +} diff --git a/web/src/lib/tmdbGenres.ts b/web/src/lib/tmdbGenres.ts new file mode 100644 index 0000000000..9f5ac26f2f --- /dev/null +++ b/web/src/lib/tmdbGenres.ts @@ -0,0 +1,69 @@ +/** + * TMDB's genre lists. Request routing matches on genre IDs, not names, because + * names follow the server's configured TMDB language while the IDs never + * change. Movies and series use separate lists that share some IDs (16 is + * Animation in both) and differ elsewhere (series fold Action and Adventure + * into 10759). + */ +export interface TmdbGenre { + id: number; + name: string; +} + +export const TMDB_MOVIE_GENRES: readonly TmdbGenre[] = [ + { id: 28, name: "Action" }, + { id: 12, name: "Adventure" }, + { id: 16, name: "Animation" }, + { id: 35, name: "Comedy" }, + { id: 80, name: "Crime" }, + { id: 99, name: "Documentary" }, + { id: 18, name: "Drama" }, + { id: 10751, name: "Family" }, + { id: 14, name: "Fantasy" }, + { id: 36, name: "History" }, + { id: 27, name: "Horror" }, + { id: 10402, name: "Music" }, + { id: 9648, name: "Mystery" }, + { id: 10749, name: "Romance" }, + { id: 878, name: "Science Fiction" }, + { id: 10770, name: "TV Movie" }, + { id: 53, name: "Thriller" }, + { id: 10752, name: "War" }, + { id: 37, name: "Western" }, +]; + +export const TMDB_SERIES_GENRES: readonly TmdbGenre[] = [ + { id: 10759, name: "Action & Adventure" }, + { id: 16, name: "Animation" }, + { id: 35, name: "Comedy" }, + { id: 80, name: "Crime" }, + { id: 99, name: "Documentary" }, + { id: 18, name: "Drama" }, + { id: 10751, name: "Family" }, + { id: 10762, name: "Kids" }, + { id: 9648, name: "Mystery" }, + { id: 10763, name: "News" }, + { id: 10764, name: "Reality" }, + { id: 10765, name: "Sci-Fi & Fantasy" }, + { id: 10766, name: "Soap" }, + { id: 10767, name: "Talk" }, + { id: 10768, name: "War & Politics" }, + { id: 37, name: "Western" }, +]; + +export function tmdbGenresFor(mediaType: "movie" | "series"): readonly TmdbGenre[] { + return mediaType === "series" ? TMDB_SERIES_GENRES : TMDB_MOVIE_GENRES; +} + +/** + * The English name of a genre ID. A title's facts can carry a genre from the + * other media type's list, so both are searched before giving up. + */ +export function tmdbGenreName(id: number, mediaType: "movie" | "series"): string { + const own = tmdbGenresFor(mediaType).find((genre) => genre.id === id); + if (own) return own.name; + const other = tmdbGenresFor(mediaType === "series" ? "movie" : "series").find( + (genre) => genre.id === id, + ); + return other?.name ?? `Genre ${id}`; +} diff --git a/web/src/pages/AdminRequests.tsx b/web/src/pages/AdminRequests.tsx index 5324d4a97f..b3e9f8fd37 100644 --- a/web/src/pages/AdminRequests.tsx +++ b/web/src/pages/AdminRequests.tsx @@ -1,40 +1,27 @@ -import { - getAdminRequestIntegrationV2, - isRequestEditorConflict, - requestValidationErrors, -} from "@/api/v2/adminRequests"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { isRequestEditorConflict } from "@/api/v2/adminRequests"; +import { useMemo, useState } from "react"; import type { ReactNode } from "react"; -import { Link, useSearchParams } from "react-router"; +import { Link, Navigate, useSearchParams } from "react-router"; import { AlertTriangle, Check, Library, - Plug, - Plus, RefreshCw, Save, Settings2, SlidersHorizontal, - Trash2, X, } from "lucide-react"; import type { MediaRequest, MediaRequestOutcome, MediaRequestStatus, - PluginCapability, - PluginInstallation, RequestApprovalMode, - RequestIntegration, - RequestIntegrationOptions, RequestLimitMode, - RequestSettings, RequestTarget, RequestUserLimit, } from "@/api/types"; -import { SchemaForm } from "@/components/admin/plugins/SchemaForm"; -import { buildSchemaValues, parseFieldTypes } from "@/components/admin/plugins/schemaFormUtils"; +import { EditorConflict } from "@/components/admin/EditorConflict"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -55,7 +42,6 @@ import { SelectValue, } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; -import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Table, @@ -65,23 +51,14 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { useDebounce } from "@/hooks/useDebounce"; -import { useAdminPluginInstallations } from "@/hooks/queries/admin/plugins"; import { useAdminUsers } from "@/hooks/queries/admin/users"; import { useAdminMediaRequests, useAdminRequestCapabilities, useApproveMediaRequest, - useCreateRequestIntegration, useDeclineMediaRequest, - useDeleteRequestIntegration, - useLoadRequestIntegrationOptions, - useRequestIntegrations, - useRequestSettings, useRequestUserLimit, useRetryMediaRequest, - useUpdateRequestIntegration, - useUpdateRequestSettings, useUpdateRequestUserLimit, } from "@/hooks/queries/useRequests"; import { @@ -94,15 +71,20 @@ import { REQUEST_OUTCOMES, REQUEST_STATUSES, } from "@/lib/mediaRequests"; -import { applyExclusivity } from "./requestExclusivity"; -import { supportedMediaTypesForConfig } from "./requestIntegrationMediaTypes"; type StatusFilter = MediaRequestStatus | "all"; type OutcomeFilter = MediaRequestOutcome | "all"; -const ADMIN_REQUEST_TABS = ["queue", "settings", "integrations", "overrides"] as const; +const ADMIN_REQUEST_TABS = ["queue", "overrides"] as const; type AdminRequestTab = (typeof ADMIN_REQUEST_TABS)[number]; +/** Where request settings, servers, and routing live now. */ +export const REQUEST_SETTINGS_HREF = "/admin/settings/requests"; + +// Tabs this page used to have. Their content moved to Settings → Requests, so +// bookmarks to them land there instead of on the queue. +const MOVED_TABS = new Set(["settings", "integrations"]); + function normalizeAdminRequestTab(value: string | null): AdminRequestTab { return ADMIN_REQUEST_TABS.includes(value as AdminRequestTab) ? (value as AdminRequestTab) @@ -111,7 +93,8 @@ function normalizeAdminRequestTab(value: string | null): AdminRequestTab { export default function AdminRequests() { const [searchParams, setSearchParams] = useSearchParams(); - const activeTab = normalizeAdminRequestTab(searchParams.get("tab")); + const requestedTab = searchParams.get("tab"); + const activeTab = normalizeAdminRequestTab(requestedTab); const capabilities = useAdminRequestCapabilities(); function setActiveTab(value: string) { @@ -127,6 +110,9 @@ export default function AdminRequests() { setSearchParams(next, { replace: true }); } + if (requestedTab !== null && MOVED_TABS.has(requestedTab)) { + return ; + } if (capabilities.isLoading) return ; if ( !capabilities.data?.available || @@ -142,14 +128,22 @@ export default function AdminRequests() { return (
+ {/* The action sits under the text, as on the other admin pages: the + shell's search button floats over the header's top-right corner. */}

Requests

- Review media requests, set limits, and manage Radarr or Sonarr routing. + Review media requests and give one account its own request limit.

+
@@ -161,20 +155,12 @@ export default function AdminRequests() { className="border-border w-max min-w-full justify-start border-b" > Queue - Settings - Integrations User Overrides
- - - - - - @@ -490,846 +476,6 @@ function RequestTargetBadge({ target }: { target: RequestTarget }) { ); } -type SettingsFormState = { - requests_enabled: boolean; - global_max_requests: string; - global_window_days: string; - global_auto_approval_enabled: boolean; - force_dual_quality: boolean; - updated_at: string; -}; - -function RequestSettingsTab() { - const settings = useRequestSettings(); - const [generation, setGeneration] = useState(0); - - if (settings.isLoading) return ; - if (settings.isError && !settings.data) { - return ; - } - if (!settings.data) { - return ; - } - - return ( - { - const result = await settings.refetch(); - if (result.isSuccess) setGeneration((n) => n + 1); - }} - /> - ); -} - -function RequestSettingsForm({ - settings, - onReload, -}: { - settings: RequestSettings; - onReload: () => Promise; -}) { - const [etag, setETag] = useState(settings.etag); - const [conflict, setConflict] = useState(false); - const updateSettings = useUpdateRequestSettings(); - const [form, setForm] = useState(() => ({ - requests_enabled: settings.requests_enabled, - global_max_requests: String(settings.global_max_requests), - global_window_days: String(settings.global_window_days), - global_auto_approval_enabled: settings.global_auto_approval_enabled, - force_dual_quality: settings.force_dual_quality, - updated_at: settings.updated_at, - })); - - function saveSettings() { - const payload: RequestSettings = { - requests_enabled: form.requests_enabled, - global_max_requests: Math.max(0, Number(form.global_max_requests) || 0), - global_window_days: Math.max(1, Number(form.global_window_days) || 1), - global_auto_approval_enabled: form.global_auto_approval_enabled, - force_dual_quality: form.force_dual_quality, - updated_at: form.updated_at, - etag, - }; - updateSettings.mutate(payload, { - onSuccess: (saved) => setETag(saved.etag), - onError: (error) => setConflict(isRequestEditorConflict(error)), - }); - } - - return ( -
-
- -

Global Settings

-
- -
- - setForm((current) => ({ ...current, requests_enabled: checked })) - } - /> - - setForm((current) => ({ ...current, global_auto_approval_enabled: checked })) - } - /> - - - setForm((current) => ({ ...current, global_max_requests: event.target.value })) - } - /> - - - - setForm((current) => ({ ...current, global_window_days: event.target.value })) - } - /> - -
- - setForm((current) => ({ ...current, force_dual_quality: checked })) - } - /> -
-
- - {conflict ? : null} - -
- ); -} - -// Host chrome owned by Silo; everything arr-specific now lives in pluginConfig -// and is rendered by the plugin's connection descriptor via . -type IntegrationFormState = { - id: string; - name: string; - enabled: boolean; - base_url: string; - api_key_ref: string; - has_api_key: boolean; - // Selected installed plugin (which request-router plugin fulfills this connection). - // Empty string means "none selected"; the backend requires a non-empty value. - installation_id: string; - // The specific request_router.v1 capability sub-id on that installation. Tracked - // alongside installation_id because one installation can expose more than one - // capability, so installation_id alone can't identify the chosen backend. - capability_id: string; -}; - -// All request integrations are fulfilled by a plugin exposing this capability. -const REQUEST_ROUTER_CAPABILITY = "request_router.v1"; - -type RequestRouterInstallation = { - installationID: number; - pluginID: string; - capability: PluginCapability; -}; - -// requestRouterInstallations flattens installed plugins to one entry per -// request_router.v1 capability so the form can offer an installation selector. -function requestRouterInstallations( - installations: PluginInstallation[], -): RequestRouterInstallation[] { - const out: RequestRouterInstallation[] = []; - for (const installation of installations) { - for (const capability of installation.capabilities ?? []) { - if ( - capability.type === REQUEST_ROUTER_CAPABILITY || - capability.id === REQUEST_ROUTER_CAPABILITY - ) { - out.push({ - installationID: installation.id, - pluginID: installation.plugin_id, - capability, - }); - } - } - } - return out; -} - -function installationOptionLabel(entry: RequestRouterInstallation): string { - const name = entry.capability.display_name || entry.pluginID; - return `${name} (${entry.capability.id})`; -} - -// installationOptionValue is the patchForm({ name: event.target.value })} - placeholder="Connection name" - /> - - - patchForm({ api_key_ref: event.target.value })} - placeholder={form.has_api_key ? "Leave blank to keep saved key" : "API key"} - /> - -
- - - patchForm({ base_url: event.target.value })} - placeholder="http://localhost:7878" - /> - - - - {installationsLoading ? ( - - ) : installations.length === 0 ? ( -

- No installed plugin exposes the {REQUEST_ROUTER_CAPABILITY} capability. Install a - request-router plugin before adding connections. -

- ) : ( - - )} - {!installationsLoading && installations.length > 0 && !hasInstallation ? ( -

Select a plugin to fulfill this connection.

- ) : null} -
- - {descriptor ? ( -
- - {optionsStatus === "error" && form.base_url.trim().length > 0 ? ( -

- - - Couldn't load options from the service — check the base URL and API key, then - edit a field to retry. - -

- ) : null} -
- ) : hasInstallation ? ( -

- This plugin does not expose a connection configuration form. -

- ) : ( -

- Select a plugin to configure this connection. -

- )} - - {conflict ? : null} -
- - {isNew ? ( - - ) : ( - - )} -
- - { - if (!open) setConfirmDelete(false); - }} - > - - - Delete connection - - {`"${form.name.trim() || title}" will be permanently removed. New requests will no longer route to this connection.`} - - - {conflict ? : null} - - - - - - -
- ); -} - type UserLimitFormState = { limit_mode: RequestLimitMode; max_requests: string; @@ -1519,33 +665,6 @@ function UserLimitEditor({ ); } -function EditorConflict({ onReload }: { onReload: () => Promise }) { - const [loading, setLoading] = useState(false); - return ( -
-

- This was changed by another administrator. Your edits have been kept. Reload to review the - latest version before saving again. -

- -
- ); -} - function Field({ label, children, error }: { label: string; children: ReactNode; error?: string }) { return (
@@ -1560,30 +679,6 @@ function Field({ label, children, error }: { label: string; children: ReactNode; ); } -function SwitchField({ - label, - checked, - onCheckedChange, - description, - disabled, -}: { - label: string; - checked: boolean; - onCheckedChange: (checked: boolean) => void; - description?: string; - disabled?: boolean; -}) { - return ( -
-
- - {description ?

{description}

: null} -
- -
- ); -} - function RowsSkeleton() { return (
diff --git a/web/src/pages/AdminRequests.v2.test.tsx b/web/src/pages/AdminRequests.v2.test.tsx index 98c1026f0c..3264715f64 100644 --- a/web/src/pages/AdminRequests.v2.test.tsx +++ b/web/src/pages/AdminRequests.v2.test.tsx @@ -1,10 +1,9 @@ // @vitest-environment jsdom import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; -import { MemoryRouter } from "react-router"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router"; import { afterEach, describe, expect, it, vi } from "vitest"; import { v2, V2ProblemError } from "@/api/v2/request"; -import { adminKeys } from "@/hooks/queries/keys"; import AdminRequests from "./AdminRequests"; vi.mock("@/api/v2/request", async (importOriginal) => ({ @@ -23,27 +22,12 @@ vi.mock("@/api/client", async (importOriginal) => ({ isProfileRequestContextCurrent: () => true, })); vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); -vi.mock("@/hooks/queries/admin/plugins", () => ({ - useAdminPluginInstallations: () => ({ - data: [ - { id: 1, plugin_id: "router", capabilities: [{ type: "request_router.v1", id: "arr" }] }, - ], - isLoading: false, - }), -})); vi.mock("@/hooks/queries/admin/users", () => ({ useAdminUsers: () => ({ data: [{ id: 1, username: "member" }], isLoading: false }), })); -const settings = { - requests_enabled: true, - global_max_requests: 5, - global_window_days: 7, - global_auto_approval_enabled: false, - force_dual_quality: false, -}; const conflict = () => new V2ProblemError( - "updateAdminRequestSettings", + "updateRequestUserLimit", { type: "https://silo.test/problems/precondition_failed", title: "Changed", @@ -67,7 +51,10 @@ function mount(tab: string) { render( - + + } /> + Request settings page} /> + , ); @@ -78,157 +65,41 @@ afterEach(() => { vi.clearAllMocks(); }); -describe("request administration conflict handling", () => { - it("retains settings and original validator across background refresh and 412, then explicitly reloads", async () => { - let reads = 0; - vi.mocked(v2).mockImplementation((operation, options) => { - if (operation === "GET /api/v2/admin/requests/capabilities") - return reply(options, { available: true, guarded_configuration: true }); - if (operation === "GET /api/v2/admin/request-settings") - return reply( - options, - { ...settings, global_max_requests: ++reads === 1 ? 5 : 9 }, - reads === 1 ? '"initial"' : '"reloaded"', - ); - if (operation === "PUT /api/v2/admin/request-settings") return Promise.reject(conflict()); - throw new Error(operation); - }); - const client = mount("settings"); - await screen.findByText("Save Settings"); - const input = screen.getAllByRole("spinbutton")[0]!; - fireEvent.change(input, { target: { value: "13" } }); - act(() => - client.setQueryData(adminKeys.requestSettings(), { - ...settings, - global_max_requests: 22, - etag: '"background"', - updated_at: "", - }), - ); - expect((input as HTMLInputElement).value).toBe("13"); - fireEvent.click(screen.getByText("Save Settings")); - await screen.findByRole("alert"); - expect((input as HTMLInputElement).value).toBe("13"); - const writes = vi - .mocked(v2) - .mock.calls.filter(([op]) => op === "PUT /api/v2/admin/request-settings"); - expect(writes).toHaveLength(1); - expect(writes[0]![1]!).toMatchObject({ - headers: { "If-Match": '"initial"' }, - body: { global_max_requests: 13 }, - }); - expect( - (screen.getByText("Save Settings").closest("button") as HTMLButtonElement).disabled, - ).toBe(true); - fireEvent.click(screen.getByText("Reload latest version")); - await waitFor(() => - expect((screen.getAllByRole("spinbutton")[0]! as HTMLInputElement).value).toBe("9"), - ); - fireEvent.click(screen.getByText("Save Settings")); - await waitFor(() => +describe("request administration", () => { + it.each(["settings", "integrations"])( + "sends the retired ?tab=%s to the Requests settings page", + async (tab) => { + vi.mocked(v2).mockImplementation((operation, options) => { + if (operation === "GET /api/v2/admin/requests/capabilities") + return reply(options, { available: true, guarded_configuration: true }); + throw new Error(operation); + }); + mount(tab); expect( - vi.mocked(v2).mock.calls.filter(([op]) => op === "PUT /api/v2/admin/request-settings"), - ).toHaveLength(2), - ); - expect( - vi - .mocked(v2) - .mock.calls.filter(([op]) => op === "PUT /api/v2/admin/request-settings")[1]![1]!, - ).toMatchObject({ headers: { "If-Match": '"reloaded"' } }); - }); - it("keeps deletion confirmation open after a stale integration delete", async () => { - const row = { - id: "integration", - name: "Connection", - enabled: true, - base_url: "https://example.invalid", - has_api_key: true, - installation_id: "1", - capability_id: "arr", - plugin_config: {}, - supported_media_types: [], - last_check_at: null, - last_check_status: "", - last_check_error: "", - updated_at: "2026-09-05T00:00:00Z", - }; - vi.mocked(v2).mockImplementation((operation, options) => { - if (operation === "GET /api/v2/admin/requests/capabilities") - return reply(options, { available: true, guarded_configuration: true }); - if (operation === "GET /api/v2/admin/request-integrations") - return reply(options, { items: [row], page: { has_more: false } }); - if (operation === "GET /api/v2/admin/request-integrations/{id}") return reply(options, row); - if (operation === "DELETE /api/v2/admin/request-integrations/{id}") - return Promise.reject(conflict()); - if (operation === "POST /api/v2/admin/request-integrations/{id}/options") - return reply(options, { options: {} }); - throw new Error(operation); - }); - mount("integrations"); - fireEvent.click(await screen.findByRole("button", { name: "Delete" })); - const dialog = await screen.findByRole("dialog"); - fireEvent.click(within(dialog).getByRole("button", { name: "Delete" })); - await waitFor(() => expect(within(dialog).getByRole("alert")).toBeTruthy()); - expect(screen.getByRole("dialog")).toBeTruthy(); - const writes = vi - .mocked(v2) - .mock.calls.filter(([op]) => op === "DELETE /api/v2/admin/request-integrations/{id}"); - expect(writes).toHaveLength(1); - expect(writes[0]![1]!).toMatchObject({ headers: { "If-Match": '"initial"' } }); - }); - it("renders generic integration field errors inline and clears them on edit", async () => { - const row = { - id: "integration", - name: "Connection", - enabled: true, - base_url: "https://example.invalid", - has_api_key: true, - installation_id: "1", - capability_id: "arr", - plugin_config: {}, - supported_media_types: [], - last_check_at: null, - last_check_status: "", - last_check_error: "", - updated_at: "2026-09-05T00:00:00Z", - }; - const errors = [ - { location: "body.name", code: "invalid", detail: "Connection name is rejected" }, - { location: "body.api_key_ref", code: "invalid", detail: "Re-enter the credential" }, - { location: "body.base_url", code: "invalid", detail: "Server URL is rejected" }, - { location: "body.installation_id", code: "invalid", detail: "Choose another installation" }, - ]; + await screen.findByRole("heading", { name: "Request settings page" }), + ).toBeInTheDocument(); + }, + ); + + it("links to the Requests settings page and keeps only the queue and overrides tabs", async () => { vi.mocked(v2).mockImplementation((operation, options) => { if (operation === "GET /api/v2/admin/requests/capabilities") return reply(options, { available: true, guarded_configuration: true }); - if (operation === "GET /api/v2/admin/request-integrations") - return reply(options, { items: [row], page: { has_more: false } }); - if (operation === "GET /api/v2/admin/request-integrations/{id}") return reply(options, row); - if (operation === "POST /api/v2/admin/request-integrations/{id}/options") - return reply(options, { options: {} }); - if (operation === "PUT /api/v2/admin/request-integrations/{id}") - return Promise.reject( - new V2ProblemError("updateRequestIntegration", { - type: "https://example.invalid/problems/validation_failed", - title: "Invalid", - status: 422, - detail: "Review invalid fields", - instance: "test", - errors, - }), - ); + if (operation === "GET /api/v2/admin/requests") + return reply(options, { items: [], page: { has_more: false } }); throw new Error(operation); }); - mount("integrations"); - fireEvent.click(await screen.findByRole("button", { name: "Save" })); - for (const error of errors) expect(await screen.findByText(error.detail)).toBeTruthy(); - const name = screen.getByPlaceholderText("Connection name"); - expect(name.getAttribute("aria-invalid")).toBe("true"); - expect(name.parentElement?.textContent).toContain("Connection name is rejected"); - fireEvent.change(name, { target: { value: "Corrected" } }); - expect(screen.queryByText("Connection name is rejected")).toBeNull(); - expect(name.getAttribute("aria-invalid")).toBe("false"); + mount("queue"); + expect(await screen.findByRole("link", { name: "Request settings" })).toHaveAttribute( + "href", + "/admin/settings/requests", + ); + expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual([ + "Queue", + "User Overrides", + ]); }); + it("offers Decline only for requests nothing has been sent for", async () => { const request = (id: string, title: string, status: string, targets: unknown[] = []) => ({ id, diff --git a/web/src/pages/admin-settings/AdminSettingsLayout.tsx b/web/src/pages/admin-settings/AdminSettingsLayout.tsx index be5130dd51..272319eeff 100644 --- a/web/src/pages/admin-settings/AdminSettingsLayout.tsx +++ b/web/src/pages/admin-settings/AdminSettingsLayout.tsx @@ -23,6 +23,7 @@ import SecurityAccessSettings from "./SecurityAccessSettings"; import LibraryMetadataSettings from "./LibraryMetadataSettings"; import PlaybackSettings from "./PlaybackSettings"; import DownloadsSettings from "./DownloadsSettings"; +import RequestsSettings from "./RequestsSettings"; import ProvidersSettings from "./ProvidersSettings"; import WatchSyncSettings from "./WatchSyncSettings"; import AISettings from "./AISettings"; @@ -44,6 +45,7 @@ const SETTINGS_COMPONENTS: Record = { library: LibraryMetadataSettings, playback: PlaybackSettings, downloads: DownloadsSettings, + requests: RequestsSettings, providers: ProvidersSettings, "watch-sync": WatchSyncSettings, ai: AISettings, diff --git a/web/src/pages/admin-settings/RequestRouteFields.tsx b/web/src/pages/admin-settings/RequestRouteFields.tsx new file mode 100644 index 0000000000..b0bd41e349 --- /dev/null +++ b/web/src/pages/admin-settings/RequestRouteFields.tsx @@ -0,0 +1,450 @@ +import { useId } from "react"; +import { X } from "lucide-react"; + +import type { PluginAdminFormField, RequestIntegration } from "@/api/types"; +import { coerceFieldValue, parseFieldTypes } from "@/components/admin/plugins/schemaFormUtils"; +import { AdvancedSection } from "@/components/settings/AdvancedSection"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useRequestIntegrationOptions } from "@/hooks/queries/useRequests"; +import { cn } from "@/lib/utils"; + +import { overrideFields, type RouteDestinationDraft } from "./requestRoutingModel"; +import { + serverConfigSchema, + serverInstallation, + type RequestRouterInstallation, +} from "./requestServerModel"; +import { SETTINGS_CONTROL_WIDTH, SettingFieldRow } from "./SettingField"; + +/** Select values for the choices that are not a server. */ +export const DEST_NONE = "__none__"; +export const DEST_NEXT = "__next__"; +export const DEST_SKIP = "__skip__"; +const SERVER_SETTING = "__server__"; + +export interface Choice { + value: string; + label: string; +} + +/** An inline error under a field, announced when it appears. */ +export function FieldError({ children }: { children?: string }) { + if (!children) return null; + return ( +

+ {children} +

+ ); +} + +/** + * A row of toggle chips for a short, fixed list (genres, a server's tags): + * every choice visible, each one a pressed or unpressed button. + */ +export function ChipToggleList({ + label, + options, + selected, + onChange, + className, +}: { + label: string; + options: readonly Choice[]; + selected: readonly string[]; + onChange: (next: string[]) => void; + className?: string; +}) { + return ( +
+ {options.map((option) => { + const on = selected.includes(option.value); + return ( + + ); + })} +
+ ); +} + +/** + * The chosen values of a long list (languages, countries, accounts) as + * removable chips, and one select to add another. + */ +export function ValuePicker({ + addLabel, + options, + selected, + onChange, + labelOf, + disabled, + unavailableHint, +}: { + addLabel: string; + options: readonly Choice[]; + selected: readonly string[]; + onChange: (next: string[]) => void; + labelOf?: (value: string) => string; + disabled?: boolean; + /** Why there is nothing to pick from; shown in place of the select. Chosen values stay. */ + unavailableHint?: string; +}) { + const nameOf = (value: string) => + labelOf?.(value) ?? options.find((option) => option.value === value)?.label ?? value; + const available = options.filter((option) => !selected.includes(option.value)); + return ( +
+ {selected.length > 0 ? ( +
    + {selected.map((value) => ( +
  • + {nameOf(value)} + +
  • + ))} +
+ ) : null} + {unavailableHint ? ( +

{unavailableHint}

+ ) : ( + + )} +
+ ); +} + +function overrideValue(value: unknown): string { + return value === undefined || value === null ? "" : String(value); +} + +/** + * One server setting a route may replace. Unset means the server's own + * setting applies, and the first choice says what that is. + */ +function OverrideFieldRow({ + field, + server, + options, + optionsLoading, + value, + onChange, + error, +}: { + field: PluginAdminFormField; + server: RequestIntegration; + options: readonly Choice[]; + optionsLoading: boolean; + value: unknown; + onChange: (raw: unknown) => void; + error?: string; +}) { + const controlId = useId(); + const serverValue = server.plugin_config?.[field.key] ?? field.default_value; + + if (field.control === "MULTI_SELECT") { + const selected = Array.isArray(value) ? value.map((entry) => String(entry)) : []; + return ( + {error}} + > + {optionsLoading && options.length === 0 ? ( + Loading… + ) : options.length === 0 ? ( + Nothing to choose on this server. + ) : ( + + )} + + ); + } + + const choices: Choice[] = + field.control === "SWITCH" + ? [ + { value: "true", label: "On" }, + { value: "false", label: "Off" }, + ] + : [...options]; + const current = overrideValue(value); + if (current && !choices.some((choice) => choice.value === current)) { + choices.push({ value: current, label: current }); + } + const serverLabel = + field.control === "SWITCH" + ? serverValue === true || serverValue === "true" + ? "On" + : "Off" + : (choices.find((choice) => choice.value === overrideValue(serverValue))?.label ?? + overrideValue(serverValue)); + + return ( + {error}} + > + + + ); +} + +/** + * The server settings a route can replace for one tier (root folder, quality + * profile, tags, ...), offered from the server's own form and filled from the + * server's options. Collapsed until the route overrides something. + */ +function RouteOverrideFields({ + sectionId, + server, + installations, + tierLabel, + overrides, + onChange, + errors, + errorPrefix, +}: { + sectionId: string; + server: RequestIntegration; + installations: RequestRouterInstallation[]; + tierLabel: string; + overrides: Record; + onChange: (overrides: Record) => void; + errors: Record; + errorPrefix: string; +}) { + const entry = serverInstallation(installations, server.installation_id, server.capability_id); + const { descriptor, jsonSchema } = serverConfigSchema(entry); + const fields = overrideFields(descriptor, server.plugin_config ?? {}); + const fieldTypes = parseFieldTypes(jsonSchema); + const needsOptions = fields.some((field) => field.dynamic_options); + const options = useRequestIntegrationOptions(needsOptions ? server.id : undefined); + + if (fields.length === 0) return null; + + const overrideErrors = Object.keys(errors).some((key) => + key.startsWith(`${errorPrefix}.overrides.`), + ); + const overridden = Object.values(overrides).some((value) => value !== undefined); + + function set(field: PluginAdminFormField, raw: unknown) { + const next = { ...overrides }; + if (raw === undefined || (Array.isArray(raw) && raw.length === 0)) { + delete next[field.key]; + } else { + next[field.key] = coerceFieldValue(field, raw, fieldTypes[field.key]); + } + onChange(next); + } + + return ( + + {options.isError ? ( +

+ Couldn't read root folders and profiles from {server.name}:{" "} + {options.error instanceof Error ? options.error.message : "unknown error"} +

+ ) : null} + {fields.map((field) => ( + set(field, raw)} + error={errors[`${errorPrefix}.overrides.${field.key}`]} + /> + ))} +
+ ); +} + +/** + * Where a route sends one quality tier: a server select, with the choices + * that are not a server first, and the overrides for the chosen server. + * `errors` holds the route editor's field errors keyed like the API's + * (`hd.integration_id`, `uhd.overrides.root_folder`, ...). + */ +export function RouteDestinationFields({ + sectionId, + label, + tierLabel, + servers, + allServers, + installations, + value, + choices, + selected, + onSelect, + onOverridesChange, + errors, + errorPrefix, + dirty, +}: { + label: string; + tierLabel: string; + /** The servers this tier may go to (the media type's kind). */ + servers: readonly RequestIntegration[]; + /** Every server, to name one that no longer fits the media type. */ + allServers: readonly RequestIntegration[]; + installations: RequestRouterInstallation[]; + value: RouteDestinationDraft; + choices: readonly Choice[]; + selected: string; + onSelect: (value: string) => void; + onOverridesChange: (overrides: Record) => void; + errors: Record; + errorPrefix: string; + dirty?: boolean; + /** + * Names this tier's override section, e.g. `fallback-movie.hd`, so each + * section keeps its own open state instead of opening every other one. + */ + sectionId: string; +}) { + const controlId = useId(); + const server = value.integration_id + ? allServers.find((candidate) => candidate.id === value.integration_id) + : undefined; + const serverChoices: Choice[] = servers.map((candidate) => ({ + value: candidate.id, + label: candidate.enabled ? candidate.name : `${candidate.name} (disabled)`, + })); + if ( + value.integration_id && + !serverChoices.some((choice) => choice.value === value.integration_id) + ) { + serverChoices.push({ value: value.integration_id, label: server?.name ?? "Missing server" }); + } + const error = errors[errorPrefix] ?? errors[`${errorPrefix}.integration_id`]; + + return ( + <> + {error}} + > + + + {server ? ( + + ) : null} + + ); +} diff --git a/web/src/pages/admin-settings/RequestRoutePreview.tsx b/web/src/pages/admin-settings/RequestRoutePreview.tsx new file mode 100644 index 0000000000..9f0a8e114c --- /dev/null +++ b/web/src/pages/admin-settings/RequestRoutePreview.tsx @@ -0,0 +1,253 @@ +import { useId, useState } from "react"; + +import type { RequestIntegration, RequestMediaResult } from "@/api/types"; +import type { + RequestRoutePreview as RoutePreview, + RequestRoutePreviewTier, + RequestRouteMediaType, +} from "@/api/v2/adminRequests"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useDebounce } from "@/hooks/useDebounce"; +import { + usePreviewRequestRoute, + useRequestIntegrationOptions, + useRequestSearch, +} from "@/hooks/queries/useRequests"; +import { routingCountryName, routingLanguageName } from "@/lib/requestRoutingOptions"; +import { tmdbGenreName } from "@/lib/tmdbGenres"; + +import { overridesSummary, SERVER_NAMED_OVERRIDES } from "./requestRoutingModel"; +import { + mediaTypePlural, + serverConfigSchema, + serverInstallation, + type RequestRouterInstallation, +} from "./requestServerModel"; + +const MAX_RESULTS = 6; +const TIER_LABELS: Record = { "1080p": "HD", "2160p": "4K" }; + +function factsSummary(facts: RoutePreview["facts"], mediaType: RequestRouteMediaType): string { + return [ + facts.anime ? "Anime" : null, + facts.genre_ids.length > 0 + ? facts.genre_ids.map((id) => tmdbGenreName(id, mediaType)).join(", ") + : null, + facts.original_language ? routingLanguageName(facts.original_language) : null, + facts.origin_countries.length > 0 + ? facts.origin_countries.map(routingCountryName).join(", ") + : null, + facts.year ? String(facts.year) : null, + ] + .filter(Boolean) + .join(" · "); +} + +function TierOutcome({ + tier, + servers, + installations, +}: { + tier: RequestRoutePreviewTier; + servers: readonly RequestIntegration[]; + installations: RequestRouterInstallation[]; +}) { + const server = servers.find((candidate) => candidate.id === tier.integration_id); + const entry = server + ? serverInstallation(installations, server.installation_id, server.capability_id) + : undefined; + const fields = serverConfigSchema(entry).descriptor?.fields; + // Quality profiles and tags are IDs on the server; reading its options names + // them. Without them (or while they load) the summary shows the IDs. + const needsNames = Object.keys(tier.overrides ?? {}).some((key) => + SERVER_NAMED_OVERRIDES.has(key), + ); + const options = useRequestIntegrationOptions(needsNames && server ? server.id : undefined); + const overrides = overridesSummary(tier.overrides, { options: options.data, fields }); + return ( +
+ {tier.integration_id ? ( +

+ {tier.integration_name || "Missing server"} + {tier.route_name ? ( + · by {tier.route_name} + ) : null} +

+ ) : null} + {overrides ?

{overrides}

: null} + {tier.note ? ( +

+ {tier.note} +

+ ) : null} +
+ ); +} + +/** + * Pick a title and see where each quality tier of a request for it would go + * right now, from TMDB's facts and the saved rules. + */ +export function RequestRoutePreview({ + mediaType, + requestsEnabled, + servers, + installations, +}: { + mediaType: RequestRouteMediaType; + /** Title search is the requesters' search, which answers only while requests are on. */ + requestsEnabled: boolean; + servers: readonly RequestIntegration[]; + installations: RequestRouterInstallation[]; +}) { + const inputId = useId(); + const idInputId = useId(); + const [query, setQuery] = useState(""); + const [tmdbId, setTmdbId] = useState(""); + const [picked, setPicked] = useState<{ tmdbId: number; label: string } | null>(null); + const debounced = useDebounce(query, 300); + const search = useRequestSearch(mediaType, debounced, 1, { + enabled: requestsEnabled && picked === null, + retry: false, + }); + const preview = usePreviewRequestRoute(); + const results = (search.data?.results ?? []) + .filter((result) => result.media_type === mediaType) + .slice(0, MAX_RESULTS); + const searching = requestsEnabled && picked === null && debounced.trim().length > 1; + // The preview itself works with requests off; only finding the title needs + // the search. Without it, the admin names the title by its TMDB ID. + const byId = !requestsEnabled || search.isError; + const parsedId = Number(tmdbId.trim()); + const validId = Number.isInteger(parsedId) && parsedId > 0; + + function check(id: number, label: string) { + setPicked({ tmdbId: id, label }); + preview.mutate({ mediaType, tmdbId: id }); + } + + function pick(result: RequestMediaResult) { + setQuery(result.title); + check(result.tmdb_id, result.year ? `${result.title} (${result.year})` : result.title); + } + + return ( +
+
+ +

+ See where a request would go with the rules as saved. +

+ {requestsEnabled ? ( + { + setQuery(event.target.value); + setPicked(null); + preview.reset(); + }} + className="sm:max-w-sm" + /> + ) : null} +
+ + {byId ? ( +
{ + event.preventDefault(); + if (validId) check(parsedId, `TMDB ${parsedId}`); + }} + > +

+ {requestsEnabled + ? "Search isn't answering; enter the title's TMDB ID instead." + : "Search works only while requests are allowed; enter the title's TMDB ID instead."} +

+
+ setTmdbId(event.target.value)} + className="w-40" + /> + +
+
+ ) : null} + + {searching ? ( + search.isLoading ? ( +

Searching…

+ ) : search.isError ? ( +

+ {search.error instanceof Error ? search.error.message : "Search failed."} +

+ ) : results.length === 0 ? ( +

No matches.

+ ) : ( +
    + {results.map((result) => ( +
  • + +
  • + ))} +
+ ) + ) : null} + + {picked ? ( +
+

{picked.label}

+ {preview.isPending ? ( +

Checking…

+ ) : preview.isError ? ( +

+ {preview.error instanceof Error ? preview.error.message : "The preview failed."} +

+ ) : preview.data ? ( + <> +

+ {factsSummary(preview.data.facts, mediaType) || "TMDB has no facts for this title."} +

+
+ {preview.data.tiers.map((tier) => ( +
+
+ {TIER_LABELS[tier.quality] ?? tier.quality} +
+
+ +
+
+ ))} +
+ + ) : null} +
+ ) : null} +
+ ); +} diff --git a/web/src/pages/admin-settings/RequestRouting.tsx b/web/src/pages/admin-settings/RequestRouting.tsx new file mode 100644 index 0000000000..941d598b31 --- /dev/null +++ b/web/src/pages/admin-settings/RequestRouting.tsx @@ -0,0 +1,407 @@ +import { useId, useMemo, useState } from "react"; +import { ArrowDown, ArrowUp, Plus } from "lucide-react"; + +import type { RequestIntegration } from "@/api/types"; +import type { RequestRoute, RequestRouteMediaType } from "@/api/v2/adminRequests"; +import { EditorConflict } from "@/components/admin/EditorConflict"; +import { SettingsSubheading } from "@/components/settings/SettingsSubheading"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; +import { useAdminUsers } from "@/hooks/queries/admin/users"; +import { + useDiscoverNetworks, + useDiscoverStudios, + useReorderRequestRoutes, + useUpdateRequestRoute, +} from "@/hooks/queries/useRequests"; + +import { FieldGroup } from "./FieldGroup"; +import { DEST_NONE, RouteDestinationFields } from "./RequestRouteFields"; +import { RequestRoutePreview } from "./RequestRoutePreview"; +import { RequestRuleEditor } from "./RequestRuleEditor"; +import { + conditionSummary, + destinationChanges, + destinationDraft, + destinationSummary, + addRuleBlockedReason, + routeBody, + type FallbackDraft, +} from "./requestRoutingModel"; +import { + mediaTypePlural, + serverServesMediaType, + type RequestRouterInstallation, +} from "./requestServerModel"; +import type { StagedDraft } from "./useStagedDraft"; + +const GROUP_LABELS: Record = { + movie: "Movie routing", + series: "Series routing", +}; +const KIND_NAMES: Record = { movie: "Radarr", series: "Sonarr" }; + +function RuleRow({ + rule, + index, + count, + servers, + mediaType, + names, + busy, + onEdit, + onToggle, + onMove, +}: { + rule: RequestRoute; + index: number; + count: number; + servers: RequestIntegration[]; + mediaType: RequestRouteMediaType; + names: Parameters[2]; + busy: boolean; + onEdit: () => void; + onToggle: (enabled: boolean) => void; + onMove: (delta: -1 | 1) => void; +}) { + const summaryId = useId(); + const matches = conditionSummary(rule.conditions, mediaType, names); + const sends = [ + destinationSummary("HD", rule.hd, servers), + destinationSummary("4K", rule.uhd, servers, rule.skip_uhd), + ].join(" · "); + return ( +
  • + + +
    + + +
    +
  • + ); +} + +/** + * One media type's routing: the default destination (staged into the page's + * save bar), the ordered rules (each saved from its own editor), and a way to + * check where a title would go. + */ +export function RequestRoutingGroup({ + mediaType, + routes, + routesLoading, + routesFetching, + routesError, + allServers, + installations, + requestsEnabled, + fallback, + fallbackSaving, + fallbackErrors, + fallbackConflict, + onFallbackEdited, + onReloadFallback, +}: { + mediaType: RequestRouteMediaType; + routes: RequestRoute[]; + routesLoading: boolean; + /** The list is being read again; its order and validators may be stale. */ + routesFetching: boolean; + routesError: boolean; + allServers: RequestIntegration[]; + installations: RequestRouterInstallation[]; + /** The saved request switch; undefined until the settings have loaded. */ + requestsEnabled: boolean | undefined; + fallback: StagedDraft; + /** The page's save bar is saving; the default destination is locked meanwhile. */ + fallbackSaving: boolean; + fallbackErrors: Record; + fallbackConflict: boolean; + onFallbackEdited: (tier: "hd" | "uhd") => void; + onReloadFallback: () => Promise; +}) { + const [editing, setEditing] = useState(null); + const [newKey, setNewKey] = useState(0); + const users = useAdminUsers(); + // The curated networks and studios come from the requesters' discover API, + // which answers only while requests are on (and refuses with a 409 when + // they are off, which asking again does not change). + const brandsReadable = requestsEnabled === true; + const networks = useDiscoverNetworks({ enabled: brandsReadable, retry: false }); + const studios = useDiscoverStudios({ enabled: brandsReadable, retry: false }); + const brands = (mediaType === "series" ? networks.data : studios.data) ?? []; + const toggleRule = useUpdateRequestRoute(); + const reorder = useReorderRequestRoutes(); + + const servers = useMemo( + () => allServers.filter((server) => serverServesMediaType(server, mediaType)), + [allServers, mediaType], + ); + const rules = routes + .filter((route) => route.media_type === mediaType && !route.is_fallback) + .sort((a, b) => a.position - b.position); + const names = useMemo( + () => ({ + users: new Map((users.data ?? []).map((user) => [user.id, user.username])), + brands: new Map( + [...(networks.data ?? []), ...(studios.data ?? [])] + .filter((brand) => brand.tmdb_id) + .map((brand) => [brand.tmdb_id!, brand.display_name]), + ), + }), + [users.data, networks.data, studios.data], + ); + + const base = fallback.base; + const draft = fallback.draft; + const fallbackReady = Boolean(base?.hd.integration_id); + // The destination rows show their own errors; anything else is listed once. + const otherFallbackErrors = Object.entries(fallbackErrors).filter( + ([key]) => !/^u?hd(\.|$)/.test(key), + ); + const editingRule = editing && editing !== "new" ? rules.find((r) => r.id === editing) : null; + const plural = mediaTypePlural(mediaType); + // Nothing routes this media type yet: no rules, and no default server. + const unrouted = rules.length === 0 && !base?.hd.integration_id && !base?.uhd.integration_id; + + function move(index: number, delta: -1 | 1) { + const ids = rules.map((rule) => rule.id); + const target = index + delta; + if (target < 0 || target >= ids.length) return; + [ids[index], ids[target]] = [ids[target]!, ids[index]!]; + reorder.mutate({ mediaType, ids }); + } + + // Until the reread lands, the order and validators on screen are the ones a + // reorder or save just replaced, so nothing may be computed from them. + const listBusy = reorder.isPending || toggleRule.isPending || routesFetching; + + function choose(tier: "hd" | "uhd", value: string) { + onFallbackEdited(tier); + fallback.update((current) => { + const id = value === DEST_NONE ? "" : value; + if (current[tier].integration_id === id) return current; + return { ...current, [tier]: { integration_id: id, overrides: {} } }; + }); + } + + function setOverrides(tier: "hd" | "uhd", overrides: Record) { + onFallbackEdited(tier); + fallback.update((current) => ({ ...current, [tier]: { ...current[tier], overrides } })); + } + + return ( + 0} + > + {routesLoading ? ( +
    + + +
    + ) : routesError || !base || !draft ? ( +

    Routing could not be loaded.

    + ) : ( + <> + + Default destination + + {servers.length === 0 ? ( +

    + Add a {KIND_NAMES[mediaType]} server above to send {plural} anywhere. +

    + ) : unrouted ? ( +

    + Choose an HD server so approved requests for {plural} have somewhere to go. +

    + ) : null} + {fallbackConflict ? ( +
    + +
    + ) : null} + {otherFallbackErrors.length > 0 ? ( +
    + {otherFallbackErrors.map(([key, detail]) => ( +

    {detail}

    + ))} +
    + ) : null} + {/* Locked while the save bar saves it: the save adopts what the + server returned, which would drop an edit made meanwhile. */} +
    + choose("hd", value)} + onOverridesChange={(overrides) => setOverrides("hd", overrides)} + errors={fallbackErrors} + errorPrefix="hd" + dirty={destinationChanges(draft.hd, destinationDraft(base.hd)) > 0} + /> + choose("uhd", value)} + onOverridesChange={(overrides) => setOverrides("uhd", overrides)} + errors={fallbackErrors} + errorPrefix="uhd" + dirty={destinationChanges(draft.uhd, destinationDraft(base.uhd)) > 0} + /> +
    + + + Rules + +
    + {rules.length === 0 ? ( +

    + No rules yet, so every {mediaType === "series" ? "series" : "movie"} goes to the + default destination. +

    + ) : ( +
      + {rules.map((rule, index) => ( + setEditing(rule.id)} + onToggle={(enabled) => + toggleRule.mutate({ route: rule, body: { ...routeBody(rule), enabled } }) + } + onMove={(delta) => move(index, delta)} + /> + ))} +
    + )} +
    + + {!fallbackReady ? ( +

    {addRuleBlockedReason(mediaType)}

    + ) : null} +
    +
    + + + + )} + + { + if (!open) setEditing(null); + }} + > + + {editing === "new" || editingRule ? ( + setEditing(null)} + /> + ) : null} + + +
    + ); +} diff --git a/web/src/pages/admin-settings/RequestRuleEditor.tsx b/web/src/pages/admin-settings/RequestRuleEditor.tsx new file mode 100644 index 0000000000..5eddb9557c --- /dev/null +++ b/web/src/pages/admin-settings/RequestRuleEditor.tsx @@ -0,0 +1,645 @@ +import { useId, useState, type ReactNode } from "react"; +import { Trash2 } from "lucide-react"; + +import type { DiscoverBrandCard, RequestIntegration } from "@/api/types"; +import { + getAdminRequestRouteV2, + isRequestEditorConflict, + requestValidationErrors, + type RequestRoute, + type RequestRouteConditions, + type RequestRouteMediaType, +} from "@/api/v2/adminRequests"; +import { EditorConflict } from "@/components/admin/EditorConflict"; +import { AdvancedSection } from "@/components/settings/AdvancedSection"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { + useCreateRequestRoute, + useDeleteRequestRoute, + useUpdateRequestRoute, +} from "@/hooks/queries/useRequests"; +import { + ROUTING_COUNTRIES, + ROUTING_DECADES, + ROUTING_LANGUAGES, + routingCountryName, + routingLanguageName, +} from "@/lib/requestRoutingOptions"; +import { tmdbGenresFor } from "@/lib/tmdbGenres"; + +import { + ChipToggleList, + DEST_NEXT, + DEST_SKIP, + FieldError, + RouteDestinationFields, + ValuePicker, +} from "./RequestRouteFields"; +import { ruleBody, ruleDraft, type RouteRuleDraft } from "./requestRoutingModel"; +import type { RequestRouterInstallation } from "./requestServerModel"; +import { SETTINGS_CONTROL_WIDTH, SettingFieldRow } from "./SettingField"; + +type AnimeChoice = "any" | "only" | "not"; + +function animeChoice(anime: boolean | undefined): AnimeChoice { + return anime === true ? "only" : anime === false ? "not" : "any"; +} + +/** Field errors this editor shows beside a control; the rest go to the top. */ +function isPlacedError(key: string): boolean { + return ( + key === "name" || + key === "conditions" || + key.startsWith("conditions.") || + key === "hd" || + key.startsWith("hd.") || + key === "uhd" || + key.startsWith("uhd.") + ); +} + +/** A condition whose control needs the full width: label above, control below. */ +function ConditionBlock({ + label, + htmlFor, + description, + error, + children, +}: { + label: string; + htmlFor?: string; + description?: string; + error?: string; + children: ReactNode; +}) { + return ( +
    +
    + + {description ? ( +

    {description}

    + ) : null} +
    + {children} + {error} +
    + ); +} + +function parseIds(text: string): number[] | null { + const tokens = text + .split(/[\s,]+/) + .map((token) => token.trim()) + .filter(Boolean); + const ids = tokens.map((token) => Number(token)); + return ids.every((id) => Number.isInteger(id) && id > 0) ? ids : null; +} + +function parseYear(value: string): number | undefined { + const year = Number.parseInt(value, 10); + return Number.isFinite(year) && year > 0 ? year : undefined; +} + +/** + * Adds or edits one routing rule: the titles it matches and where it sends + * each quality tier. Saves on its own, like a server; the page's save bar is + * for the settings above it. + */ +export function RequestRuleEditor({ + mediaType, + source, + servers, + allServers, + installations, + users, + brands, + brandsHint, + onDone, +}: { + mediaType: RequestRouteMediaType; + source: RequestRoute | null; + servers: RequestIntegration[]; + allServers: RequestIntegration[]; + installations: RequestRouterInstallation[]; + users: readonly { id: number; username: string }[]; + brands: readonly DiscoverBrandCard[]; + /** Why the curated network/studio list cannot be offered; a TMDB ID still can. */ + brandsHint?: string; + onDone: () => void; +}) { + const [draft, setDraft] = useState(() => ruleDraft(source)); + const [keywords, setKeywords] = useState(() => (source?.conditions.keyword_ids ?? []).join(", ")); + const [brandId, setBrandId] = useState(""); + const [etag, setETag] = useState(source?.etag); + const [conflict, setConflict] = useState(false); + const [fieldErrors, setFieldErrors] = useState>({}); + const [formError, setFormError] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(false); + const createRoute = useCreateRequestRoute(); + const updateRoute = useUpdateRequestRoute({ inlineErrors: true }); + const deleteRoute = useDeleteRequestRoute(); + const nameId = useId(); + const animeId = useId(); + const yearId = useId(); + const keywordsId = useId(); + const brandInputId = useId(); + const isNew = source === null; + + const brandKey = mediaType === "series" ? "network_ids" : "company_ids"; + const brandNoun = mediaType === "series" ? "network" : "studio"; + const brandNames = new Map( + brands + .filter((brand) => brand.tmdb_id) + .map((brand) => [String(brand.tmdb_id), brand.display_name]), + ); + const conditions = draft.conditions; + + function edit(change: (current: RouteRuleDraft) => RouteRuleDraft) { + setFieldErrors((current) => (Object.keys(current).length === 0 ? current : {})); + setFormError(null); + setDraft(change); + } + + function setCondition( + key: K, + value: RequestRouteConditions[K], + ) { + edit((current) => ({ ...current, conditions: { ...current.conditions, [key]: value } })); + } + + function addBrandId() { + const id = Number(brandId.trim()); + if (!Number.isInteger(id) || id <= 0) return; + const current = conditions[brandKey] ?? []; + if (!current.includes(id)) setCondition(brandKey, [...current, id]); + setBrandId(""); + } + + async function reload() { + if (!source) return; + try { + const latest = await getAdminRequestRouteV2(source.id); + setDraft(ruleDraft(latest)); + setKeywords((latest.conditions.keyword_ids ?? []).join(", ")); + setETag(latest.etag); + setConflict(false); + setFieldErrors({}); + setFormError(null); + } catch (error) { + setFormError(error instanceof Error ? error.message : "Reload failed"); + } + } + + function onSaveError(error: unknown) { + if (isRequestEditorConflict(error)) setConflict(true); + const validation = requestValidationErrors(error); + if (validation) { + setFieldErrors(validation.fields); + setFormError(validation.message); + } + } + + function save() { + const keywordIds = parseIds(keywords); + if (keywordIds === null) { + setFieldErrors({ "conditions.keyword_ids": "Enter TMDB keyword IDs as numbers." }); + return; + } + const withKeywords = { + ...draft, + conditions: { ...draft.conditions, keyword_ids: keywordIds }, + }; + setFieldErrors({}); + setFormError(null); + if (isNew) { + createRoute.mutate(ruleBody(withKeywords, mediaType), { + onSuccess: onDone, + onError: onSaveError, + }); + } else if (etag) { + updateRoute.mutate( + { route: { id: source.id, etag }, body: ruleBody(withKeywords) }, + { onSuccess: onDone, onError: onSaveError }, + ); + } + } + + const saving = createRoute.isPending || updateRoute.isPending; + const unplaced = Object.entries(fieldErrors).filter(([key]) => !isPlacedError(key)); + const genres = tmdbGenresFor(mediaType).map((genre) => ({ + value: String(genre.id), + label: genre.name, + })); + const tierChoice = [{ value: DEST_NEXT, label: "Next matching rule or default" }]; + + return ( + <> + + {isNew ? "Add rule" : `Edit ${source.name}`} + + A title matches when it meets every condition you set; a condition with several values + matches any of them. + + + + {formError || unplaced.length > 0 ? ( +
    + {formError ?

    {formError}

    : null} + {unplaced.map(([key, detail]) => ( +

    {detail}

    + ))} +
    + ) : null} + +
    + {fieldErrors.name}} + > + edit((current) => ({ ...current, name: event.target.value }))} + placeholder="e.g. Anime" + aria-invalid={Boolean(fieldErrors.name)} + className={SETTINGS_CONTROL_WIDTH} + /> + + + edit((current) => ({ ...current, enabled }))} + /> + +
    + +
    +

    + Titles it matches +

    + {fieldErrors.conditions} +
    + {fieldErrors["conditions.anime"]}} + > + + + + + setCondition("genre_ids", next.map(Number))} + /> + + + + ({ + value: language.code, + label: language.name, + }))} + selected={conditions.original_languages ?? []} + labelOf={routingLanguageName} + onChange={(next) => setCondition("original_languages", next)} + /> + + + + ({ + value: country.code, + label: country.name, + }))} + selected={conditions.origin_countries ?? []} + labelOf={routingCountryName} + onChange={(next) => setCondition("origin_countries", next)} + /> + + + +
    + setCondition("year_from", parseYear(event.target.value))} + aria-invalid={Boolean(fieldErrors["conditions.year_from"])} + /> + + setCondition("year_to", parseYear(event.target.value))} + aria-invalid={Boolean(fieldErrors["conditions.year_to"])} + /> +
    +
    + {ROUTING_DECADES.map((decade) => { + const on = conditions.year_from === decade.from && conditions.year_to === decade.to; + return ( + + ); + })} +
    +
    + + + ({ value, label }))} + selected={(conditions[brandKey] ?? []).map(String)} + labelOf={(value) => brandNames.get(value) ?? `TMDB ${value}`} + onChange={(next) => setCondition(brandKey, next.map(Number))} + unavailableHint={brandsHint} + /> +
    + setBrandId(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + addBrandId(); + } + }} + /> + +
    +
    + + + ({ value: String(user.id), label: user.username }))} + selected={(conditions.requester_user_ids ?? []).map(String)} + labelOf={(value) => + users.find((user) => String(user.id) === value)?.username ?? `Account ${value}` + } + onChange={(next) => setCondition("requester_user_ids", next.map(Number))} + /> + + + + {fieldErrors["conditions.keyword_ids"]}} + > + { + setFieldErrors({}); + setKeywords(event.target.value); + }} + placeholder="210024" + className={SETTINGS_CONTROL_WIDTH} + aria-invalid={Boolean(fieldErrors["conditions.keyword_ids"])} + /> + + +
    +
    + +
    +

    + Where they go +

    +
    + + edit((current) => ({ + ...current, + hd: + value === DEST_NEXT + ? { integration_id: "", overrides: {} } + : value === current.hd.integration_id + ? current.hd + : { integration_id: value, overrides: {} }, + })) + } + onOverridesChange={(overrides) => + edit((current) => ({ ...current, hd: { ...current.hd, overrides } })) + } + errors={fieldErrors} + errorPrefix="hd" + /> + + edit((current) => ({ + ...current, + skipUhd: value === DEST_SKIP, + uhd: + value === DEST_SKIP || value === DEST_NEXT + ? { integration_id: "", overrides: {} } + : value === current.uhd.integration_id + ? current.uhd + : { integration_id: value, overrides: {} }, + })) + } + onOverridesChange={(overrides) => + edit((current) => ({ ...current, uhd: { ...current.uhd, overrides } })) + } + errors={fieldErrors} + errorPrefix="uhd" + /> +
    +
    + + {conflict ? : null} + + +
    + {!isNew ? ( + + ) : null} +
    +
    + + +
    +
    + + {!isNew ? ( + + + + Delete {source.name}? + + Titles it matched go to the next matching rule or the default destination. + + + {conflict ? : null} + + Cancel + { + event.preventDefault(); + if (!etag) return; + deleteRoute.mutate( + { id: source.id, etag }, + { + onSuccess: () => { + setConfirmDelete(false); + onDone(); + }, + onError: (error) => { + if (isRequestEditorConflict(error)) setConflict(true); + }, + }, + ); + }} + > + Delete + + + + + ) : null} + + ); +} diff --git a/web/src/pages/admin-settings/RequestServers.tsx b/web/src/pages/admin-settings/RequestServers.tsx new file mode 100644 index 0000000000..2ea4c45462 --- /dev/null +++ b/web/src/pages/admin-settings/RequestServers.tsx @@ -0,0 +1,824 @@ +import { useEffect, useId, useMemo, useRef, useState } from "react"; +import { Plus, Trash2 } from "lucide-react"; +import { Link } from "react-router"; + +import type { + RequestIntegration, + RequestIntegrationOptions, + LoadRequestIntegrationOptionsRequest, +} from "@/api/types"; +import { + getAdminRequestIntegrationV2, + isRequestEditorConflict, + requestValidationErrors, + type RequestRoute, +} from "@/api/v2/adminRequests"; +import { EditorConflict } from "@/components/admin/EditorConflict"; +import { SchemaForm } from "@/components/admin/plugins/SchemaForm"; +import { buildSchemaValues, parseFieldTypes } from "@/components/admin/plugins/schemaFormUtils"; +import { ProviderTile, ProviderTileGrid } from "@/components/settings/ProviderTile"; +import { providerMonogram } from "@/lib/monogram"; +import { SecretField } from "@/components/settings/SecretField"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; +import { useDebounce } from "@/hooks/useDebounce"; +import { + useCreateRequestIntegration, + useDeleteRequestIntegration, + useLoadRequestIntegrationOptions, + useUpdateRequestIntegration, +} from "@/hooks/queries/useRequests"; +import { cn } from "@/lib/utils"; + +import { FieldGroup } from "./FieldGroup"; +import { FieldError } from "./RequestRouteFields"; +import { supportedMediaTypesForConfig } from "./requestIntegrationMediaTypes"; +import { + installationOptionLabel, + installationOptionValue, + ROUTING_OWNED_CONFIG_KEYS, + serverConfigSchema, + serverInstallation, + serverKind, + serverReady, + serverRouteUsage, + serverTypeLabel, + SERVICE_KIND_KEY, + type RequestRouterInstallation, +} from "./requestServerModel"; +import { SETTINGS_CONTROL_WIDTH, SettingFieldRow } from "./SettingField"; + +const KIND_TILE_CLASSES: Record = { + radarr: "bg-amber-500/20 text-amber-700 dark:text-amber-300", + sonarr: "bg-sky-500/20 text-sky-700 dark:text-sky-300", +}; + +/** The plugin's per-kind default switches, which routing replaced. */ +const RETIRED_DEFAULT_KEYS = ["is_default", "is_default_4k"] as const; + +function plural(count: number, word: string) { + return `${count} ${word}${count === 1 ? "" : "s"}`; +} + +/** + * The Test result in words. Sonarr and Radarr answer with their quality + * profiles and root folders; another plugin's options are just counted. + */ +function connectedMessage(options: RequestIntegrationOptions): string { + const profiles = options.quality_profile_id?.length; + const folders = options.root_folder?.length; + const parts = [ + profiles !== undefined ? plural(profiles, "quality profile") : null, + folders !== undefined ? plural(folders, "root folder") : null, + ].filter(Boolean); + return parts.length > 0 ? `Connected — ${parts.join(", ")}` : "Connected"; +} + +function ServerTile({ + server, + installations, + routes, + onEdit, +}: { + server: RequestIntegration; + installations: RequestRouterInstallation[]; + routes: RequestRoute[]; + onEdit: () => void; +}) { + const type = serverTypeLabel(server, installations); + const ready = serverReady(server); + const usage = serverRouteUsage(server.id, routes); + const failing = ready && Boolean(server.last_check_error); + return ( + + ); +} + +/** + * The request servers: one tile each, and one editor for adding or changing + * a server. Which requests go to which server is decided by routing below. + */ +export function RequestServersGroup({ + servers, + serversLoading, + serversError, + installations, + installationsLoading, + routes, +}: { + servers: RequestIntegration[]; + serversLoading: boolean; + serversError: boolean; + installations: RequestRouterInstallation[]; + installationsLoading: boolean; + routes: RequestRoute[]; +}) { + // null: closed; "new": adding; otherwise the id of the server being edited. + const [editing, setEditing] = useState(null); + const [newKey, setNewKey] = useState(0); + const noRouterPlugin = !installationsLoading && installations.length === 0; + const editingServer = + editing && editing !== "new" ? servers.find((server) => server.id === editing) : undefined; + + return ( + { + setNewKey((key) => key + 1); + setEditing("new"); + }} + disabled={noRouterPlugin || installationsLoading} + > + + ); +} + +// Host-owned connection chrome; everything *arr-specific is the plugin's +// config, rendered from its form descriptor by SchemaForm. +interface ServerFormState { + id: string; + name: string; + enabled: boolean; + base_url: string; + /** A newly typed key; empty keeps the saved one. */ + api_key_ref: string; + has_api_key: boolean; + installation_id: string; + capability_id: string; +} + +function serverForm( + server: RequestIntegration | null, + sole: RequestRouterInstallation | undefined, +): ServerFormState { + return { + id: server?.id ?? "", + name: server?.name ?? "", + enabled: server?.enabled ?? true, + base_url: server?.base_url ?? "", + api_key_ref: "", + has_api_key: server?.has_api_key ?? false, + installation_id: server?.installation_id + ? String(server.installation_id) + : sole + ? String(sole.installationID) + : "", + capability_id: server?.capability_id ?? sole?.capability.id ?? "", + }; +} + +type OptionsStatus = "idle" | "loading" | "error"; + +/** + * Keeps the plugin form's dynamic choices (root folders, quality profiles, + * tags) loaded for the connection being edited. The probe is keyed on the + * connection itself (URL, key, plugin), not on the rest of the config, so + * choosing a quality profile does not call the server again; the latest probe + * wins. `probe` runs one now, for the Test button. + */ +function useServerOptions( + connectionID: string, + draft: { + base_url: string; + api_key_ref: string; + has_api_key: boolean; + installation_id?: number; + capability_id: string; + plugin_config: Record; + }, +) { + const load = useLoadRequestIntegrationOptions(); + const [options, setOptions] = useState({}); + const [status, setStatus] = useState("idle"); + const genRef = useRef(0); + + const canLoad = + draft.base_url.trim().length > 0 && + Boolean(draft.installation_id) && + draft.capability_id.trim().length > 0 && + Boolean(draft.api_key_ref.trim() || draft.has_api_key); + + const sig = JSON.stringify({ + u: draft.base_url, + k: draft.api_key_ref, + i: draft.installation_id, + c: draft.capability_id, + }); + const debouncedSig = useDebounce(sig, 400); + const draftRef = useRef(draft); + draftRef.current = draft; + + function body(): LoadRequestIntegrationOptionsRequest { + const current = draftRef.current; + return { + base_url: current.base_url, + api_key_ref: current.api_key_ref.trim() || undefined, + capability_id: current.capability_id, + installation_id: current.installation_id, + plugin_config: current.plugin_config, + }; + } + + async function probe(): Promise { + const gen = ++genRef.current; + setStatus("loading"); + try { + const loaded = await load.mutateAsync({ id: connectionID || "new", body: body() }); + if (gen === genRef.current) { + setOptions(loaded); + setStatus("idle"); + } + return loaded; + } catch (error) { + if (gen === genRef.current) { + setOptions({}); + setStatus("error"); + } + throw error; + } + } + + useEffect(() => { + if (!canLoad) { + genRef.current += 1; + setOptions({}); + setStatus("idle"); + return; + } + probe().catch(() => { + // Shown inline through `status`; the Test button reports the reason. + }); + // Only the connection identity decides when to probe again. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSig, canLoad]); + + return { options, status, canLoad, probe }; +} + +interface TestResult { + ok: boolean; + message: string; +} + +/** + * Adds or edits one request server: which plugin serves it, how to reach it, + * and the plugin's own settings. The keys routing owns (default and 4K + * switches, the anime overlay) stay hidden; routing decides those now. + */ +export function RequestServerEditor({ + source, + installations, + routes, + onDone, +}: { + source: RequestIntegration | null; + installations: RequestRouterInstallation[]; + routes: RequestRoute[]; + onDone: () => void; +}) { + const sole = installations.length === 1 ? installations[0] : undefined; + const [form, setForm] = useState(() => serverForm(source, sole)); + const [pluginConfig, setPluginConfig] = useState>(() => ({ + ...(source?.plugin_config ?? {}), + })); + const [etag, setETag] = useState(source?.etag); + const [conflict, setConflict] = useState(false); + const [fieldErrors, setFieldErrors] = useState>({}); + const [formError, setFormError] = useState(null); + const [schemaValid, setSchemaValid] = useState(true); + const [test, setTest] = useState(null); + const [testing, setTesting] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const [deleteError, setDeleteError] = useState(null); + // The server refuses to delete a server routing still sends to. + const routedBy = source ? serverRouteUsage(source.id, routes) : ""; + // Nor does it let a routed server change type (Sonarr ↔ Radarr): the routes + // pointing at it are for one media type. + const typeLock = routedBy + ? `Routing sends requests here (${routedBy}). Change that first to switch the type.` + : undefined; + const deleteBlockedId = useId(); + const createServer = useCreateRequestIntegration(); + const updateServer = useUpdateRequestIntegration(); + const deleteServer = useDeleteRequestIntegration(); + const isNew = form.id === ""; + const nameId = useId(); + const urlId = useId(); + const typeId = useId(); + + const installationID = Number(form.installation_id); + const hasInstallation = Number.isInteger(installationID) && installationID > 0; + const selected = serverInstallation( + installations, + hasInstallation ? installationID : undefined, + form.capability_id, + ); + const { descriptor, jsonSchema } = serverConfigSchema(selected); + const fieldTypes = useMemo(() => parseFieldTypes(jsonSchema), [jsonSchema]); + + const { + options, + status: optionsStatus, + canLoad, + probe, + } = useServerOptions(form.id, { + base_url: form.base_url, + api_key_ref: form.api_key_ref, + has_api_key: form.has_api_key, + installation_id: hasInstallation ? installationID : undefined, + capability_id: selected?.capability.id ?? "", + plugin_config: pluginConfig, + }); + + // Once the server's choices arrive, fill each empty single choice with the + // first one (the root folder and quality profile a new server needs). A + // choice the admin already made is never replaced. + const configRef = useRef(pluginConfig); + configRef.current = pluginConfig; + useEffect(() => { + if (!descriptor) return; + const patch: Record = {}; + for (const field of descriptor.fields) { + if (field.control !== "SELECT" || !field.dynamic_options) continue; + if (ROUTING_OWNED_CONFIG_KEYS.includes(field.key)) continue; + const first = options[field.key]?.[0]; + const value = configRef.current[field.key]; + if (first && (value === undefined || value === null || value === "")) { + patch[field.key] = first.value; + } + } + if (Object.keys(patch).length > 0) setPluginConfig({ ...configRef.current, ...patch }); + }, [options, descriptor]); + + // Any edit makes a failed save's answer stale. + function clearSaveErrors() { + setFieldErrors((current) => (Object.keys(current).length === 0 ? current : {})); + setFormError(null); + } + + function patch(next: Partial) { + clearSaveErrors(); + setTest(null); + setForm((current) => ({ ...current, ...next })); + } + + function patchConfig(next: Record) { + clearSaveErrors(); + setPluginConfig(next); + } + + // A different plugin gets a clean config: the old one's keys mean nothing + // to it. + function changeType(value: string) { + const entry = installations.find((candidate) => installationOptionValue(candidate) === value); + if (!entry || (selected && installationOptionValue(selected) === value)) return; + patch({ installation_id: String(entry.installationID), capability_id: entry.capability.id }); + setPluginConfig({}); + } + + async function reload() { + try { + const latest = await getAdminRequestIntegrationV2(form.id); + setForm(serverForm(latest, sole)); + setPluginConfig({ ...(latest.plugin_config ?? {}) }); + setETag(latest.etag); + setConflict(false); + clearSaveErrors(); + } catch (error) { + setFormError(error instanceof Error ? error.message : "Reload failed"); + } + } + + async function runTest() { + setTesting(true); + try { + const loaded = await probe(); + setTest({ ok: true, message: connectedMessage(loaded) }); + } catch (error) { + setTest({ + ok: false, + message: error instanceof Error ? error.message : "The server could not be reached.", + }); + } finally { + setTesting(false); + } + } + + const hasKey = form.api_key_ref.trim().length > 0 || form.has_api_key; + const saving = createServer.isPending || updateServer.isPending; + const canSave = + !conflict && + (isNew || Boolean(etag)) && + form.name.trim().length > 0 && + form.base_url.trim().length > 0 && + hasKey && + hasInstallation && + (!descriptor || schemaValid); + + function save() { + const nextConfig = descriptor + ? buildSchemaValues(descriptor, pluginConfig, fieldTypes) + : { ...pluginConfig }; + // The plugin still allows one default per kind and checks it on save. A + // server that changes kind would carry its hidden default switches into + // the other kind, where they can clash with a server the admin cannot fix + // from here. Routing decides defaults now, so they are dropped. + if (source && serverKind(source) !== serverKind({ plugin_config: nextConfig })) { + for (const key of RETIRED_DEFAULT_KEYS) { + if (nextConfig[key] === true || nextConfig[key] === "true") nextConfig[key] = false; + } + } + const payload: RequestIntegration = { + id: form.id, + etag, + name: form.name.trim(), + enabled: form.enabled, + base_url: form.base_url.trim(), + api_key_ref: form.api_key_ref.trim() || undefined, + capability_id: selected?.capability.id ?? "", + installation_id: hasInstallation ? installationID : undefined, + supported_media_types: supportedMediaTypesForConfig(nextConfig, source), + plugin_config: nextConfig, + }; + setFieldErrors({}); + setFormError(null); + (isNew ? createServer : updateServer).mutate(payload, { + onSuccess: onDone, + onError: (error) => { + if (isRequestEditorConflict(error)) setConflict(true); + const validation = requestValidationErrors(error); + if (validation) { + setFieldErrors(validation.fields); + setFormError(validation.message); + } + }, + }); + } + + const title = isNew ? "Add server" : `Edit ${source?.name || "server"}`; + const typeLabel = selected ? installationOptionLabel(selected) : ""; + // Errors for a field the editor does not show (a hidden routing key the + // plugin still validated, or the server type when there is only one) are + // listed at the top instead of going unseen. + const shownKeys = new Set([ + "name", + "base_url", + "api_key_ref", + ...(installations.length > 1 ? ["installation_id", "capability_id"] : []), + ...(descriptor?.fields ?? []) + .map((field) => field.key) + .filter((key) => !ROUTING_OWNED_CONFIG_KEYS.includes(key)), + ]); + const unshownErrors = Object.entries(fieldErrors).filter(([key]) => !shownKeys.has(key)); + + return ( + <> + + {title} + + Approved requests are sent here. Routing decides which requests. + + + + {formError || unshownErrors.length > 0 ? ( +
    + {formError ?

    {formError}

    : null} + {unshownErrors.map(([key, detail]) => ( +

    {detail}

    + ))} +
    + ) : null} + +
    + {installations.length > 1 ? ( + + {[fieldErrors.installation_id, fieldErrors.capability_id].filter(Boolean).join(" ")} + + } + > + + + ) : null} + {fieldErrors.name}} + > + patch({ name: event.target.value })} + placeholder={typeLabel ? `e.g. ${typeLabel} 4K` : "e.g. Radarr 4K"} + aria-invalid={Boolean(fieldErrors.name)} + className={SETTINGS_CONTROL_WIDTH} + /> + + {fieldErrors.base_url}} + > + patch({ base_url: event.target.value })} + placeholder="http://radarr:7878" + aria-invalid={Boolean(fieldErrors.base_url)} + className={SETTINGS_CONTROL_WIDTH} + /> + + patch({ api_key_ref: value })} + hint={ + form.has_api_key + ? "Saved. Type a new key to replace it; leave blank to keep it." + : "From the server's Settings › General." + } + status={{fieldErrors.api_key_ref}} + /> + + patch({ enabled })} + /> + +
    + + {descriptor ? ( +
    + + {optionsStatus === "error" && !test ? ( +

    + Couldn't read root folders and profiles from the server. Check the URL and API + key, then Test. +

    + ) : null} +
    + ) : hasInstallation ? ( +

    This plugin has no settings of its own.

    + ) : ( +

    Choose a server type to continue.

    + )} + + {conflict ? : null} + + +
    + {!isNew ? ( + + ) : null} + {routedBy ? ( + + Routing sends requests here ({routedBy}). Change that first to delete it. + + ) : null} + {test ? ( + + {test.message} + + ) : null} +
    +
    + + + +
    +
    + + + + + Delete {source?.name || "server"}? + + Silo stops sending requests to it, and Autoscan connections that reuse it lose their + connection details. + + + {deleteError ? ( +

    + {deleteError} +

    + ) : null} + {conflict ? : null} + + Cancel + { + // Stay open until the server answers, so a refused delete can + // say why right here. + event.preventDefault(); + deleteServer.mutate( + { id: form.id, etag }, + { + onSuccess: () => { + setConfirmDelete(false); + onDone(); + }, + onError: (error) => { + if (isRequestEditorConflict(error)) setConflict(true); + else + setDeleteError(error instanceof Error ? error.message : "Delete failed."); + }, + }, + ); + }} + > + Delete + + +
    +
    + + ); +} diff --git a/web/src/pages/admin-settings/RequestsSettings.test.tsx b/web/src/pages/admin-settings/RequestsSettings.test.tsx new file mode 100644 index 0000000000..547af75734 --- /dev/null +++ b/web/src/pages/admin-settings/RequestsSettings.test.tsx @@ -0,0 +1,1256 @@ +// @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router"; +import { toast } from "sonner"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PluginAdminForm } from "@/api/types"; +import { v2, V2ProblemError } from "@/api/v2/request"; +import { adminKeys } from "@/hooks/queries/keys"; + +import RequestsSettings from "./RequestsSettings"; + +vi.mock("@/api/v2/request", async (importOriginal) => ({ + ...(await importOriginal()), + v2: vi.fn(), +})); +vi.mock("@/api/client", async (importOriginal) => ({ + ...(await importOriginal()), + captureProfileRequestContext: () => ({ + accessToken: "test", + profileId: "profile", + profileToken: null, + authContextVersion: 1, + serverOrigin: "", + }), + isProfileRequestContextCurrent: () => true, +})); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock("@/hooks/queries/admin/users", () => ({ + useAdminUsers: () => ({ + data: [ + { id: 1, username: "admin" }, + { id: 2, username: "kid" }, + ], + isLoading: false, + }), +})); + +// The Sonarr/Radarr plugin's form, trimmed to the fields these tests touch. +const field = (extra: Record) => ({ + required: false, + secret: false, + multiline: false, + ...extra, +}); +const descriptor = { + fields: [ + field({ + key: "service_kind", + label: "Service", + control: "SELECT", + required: true, + options: [ + { value: "radarr", label: "Radarr (movies)" }, + { value: "sonarr", label: "Sonarr (series)" }, + ], + }), + field({ key: "root_folder", label: "Root folder", control: "SELECT", dynamic_options: true }), + field({ + key: "quality_profile_id", + label: "Quality profile", + control: "SELECT", + required: true, + dynamic_options: true, + }), + field({ key: "tags", label: "Tags", control: "MULTI_SELECT", dynamic_options: true }), + field({ key: "is_default", label: "Default (HD/1080p)", control: "SWITCH" }), + field({ key: "is_4k", label: "4K instance", control: "SWITCH" }), + field({ + key: "is_default_4k", + label: "Default 4K (2160p)", + control: "SWITCH", + show_when: [{ field: "is_4k", equals: ["true"] }], + }), + field({ key: "search_on_add", label: "Search on add", control: "SWITCH", default_value: true }), + field({ key: "anime_enabled", label: "Enable anime overrides", control: "SWITCH" }), + field({ + key: "anime_root_folder", + label: "Anime root folder", + control: "SELECT", + dynamic_options: true, + show_when: [{ field: "anime_enabled", equals: ["true"] }], + }), + ], + sections: [ + { + key: "library", + title: "Library", + collapsible: true, + collapsed_default: true, + field_keys: [ + "service_kind", + "root_folder", + "quality_profile_id", + "tags", + "is_default", + "is_4k", + "is_default_4k", + "search_on_add", + ], + }, + { + key: "anime", + title: "Anime overrides", + collapsible: false, + collapsed_default: false, + field_keys: ["anime_enabled", "anime_root_folder"], + }, + ], +} as PluginAdminForm; +const jsonSchema = JSON.stringify({ + type: "object", + properties: { + service_kind: { type: "string" }, + root_folder: { type: "string" }, + quality_profile_id: { type: "integer" }, + tags: { type: "array", items: { type: "integer" } }, + is_default: { type: "boolean" }, + is_4k: { type: "boolean" }, + is_default_4k: { type: "boolean" }, + search_on_add: { type: "boolean" }, + anime_enabled: { type: "boolean" }, + anime_root_folder: { type: "string" }, + }, +}); +vi.mock("@/hooks/queries/admin/plugins", () => ({ + useAdminPluginInstallations: () => ({ + data: [ + { + id: 1, + plugin_id: "silo.requests.arr", + enabled: true, + capabilities: [ + { + type: "request_router.v1", + id: "arr", + display_name: "Sonarr / Radarr", + config_schema: [ + { + key: "connection", + title: "Connection", + json_schema: jsonSchema, + required: false, + admin_form: descriptor, + }, + ], + }, + ], + }, + ], + isLoading: false, + }), +})); + +// Radix Select opens through pointer capture and measures with ResizeObserver, +// neither of which jsdom has. +class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} +beforeEach(() => { + vi.stubGlobal("ResizeObserver", ResizeObserverStub); + window.HTMLElement.prototype.hasPointerCapture = () => false; + window.HTMLElement.prototype.scrollIntoView = () => {}; + localStorage.clear(); +}); +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +const settings = { + requests_enabled: true, + global_max_requests: 5, + global_window_days: 7, + global_auto_approval_enabled: false, + force_dual_quality: false, +}; + +function server(id: string, name: string, kind: "radarr" | "sonarr", extra = {}) { + return { + id, + name, + enabled: true, + base_url: `http://${id}:7878`, + has_api_key: true, + installation_id: "1", + capability_id: "arr", + plugin_config: { + service_kind: kind, + quality_profile_id: 1, + root_folder: kind === "radarr" ? "/movies" : "/tv", + is_default: true, + }, + supported_media_types: [kind === "radarr" ? "movie" : "series"], + last_check_at: null, + last_check_status: "", + last_check_error: "", + updated_at: "2026-09-05T00:00:00Z", + ...extra, + }; +} +const radarr = server("radarr-1", "Radarr", "radarr"); +const radarrAnime = server("radarr-2", "Radarr Anime", "radarr"); +const sonarr = server("sonarr-1", "Sonarr", "sonarr"); + +type Route = { + id: string; + media_type: "movie" | "series"; + position: number; + name: string; + enabled: boolean; + is_fallback: boolean; + conditions: Record; + hd: { integration_id?: string; overrides?: Record }; + uhd: { integration_id?: string; overrides?: Record }; + skip_uhd: boolean; +}; +function route(extra: Partial & Pick): Route { + return { + media_type: "movie", + position: 0, + name: extra.id, + enabled: true, + is_fallback: false, + conditions: {}, + hd: {}, + uhd: {}, + skip_uhd: false, + ...extra, + }; +} +const fallback = (mediaType: "movie" | "series", hd?: string) => + route({ + id: `fallback-${mediaType}`, + media_type: mediaType, + position: 1000, + name: "Everything else", + is_fallback: true, + hd: hd ? { integration_id: hd } : {}, + }); + +const serverOptions = { + root_folder: [ + { value: "/movies", label: "/movies (1.2 TiB free)" }, + { value: "/anime", label: "/anime (300 GiB free)" }, + ], + quality_profile_id: [{ value: "1", label: "HD-1080p" }], + tags: [{ value: "2", label: "anime" }], +}; + +function reply(options: unknown, body: unknown, etag = '"initial"') { + (options as { onResponse?: (r: Response) => void })?.onResponse?.( + new Response(null, { headers: { ETag: etag } }), + ); + return Promise.resolve(body) as never; +} +const problem = (status: number, type: string, detail: string, errors?: unknown[]) => + new V2ProblemError("test", { + type: `https://silo.test/problems/${type}`, + title: detail, + status, + detail, + instance: "test", + ...(errors ? { errors } : {}), + } as never); +const conflict = () => problem(412, "precondition_failed", "Changed"); + +type Options = { path?: { id?: string }; body?: unknown; headers?: Record }; +type Handler = (options: Options) => unknown; + +/** + * Serves the page's reads from fixtures and lets a test replace any + * operation. Every route is read with an ETag naming its id, so a test can + * tell which validator a write sent. + */ +function serve({ + servers = [radarr, radarrAnime, sonarr], + routes = [fallback("movie"), fallback("series", "sonarr-1")], + handlers = {}, +}: { + servers?: ReturnType[]; + routes?: Route[]; + handlers?: Record; +} = {}) { + vi.mocked(v2).mockImplementation(((operation: string, options: Options) => { + const custom = handlers[operation]; + if (custom) return custom(options); + switch (operation) { + case "GET /api/v2/admin/requests/capabilities": + return reply(options, { available: true, guarded_configuration: true }); + case "GET /api/v2/admin/request-settings": + return reply(options, settings); + case "GET /api/v2/admin/request-integrations": + return reply(options, { items: servers, page: { has_more: false } }); + case "GET /api/v2/admin/request-integrations/{id}": + return reply( + options, + servers.find((s) => s.id === options.path?.id), + ); + case "POST /api/v2/admin/request-integrations/{id}/options": + return reply(options, { options: serverOptions }); + case "GET /api/v2/admin/request-routes": + return reply(options, { items: routes }); + case "GET /api/v2/admin/request-routes/{id}": + return reply( + options, + routes.find((r) => r.id === options.path?.id), + `"${options.path?.id}-v1"`, + ); + case "GET /api/v2/requests/discover/networks": + case "GET /api/v2/requests/discover/studios": + return reply(options, { items: [] }); + default: + return Promise.reject(new Error(`unexpected ${operation}`)); + } + }) as never); +} + +function calls(operation: string) { + return vi + .mocked(v2) + .mock.calls.filter(([op]) => op === operation) + .map(([, options]) => options as Options); +} + +function mount() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + render( + + + + + , + ); + return client; +} + +const group = (name: string) => screen.getByRole("group", { name }); +const user = () => userEvent.setup({ pointerEventsCheck: 0 }); + +async function choose(scope: HTMLElement, label: string, option: string) { + await user().click(await within(scope).findByRole("combobox", { name: label })); + await user().click(await screen.findByRole("option", { name: option })); +} + +describe("Requests settings: general", () => { + it("saves through the save bar with the validator it read, and reloads after a 412", async () => { + let reads = 0; + serve({ + handlers: { + "GET /api/v2/admin/request-settings": (options) => { + reads += 1; + return reply( + options, + { ...settings, global_max_requests: reads === 1 ? 5 : 9 }, + reads === 1 ? '"initial"' : '"reloaded"', + ); + }, + "PUT /api/v2/admin/request-settings": (options) => + options.headers?.["If-Match"] === '"initial"' + ? Promise.reject(conflict()) + : reply(options, { ...settings, global_max_requests: 10 }, '"saved"'), + }, + }); + const client = mount(); + const limit = (await screen.findByLabelText("Request limit")) as HTMLInputElement; + fireEvent.change(limit, { target: { value: "13" } }); + + // A background refresh must not replace the edit or its validator. + act(() => + client.setQueryData(adminKeys.requestSettings(), { + ...settings, + global_max_requests: 22, + etag: '"background"', + updated_at: "", + }), + ); + expect(limit.value).toBe("13"); + + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await within(group("General")).findByRole("alert"); + expect(limit.value).toBe("13"); + expect(calls("PUT /api/v2/admin/request-settings")).toHaveLength(1); + expect(calls("PUT /api/v2/admin/request-settings")[0]).toMatchObject({ + headers: { "If-Match": '"initial"' }, + body: { global_max_requests: 13, global_window_days: 7 }, + }); + // Nothing left that could save until the admin reloads. + expect((screen.getByRole("button", { name: "Save" }) as HTMLButtonElement).disabled).toBe(true); + + fireEvent.click(screen.getByRole("button", { name: "Reload latest version" })); + await waitFor(() => + expect((screen.getByLabelText("Request limit") as HTMLInputElement).value).toBe("9"), + ); + expect(screen.queryByRole("button", { name: "Save" })).toBeNull(); + + fireEvent.change(screen.getByLabelText("Request limit"), { target: { value: "10" } }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => expect(calls("PUT /api/v2/admin/request-settings")).toHaveLength(2)); + expect(calls("PUT /api/v2/admin/request-settings")[1]).toMatchObject({ + headers: { "If-Match": '"reloaded"' }, + body: { global_max_requests: 10 }, + }); + }); + + it("does not offer a request limit below 1", async () => { + serve(); + mount(); + const limit = await screen.findByLabelText("Request limit"); + fireEvent.change(limit, { target: { value: "0" } }); + expect(screen.getByText(/Allow at least 1/)).toBeInTheDocument(); + expect((screen.getByRole("button", { name: "Save" }) as HTMLButtonElement).disabled).toBe(true); + }); + + it("explains what the 4K switch does", async () => { + serve(); + mount(); + expect( + await screen.findByText( + "Normally only requesters who can play 4K get a 4K copy, and only when a server takes 4K. With this on, every request also asks for 4K.", + ), + ).toBeInTheDocument(); + }); +}); + +async function openServer(name: string) { + mount(); + const tile = await screen.findByRole("group", { name }); + fireEvent.click(within(tile).getByRole("button", { name: "Edit" })); + return screen.findByRole("dialog"); +} + +describe("Requests settings: servers", () => { + it("names each server by type and hides the switches routing owns", async () => { + serve({ + handlers: { + "PUT /api/v2/admin/request-integrations/{id}": (options) => + reply(options, radarr, '"saved"'), + }, + }); + mount(); + const tile = await screen.findByRole("group", { name: "Radarr Anime" }); + expect(within(tile).getByText("Radarr")).toBeInTheDocument(); + expect(within(group("Sonarr")).getByText("HD default for series")).toBeInTheDocument(); + fireEvent.click(within(group("Radarr")).getByRole("button", { name: "Edit" })); + const dialog = await screen.findByRole("dialog"); + + for (const hidden of ["Default (HD/1080p)", "4K instance", "Enable anime overrides"]) { + expect(within(dialog).queryByText(hidden)).toBeNull(); + } + // The plugin's collapsed Library section is shown open. + expect(within(dialog).getByText("Service")).toBeInTheDocument(); + expect(within(dialog).getByText("Quality profile")).toBeInTheDocument(); + expect(within(dialog).queryByRole("button", { name: "Show" })).toBeNull(); + + const key = within(dialog).getByLabelText("API key") as HTMLInputElement; + expect(key.type).toBe("password"); + expect(key.value).toBe(""); + expect(key.placeholder).toBe("••••••••••••"); + + fireEvent.click(within(dialog).getByRole("button", { name: "Save" })); + await waitFor(() => + expect(calls("PUT /api/v2/admin/request-integrations/{id}")).toHaveLength(1), + ); + const [write] = calls("PUT /api/v2/admin/request-integrations/{id}"); + expect(write).toMatchObject({ headers: { "If-Match": '"initial"' } }); + const body = write!.body as { api_key_ref?: string; plugin_config: Record }; + // A blank key keeps the saved one, and hidden settings pass through. + expect(body.api_key_ref).toBeUndefined(); + expect(body.plugin_config).toMatchObject({ is_default: true, quality_profile_id: 1 }); + }); + + it("tests the connection and reports what it found or why it failed", async () => { + let fail = false; + serve({ + handlers: { + "POST /api/v2/admin/request-integrations/{id}/options": (options) => + fail + ? Promise.reject(problem(502, "dependency_unavailable", "401 Unauthorized from Radarr")) + : reply(options, { options: serverOptions }), + }, + }); + const dialog = await openServer("Radarr"); + const test = within(dialog).getByRole("button", { name: "Test" }); + + fireEvent.click(test); + expect( + await within(dialog).findByText("Connected — 1 quality profile, 2 root folders"), + ).toBeInTheDocument(); + + fail = true; + fireEvent.click(within(dialog).getByRole("button", { name: "Test" })); + expect(await within(dialog).findByText("401 Unauthorized from Radarr")).toBeInTheDocument(); + const probes = calls("POST /api/v2/admin/request-integrations/{id}/options"); + expect(probes.at(-1)).toMatchObject({ path: { id: "radarr-1" } }); + }); + + it("keeps the delete confirmation open after a stale delete", async () => { + serve({ + handlers: { + "DELETE /api/v2/admin/request-integrations/{id}": () => Promise.reject(conflict()), + }, + }); + const dialog = await openServer("Radarr"); + fireEvent.click(within(dialog).getByRole("button", { name: "Delete" })); + const confirm = await screen.findByRole("alertdialog"); + fireEvent.click(within(confirm).getByRole("button", { name: "Delete" })); + await waitFor(() => expect(within(confirm).getByRole("alert")).toBeTruthy()); + expect(screen.getByRole("alertdialog")).toBeTruthy(); + expect(calls("DELETE /api/v2/admin/request-integrations/{id}")).toHaveLength(1); + expect(calls("DELETE /api/v2/admin/request-integrations/{id}")[0]).toMatchObject({ + headers: { "If-Match": '"initial"' }, + }); + }); + + it("shows save errors beside their fields and clears them on edit", async () => { + const errors = [ + { location: "body.name", code: "invalid", detail: "Server name is rejected" }, + { location: "body.api_key_ref", code: "invalid", detail: "Re-enter the key" }, + { location: "body.base_url", code: "invalid", detail: "Server URL is rejected" }, + { location: "body.installation_id", code: "invalid", detail: "Choose another plugin" }, + { location: "body.is_default", code: "invalid", detail: "Radarr already has a default" }, + ]; + serve({ + handlers: { + "PUT /api/v2/admin/request-integrations/{id}": () => + Promise.reject(problem(422, "validation_failed", "Review invalid fields", errors)), + }, + }); + const dialog = await openServer("Radarr"); + fireEvent.click(within(dialog).getByRole("button", { name: "Save" })); + for (const error of errors) { + expect(await within(dialog).findByText(error.detail)).toBeInTheDocument(); + } + const name = within(dialog).getByLabelText("Name"); + expect(name.getAttribute("aria-invalid")).toBe("true"); + fireEvent.change(name, { target: { value: "Corrected" } }); + expect(within(dialog).queryByText("Server name is rejected")).toBeNull(); + expect(name.getAttribute("aria-invalid")).toBe("false"); + }); +}); + +describe("Requests settings: routing", () => { + it("saves a never-saved default destination with the revision-zero validator it read", async () => { + serve({ + handlers: { + "PUT /api/v2/admin/request-routes/{id}": (options) => + reply(options, { ...fallback("movie", "radarr-1") }, '"fallback-movie-v2"'), + }, + }); + mount(); + const movies = await screen.findByRole("group", { name: "Movie routing" }); + await within(movies).findByRole("combobox", { name: "HD server" }); + await choose(movies, "HD server", "Radarr"); + fireEvent.click(await screen.findByRole("button", { name: "Save" })); + + await waitFor(() => expect(calls("PUT /api/v2/admin/request-routes/{id}")).toHaveLength(1)); + expect(calls("PUT /api/v2/admin/request-routes/{id}")[0]).toEqual( + expect.objectContaining({ + path: { id: "fallback-movie" }, + headers: { "If-Match": '"fallback-movie-v1"' }, + body: { + name: "Everything else", + enabled: true, + conditions: {}, + hd: { integration_id: "radarr-1" }, + uhd: {}, + skip_uhd: false, + }, + }), + ); + }); + + it("offers Add rule only once the default destination has an HD server", async () => { + serve(); + mount(); + const movies = await screen.findByRole("group", { name: "Movie routing" }); + const addMovie = await within(movies).findByRole("button", { name: "Add rule" }); + expect((addMovie as HTMLButtonElement).disabled).toBe(true); + expect( + within(movies).getByText( + "Choose the default server for movies before adding rules; titles no rule matches go there.", + ), + ).toBeInTheDocument(); + + const series = group("Series routing"); + expect( + (within(series).getByRole("button", { name: "Add rule" }) as HTMLButtonElement).disabled, + ).toBe(false); + }); + + const anime = route({ + id: "anime-movie", + name: "Anime", + position: 0, + conditions: { + anime: true, + original_languages: ["ja"], + year_from: 1980, + year_to: 1989, + genre_ids: [16], + }, + hd: { integration_id: "radarr-2", overrides: { root_folder: "/anime" } }, + }); + + it("summarises a rule and round-trips its edits, including skipping 4K", async () => { + serve({ + routes: [anime, fallback("movie", "radarr-1"), fallback("series", "sonarr-1")], + handlers: { + "PUT /api/v2/admin/request-routes/{id}": (options) => + reply(options, anime, '"anime-movie-v2"'), + }, + }); + mount(); + const movies = await screen.findByRole("group", { name: "Movie routing" }); + expect( + await within(movies).findByText("Anime · Japanese · 1980–1989 · Animation"), + ).toBeInTheDocument(); + expect( + within(movies).getByText("HD → Radarr Anime · /anime · 4K → default"), + ).toBeInTheDocument(); + + fireEvent.click(within(movies).getByRole("button", { name: "Edit Anime" })); + const dialog = await screen.findByRole("dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: "Family" })); + await choose(dialog, "Add a country", "Japan"); + await choose(dialog, "4K server", "No 4K copy"); + fireEvent.click(within(dialog).getByRole("button", { name: "Save rule" })); + + await waitFor(() => expect(calls("PUT /api/v2/admin/request-routes/{id}")).toHaveLength(1)); + const [write] = calls("PUT /api/v2/admin/request-routes/{id}"); + expect(write).toMatchObject({ + path: { id: "anime-movie" }, + headers: { "If-Match": '"anime-movie-v1"' }, + }); + expect(write!.body).toEqual({ + name: "Anime", + enabled: true, + conditions: { + anime: true, + genre_ids: [16, 10751], + original_languages: ["ja"], + origin_countries: ["JP"], + year_from: 1980, + year_to: 1989, + }, + hd: { integration_id: "radarr-2", overrides: { root_folder: "/anime" } }, + uhd: {}, + skip_uhd: true, + }); + }); + + it("creates a rule from a decade and a server", async () => { + serve({ + routes: [fallback("movie", "radarr-1"), fallback("series", "sonarr-1")], + handlers: { + "POST /api/v2/admin/request-routes": (options) => + reply(options, route({ id: "new" }), '"new-v1"'), + }, + }); + mount(); + const movies = await screen.findByRole("group", { name: "Movie routing" }); + fireEvent.click(await within(movies).findByRole("button", { name: "Add rule" })); + const dialog = await screen.findByRole("dialog"); + fireEvent.change(within(dialog).getByLabelText("Name"), { target: { value: "Eighties" } }); + fireEvent.click(within(dialog).getByRole("button", { name: "1980s" })); + await choose(dialog, "HD server", "Radarr Anime"); + fireEvent.click(within(dialog).getByRole("button", { name: "Add rule" })); + + await waitFor(() => expect(calls("POST /api/v2/admin/request-routes")).toHaveLength(1)); + expect(calls("POST /api/v2/admin/request-routes")[0]!.body).toEqual({ + media_type: "movie", + name: "Eighties", + enabled: true, + conditions: { year_from: 1980, year_to: 1989 }, + hd: { integration_id: "radarr-2" }, + uhd: {}, + skip_uhd: false, + }); + }); + + it("shows the server's field errors inline in the rule editor", async () => { + serve({ + routes: [anime, fallback("movie", "radarr-1"), fallback("series", "sonarr-1")], + handlers: { + "PUT /api/v2/admin/request-routes/{id}": () => + Promise.reject( + problem(422, "validation_failed", "The request did not pass validation; see errors.", [ + { + location: "body.conditions.year_to", + code: "invalid", + detail: "The end year comes before the start year.", + }, + { + location: "body.hd.integration_id", + code: "invalid", + detail: "That server no longer exists.", + }, + ]), + ), + }, + }); + mount(); + const movies = await screen.findByRole("group", { name: "Movie routing" }); + fireEvent.click(await within(movies).findByRole("button", { name: "Edit Anime" })); + const dialog = await screen.findByRole("dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: "Save rule" })); + + expect( + await within(dialog).findByText("The end year comes before the start year."), + ).toBeInTheDocument(); + expect(within(dialog).getByText("That server no longer exists.")).toBeInTheDocument(); + expect( + within(dialog).getByText("The request did not pass validation; see errors."), + ).toBeInTheDocument(); + expect(within(dialog).getByLabelText("To year").getAttribute("aria-invalid")).toBe("true"); + }); + + it("reorders by sending every rule of the media type in the new order", async () => { + serve({ + routes: [ + route({ id: "a", name: "First", position: 0, conditions: { anime: true } }), + route({ id: "b", name: "Second", position: 1, conditions: { genre_ids: [27] } }), + fallback("movie", "radarr-1"), + fallback("series", "sonarr-1"), + ], + handlers: { + "POST /api/v2/admin/request-routes/order": (options) => reply(options, { items: [] }), + }, + }); + mount(); + const movies = await screen.findByRole("group", { name: "Movie routing" }); + fireEvent.click(await within(movies).findByRole("button", { name: "Move Second up" })); + await waitFor(() => expect(calls("POST /api/v2/admin/request-routes/order")).toHaveLength(1)); + expect(calls("POST /api/v2/admin/request-routes/order")[0]!.body).toEqual({ + media_type: "movie", + ids: ["b", "a"], + }); + }); + + it("previews where a title would go, tier by tier", async () => { + serve({ + routes: [anime, fallback("movie", "radarr-1"), fallback("series", "sonarr-1")], + handlers: { + "GET /api/v2/requests/search": (options) => + reply(options, { + page: 1, + total_pages: 1, + total_results: 1, + results: [ + { + media_type: "movie", + tmdb_id: 129, + title: "Spirited Away", + year: 2001, + availability: "none", + request: {}, + }, + ], + }), + "POST /api/v2/admin/request-routes/preview": (options) => + reply(options, { + facts: { + anime: true, + genre_ids: [16, 14], + keyword_ids: [], + original_language: "ja", + origin_countries: ["JP"], + year: 2001, + network_ids: [], + company_ids: [], + }, + tiers: [ + { + quality: "1080p", + route_id: "anime-movie", + route_name: "Anime", + integration_id: "radarr-2", + integration_name: "Radarr Anime", + overrides: { root_folder: "/anime", quality_profile_id: 1, tags: [2] }, + }, + { quality: "2160p", note: "No rule sends 4K for this title." }, + ], + }), + }, + }); + mount(); + const movies = await screen.findByRole("group", { name: "Movie routing" }); + fireEvent.change(await within(movies).findByLabelText("Test a title"), { + target: { value: "Spirited" }, + }); + fireEvent.click(await within(movies).findByRole("button", { name: /Spirited Away/ })); + + expect(await within(movies).findByText("Radarr Anime")).toBeInTheDocument(); + expect(calls("POST /api/v2/admin/request-routes/preview")[0]!.body).toEqual({ + media_type: "movie", + tmdb_id: 129, + }); + expect( + within(movies).getByText("Anime · Animation, Fantasy · Japanese · Japan · 2001"), + ).toBeInTheDocument(); + expect(within(movies).getByText("· by Anime")).toBeInTheDocument(); + // Quality profiles and tags are named from the server's options; the root + // folder keeps its bare path. + expect( + await within(movies).findByText("Root folder /anime · Quality profile HD-1080p · Tags anime"), + ).toBeInTheDocument(); + expect(within(movies).getByText("No rule sends 4K for this title.")).toBeInTheDocument(); + }); + + it("previews by TMDB ID while requests are off, without the search", async () => { + serve({ + handlers: { + "GET /api/v2/admin/request-settings": (options) => + reply(options, { ...settings, requests_enabled: false }), + "POST /api/v2/admin/request-routes/preview": (options) => + reply(options, { + facts: { + anime: false, + genre_ids: [], + keyword_ids: [], + origin_countries: [], + network_ids: [], + company_ids: [], + }, + tiers: [{ quality: "1080p", note: "No rule sends HD for this title." }], + }), + }, + }); + mount(); + const movies = await screen.findByRole("group", { name: "Movie routing" }); + expect(within(movies).queryByRole("searchbox")).toBeNull(); + fireEvent.change(await within(movies).findByLabelText("Movie TMDB ID"), { + target: { value: "129" }, + }); + fireEvent.click(within(movies).getByRole("button", { name: "Check" })); + + expect(await within(movies).findByText("No rule sends HD for this title.")).toBeInTheDocument(); + expect(calls("POST /api/v2/admin/request-routes/preview")[0]!.body).toEqual({ + media_type: "movie", + tmdb_id: 129, + }); + expect(calls("GET /api/v2/requests/search")).toHaveLength(0); + }); + + it("offers the TMDB ID when the search fails", async () => { + serve({ + handlers: { + "GET /api/v2/requests/search": () => + Promise.reject(problem(503, "capability_disabled", "Requests are turned off")), + }, + }); + mount(); + const movies = await screen.findByRole("group", { name: "Movie routing" }); + fireEvent.change(await within(movies).findByLabelText("Test a title"), { + target: { value: "Spirited" }, + }); + expect(await within(movies).findByText("Requests are turned off")).toBeInTheDocument(); + expect(within(movies).getByLabelText("Movie TMDB ID")).toBeInTheDocument(); + }); +}); + +/** A promise the test settles when it chooses. */ +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +describe("Requests settings: after a save", () => { + it("keeps a saved general edit while the refetch is slow, and saves the next against the new validator", async () => { + let stored = { ...settings }; + let version = 0; + let slow = false; + serve({ + handlers: { + "GET /api/v2/admin/request-settings": (options) => + slow ? new Promise(() => {}) : reply(options, stored, `"settings-${version}"`), + "PUT /api/v2/admin/request-settings": (options) => { + stored = { ...stored, ...(options.body as object) }; + version += 1; + slow = true; + return reply(options, stored, `"settings-${version}"`); + }, + }, + }); + mount(); + const allow = await screen.findByRole("switch", { name: "Allow requests" }); + await waitFor(() => expect(calls("GET /api/v2/admin/request-routes")).toHaveLength(1)); + fireEvent.click(allow); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => expect(calls("PUT /api/v2/admin/request-settings")).toHaveLength(1)); + await waitFor(() => expect(screen.queryByRole("button", { name: "Save" })).toBeNull()); + + // The edit stays saved on screen, not the replaced record. + expect(screen.getByRole("switch", { name: "Allow requests" })).toHaveAttribute( + "aria-checked", + "false", + ); + // Settings writes leave the routing list alone. + expect(calls("GET /api/v2/admin/request-routes")).toHaveLength(1); + + fireEvent.change(screen.getByLabelText("Request limit"), { target: { value: "8" } }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => expect(calls("PUT /api/v2/admin/request-settings")).toHaveLength(2)); + expect(calls("PUT /api/v2/admin/request-settings")[1]).toMatchObject({ + headers: { "If-Match": '"settings-1"' }, + body: { requests_enabled: false, global_max_requests: 8 }, + }); + }); + + it("keeps a saved default destination and saves the next edit against its new validator", async () => { + let routes = [fallback("movie"), fallback("series", "sonarr-1")]; + const versions: Record = {}; + serve({ + handlers: { + "GET /api/v2/admin/request-routes": (options) => reply(options, { items: routes }), + "GET /api/v2/admin/request-routes/{id}": (options) => { + const id = options.path!.id!; + return reply( + options, + routes.find((r) => r.id === id), + `"${id}-v${versions[id] ?? 0}"`, + ); + }, + "PUT /api/v2/admin/request-routes/{id}": (options) => { + const id = options.path!.id!; + const body = options.body as Pick; + routes = routes.map((r) => (r.id === id ? { ...r, hd: body.hd, uhd: body.uhd } : r)); + versions[id] = (versions[id] ?? 0) + 1; + return reply( + options, + routes.find((r) => r.id === id), + `"${id}-v${versions[id]}"`, + ); + }, + }, + }); + mount(); + const movies = await screen.findByRole("group", { name: "Movie routing" }); + await choose(movies, "HD server", "Radarr"); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => expect(screen.queryByRole("button", { name: "Save" })).toBeNull()); + expect(within(movies).getByRole("combobox", { name: "HD server" })).toHaveTextContent("Radarr"); + expect( + (within(movies).getByRole("button", { name: "Add rule" }) as HTMLButtonElement).disabled, + ).toBe(false); + + await choose(movies, "4K server", "Radarr Anime"); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => expect(calls("PUT /api/v2/admin/request-routes/{id}")).toHaveLength(2)); + expect(calls("PUT /api/v2/admin/request-routes/{id}")[1]).toMatchObject({ + headers: { "If-Match": '"fallback-movie-v1"' }, + body: { hd: { integration_id: "radarr-1" }, uhd: { integration_id: "radarr-2" } }, + }); + }); + + it("holds the rule list until the reorder has been read back", async () => { + let routes = [ + route({ id: "a", name: "First", position: 0, conditions: { anime: true } }), + route({ id: "b", name: "Second", position: 1, conditions: { genre_ids: [27] } }), + fallback("movie", "radarr-1"), + fallback("series", "sonarr-1"), + ]; + let hold: ReturnType> | null = null; + serve({ + handlers: { + "GET /api/v2/admin/request-routes": async (options) => { + if (hold) await hold.promise; + return reply(options, { items: routes }); + }, + "GET /api/v2/admin/request-routes/{id}": (options) => + reply( + options, + routes.find((r) => r.id === options.path?.id), + '"v"', + ), + "POST /api/v2/admin/request-routes/order": (options) => { + const ids = (options.body as { ids: string[] }).ids; + routes = routes.map((r) => + ids.includes(r.id) ? { ...r, position: ids.indexOf(r.id) } : r, + ); + hold = deferred(); + return reply(options, { items: [] }); + }, + }, + }); + mount(); + const movies = await screen.findByRole("group", { name: "Movie routing" }); + fireEvent.click(await within(movies).findByRole("button", { name: "Move Second up" })); + await waitFor(() => expect(calls("POST /api/v2/admin/request-routes/order")).toHaveLength(1)); + + // The old order is still on screen, so no second move may be made from it. + const firstDown = within(movies).getByRole("button", { name: "Move First down" }); + await waitFor(() => expect((firstDown as HTMLButtonElement).disabled).toBe(true)); + expect( + (within(movies).getByRole("switch", { name: "First enabled" }) as HTMLButtonElement).disabled, + ).toBe(true); + + await act(async () => hold!.resolve()); + await waitFor(() => + expect( + (within(movies).getByRole("button", { name: "Move First up" }) as HTMLButtonElement) + .disabled, + ).toBe(false), + ); + const names = within(within(movies).getByRole("list", { name: "Movie routing rules" })) + .getAllByRole("button", { name: /^Edit / }) + .map((button) => button.getAttribute("aria-label")); + expect(names).toEqual(["Edit Second", "Edit First"]); + }); + + it("says why a rule toggle was refused", async () => { + serve({ + routes: [ + route({ id: "a", name: "First", conditions: { anime: true } }), + fallback("movie", "radarr-1"), + fallback("series", "sonarr-1"), + ], + handlers: { + "PUT /api/v2/admin/request-routes/{id}": () => + Promise.reject( + problem(422, "validation_failed", "The request did not pass validation; see errors.", [ + { + location: "body.hd.integration_id", + code: "invalid", + detail: "That server no longer exists.", + }, + ]), + ), + }, + }); + mount(); + const movies = await screen.findByRole("group", { name: "Movie routing" }); + fireEvent.click(await within(movies).findByRole("switch", { name: "First enabled" })); + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith( + "The request did not pass validation; see errors. That server no longer exists.", + ), + ); + }); +}); + +describe("Requests settings: recovering from errors", () => { + it("clears a conflict on discard and starts over from the latest version", async () => { + let reads = 0; + serve({ + handlers: { + "GET /api/v2/admin/request-settings": (options) => { + reads += 1; + return reply( + options, + { ...settings, global_max_requests: reads === 1 ? 5 : 9 }, + reads === 1 ? '"initial"' : '"reloaded"', + ); + }, + "PUT /api/v2/admin/request-settings": () => Promise.reject(conflict()), + }, + }); + mount(); + fireEvent.change(await screen.findByLabelText("Request limit"), { target: { value: "13" } }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await within(group("General")).findByRole("alert"); + + fireEvent.click(screen.getByRole("button", { name: "Discard" })); + expect(within(group("General")).queryByRole("alert")).toBeNull(); + await waitFor(() => + expect((screen.getByLabelText("Request limit") as HTMLInputElement).value).toBe("9"), + ); + }); + + it("clears a default destination's save error when that tier is edited", async () => { + serve({ + handlers: { + "PUT /api/v2/admin/request-routes/{id}": () => + Promise.reject( + problem(422, "validation_failed", "The request did not pass validation; see errors.", [ + { + location: "body.hd.integration_id", + code: "invalid", + detail: "Radarr is a sonarr server; movies go to radarr.", + }, + ]), + ), + }, + }); + mount(); + const movies = await screen.findByRole("group", { name: "Movie routing" }); + await choose(movies, "HD server", "Radarr"); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + expect( + await within(movies).findByText("Radarr is a sonarr server; movies go to radarr."), + ).toBeInTheDocument(); + + await choose(movies, "HD server", "Radarr Anime"); + expect( + within(movies).queryByText("Radarr is a sonarr server; movies go to radarr."), + ).toBeNull(); + }); + + it("says so when reloading the default destination fails", async () => { + let listReads = 0; + serve({ + handlers: { + "GET /api/v2/admin/request-routes": (options) => { + listReads += 1; + return listReads === 1 + ? reply(options, { items: [fallback("movie"), fallback("series", "sonarr-1")] }) + : Promise.reject(new Error("offline")); + }, + "PUT /api/v2/admin/request-routes/{id}": () => Promise.reject(conflict()), + }, + }); + mount(); + const movies = await screen.findByRole("group", { name: "Movie routing" }); + await choose(movies, "HD server", "Radarr"); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + fireEvent.click(await within(movies).findByRole("button", { name: "Reload latest version" })); + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith("Couldn't reload the default destination: offline"), + ); + }); +}); + +describe("Requests settings: with requests off, and override sections", () => { + const studioRule = route({ + id: "ghibli", + name: "Ghibli", + conditions: { company_ids: [10342] }, + hd: { integration_id: "radarr-2" }, + }); + + it("does not read the studio list while requests are off, and keeps IDs on a rule", async () => { + serve({ + routes: [studioRule, fallback("movie", "radarr-1"), fallback("series", "sonarr-1")], + handlers: { + "GET /api/v2/admin/request-settings": (options) => + reply(options, { ...settings, requests_enabled: false }), + }, + }); + mount(); + const movies = await screen.findByRole("group", { name: "Movie routing" }); + expect(await within(movies).findByText("Studio 10342")).toBeInTheDocument(); + fireEvent.click(within(movies).getByRole("button", { name: "Edit Ghibli" })); + const dialog = await screen.findByRole("dialog"); + + expect( + within(dialog).getByText("Turn on requests to pick networks and studios."), + ).toBeInTheDocument(); + expect(within(dialog).queryByRole("combobox", { name: "Add a studio" })).toBeNull(); + expect(within(dialog).getByText("TMDB 10342")).toBeInTheDocument(); + expect(within(dialog).getByLabelText("Studio TMDB ID")).toBeInTheDocument(); + expect(calls("GET /api/v2/requests/discover/studios")).toHaveLength(0); + expect(calls("GET /api/v2/requests/discover/networks")).toHaveLength(0); + }); + + it("gives each override section its own open state", async () => { + serve({ + routes: [ + { ...fallback("movie", "radarr-1"), uhd: { integration_id: "radarr-2" } }, + fallback("series", "sonarr-1"), + ], + }); + mount(); + const movies = await screen.findByRole("group", { name: "Movie routing" }); + const hd = await within(movies).findByRole("button", { name: /^Override HD server settings/ }); + const uhd = within(movies).getByRole("button", { name: /^Override 4K server settings/ }); + fireEvent.click(hd); + expect(hd).toHaveAttribute("aria-expanded", "true"); + expect(uhd).toHaveAttribute("aria-expanded", "false"); + + // A section that mounts later (a rule editor) starts closed too. + fireEvent.click(within(movies).getByRole("button", { name: "Add rule" })); + const dialog = await screen.findByRole("dialog"); + await choose(dialog, "HD server", "Radarr Anime"); + expect( + within(dialog).getByRole("button", { name: /^Override HD server settings/ }), + ).toHaveAttribute("aria-expanded", "false"); + }); +}); + +describe("Requests settings: server delete and kind", () => { + it("does not offer Delete for a server routing still sends to, and says which routes", async () => { + serve(); + const dialog = await openServer("Sonarr"); + const del = within(dialog).getByRole("button", { name: "Delete" }) as HTMLButtonElement; + expect(del.disabled).toBe(true); + expect( + within(dialog).getByText( + "Routing sends requests here (HD default for series). Change that first to delete it.", + ), + ).toBeInTheDocument(); + }); + + it("does not let a routed server change type, and says why", async () => { + serve(); + const dialog = await openServer("Sonarr"); + const service = within(dialog).getByRole("combobox", { name: "Service" }); + expect(service).toBeDisabled(); + const reason = within(dialog).getByText( + "Routing sends requests here (HD default for series). Change that first to switch the type.", + ); + expect(service.getAttribute("aria-describedby")).toBe(reason.id); + + // An unrouted server can still change type. + cleanup(); + serve(); + const other = await openServer("Radarr Anime"); + expect(within(other).getByRole("combobox", { name: "Service" })).toBeEnabled(); + }); + + it("drops the retired default switches when a server changes kind", async () => { + serve({ + handlers: { + "PUT /api/v2/admin/request-integrations/{id}": (options) => + reply(options, radarr, '"saved"'), + }, + }); + const dialog = await openServer("Radarr Anime"); + await choose(dialog, "Service", "Sonarr (series)"); + fireEvent.click(within(dialog).getByRole("button", { name: "Save" })); + await waitFor(() => + expect(calls("PUT /api/v2/admin/request-integrations/{id}")).toHaveLength(1), + ); + expect(calls("PUT /api/v2/admin/request-integrations/{id}")[0]!.body).toMatchObject({ + supported_media_types: ["series"], + plugin_config: { service_kind: "sonarr", is_default: false }, + }); + }); + + it("keeps the default switches when the kind is unchanged", async () => { + serve({ + handlers: { + "PUT /api/v2/admin/request-integrations/{id}": (options) => + reply(options, radarr, '"saved"'), + }, + }); + const dialog = await openServer("Radarr Anime"); + fireEvent.click(within(dialog).getByRole("button", { name: "Save" })); + await waitFor(() => + expect(calls("PUT /api/v2/admin/request-integrations/{id}")).toHaveLength(1), + ); + expect(calls("PUT /api/v2/admin/request-integrations/{id}")[0]!.body).toMatchObject({ + plugin_config: { service_kind: "radarr", is_default: true }, + }); + }); +}); diff --git a/web/src/pages/admin-settings/RequestsSettings.tsx b/web/src/pages/admin-settings/RequestsSettings.tsx new file mode 100644 index 0000000000..01d0313d62 --- /dev/null +++ b/web/src/pages/admin-settings/RequestsSettings.tsx @@ -0,0 +1,439 @@ +import { useMemo, useState } from "react"; +import { Link } from "react-router"; +import { toast } from "sonner"; + +import type { RequestSettings } from "@/api/types"; +import { + isRequestEditorConflict, + requestValidationErrors, + type RequestRoute, + type RequestRouteMediaType, +} from "@/api/v2/adminRequests"; +import { EditorConflict } from "@/components/admin/EditorConflict"; +import { SettingsPageHeader } from "@/components/settings/SettingsPageHeader"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useAdminPluginInstallations } from "@/hooks/queries/admin/plugins"; +import { + useAdminRequestCapabilities, + useRequestIntegrations, + useRequestRoutes, + useRequestSettings, + useUpdateRequestRoute, + useUpdateRequestSettings, +} from "@/hooks/queries/useRequests"; +import { useReportUnsavedChanges } from "@/hooks/useUnsavedChanges"; + +import { FieldGroup } from "./FieldGroup"; +import { RequestRoutingGroup } from "./RequestRouting"; +import { RequestServersGroup } from "./RequestServers"; +import { + fallbackBody, + fallbackChanges, + fallbackDraft, + type FallbackDraft, +} from "./requestRoutingModel"; +import { requestRouterInstallations } from "./requestServerModel"; +import { SaveBar } from "./SaveBar"; +import { SettingField, SettingFieldRow, SettingFieldStatus } from "./SettingField"; +import { useStagedDraft, type StagedDraft } from "./useStagedDraft"; + +interface GeneralDraft { + requests_enabled: boolean; + auto_approve: boolean; + max_requests: string; + window_days: string; + force_dual_quality: boolean; +} + +function generalDraft(settings: RequestSettings): GeneralDraft { + return { + requests_enabled: settings.requests_enabled, + auto_approve: settings.global_auto_approval_enabled, + max_requests: String(settings.global_max_requests), + window_days: String(settings.global_window_days), + force_dual_quality: settings.force_dual_quality, + }; +} + +function generalChanges(draft: GeneralDraft, base: GeneralDraft): number { + return (Object.keys(draft) as (keyof GeneralDraft)[]).filter((key) => draft[key] !== base[key]) + .length; +} + +function positiveInt(value: string): number | null { + const n = Number(value.trim()); + return Number.isInteger(n) && n >= 1 ? n : null; +} + +const MEDIA_TYPES: RequestRouteMediaType[] = ["movie", "series"]; + +function fallbackOf(routes: RequestRoute[] | undefined, mediaType: RequestRouteMediaType) { + return routes?.find((route) => route.is_fallback && route.media_type === mediaType); +} + +function PageSkeleton() { + return ( +
    + + + + Loading request settings +
    + ); +} + +/** + * Settings → Requests: whether and how people request titles, the servers + * requests go to, and the rules that pick a server for each title. + */ +export default function RequestsSettings() { + const capabilities = useAdminRequestCapabilities(); + if (capabilities.isLoading) return ; + if (!capabilities.data?.available || !capabilities.data.guarded_configuration) { + return ( +
    + +

    + Request settings are not available on this server. +

    +
    + ); + } + return ; +} + +function RequestsSettingsContent() { + const settingsQuery = useRequestSettings(); + const routesQuery = useRequestRoutes(); + const serversQuery = useRequestIntegrations(); + const installationsQuery = useAdminPluginInstallations(); + const updateSettings = useUpdateRequestSettings(); + const updateRoute = useUpdateRequestRoute({ inlineErrors: true }); + const installations = useMemo( + () => requestRouterInstallations(installationsQuery.data ?? []), + [installationsQuery.data], + ); + + const general = useStagedDraft(settingsQuery.data, generalDraft, generalChanges); + const [generalConflict, setGeneralConflict] = useState(false); + const movie = useStagedDraft( + fallbackOf(routesQuery.data, "movie"), + fallbackDraft, + fallbackChanges, + ); + const series = useStagedDraft( + fallbackOf(routesQuery.data, "series"), + fallbackDraft, + fallbackChanges, + ); + const fallbacks: Record> = { + movie, + series, + }; + const [fallbackConflicts, setFallbackConflicts] = useState>({}); + const [fallbackErrors, setFallbackErrors] = useState>>({}); + const [saving, setSaving] = useState(false); + + const maxRequests = general.draft ? positiveInt(general.draft.max_requests) : null; + const windowDays = general.draft ? positiveInt(general.draft.window_days) : null; + const baseGeneral = general.base ? generalDraft(general.base) : undefined; + const maxInvalid = + maxRequests === null && general.draft?.max_requests !== baseGeneral?.max_requests; + const windowInvalid = + windowDays === null && general.draft?.window_days !== baseGeneral?.window_days; + + const dirtyCount = general.changes + movie.changes + series.changes; + useReportUnsavedChanges(dirtyCount > 0); + const saveable = + (general.changes > 0 && !generalConflict && !maxInvalid && !windowInvalid) || + MEDIA_TYPES.some((type) => fallbacks[type].changes > 0 && !fallbackConflicts[type]); + + function editGeneral(change: Partial) { + general.update((current) => ({ ...current, ...change })); + } + + async function saveGeneral() { + const { base, draft } = general; + if (!base || !draft || general.changes === 0 || generalConflict) return; + if (maxInvalid || windowInvalid) return; + try { + const saved = await updateSettings.mutateAsync({ + requests_enabled: draft.requests_enabled, + global_auto_approval_enabled: draft.auto_approve, + // An untouched stored value is sent back as it is, even one the + // controls would not offer. + global_max_requests: maxRequests ?? base.global_max_requests, + global_window_days: windowDays ?? base.global_window_days, + force_dual_quality: draft.force_dual_quality, + updated_at: base.updated_at, + etag: base.etag, + }); + general.adopt(saved); + } catch (error) { + if (isRequestEditorConflict(error)) setGeneralConflict(true); + } + } + + async function saveFallback(mediaType: RequestRouteMediaType) { + const staged = fallbacks[mediaType]; + const { base, draft } = staged; + if (!base || !draft || staged.changes === 0 || fallbackConflicts[mediaType]) return; + setFallbackErrors((current) => ({ ...current, [mediaType]: {} })); + try { + const saved = await updateRoute.mutateAsync({ route: base, body: fallbackBody(base, draft) }); + staged.adopt(saved); + } catch (error) { + if (isRequestEditorConflict(error)) { + setFallbackConflicts((current) => ({ ...current, [mediaType]: true })); + } + const validation = requestValidationErrors(error); + if (validation) { + // With no field to point at, the server's own sentence is the error. + const fields = + Object.keys(validation.fields).length > 0 + ? validation.fields + : { "": validation.message }; + setFallbackErrors((current) => ({ ...current, [mediaType]: fields })); + } + } + } + + // One save bar, three writers, one after another: the request settings and + // each media type's default destination. A writer that fails keeps its + // edits and says why beside them; the others still save. + async function saveAll() { + setSaving(true); + try { + await saveGeneral(); + for (const mediaType of MEDIA_TYPES) await saveFallback(mediaType); + } finally { + setSaving(false); + } + } + + // A discarded draft goes back to its record and then, being clean, follows + // the query to whatever is newest, so a conflict it hit no longer applies. + function discardAll() { + general.reset(); + movie.reset(); + series.reset(); + setGeneralConflict(false); + setFallbackConflicts({}); + setFallbackErrors({}); + } + + // An edit to one tier makes that tier's save errors, and the form-level + // one, stale; the other tier's stay until it is edited too. + function clearFallbackErrors(mediaType: RequestRouteMediaType, tier: "hd" | "uhd") { + setFallbackErrors((current) => { + const errors = current[mediaType]; + if (!errors) return current; + const kept = Object.fromEntries( + Object.entries(errors).filter( + ([key]) => key !== "" && key !== tier && !key.startsWith(`${tier}.`), + ), + ); + return Object.keys(kept).length === Object.keys(errors).length + ? current + : { ...current, [mediaType]: kept }; + }); + } + + async function reloadGeneral() { + const result = await settingsQuery.refetch(); + if (result.data && !result.isError) { + general.adopt(result.data); + setGeneralConflict(false); + } else { + toast.error( + result.error instanceof Error + ? `Couldn't reload request settings: ${result.error.message}` + : "Couldn't reload request settings.", + ); + } + } + + // Reloads through the query, so the draft and the cache it follows agree + // on the newest version. + async function reloadFallback(mediaType: RequestRouteMediaType) { + const result = await routesQuery.refetch(); + const latest = result.isError ? undefined : fallbackOf(result.data, mediaType); + if (!latest) { + toast.error( + result.error instanceof Error + ? `Couldn't reload the default destination: ${result.error.message}` + : "Couldn't reload the default destination.", + ); + return; + } + fallbacks[mediaType].adopt(latest); + setFallbackConflicts((current) => ({ ...current, [mediaType]: false })); + setFallbackErrors((current) => ({ ...current, [mediaType]: {} })); + } + + const draft = general.draft; + const servers = serversQuery.data ?? []; + const routes = routesQuery.data ?? []; + + return ( +
    + + Request queue + + } + /> + +
    + {/* Locked while the save bar is saving: a save adopts what the server + returned, which would drop an edit made in the meantime. */} +
    + 0}> + {settingsQuery.isLoading ? ( +
    + + +
    + ) : !draft || !baseGeneral ? ( +

    + Request settings could not be loaded. +

    + ) : ( + <> + editGeneral({ requests_enabled: value === "true" })} + dirty={draft.requests_enabled !== baseGeneral.requests_enabled} + /> + editGeneral({ auto_approve: value === "auto" })} + dirty={draft.auto_approve !== baseGeneral.auto_approve} + /> + editGeneral({ max_requests: value })} + dirty={draft.max_requests !== baseGeneral.max_requests} + status={ + maxInvalid ? ( + + Allow at least 1. To stop requests, turn off Allow requests. + + ) : undefined + } + /> + editGeneral({ window_days: value })} + dirty={draft.window_days !== baseGeneral.window_days} + status={ + windowInvalid ? ( + Use at least 1 day. + ) : undefined + } + /> + editGeneral({ force_dual_quality: value === "true" })} + dirty={draft.force_dual_quality !== baseGeneral.force_dual_quality} + /> + {generalConflict ? ( +
    + +
    + ) : null} + + )} +
    +
    + + + + {MEDIA_TYPES.map((mediaType) => ( + clearFallbackErrors(mediaType, tier)} + onReloadFallback={() => reloadFallback(mediaType)} + /> + ))} + + + + + + + + + + + + +
    + + void saveAll()} + onDiscard={discardAll} + isSaving={saving} + canSave={saveable} + /> +
    + ); +} diff --git a/web/src/pages/admin-settings/SaveBar.tsx b/web/src/pages/admin-settings/SaveBar.tsx index cc0e871e71..9883d5dce4 100644 --- a/web/src/pages/admin-settings/SaveBar.tsx +++ b/web/src/pages/admin-settings/SaveBar.tsx @@ -7,6 +7,11 @@ interface SaveBarProps { onDiscard: () => void; isSaving: boolean; saveLabel?: string; + /** + * False while nothing staged can be saved as it stands, e.g. every edit is + * waiting on a reload after another admin's change. Discard stays available. + */ + canSave?: boolean; } function plural(count: number, word: string) { @@ -26,6 +31,7 @@ export function SaveBar({ onDiscard, isSaving, saveLabel = "Save", + canSave = true, }: SaveBarProps) { if (dirtyCount <= 0) return null; @@ -59,7 +65,7 @@ export function SaveBar({ -
    - - {requests.isLoading ? ( - - ) : requests.isError ? ( - - ) : ( -
    - - - - Title - Requested - Status - Outcome - Integration - Actions - - - - {(requests.data ?? []).length === 0 ? ( - - - No requests match the current filters. - - - ) : ( - requests.data?.map((request) => ( - approve.mutate(request.id)} - onDecline={() => handleDecline(request)} - onRetry={() => retry.mutate(request.id)} - /> - )) - )} - -
    -
    - )} - { - if (!open) { - setDeclineTarget(null); - setDeclineReason(""); - } - }} - > - - - Decline request - - {declineTarget - ? `"${declineTarget.title}" will be marked declined. Add an optional note for the requester.` - : null} - - - -