From 9d63465bf7b9bc1abab5804fbb0bb9ac9bf622b1 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:05:04 -0400 Subject: [PATCH 1/5] feat(requests): route requests by rules, request seasons, and rework request pages and admin (#1632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * feat(admin): manage requests, servers and routing in Settings 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) * feat(requests): request individual seasons of a series A series request now names the seasons it wants. When the requester names none, the server asks for every aired season that is not complete in the library. A season is complete when every aired episode has a file in an enabled library, judged by the library's own episode metadata, so no external service is involved. Episodes a multi-episode file spans count, and only aired episodes count toward aired ones. A season request completes, and notifies, when all its seasons are complete rather than when the first episode is scanned in, and reads as partially_available while only some are. Once the download server reports the request done, a season with any episode present also counts, so an episode the server cannot find does not hold the request open. Requests from before this change, and every v1 request, mean the whole series and keep the old any-episode rule. A series partly in the library can be requested for its missing seasons only when no download server takes series: the router plugin does not receive seasons yet (that needs an SDK and plugin release) and would add the whole series again. With a download server, such a series stays already_available, as before. The fulfilled-notification pass now stamps each request it checks without notifying, so requests still waiting on the library rotate behind newer completions instead of starving them. Request lists read every series' season counts in one query. API (v2): createRequest accepts seasons; series detail lists each regular season with its availability (missing, partial, available) and whether the active request covers it; requests carry seasons, season_progress and the partially_available state; GET /requests/status advertises season_requests_supported and missing_seasons_requestable. The TMDB client now carries a series' seasons. v1 is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(web): show titles outside the library on the shared detail layout A title the server does not have used its own request page, which reused only the hero banner and built everything else itself. It now renders through the same detail layout, sections and components as a library item: the metadata badges, a score row with the TMDB score, the crew line, the action bar, the cast section and a "More Like This" row. The action bar's primary pill is "Request movie"/"Request series", or the request's state once there is one ("Requested", "Approved", "Processing"), with "Cancel request" and "Notify me when available" as secondary actions and IMDb/TMDB as links. The action bar gained optional primary, secondary and link slots; library items render exactly as before. Titles now live at /title/:mediaType/:tmdbId, with the same shell as /item. /requests/:mediaType/:tmdbId redirects there, and an unknown media type shows the unavailable page instead of silently loading a movie. A title already in the library redirects to its /item page when the viewer can open it, and shows a disabled "In the library" when they cannot. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(web): pick the seasons of a series to request Requesting a series outside the library now opens a season picker. Aired seasons the library lacks start picked, as the server would choose on its own; seasons already in the library or already requested show but cannot be picked, and upcoming ones can be added. The title page also lists the series' seasons with what the library has and what is requested. A series already in the library offers "Request Seasons" in its More menu when the server allows missing seasons to be requested (the missing_seasons_requestable capability, true while no download server takes series). The dialog loads the series' request detail only when opened, so the series page makes no extra request on load. My requests and the admin queue show which seasons a request asks for; a request with only some seasons in the library reads "Partially available" with how many are in. My requests now groups a request under "Landed in your library" by its state, so a download the library has not scanned yet stays in motion. Co-Authored-By: Claude Opus 5.5 (1M context) * chore(web): raise the launch bundle budget for season requests Season requests and the shared title page add request code that search and title pages load at launch. The budget goes up until the request administration hooks leave the launch bundle later in this branch, which brings it back under the original budget. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(admin): rework the request queue The admin queue groups requests by what an admin does next: Needs approval, In progress, Failed and Done, each tab with its count. It opens on what needs approval, searches titles or a TMDB ID, filters by media type or by one account (?user=), and loads more as the admin scrolls. - Each row shows the poster, requested seasons, requester, state and, once sent, each server the request went to with the rule that chose it. - Actions follow the server's rules per view: approve or decline (with a reason) what needs approval, cancel what nothing was sent for yet, retry or close what failed. Needs approval also approves or declines a selection, four requests at a time, and lists any that failed. - A side sheet shows the request's details, where routing would send it now, and its history. - The admin sidebar shows how many requests need approval. Server (additive v2): the admin list takes view, q, media_type and requested_by_user_id; GET /admin/requests/counts counts each view; GET /admin/requests/{id}/events returns a request's history. The list reads its targets in one query. The v2 admin cancel can now close a failed request (v1 is unchanged), a closed request stays closed when a target reports late, and a request's history records each change of its status or outcome once rather than once per target. Co-Authored-By: Claude Opus 5.5 (1M context) * refactor(web): show request titles on the library's media card Titles known only from TMDB (Requests rows and the Yours tab, brand pages, "More Like This" on a title page, "Request to add" in search) used their own card. They now use the library's card frame: the same artwork box and missing-poster fallback, radius, hover lift, focus handling and caption, and they follow the viewer's poster-size and caption settings. Request takes the place of Play in the centre of the artwork; the request state badge and the Library chip sit where the library puts its badges. Under a caption a TMDB title always names its type and year, since a movie and a series can share a title, and a poster that fails to load shows the title instead of an empty box. The artwork box, caption classes and centre-action class move into MediaCardArtwork, shared by ItemCard, SectionItemCard and the request card; library cards render the same markup as before. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(admin): manage request access on accounts and access groups Who may request, whether an admin approves, and how many titles an account may request now live where accounts and access groups are managed, and the admin Requests page is the queue alone. - Accounts: the user page gets a Requests section with the account's own approval and limit, a line saying what applies now and where it comes from, and a link to the account's requests in the queue. - Access groups: a group sets approval and a limit beside its existing requests switch (new v2 GET/PUT /admin/request-groups/{group_id}/limit, guarded by If-Match). An account's own setting wins, then its group's, then the server-wide default; admins never take a group's. - One way to block: the requests switch on the account or its group (or requests off server-wide). The old "blocked" limit and approval modes are gone from the editors; a migration moves accounts blocked that way onto their switch. The API still honors "blocked" when written. Because the switch now feeds the request policy too, v1 and v2 detail and search report "blocked" for such an account, as creating a request already did. - Quota: declined and failed requests give their slot back; cancelled ones still count, so requesting and withdrawing cannot repeat without limit. - The User Overrides tab is removed; its old links open the Users page. A detail page, and the discover rows, resolve the viewer's policy once per call rather than once per page of results. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(web): make the Requests page read like the rest of the app The Requests page drops its marketing hero, its own search form and the status guide, and takes the header, rows and grids of the app's other browse pages. - Discover: carousel rows of the discover sections, each with Explore all (a new paged grid at /requests/discover/:section); studio, network and genre tiles are links. - Search: the header's search field opens the app's search page, where "Request to add" now reads like the People section, pages through TMDB results and follows the search scope. Old /requests?q= links redirect there. - Yours: a list instead of a poster grid, grouped by what happens next (needs attention, on the way, in your library, cancelled). Each row shows the state, requested seasons and progress, dates, and the decline reason or error, with Cancel and Open in library. A popover explains the states. - Request notifications refresh the list without a reload. Co-Authored-By: Claude Opus 5.5 (1M context) * chore(web): raise the launch bundle budget for the request queue and access The request queue and request access hooks sit in the module search and title pages load at launch. The budget goes up until those hooks move to their own admin module later in this branch, which brings it back under the original budget. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(requests): route on exclusions and content rating, and explain each decision Server support for a simpler routing admin. - Conditions: every list condition gets an exclude form ("original language is not English"), and a US content-rating ceiling ("rated PG or lower") matches titles rated at most that; a title with no US rating does not match. The rating is captured with the other routing facts from the detail TMDB already returns, and fetched at submission for older requests only when a route checks ratings. - The preview explains a decision route by route: which conditions each failed and what it did for the HD and 4K copies. Try-a-title gets an admin TMDB search (GET /admin/request-routes/titles) that works while requests are off, and the preview matches rules for a chosen requester. - Everything else with no 4K server makes no 4K copy, even when every request asks for 4K, instead of failing the 4K copy: "no 4K copy" now means the same on a rule and on the fallback. - The first Radarr (Sonarr) server added becomes Everything else for movies (series), and a migration does the same for installs with exactly one usable server of a kind, so a single-server setup needs no routing. Deleting the last server of a kind takes that Everything else with it when no rule routes the media type. - Validation messages use the words the admin sees ("Everything else", "4K copies"). All v2 changes are additive. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(admin): make request routing a plain-language list with presets Settings › Requests replaces its two "Movie routing" / "Series routing" blocks with one "Where requests go" section and a Movies | Series switch. - Each switch shows one ordered list: rules as sentences ("When a series is anime → Sonarr · /tv/anime · Anime 1080p"), numbered, dragged or moved to reorder, turned on and off in place, and Everything else pinned last. Changes save as the admin goes; the save bar keeps only the general settings. - "Add a rule" starts from Anime, Foreign language or Kids & family, or a custom rule. A preset asks only where the titles should go; the Anime preset also sets Sonarr's series type to Anime. - The rule editor shows only the conditions in use, each "is any of" or "is none of", added from a menu. Where they go puts folder, quality and tags up front and the rest under More settings, for HD copies and 4K copies ("Same as Everything else", another server, or no 4K copy). - Try a title works while requests are off, can act as a given account, and explains the decision rule by rule; the queue's side sheet shows the same result. - Rows warn about rules that can never match, anime below a language rule, a server that is off or of the wrong kind, a Sonarr anime rule without the Anime series type, and force-dual with no 4K server, with a fix where one click will do. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(admin): open a queued request in a dialog instead of a side sheet The request detail (servers, where it would go, history, actions) now opens as a centered card with its actions pinned below a scrolling body, so it no longer covers the queue from the side. The row's details button drops the side-panel icon. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(requests): add Standard routing that needs no rules Most installs have one Radarr and one Sonarr, and maybe a 4K copy of each, yet the routing rules were the first thing they saw. Standard, now the default for such installs, sends each media type to its one server and 4K copies to its one server marked 4K, with each server's own settings; the rules stay stored but paused. Advanced is the rule-based routing. - request_routing holds the mode, guarded by its revision: GET/PUT /api/v2/admin/request-routing, which also says where Standard sends each media type or why it cannot be used. - Standard needs at most one enabled normal and one 4K server per media type. Adding or enabling a second turns Advanced on in the same transaction and gives Everything else the servers Standard was using, so requests keep going where they went. Every server write and mode switch takes one advisory lock first. - A migration puts installs whose routing Standard would change on Advanced, everyone else on Standard. - Settings > Requests: a Standard/Advanced choice, one line per media type under Standard, a "4K server" switch on Radarr/Sonarr servers, and a toast when a server save turns Advanced on. - Requests go without routing facts when TMDB is down and no rule has a condition, since the facts could not change where they go. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(requests): classify anime and ratings beyond TMDB's US data TMDB's anime keyword misses about one anime series in eight and one film in three, and routing only knew a title's US rating, so titles never rated in the US matched no rating rule. - Anime is Japanese animation: TMDB's anime keyword, Animation in Japanese or from Japan, or a title an AniDB-based list names (Kometa's Anime-IDs, matched by TVDB or IMDb ID). A listed series also needs a Japanese signal, since the list names some Western series and the flag can set Sonarr's series type. Chinese and Korean animation counts only when TMDB tags it. - A "Refresh Anime List" task downloads the list daily into anime_ids, one server at a time under a lease, with an ETag, a size cap, a same-host redirect guard and a truncation check; a failed download keeps the stored copy. Requests only read the table. - A title with no US rating (or only "NR") is routed on its own country's rating, stored as "JP:PG12". The TMDB client exposes every country's certifications; the US-only parental-control read is unchanged. - On a sample of 118 anime series and 60 films from the AniDB mappings, detection goes from 87%/62% to 100%/95%, with no Western, Chinese or Japanese live-action title flagged. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(admin): line settings controls up on one edge A settings row's label column stops growing at its maximum width, and nothing pushed the control column to the right after it. On a wide window a row with a short label and a narrow control (a switch, a link button) left its control wherever the label column ended, so switches sat short of the selects and inputs above them and link buttons landed at different places. The control column now always sits on the row's right edge, as the row's own contract says, on every settings page. On Settings > Requests, a routing rule's warning fix now lines up with the rule switches, and Try a title's search fills the row with the requester select, now the same height, on the right edge. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(admin): make the Requests settings link buttons visible on their card The Related rows' outline buttons had a border and fill close to the card's own colour. They now use the dark field and border the settings inputs and selects use. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(admin): lay out routing destinations as stacked panels The Everything else and rule editors borrowed the settings page's side-by-side rows inside a narrow dialog: a floating "Send to" beside the server select, an indented block of overrides, tag chips ragged against the right edge, and a stray field count on More settings. Each copy (HD, 4K) is now its own panel: a heading, the server, then Folder on a full row, the other settings in two columns, and tags as left-aligned chips that show a check when picked and say whose tags apply when none are. More settings says how many are changed instead of how many exist. Unset settings read "Server default (...)". The rule's Name field stacks the same way. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): use the library season carousel on request pages The request detail page rendered seasons in a native overflow row with a browser scrollbar and no drag scrolling. Extract the season carousel's Embla rail into SeasonRail and render request seasons inside it. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): send anime series under Standard routing as Sonarr anime Standard routing sent every series with the Sonarr server's own series type, so anime got standard episode numbering unless an admin switched to Advanced and wrote a rule. Anime series now go to the same servers with Sonarr's anime series type, as Seerr sends them; everything else keeps the server's settings. Co-Authored-By: Claude Opus 5.5 (1M context) * perf(web): keep request administration out of the launch bundle Search and title pages load the request hooks at launch, and that module had grown to hold every admin hook too: the queue, request settings, servers, routing and limits, with the admin request API behind them. Those move to hooks/queries/admin/requests, which only the admin pages import, taking about 3 KB brotli off the launch bundle. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(web): refresh request search and reconnect state after request changes A request notification refreshed the request list, title pages, and Discover, but not request search results, which also show each title's request state and availability. A reconnect also delivers request changes made while the socket was down as unread rows in the notifications snapshot rather than as notification.created events, so those changes never refreshed anything. Refresh the same request queries, now including search, from both paths. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(admin): let Standard delete a server only Everything else points at Under Standard routing the server clears the hidden Everything else before it checks what still sends requests to a server, so a fallback reference never blocks the delete. The server editor still counted it and disabled Delete, for example on the normal Radarr when a 4K Radarr also exists. The editor now takes the routing mode and ignores fallback references under Standard; paused rules still block. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): keep an upcoming season open when an episode arrives early A season whose episodes are all dated after today counted as complete as soon as one of them was in the library, because the "no air dates" rule only looked at the aired count. An early episode could then refuse a request for that season as already available, or complete a request for it. Season counts now carry how many episodes are dated to air later. The any-episode rule applies only to a season with no dated episodes; a dated season waits for its first episode to air. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): show a season request's progress on the title detail The title detail derived an active season request's state without its season progress, so a request with some seasons already in the library read as approved or processing there, while the request lists showed it partially available. The detail now attaches the progress from the season counts it already reads. Co-Authored-By: Claude Opus 5.5 (1M context) * perf(requests): read season counts once per reconcile batch Reconciliation checked each season request with its own season-count query, so a full batch and its waiting batch could run well over a thousand queries every pass. The presence check now collects the season requests whose series is in the library and reads their counts in one query, as the request lists already do. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): keep the quota refund when an admin closes a failed request Closing a failed request from the admin queue turned its outcome from failed into cancelled, and the quota counts cancelled requests, so the cleanup took back the slot the failure had returned. A cancelled request that still carries a submission error now keeps its refund; a plain withdrawal still counts. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): keep library season requests from a later download server A series already in the library can be requested for its missing seasons only while no download server takes series, because router plugins cannot receive seasons and would add the whole series. That was checked only when the request was made: if an admin set up a series download server before the request was approved or reconciled, the submission sent it as a whole-series request. Submission now checks again: a season request whose series is in the library stays approved and waits for the library instead of going to the router. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): key title follows by account as well as profile A profile id is only unique within its account: every account from before profiles has one named "default". Follows were keyed by profile id alone, so a second account's "default" profile could not follow a title the first one followed, could unfollow or see the other's follow, and was treated as the requester when the requester's profile id matched. Key media_request_follows by (media_type, tmdb_id, user_id, profile_id) in a new migration, scope follow lookups, unfollows and clears by account and profile, and compare the account when deciding whether the viewer made the request or was already told. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): clear a title's followers before stamping it notified The fulfilled pass stamped the request notified and then cleared its followers. If the clear failed, the stamped request never came back to the pass, and its follows stayed behind to fire for a later request of the same title. Clear first and stamp only after the clear succeeds; a retry re-sends into the per-recipient delivery dedupe. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): keep Standard's 4K server when a save turns Advanced on When Standard sent HD to one server and 4K to another, and an edit made the HD server a second 4K server, the switch to Advanced skipped the media type entirely because its HD server no longer fit. Everything else then kept no 4K destination and 4K copies stopped. The seed now carries each tier on its own, so the unchanged 4K server is still filled in. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): flag unusable route servers in preview and validation The route preview now reports every server problem a routed submission fails on: a server that no longer exists, is not bound to a plugin installation, has no API key, or does not take the media type. Before, an unbound or keyless server previewed as working. A route can no longer send a media type to a server whose supported media types exclude it, a routed submission refuses such a server with a retryable error instead of handing it the request, and a server used by a route cannot drop the route's media type. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(api): report request routing in the admin request capabilities getAdminRequestCapabilities gains a routing field that says whether the /admin/request-routes operations are available, so clients detect routing without probing the routes or reading the server version. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): only let the current claim defer a failed submission DeferSubmission matched on the request id and approved state alone. A submission that outlived its ten-minute lease while another server claimed the request again could still release that newer claim and push its next attempt back, letting a third claim overlap the live one. Pass the claim's lease expiry to DeferSubmission and require it to match the stored one. A stale attempt now gets ErrInvalidState and returns the current request without touching the newer claim. Co-Authored-By: Claude Opus 5.5 (1M context) * docs(api): say the routing capability covers the routing mode The routing flag on the admin request capabilities was documented as covering only the rules under /admin/request-routes. The Standard/Advanced routing mode operations under /admin/request-routing are on the same interface and ship with them, so say the flag covers both. Co-Authored-By: Claude Opus 5.5 (1M context) * docs(requests): say plainly when failed targets are kept A failed entitlement lookup or a skipped router connection keeps every failed target. The old sentence read as if those cases shrank the set and deleted the targets. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): only let the current claim fail a submission After the last allowed attempt, the submission was marked failed through SetOutcome, which checked only that the request was still approved and active. An attempt that outlived its ten-minute lease while another server claimed the request again could fail that newer, still running attempt. Add FailSubmission, fenced on the claim's submit_lease_until the same way DeferSubmission is. It marks the request failed and releases the claim. A stale attempt now gets ErrInvalidState and returns the current request. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): hold the open request while a follow is inserted The follow insert read the title's open request without locking it. A decline, cancel or completion could commit and clear the title's follows between that read and the insert, leaving a follow on a closed title that a later request for the same title would notify unasked. Take FOR SHARE on the open request in the insert. The closing UPDATE's row lock conflicts with it, so the two serialize: a follow that waited re-checks the updated row, finds it closed, and inserts nothing. Co-Authored-By: Claude Opus 5.5 (1M context) * test(animeids): parse verbatim entries from the published anime list Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): keep Chinese and Korean productions out of list-only anime matches A title on the AniDB-based list now also needs to not be made in China or Korea, unless Japan co-produced it, before the list alone marks it anime. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(web): refresh the request queue when an admin's cancel is refused When another admin approves, retries, or closes a request first, the cancel answers a conflict. The queue is not polled, so the obsolete row kept its Cancel or Close action. Invalidate the request surfaces on settle, as approve, decline, and retry already do. Co-Authored-By: Claude Opus 5.5 (1M context) * docs(requests): say that no failed request counts against the quota The re-request section still said other accounts' failed requests count against their quota, which this branch's quota rule no longer does. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(web): scope access-group request limits to the admin's profile The limit's ETag names the profile that read it, but its cache key held only the group ID. After a household profile switch the editor reused the other profile's cached limit, and its next save failed with 412. Key the limit by the admin authority scope, as the access-group queries are, and have the group editor read and save it under the authority it read the group with. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(web): keep the Request to add page in the search URL The Request to add grid on a search kept its page only in component state, so Back from a title, a reload, or a shared search link returned to page 1. Catalog now keeps the page in a request_page URL parameter and passes it to the grid. A new query or filter builds fresh params and a scope change drops it, so those still start over at page 1. Paging the grid no longer counts as a new search for the library results. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): route older series requests under Standard while TMDB is down Standard's anime route has a condition, so a series request whose routing facts were never captured waited for TMDB before it could be sent. Standard only needs to know whether the title is anime, and the request already stores that, so it now routes on the stored flag when TMDB cannot answer. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(web): name route destinations with their Send to label, and open reloaded settings The destination select is now named by its section and its visible label ("HD copies Send to"), so screen readers and voice control hear the label shown on screen. More settings opens when a conflict reload or a preset brings in a setting, instead of saving it collapsed and unseen. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): record a submission's targets only while its claim holds A router call that outlived its 10-minute lease could still write targets after the request was withdrawn, completed from the library, or claimed again. Recomputing the status from those targets moved a cancelled request back to queued. Targets are now written in one transaction that checks the claim's lease, like the defer and fail paths; a stale attempt drops its result, logs it, and returns the request as it stands. Co-Authored-By: Claude Opus 5.5 (1M context) * test(web): give the group-limit save test a complete request body Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): keep requests with the plugin that owned them before routing The routing migration seeded routes from the default Radarr and Sonarr servers of any plugin. Before routing, a media type's requests went to the plugin owning the first usable connection by name, so an install whose first connection is Seerr would have started sending requests to Radarr or Sonarr. A new migration removes seeded routes that send outside that owner and that no admin has saved since, which hands the media type back to the plugin, as before. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): keep checking targets a plugin returned without a connection Reconciliation grouped targets by the plugin that owns their server and skipped a target with no connection, so a queued or downloading target the plugin returned without connection_id never got its status checked again. Such a target is now checked through the plugin that routes the media type without rules, with all its connections, as before routing. A routed tier goes to one server, so a target returned for it without a connection is recorded on that server. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(web): hide request routing when the server does not offer it Settings -> Requests called the route endpoints whenever request settings were available. It now reads the routing capability and, when it is false, leaves the routing groups out and does not load the routes. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(web): keep the Request to add pager when page one has nothing to request When TMDB's first page holds only titles already in the library but more pages exist, the grid now shows the section with its pager and a short note, so the viewer can reach the later pages. A single empty page still hides the section, and the dialog, which has no pager, is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): match connectionless statuses to the routed server A routed target the plugin returned without a connection is recorded on the route's server, but reconciliation matched statuses by the exact quality and connection pair, so a status that also came back without a connection never matched and the target stayed open. When every target checked through a plugin is on one server, a status without a connection is now taken as that server's. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): return season progress from create, approve, and retry A season request returned by a mutation lacked its library match and season counts, so its state read approved while a detail or list read of the same request said partially_available. Create, approve, and retry now attach them the way the reads do, with one presence lookup per mutation. A failed lookup is logged and the committed request returned. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): remove a media type's seeded routes together The owner repair removed each seeded route on its own, so when the fallback sent 4K to another plugin's server but the Anime route stayed on the owner's, only the fallback went. The surviving Anime route kept Silo routing on, and every title it did not match failed with no fallback. The repair now removes a media type's untouched seeded routes together when any of them leaves the owner, and leaves a media type alone when an admin has saved or added one of its routes, so its routing keeps a fallback. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(web): allow a request limit of 0 The API accepts a global request limit of 0, which with per-account limits lets only those accounts request, but the settings page refused anything below 1. The field now takes any whole number from 0 and says what 0 does. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): clear a closed request's follows in the closing transaction Declining or withdrawing a request cleared the title's follows after the close committed. In that gap another node could open a replacement request and a new follower, and the title-wide delete removed the new follow. SetOutcome now deletes the follows inside its transaction, and leaves them when the title has another open request. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): refuse a server save that would hand Seerr's requests to another service Under Standard, a media type sent to a plugin that picks its own server (Seerr) gets no Everything else route when a save turns Advanced on. If another plugin installation now also takes that media type, the first connection by name would get its requests, so adding a Radarr beside Seerr could silently take Seerr's movies. The save is now refused with a message to switch to Advanced and set Everything else first. Seeding Seerr into Everything else instead would change how it handles 4K and anime, since a rule sends each tier on its own. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): count a withdrawal made during submission backoff against the quota The quota refunded any cancelled request that still carried a submission error, so an admin closing a failed request kept its refund. A request backing off after a failed attempt also carries that error while it stays active, and its owner may withdraw it, so during a router outage an account could request, wait for the first failed attempt, withdraw and request again without using quota. Cancelling now clears the error unless the request had failed, so only a closed failed request keeps its refund. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(web): keep the open request sheet in step with the refreshed queue The queue's request sheet kept its own copy of the request. When another admin acted first and a local action was refused, the queue refetched but the sheet went on showing the old state and actions. The sheet now keeps only the request's id and shows the view's current row, or the answer to this page's own action when that is newer. When the request has left the view and nothing newer is known, the sheet closes. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): make Standard unavailable when Seerr shares a media type with another service Standard accepted Seerr as a media type's normal server with a Radarr marked 4K (or the other way round), since each tier had one server. But Standard hands a plugin that picks its own server the whole request, so submission fell back to the first installation by name and the 4K destination Standard showed was never used. A self-routing plugin in either tier now needs the other tier to be its own connection; otherwise Standard is unavailable with a reason, and a server save that creates the split turns Advanced on, where the existing Seerr guard refuses the save when Seerr's requests would go to another service. Radarrs or Sonarrs from different plugins stay allowed: they are routed tier by tier. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): drop routed targets returned for the other quality Each routed call asks the plugin for one quality on one server. A target it labels with the other quality was kept, and with both qualities routed it could take that quality's slot ahead of the target the right server returned, leaving Silo tracking the wrong server. Keep only the requested quality from each call and log the rest; a quality left with no target is still recorded as failed. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): read the routing mode before the routing rules The fulfillment context read the rules and then the routing mode in separate queries. A switch from Standard to Advanced writes Everything else and the mode in one commit, so landing between the two reads paired the old rules (often none) with Advanced, and the request went to the first plugin by name. Reading the mode first means a request sees either Standard, which ignores the rules, or Advanced with the rules it was committed with. Co-Authored-By: Claude Opus 5.5 (1M context) * test(requests): name the split-service test's Radarr so goconst stays quiet Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): say why a request server can't be reached The options probe answered every failure (a missing scheme or key, a rejected key, a closed port, https on an http port) with one 503. The host now classifies the plugin's error into host-written messages: field errors for the URL and API key, a 503 detail for an unreachable server, and a plugin's own InvalidArgument/FailedPrecondition text as a form error. Upstream text still never reaches the response. Base URLs without a scheme get http:// on probe and save. The editor shows probe errors beside their fields, adds http:// on blur, and takes the server type and name from a plugin that detects Sonarr or Radarr. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): keep the v1 options failure and drop stale probes The v1 options route is frozen, so it keeps answering a failed probe with its original 500 instead of the new field errors. In the editor, a changed connection now discards a probe still in flight, and a name detection filled in follows a later detection until the admin types one. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(web): keep probe defaults when detection sets the server type The first probe's root folder and quality profile defaults and the detected type were both written from the pre-probe config, so the second update dropped the first and a new server could not be added without a Test. Both now update from the latest config. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): normalize saved URLs on v2 only and drop stale Tests URL normalization on save moves from the shared service into the v2 adapter, so the frozen v1 create and update routes save base_url as given. The editor's Test ignores an answer for a connection the admin changed while it ran. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): keep v1 probes as given and settle Test and 4K naming The v1 options route now sends the submitted address unchanged; the v2 adapter normalizes it for both probe and save. In the editor, only a connection change makes a probe stale, so a Test the debounced probe overtook for the same address still reports, and a name detection filled in picks up the 4K switch. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): hide only host-classified probe errors from v1 Probe errors the host classified now carry their own type, and the v1 options route keeps its 500 for those alone. A validation error the router returns itself keeps v1's 400 as before. Co-Authored-By: Claude Opus 5.5 (1M context) * docs(requests): describe v2 integration URL and probe errors Records the v2 base_url normalization and the options probe's problem answers in the API contract, and limits "not Sonarr or Radarr" to an HTML body where the API should be, so a truncated JSON answer is not blamed on the URL. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(requests): send requested seasons to season-capable routers A series in the library could be requested for its missing seasons only when no download server took series, because the router contract carried no seasons and any router would add the whole series again. silo-plugin-sdk v0.18.0 adds RequestDescriptor.seasons and a manifest flag, request_router.supports_seasons. Move to it and: - send a series request's seasons in the descriptor for Fulfill and CheckStatus (empty still means the whole series) - read supports_seasons from the capability metadata stored at install, through plugins.Service.RequestRouterDescriptor, cached per fulfill context - offer missing seasons (missing_seasons_requestable, per-title requestability, create) when every enabled series server is bound to a plugin that declares the flag - submit a missing-seasons request for a series in the library only where the chosen servers take seasons: every series server without routing rules, the rule-chosen servers with them (every series destination until routing facts are captured); otherwise it waits for the library as before, and a server chosen after the claim that cannot take seasons fails the tier instead of receiving it Whole-series requests and plugins without the flag behave as before. Update the media-requests architecture doc and the status field's description. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(web): load more request browse titles as the viewer scrolls The studio, network, and genre browse page and a Discover row's "Explore all" page read TMDB page by page with useInfiniteQuery and an IntersectionObserver sentinel instead of a Previous/Next pager. Placeholders continue the grid while the next page loads, a title an earlier page already showed is dropped, and a failed later page keeps the loaded titles and offers Try again. Query keys no longer carry the page number, so realtime invalidation refetches every loaded page. A legacy ?page=N is ignored. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): make the routing editor's HD and 4K choices clearer Routing text says "version" instead of "copy", in the web UI and in the server messages it shows. HD dropdowns list only servers not marked 4K and 4K dropdowns only servers marked 4K; a saved choice that no longer fits stays visible with a "(marked 4K)" or "(not marked 4K)" label, and the editor says so when no server is marked 4K. The server refuses a route that sends a version to the wrong kind of server, and refuses turning a server's "4K server" switch on or off while routes depend on it. The rule conditions editor marks the AND between conditions and shows a live "Takes: ..." summary, and the preset dialog spaces its HD and 4K panels apart. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(web): show a spinner on the running request queue action Clicking Retry (or Approve, Decline, Cancel request) only disabled the row's buttons, so nothing showed the action was in progress. The queue now tracks which action is running and swaps that button's icon for a spinner until the server answers. Co-Authored-By: Claude Opus 5.5 (1M context) * test(web): cover the request queue's running-action spinner Co-Authored-By: Claude Opus 5.5 (1M context) * fix(web): keep loading request browse pages past an empty page A rating-restricted profile can get a page with nothing it may see while more pages follow. The browse and Discover row pages said the list was empty and dropped the load control, so later titles were unreachable. They now show "nothing" only when no page is left, and a later page's failure offers Try again even when every loaded page was empty. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): leave the 4K switch free under Standard routing Standard pauses the stored routes, and the editor hides them, so an admin could not follow "change those routes first" when a paused route (such as the Everything else route made for the first server) sent the other version to a server whose 4K switch they changed. The tier check on a server's 4K switch now applies only under Advanced routing. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): clear routes that changed tier under Standard, exempt Seerr Turning Advanced on, by hand or because a server save broke Standard, now clears every stored route destination whose server no longer fits its version, so a server marked 4K under Standard no longer receives HD versions from a paused route (and vice versa); that version falls through to Everything else. A server of another plugin (Seerr) has no 4K switch of ours and handles both versions, so the tier rule no longer applies to it, and the routing editor offers it for both. The inline "Everything else server" picker offers only HD servers. The media requests architecture doc records the tier rule. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): recheck a 4K switch change under the routing-mode lock The service checked a server's 4K switch against the routes with the mode it read before the save; Advanced turned on in between skipped the check. Both repository save paths now recheck under the routing-mode lock when the mode is Advanced. The check runs only when the switch changes, so a route saved before the tier rule no longer blocks unrelated edits to its server. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): recheck route servers' tiers inside the route save A route save validated its servers before its transaction, so a 4K switch change committed in between went unnoticed. The save now holds its servers' rows FOR SHARE and checks the tier rule again, and a server save locks its row FOR UPDATE before reading the routes, so whichever commits second sees the other. Servers are locked before the route row, the order a server save that turns Advanced on uses. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): read routing facts and library seasons before sending seasons A missing-seasons request whose routing facts were not captured was held whenever any series rule sent to a server that cannot take seasons, and no later pass read the facts, so it waited for the library even after TMDB recovered. The facts are now read first, as submission does; only while TMDB cannot answer does every rule's server have to take seasons. A season request approved after its seasons reached the library was still sent to the download server. It is now left for the reconcile pass, which completes it from the library. Co-Authored-By: Claude Opus 5.5 (1M context) * refactor(requests): name the route tier fields once Co-Authored-By: Claude Opus 5.5 (1M context) * fix(notifications): deliver request.fulfilled once per account and profile Profile ids repeat across accounts: every account from before profiles has a profile named "default". request.fulfilled now reaches followers on other accounts, but its at-most-once index was keyed by (profile_id, request_id), so a follower whose profile id matched the requester's, or another follower's, was silently deduped away. Key the index by (user_id, profile_id, request_id) in a new migration, and send an operational delivery's webhook, web push and mobile push only to the recipient profile's targets on the recipient's account, so the two accounts' copies do not both reach each account's devices. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): state that requested season numbers start at 1 The service already refuses a season below 1, but the v2 schema did not say so and the problem carried no field error. Add minimum 1 to the seasons items, so the schema documents the rule and a zero or negative season is refused with a validation error on that item. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): refuse a season below 1 on the seasons field A per-item minimum in the v2 schema counts as a breaking change against the feature branch and would need an approval entry that main's gate then rejects, since seasons are new there. Keep the schema's shape: the service refuses a season below 1 with a validation error on seasons, which v2 renders at body.seasons, and the field description states the rule. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(requests): show download progress while a request downloads (#1649) * feat(requests): show download progress while a request downloads Request-router plugins can now report how far a target's downloads are (TargetStatus.progress, declared with request_router.reports_download_progress). Store it on the target in new download_* columns, and show it on the Requests page, the title page's request bar and the admin queue: a bar once the size is known and "Downloading · 43% · about 12 min left", or the phase (waiting, paused, stalled, importing, import blocked). The reconcile pass records a download's first progress. A new one-minute refresh_request_downloads task then refreshes only downloading targets that have progress, from plugins that declare it. It shares a target-write lock with reconcile, which waits for it rather than skipping, and runs within a 90-second budget. Progress writes leave the target's updated_at, the request status and its history alone; progress clears on completion or failure, when the plugin stops reporting it, or 15 minutes after its server stops answering. A target's raw external status is kept in step with its phase. The v2 API adds RequestDownload on request targets, requests and the title detail's request state, and download_progress_supported on the requests status capability. Clients poll every 30 seconds while something they show downloads. TMDB title details are cached for two minutes so title pages polling a download share one fetch. The plugin SDK is pinned to the silo-plugin-sdk PR commit until v0.19.0 is tagged. Refs #1643 Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): hold both reconcile locks on one session and size requests from every live target The reconcile pass took its own advisory lock and the request target write lock on two pooled connections, so with database.max_connections at 2 its first query waited forever for a third. pglock gains Lock.AcquireAlso, which takes a second key on the session already holding a lock, and Release frees every key the session holds; the reconcile pass takes both locks through it. pglock.Acquire, which only this pass used, is gone. A request's combined download progress skipped a live target that had not reported progress yet, so a 1080p and 4K request could show 90% from the 1080p copy alone. Such a target now leaves the combined size unknown, as the docs already said. Co-Authored-By: Claude Opus 5.5 (1M context) * perf(requests): index the targets the download refresh pass looks for The download refresh pass, and its idle check on every API node, select downloading targets that have progress once a minute. Without an index on that predicate each run scanned all of media_request_targets, which grows with request history. A partial index on (request_id, download_checked_at) where status = 'downloading' and download_phase is set covers only those few rows, built concurrently. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): keep download server details out of requesters' requests (#1651) The profile-scoped v2 request operations (createRequest, listMyRequests, getRequest, cancelRequest) gave a requester every target's download server id, kind and name, the server's own id and raw status, the routing rule and the target error, plus the request's integration kind, external fields and submission error. These are admin details. They show how the admin named their servers and routing rules, and the errors can carry a plugin's raw text, such as a server URL or a release title. mediaRequestOf now takes the viewer and fills those fields for an admin only. A requester keeps the request's state and outcome_reason and each target's quality, status and download progress. An admin still sees everything, on the profile-scoped operations and on the admin request operations. /api/v1 is frozen and unchanged. The fields were already optional, so the contract diff reports no change; their descriptions now say admins only. Refs #1646 Co-authored-by: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) * fix(requests): scope follower notifications per request and recheck server types under lock (#1653) * fix(requests): tell only a request's own followers when it arrives A series can have a completed request still waiting for the library beside a newer open request for other seasons. Follows belong to the title, so the completed request's notification went to, and cleared, follows made for the open request, and declining the open request deleted follows still waiting for the completed one. The notification now goes to the follows made before its request completed, and a decline keeps the follows a completed, not yet notified request of the title is waiting to tell. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): recheck a server's type against its routes under the row lock A route save and a server save each validated the other before their transactions and rechecked only the 4K switch under the server's row lock. Two admins saving at once could leave a movie route pointing at a server that had just become a Sonarr, or one that no longer takes movies, and every request it routed would fail when sent. Both saves now recheck the server's type and media types with the row locked, and a server save does so under Standard too. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): bound a request's followers by the title's previous completion Two completed requests for one series can both wait for the library, and the newer can arrive first. Bounding followers only by the request's own completion time told the newer request's notification about the older request's followers too, and cleared them. A title has one open request at a time, so a request's followers are the follows made after the title's previous request completed and no later than it did. Clearing them is bounded the same way, so a profile that unfollowed and followed again during the dispatch keeps its new follow. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): record the open request on each follow Bounding a request's followers by timestamps broke when a follow committed while a completion was under way: the completion's timestamp comes from the start of its transaction, so it could be earlier than the follow's, and the follow was left out of the request it had read. A follow now records the request that was open when it was made, and a request's notification, its follow cleanup and a decline all go by that request. A new request takes over the follows of a failed request, or of one its requester replaced, so a follow still survives its request failing. Existing follows go to the title's open request, or else to its latest completed request that has not been notified. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): key follows by request so a profile can follow each one A profile following an older completed request of a series could not follow a newer request for other seasons: the key was per title, the insert kept the old row, and the title showed as followed. The backfill also gave every existing follow to the open request, even ones made for an older request. Follows are now keyed by account, profile and request, and the "following" state reads the follow on the title's open request. A new request takes over the follows of the title's failed requests before any it replaces are deleted. The backfill gives each follow the title's latest request created before it, which is the request that was open then. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): move adopted follows in place and backfill replaced requests' follows A new request took over a failed request's follows by deleting and re-inserting them. An unfollow running at the same moment waited on the deleted row, could not see the new one, and returned while the profile still followed the new request. The follows now move with an UPDATE, which the waiting unfollow re-checks and deletes. The backfill gave a follow the title's latest request created before it even when that request was already closed by then, as when the followed request was deleted by its requester's replacement. It now takes that request only if it could still have been open, and otherwise the title's open request. Co-Authored-By: Claude Opus 5.5 (1M context) * build: pin the plugin SDK to v0.19.0 The download-progress change pinned the SDK to its pull request commit, which was squash-merged as v0.19.0 and is no longer fetchable, so the Go jobs could not download the module. v0.19.0 has the same contents. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): backfill a failed request's follows to its replacement A follow made for a request that later failed stayed with the failed request when its replacement had already completed and was still waiting to notify, since the backfill only looked for an open request. It now gives such a follow the title's first request since the follow that still has a notification to send. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): keep a follow stamped during its request's completion A completion's timestamp is taken when its transaction begins, so a follow that committed while it ran can carry a later created_at than the request's completed_at. The backfill took that as the request having closed before the follow and moved or dropped the follow. A completed request that has not notified now keeps such a follow when the title has no request created after it; a follow whose request was replaced and deleted always has that replacement after it, so the two cases stay apart. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): backfill a follow to a failed replacement request A follow whose request was replaced and deleted, where the replacement has since failed too, was dropped: the fallback looked only at active requests. Such a follow now stays with the title's latest failed request, for the next request to take over. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) * fix(requests): rotate a download whose plugin features cannot be read The download refresh pass stopped at a target whose router features could not be read, without moving it back in the rotation. A lasting failure kept the same requests at the head of every batch, and downloads outside the batch were never refreshed. The target is now settled as unanswered, like one its server did not answer for, and the request's other targets are still asked about. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(web): find the viewer's request to cancel beyond the first page The title page looked for the viewer's cancellable request among the account's newest 50 active requests, so an older pending request lost its Cancel action. The detail names the request, so it is now read directly and kept when the viewer's account made it; a detail without the ID still falls back to the list. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): complete from the library only with nothing on its way MarkAvailable's callers check that a request has no live target before calling it, but a submission could queue one in between, and the title arriving then completed a request that was still downloading. The guarded update now also requires that no target is queued or downloading. Retrying a failed request also takes back the follows a later request for the title took over when that request failed too, so they are told when the retried request arrives. Co-Authored-By: Claude Opus 5.5 (1M context) * perf(web): load quick search's request suggestions lazily The request suggestions in quick search pulled their cards, grid and status badge into the launch bundle, which put it over its budget once merged with main. They appear only after a search runs, so they now load on first use; their keyboard options come from the search query, so navigation is in place before they arrive. Co-Authored-By: Claude Opus 5.5 (1M context) * perf(web): lower the launch bundle budget to the lazy search suggestions Moving quick search's request suggestions out of the launch bundle left it 1.4 KB under its budget; the check asks for the saving to be recorded. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(requests): announce a fulfilled request once, after it is stamped The server-channel announcement went out inside NotifyFulfilled, before the followers were cleared and the request stamped as notified. If either write failed, the next pass notified the request again: personal deliveries deduplicate, but the announcement was posted a second time. It now goes out from AnnounceFulfilled, which the notify pass calls only when its stamp took. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(web): select request suggestions only once they are on screen With the suggestions loading lazily, their rows became keyboard options as soon as the search returned, so while the code loaded, or after it failed to load, arrow keys could select a title that was not shown and Enter could open it. A signal inside their Suspense boundary now marks them shown in the same commit that paints them. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- cmd/silo/main.go | 14 +- .../api/v2/fixtures/admin_requests_ok.json | 27 + .../api/v2/fixtures/cancel_request_ok.json | 4 +- .../api/v2/fixtures/create_request_ok.json | 4 +- .../api/v2/fixtures/get_system_info_ok.json | 2 +- .../api/v2/fixtures/list_my_requests_ok.json | 34 +- .../api/v2/fixtures/request_status_ok.json | 8 +- contracts/api/v2/openapi.json | 10593 +++++++---- docs/architecture/admin-settings-ux.md | 18 +- docs/architecture/api-contract.md | 62 +- docs/architecture/media-requests.md | 529 + docs/architecture/notifications.md | 17 +- go.mod | 2 +- go.sum | 4 +- internal/animeids/animeids.go | 300 + internal/animeids/animeids_test.go | 234 + internal/api/handlers/requests.go | 25 + internal/api/handlers/requests_test.go | 67 +- internal/api/requests_wiring.go | 16 + internal/api/router.go | 2 + internal/api/testdata/media_routes.txt | 32 + internal/apiv2/admin_request_groups.go | 138 + internal/apiv2/admin_request_groups_test.go | 65 + internal/apiv2/admin_request_queue.go | 142 + internal/apiv2/admin_request_queue_test.go | 76 + internal/apiv2/admin_request_routes.go | 531 + internal/apiv2/admin_request_routes_test.go | 284 + internal/apiv2/admin_requests.go | 52 +- internal/apiv2/admin_requests_test.go | 93 +- internal/apiv2/document.go | 3 + internal/apiv2/document_test.go | 3 +- internal/apiv2/request_downloads_test.go | 143 + internal/apiv2/request_lifecycle.go | 19 +- internal/apiv2/requests.go | 257 +- internal/apiv2/requests_test.go | 233 +- internal/apiv2/router.go | 2 + internal/catalog/season_availability.go | 73 + .../catalog/season_availability_db_test.go | 90 + internal/contractledger/ledger_test.go | 16 +- internal/database/pglock/pglock.go | 100 +- internal/database/pglock/pglock_test.go | 152 + internal/metadata/tmdb/client.go | 290 +- internal/metadata/tmdb/client_test.go | 335 + internal/metadata/tmdb/types.go | 74 +- internal/notifications/display.go | 8 +- internal/notifications/email_compose.go | 3 + .../notifications/operational_dispatch.go | 16 +- internal/notifications/request_notifier.go | 135 +- .../notifications/request_notifier_test.go | 238 +- .../notifications/webhook_payload_discord.go | 9 +- internal/plugins/request_router.go | 40 + internal/plugins/request_router_test.go | 107 + internal/requests/admin_queue_test.go | 218 + internal/requests/anime.go | 93 +- internal/requests/anime_test.go | 96 +- internal/requests/capability.go | 14 +- internal/requests/downloads.go | 225 + internal/requests/downloads_test.go | 1181 ++ internal/requests/editor_concurrency.go | 17 + internal/requests/editor_concurrency_test.go | 2 +- internal/requests/errors.go | 9 +- internal/requests/follows.go | 284 + internal/requests/follows_test.go | 729 + internal/requests/group_limits.go | 233 + internal/requests/group_limits_test.go | 320 + internal/requests/integration_probe.go | 226 + internal/requests/integration_probe_test.go | 181 + internal/requests/library_completion_test.go | 162 + internal/requests/lifecycle_guard_test.go | 808 + internal/requests/notify.go | 57 +- internal/requests/notify_test.go | 7 + internal/requests/presence.go | 35 +- internal/requests/provider.go | 55 + internal/requests/repository.go | 714 +- internal/requests/repository_page_test.go | 6 +- internal/requests/router_seasons.go | 165 + internal/requests/router_seasons_test.go | 319 + internal/requests/routes_admin.go | 903 + internal/requests/routes_admin_test.go | 611 + internal/requests/routing.go | 383 + internal/requests/routing_facts.go | 122 + internal/requests/routing_facts_test.go | 71 + internal/requests/routing_mode.go | 559 + internal/requests/routing_mode_test.go | 856 + internal/requests/routing_repository.go | 67 + internal/requests/routing_test.go | 694 + internal/requests/routing_trace_test.go | 235 + internal/requests/seasons.go | 232 + internal/requests/seasons_test.go | 465 + internal/requests/service.go | 1561 +- internal/requests/service_test.go | 1007 +- internal/requests/store.go | 103 +- internal/requests/targets.go | 199 +- internal/requests/types.go | 298 +- .../taskmanager/tasks/reconcile_requests.go | 109 +- .../taskmanager/tasks/refresh_anime_ids.go | 70 + .../tasks/refresh_request_downloads.go | 97 + .../tasks/refresh_request_downloads_test.go | 368 + internal/workmetrics/work.go | 2 +- ...0260926140759_request_submission_state.sql | 19 + .../sql/20260926145026_request_follows.sql | 22 + .../20260926152928_request_outcome_reason.sql | 20 + .../20260926155103_request_routing_facts.sql | 10 + .../sql/20260926155617_request_routes.sql | 107 + .../sql/20260926163857_request_seasons.sql | 8 + .../20260926181741_request_group_limits.sql | 36 + ..._request_routes_single_server_fallback.sql | 41 + .../20260926223444_request_routing_mode.sql | 51 + migrations/sql/20260927005655_anime_ids.sql | 26 + ...0927150633_request_follows_account_key.sql | 26 + ...70233_request_routes_keep_legacy_owner.sql | 56 + ...request_fulfilled_delivery_account_key.sql | 30 + ...43912_request_target_download_progress.sql | 29 + ..._request_target_download_refresh_index.sql | 13 + ...0929001953_request_follows_request_key.sql | 87 + web/perf-budget.json | 2 +- web/src/App.tsx | 18 +- web/src/api/types.ts | 90 + web/src/api/v2/adminRequests.test.ts | 35 +- web/src/api/v2/adminRequests.ts | 279 +- web/src/api/v2/operations.ts | 16 + web/src/api/v2/requests.test.ts | 69 + web/src/api/v2/requests.ts | 25 +- web/src/api/v2/schema.ts | 14660 +++++++++------- web/src/components/AdminSidebar.test.tsx | 24 + web/src/components/AdminSidebar.tsx | 15 +- web/src/components/BrandCard.test.tsx | 45 + web/src/components/BrandCard.tsx | 47 +- web/src/components/BrandCarousel.tsx | 7 +- web/src/components/CancelRequestDialog.tsx | 34 + web/src/components/CardPlayOverlay.tsx | 9 +- web/src/components/ConfirmDialog.tsx | 5 +- .../GlobalSearch.lazySuggestions.test.tsx | 127 + web/src/components/GlobalSearch.test.tsx | 208 +- web/src/components/GlobalSearch.tsx | 155 +- web/src/components/ItemCard.tsx | 57 +- web/src/components/Layout.test.tsx | 25 + web/src/components/Layout.tsx | 19 +- web/src/components/MediaCardArtwork.tsx | 116 + web/src/components/MediaCarousel.test.tsx | 48 + web/src/components/MediaCarousel.tsx | 20 +- .../RealtimeEventsProvider.test.tsx | 121 +- web/src/components/RealtimeEventsProvider.tsx | 55 +- web/src/components/RecommendationGrid.tsx | 43 +- .../RequestDownloadProgress.test.tsx | 86 + .../components/RequestDownloadProgress.tsx | 49 + web/src/components/RequestPosterCard.test.tsx | 381 +- web/src/components/RequestPosterCard.tsx | 564 +- web/src/components/RequestResultsGrid.tsx | 154 + .../components/RequestSeasonsDialog.test.tsx | 94 + web/src/components/RequestSeasonsDialog.tsx | 256 + .../components/RequestStatusBadge.test.tsx | 92 + web/src/components/RequestStatusBadge.tsx | 81 + .../components/RequestToAddSection.test.tsx | 384 +- web/src/components/RequestToAddSection.tsx | 346 +- web/src/components/SearchBar.tsx | 13 +- web/src/components/SectionItemCard.tsx | 63 +- web/src/components/admin/EditorConflict.tsx | 36 + .../components/admin/RequestLimitFields.tsx | 212 + .../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/admin/requests.ts | 785 + web/src/hooks/queries/keys.ts | 43 +- web/src/hooks/queries/useRequests.test.tsx | 340 +- web/src/hooks/queries/useRequests.ts | 371 +- web/src/hooks/useCanRequest.test.tsx | 50 +- web/src/hooks/useCanRequest.ts | 15 + web/src/hooks/useSubmitMediaRequest.test.tsx | 86 + web/src/hooks/useSubmitMediaRequest.ts | 45 + web/src/lib/adminNavigation.test.ts | 24 + web/src/lib/adminNavigation.ts | 20 +- web/src/lib/adminSettingsSearch.ts | 47 + web/src/lib/mediaRequests.test.ts | 190 + web/src/lib/mediaRequests.ts | 287 +- web/src/lib/requestAccess.test.ts | 243 + web/src/lib/requestAccess.ts | 363 + web/src/lib/requestDownload.test.ts | 90 + web/src/lib/requestDownload.ts | 72 + web/src/lib/requestRoutingOptions.ts | 114 + web/src/lib/tmdbGenres.ts | 69 + web/src/pages/AdminAccessGroups.test.tsx | 168 + web/src/pages/AdminAccessGroups.tsx | 197 +- web/src/pages/AdminRequests.tsx | 1613 +- web/src/pages/AdminRequests.v2.test.tsx | 882 +- web/src/pages/AdminUserDetail.test.tsx | 20 + web/src/pages/AdminUserDetail.tsx | 35 +- web/src/pages/Catalog.tsx | 27 +- web/src/pages/ItemDetail/DetailLayout.tsx | 37 + .../pages/ItemDetail/ExternalTitleContent.tsx | 200 + .../pages/ItemDetail/ItemDetailSkeleton.tsx | 58 + web/src/pages/ItemDetail/MovieContent.tsx | 373 +- web/src/pages/ItemDetail/SeasonCarousel.tsx | 191 +- .../SeriesContent.requests.test.tsx | 32 +- web/src/pages/ItemDetail/SeriesContent.tsx | 273 +- .../components/ActionBar.menu.test.tsx | 12 + .../ItemDetail/components/ActionBar.test.tsx | 80 + .../pages/ItemDetail/components/ActionBar.tsx | 125 +- .../components/RequestActionBar.tsx | 211 + .../ItemDetail/components/ScoreRow.test.tsx | 33 + .../pages/ItemDetail/components/ScoreRow.tsx | 37 +- web/src/pages/ItemDetail/index.tsx | 58 +- web/src/pages/LegacyRequestDetailRedirect.tsx | 18 + web/src/pages/Notifications.test.tsx | 100 + web/src/pages/Notifications.tsx | 31 +- web/src/pages/RequestBrowse.test.tsx | 164 + web/src/pages/RequestBrowse.tsx | 215 +- web/src/pages/RequestDetail.tsx | 417 - web/src/pages/RequestDiscoverSection.test.tsx | 198 + web/src/pages/RequestDiscoverSection.tsx | 93 + web/src/pages/Requests.test.tsx | 425 + web/src/pages/Requests.tsx | 1235 +- web/src/pages/TitleDetail.test.tsx | 619 + web/src/pages/TitleDetail.tsx | 84 + .../admin-requests/RequestActionButtons.tsx | 64 + web/src/pages/admin-requests/RequestQueue.tsx | 763 + .../admin-requests/RequestQueueDialog.tsx | 397 + web/src/pages/admin-requests/queueParts.tsx | 196 + .../admin-requests/requestQueueModel.test.ts | 85 + .../pages/admin-requests/requestQueueModel.ts | 122 + .../admin-settings/AdminSettingsLayout.tsx | 2 + .../admin-settings/RequestFallbackEditor.tsx | 151 + .../admin-settings/RequestRouteFields.tsx | 605 + .../admin-settings/RequestRoutePreview.tsx | 338 + .../admin-settings/RequestRouting.test.tsx | 1304 ++ .../pages/admin-settings/RequestRouting.tsx | 161 + .../admin-settings/RequestRoutingList.tsx | 599 + .../RequestRoutingMode.test.tsx | 239 + .../admin-settings/RequestRoutingMode.tsx | 150 + .../admin-settings/RequestRuleConditions.tsx | 542 + .../admin-settings/RequestRuleEditor.tsx | 380 + .../admin-settings/RequestRulePresets.tsx | 279 + .../pages/admin-settings/RequestServers.tsx | 1009 ++ .../admin-settings/RequestsSettings.test.tsx | 797 + .../pages/admin-settings/RequestsSettings.tsx | 359 + web/src/pages/admin-settings/SaveBar.tsx | 8 +- web/src/pages/admin-settings/SettingField.tsx | 13 +- .../requestIntegrationMediaTypes.test.ts | 0 .../requestIntegrationMediaTypes.ts | 0 .../requestRoutingModel.test.ts | 741 + .../admin-settings/requestRoutingModel.ts | 821 + .../admin-settings/requestRoutingPresets.ts | 121 + .../admin-settings/requestRoutingWarnings.ts | 267 + .../admin-settings/requestServerModel.ts | 250 + .../requestsSettings.fixtures.tsx | 256 + .../requestsSettings.mockData.ts | 144 + .../pages/admin-settings/useStagedDraft.ts | 65 + .../admin-users/AccountRequestsPanel.test.tsx | 422 + .../admin-users/AccountRequestsPanel.tsx | 286 + web/src/pages/admin/autoscan/ChoiceCard.tsx | 18 +- .../autoscan/ConnectionCreateDialogs.test.tsx | 2 +- .../autoscan/ConnectionDeleteDialog.test.tsx | 2 +- .../autoscan/ConnectionTestDialogs.test.tsx | 2 +- .../autoscan/ConnectionUpdateDialog.test.tsx | 2 +- .../pages/admin/autoscan/ConnectionsPanel.tsx | 18 +- .../admin/autoscan/InlineConnectionPicker.tsx | 2 +- web/src/pages/catalogSearchParams.test.ts | 28 + web/src/pages/catalogSearchParams.ts | 26 + web/src/pages/requestExclusivity.test.ts | 59 - web/src/pages/requestExclusivity.ts | 52 - 262 files changed, 59922 insertions(+), 14628 deletions(-) create mode 100644 docs/architecture/media-requests.md create mode 100644 internal/animeids/animeids.go create mode 100644 internal/animeids/animeids_test.go create mode 100644 internal/apiv2/admin_request_groups.go create mode 100644 internal/apiv2/admin_request_groups_test.go create mode 100644 internal/apiv2/admin_request_queue.go create mode 100644 internal/apiv2/admin_request_queue_test.go create mode 100644 internal/apiv2/admin_request_routes.go create mode 100644 internal/apiv2/admin_request_routes_test.go create mode 100644 internal/apiv2/request_downloads_test.go create mode 100644 internal/catalog/season_availability.go create mode 100644 internal/catalog/season_availability_db_test.go create mode 100644 internal/plugins/request_router.go create mode 100644 internal/plugins/request_router_test.go create mode 100644 internal/requests/admin_queue_test.go create mode 100644 internal/requests/downloads.go create mode 100644 internal/requests/downloads_test.go create mode 100644 internal/requests/follows.go create mode 100644 internal/requests/follows_test.go create mode 100644 internal/requests/group_limits.go create mode 100644 internal/requests/group_limits_test.go create mode 100644 internal/requests/integration_probe.go create mode 100644 internal/requests/integration_probe_test.go create mode 100644 internal/requests/library_completion_test.go create mode 100644 internal/requests/lifecycle_guard_test.go create mode 100644 internal/requests/router_seasons.go create mode 100644 internal/requests/router_seasons_test.go create mode 100644 internal/requests/routes_admin.go create mode 100644 internal/requests/routes_admin_test.go create mode 100644 internal/requests/routing.go create mode 100644 internal/requests/routing_facts.go create mode 100644 internal/requests/routing_facts_test.go create mode 100644 internal/requests/routing_mode.go create mode 100644 internal/requests/routing_mode_test.go create mode 100644 internal/requests/routing_repository.go create mode 100644 internal/requests/routing_test.go create mode 100644 internal/requests/routing_trace_test.go create mode 100644 internal/requests/seasons.go create mode 100644 internal/requests/seasons_test.go create mode 100644 internal/taskmanager/tasks/refresh_anime_ids.go create mode 100644 internal/taskmanager/tasks/refresh_request_downloads.go create mode 100644 internal/taskmanager/tasks/refresh_request_downloads_test.go create mode 100644 migrations/sql/20260926140759_request_submission_state.sql create mode 100644 migrations/sql/20260926145026_request_follows.sql create mode 100644 migrations/sql/20260926152928_request_outcome_reason.sql create mode 100644 migrations/sql/20260926155103_request_routing_facts.sql create mode 100644 migrations/sql/20260926155617_request_routes.sql create mode 100644 migrations/sql/20260926163857_request_seasons.sql create mode 100644 migrations/sql/20260926181741_request_group_limits.sql create mode 100644 migrations/sql/20260926205431_request_routes_single_server_fallback.sql create mode 100644 migrations/sql/20260926223444_request_routing_mode.sql create mode 100644 migrations/sql/20260927005655_anime_ids.sql create mode 100644 migrations/sql/20260927150633_request_follows_account_key.sql create mode 100644 migrations/sql/20260927170233_request_routes_keep_legacy_owner.sql create mode 100644 migrations/sql/20260928142920_request_fulfilled_delivery_account_key.sql create mode 100644 migrations/sql/20260928143912_request_target_download_progress.sql create mode 100644 migrations/sql/20260928212208_request_target_download_refresh_index.sql create mode 100644 migrations/sql/20260929001953_request_follows_request_key.sql create mode 100644 web/src/api/v2/requests.test.ts create mode 100644 web/src/components/BrandCard.test.tsx create mode 100644 web/src/components/CancelRequestDialog.tsx create mode 100644 web/src/components/GlobalSearch.lazySuggestions.test.tsx create mode 100644 web/src/components/MediaCardArtwork.tsx create mode 100644 web/src/components/MediaCarousel.test.tsx create mode 100644 web/src/components/RequestDownloadProgress.test.tsx create mode 100644 web/src/components/RequestDownloadProgress.tsx create mode 100644 web/src/components/RequestResultsGrid.tsx create mode 100644 web/src/components/RequestSeasonsDialog.test.tsx create mode 100644 web/src/components/RequestSeasonsDialog.tsx create mode 100644 web/src/components/RequestStatusBadge.test.tsx create mode 100644 web/src/components/RequestStatusBadge.tsx create mode 100644 web/src/components/admin/EditorConflict.tsx create mode 100644 web/src/components/admin/RequestLimitFields.tsx create mode 100644 web/src/hooks/queries/admin/requests.ts create mode 100644 web/src/hooks/useSubmitMediaRequest.test.tsx create mode 100644 web/src/hooks/useSubmitMediaRequest.ts create mode 100644 web/src/lib/adminNavigation.test.ts create mode 100644 web/src/lib/mediaRequests.test.ts create mode 100644 web/src/lib/requestAccess.test.ts create mode 100644 web/src/lib/requestAccess.ts create mode 100644 web/src/lib/requestDownload.test.ts create mode 100644 web/src/lib/requestDownload.ts create mode 100644 web/src/lib/requestRoutingOptions.ts create mode 100644 web/src/lib/tmdbGenres.ts create mode 100644 web/src/pages/ItemDetail/DetailLayout.tsx create mode 100644 web/src/pages/ItemDetail/ExternalTitleContent.tsx create mode 100644 web/src/pages/ItemDetail/ItemDetailSkeleton.tsx create mode 100644 web/src/pages/ItemDetail/components/RequestActionBar.tsx create mode 100644 web/src/pages/ItemDetail/components/ScoreRow.test.tsx create mode 100644 web/src/pages/LegacyRequestDetailRedirect.tsx create mode 100644 web/src/pages/RequestBrowse.test.tsx delete mode 100644 web/src/pages/RequestDetail.tsx create mode 100644 web/src/pages/RequestDiscoverSection.test.tsx create mode 100644 web/src/pages/RequestDiscoverSection.tsx create mode 100644 web/src/pages/Requests.test.tsx create mode 100644 web/src/pages/TitleDetail.test.tsx create mode 100644 web/src/pages/TitleDetail.tsx create mode 100644 web/src/pages/admin-requests/RequestActionButtons.tsx create mode 100644 web/src/pages/admin-requests/RequestQueue.tsx create mode 100644 web/src/pages/admin-requests/RequestQueueDialog.tsx create mode 100644 web/src/pages/admin-requests/queueParts.tsx create mode 100644 web/src/pages/admin-requests/requestQueueModel.test.ts create mode 100644 web/src/pages/admin-requests/requestQueueModel.ts create mode 100644 web/src/pages/admin-settings/RequestFallbackEditor.tsx 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.test.tsx create mode 100644 web/src/pages/admin-settings/RequestRouting.tsx create mode 100644 web/src/pages/admin-settings/RequestRoutingList.tsx create mode 100644 web/src/pages/admin-settings/RequestRoutingMode.test.tsx create mode 100644 web/src/pages/admin-settings/RequestRoutingMode.tsx create mode 100644 web/src/pages/admin-settings/RequestRuleConditions.tsx create mode 100644 web/src/pages/admin-settings/RequestRuleEditor.tsx create mode 100644 web/src/pages/admin-settings/RequestRulePresets.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/requestRoutingPresets.ts create mode 100644 web/src/pages/admin-settings/requestRoutingWarnings.ts create mode 100644 web/src/pages/admin-settings/requestServerModel.ts create mode 100644 web/src/pages/admin-settings/requestsSettings.fixtures.tsx create mode 100644 web/src/pages/admin-settings/requestsSettings.mockData.ts create mode 100644 web/src/pages/admin-settings/useStagedDraft.ts create mode 100644 web/src/pages/admin-users/AccountRequestsPanel.test.tsx create mode 100644 web/src/pages/admin-users/AccountRequestsPanel.tsx delete mode 100644 web/src/pages/requestExclusivity.test.ts delete mode 100644 web/src/pages/requestExclusivity.ts diff --git a/cmd/silo/main.go b/cmd/silo/main.go index 217fa38bde..810801a1dc 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -38,6 +38,7 @@ import ( "github.com/Silo-Server/silo-server/internal/access" "github.com/Silo-Server/silo-server/internal/activitylog" "github.com/Silo-Server/silo-server/internal/adminjob" + "github.com/Silo-Server/silo-server/internal/animeids" "github.com/Silo-Server/silo-server/internal/api" "github.com/Silo-Server/silo-server/internal/api/handlers" "github.com/Silo-Server/silo-server/internal/apiv2" @@ -78,13 +79,13 @@ 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 // builtin registry on import; buildProviders resolves their seeded chain // entries in-process (no gRPC). _ "github.com/Silo-Server/silo-server/internal/metadata/nfo" - "github.com/Silo-Server/silo-server/internal/metadata/tmdb" "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/nodeconfig" "github.com/Silo-Server/silo-server/internal/nodemetrics" @@ -2936,14 +2937,17 @@ func main() { if trendingRefresher != nil { taskMgr.Register(tasks.NewRefreshTrendingDiscoverTask(trendingRefresher)) } + taskMgr.Register(tasks.NewRefreshAnimeIDsTask(animeids.NewRefresher(deps.DB))) if userCollectionScheduler != nil { taskMgr.Register(tasks.NewSyncUserCollectionsTask(userCollectionScheduler)) } if watchProviderService != nil { taskMgr.Register(tasks.NewSyncWatchProvidersTask(watchProviderService)) } - // The TMDB client lets a submission started here pick up a TVDB ID - // added on TMDB after the request was created. + // The reconcile pass routes requests, which reads TMDB for requests + // whose routing facts were never captured. The TMDB client also lets a + // submission started here pick up a TVDB ID added on TMDB after the + // request was created. requestReconcileSvc := mediarequests.NewService( mediarequests.NewRepository(deps.DB, deps.SecretCipher), tmdb.NewClient(cfg.TMDBAPIKey, 40), @@ -2952,6 +2956,7 @@ func main() { catalog.NewProviderIDRepository(deps.DB), ), ) + requestReconcileSvc.SetAnimeIndex(animeids.NewStore(deps.DB)) requestReconcileSvc.SetRequesterIdentityResolver(plugins.RequesterIdentityFromLookup(plugins.NewPgUserIdentityLookup(deps.DB))) if metadataService != nil { requestReconcileSvc.SetTVDBIDResolver(metadataService) @@ -2973,7 +2978,8 @@ func main() { if notificationSystem != nil { requestReconcileSvc.SetFulfillmentNotifier(notifications.NewRequestFulfillmentNotifier(notificationSystem)) } - taskMgr.Register(tasks.NewReconcileRequestsTask(requestReconcileSvc, 100)) + taskMgr.Register(tasks.NewReconcileRequestsTask(requestReconcileSvc, 100, deps.DB)) + taskMgr.Register(tasks.NewRefreshRequestDownloadsTask(requestReconcileSvc, 200, 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/contracts/api/v2/fixtures/admin_requests_ok.json b/contracts/api/v2/fixtures/admin_requests_ok.json index f973969d05..551cd3f907 100644 --- a/contracts/api/v2/fixtures/admin_requests_ok.json +++ b/contracts/api/v2/fixtures/admin_requests_ok.json @@ -9,6 +9,9 @@ "year": 1995, "status": "approved", "outcome": "active", + "state": "approved", + "seasons": [], + "season_progress": [], "requested_by_user_id": "1", "requested_by_profile_id": "p-owner", "integration_kind": "radarr", @@ -17,9 +20,15 @@ { "id": "42", "request_id": "r-3", + "integration_id": "integration-1", + "integration_kind": "radarr", + "instance_name": "Radarr", "quality": "1080p", "is_anime": false, + "external_id": "7", + "external_status": "queued", "status": "queued", + "route_name": "Movies", "created_at": "2026-01-02T03:04:05.678Z", "updated_at": "2026-01-02T03:04:05.678Z" } @@ -37,6 +46,9 @@ "year": 1995, "status": "approved", "outcome": "active", + "state": "approved", + "seasons": [], + "season_progress": [], "requested_by_user_id": "1", "requested_by_profile_id": "p-owner", "integration_kind": "radarr", @@ -45,9 +57,15 @@ { "id": "42", "request_id": "r-2", + "integration_id": "integration-1", + "integration_kind": "radarr", + "instance_name": "Radarr", "quality": "1080p", "is_anime": false, + "external_id": "7", + "external_status": "queued", "status": "queued", + "route_name": "Movies", "created_at": "2026-01-02T03:04:05.678Z", "updated_at": "2026-01-02T03:04:05.678Z" } @@ -65,6 +83,9 @@ "year": 1995, "status": "approved", "outcome": "active", + "state": "approved", + "seasons": [], + "season_progress": [], "requested_by_user_id": "1", "requested_by_profile_id": "p-owner", "integration_kind": "radarr", @@ -73,9 +94,15 @@ { "id": "42", "request_id": "r-1", + "integration_id": "integration-1", + "integration_kind": "radarr", + "instance_name": "Radarr", "quality": "1080p", "is_anime": false, + "external_id": "7", + "external_status": "queued", "status": "queued", + "route_name": "Movies", "created_at": "2026-01-02T03:04:05.678Z", "updated_at": "2026-01-02T03:04:05.678Z" } diff --git a/contracts/api/v2/fixtures/cancel_request_ok.json b/contracts/api/v2/fixtures/cancel_request_ok.json index efe13743a5..212803d679 100644 --- a/contracts/api/v2/fixtures/cancel_request_ok.json +++ b/contracts/api/v2/fixtures/cancel_request_ok.json @@ -7,9 +7,11 @@ "year": 1995, "status": "approved", "outcome": "active", + "state": "approved", + "seasons": [], + "season_progress": [], "requested_by_user_id": "1", "requested_by_profile_id": "p-owner", - "integration_kind": "radarr", "is_anime": false, "targets": [ { diff --git a/contracts/api/v2/fixtures/create_request_ok.json b/contracts/api/v2/fixtures/create_request_ok.json index 9cac369422..9f05dc9465 100644 --- a/contracts/api/v2/fixtures/create_request_ok.json +++ b/contracts/api/v2/fixtures/create_request_ok.json @@ -7,9 +7,11 @@ "year": 1995, "status": "pending", "outcome": "active", + "state": "pending", + "seasons": [], + "season_progress": [], "requested_by_user_id": "1", "requested_by_profile_id": "p-owner", - "integration_kind": "radarr", "is_anime": false, "targets": [], "created_at": "2026-01-02T03:04:05.678Z", diff --git a/contracts/api/v2/fixtures/get_system_info_ok.json b/contracts/api/v2/fixtures/get_system_info_ok.json index fe16866ca9..5429719cf8 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": "f27299fa6edb481d4c12ea8fe074683216d15bc9015c5a832e22e770be16843e", + "contract_digest": "d02d5545b862fa3ca0009fd21acf0b068bf290ba56e5864310cac20503971744", "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..67a73c0b0a 100644 --- a/contracts/api/v2/fixtures/list_my_requests_ok.json +++ b/contracts/api/v2/fixtures/list_my_requests_ok.json @@ -9,9 +9,11 @@ "year": 1995, "status": "pending", "outcome": "active", + "state": "pending", + "seasons": [], + "season_progress": [], "requested_by_user_id": "1", "requested_by_profile_id": "p-owner", - "integration_kind": "radarr", "is_anime": false, "targets": [ { @@ -35,11 +37,13 @@ "tmdb_id": 949, "title": "Heat", "year": 1995, - "status": "approved", + "status": "downloading", "outcome": "active", + "state": "processing", + "seasons": [], + "season_progress": [], "requested_by_user_id": "1", "requested_by_profile_id": "p-owner", - "integration_kind": "radarr", "is_anime": false, "targets": [ { @@ -47,14 +51,32 @@ "request_id": "r-1", "quality": "1080p", "is_anime": false, - "status": "queued", + "status": "downloading", "created_at": "2026-01-02T03:04:05.678Z", - "updated_at": "2026-01-02T03:04:05.678Z" + "updated_at": "2026-01-02T03:04:05.678Z", + "download": { + "phase": "downloading", + "percent": 43, + "bytes_total": 4294967296, + "bytes_left": 2448131358, + "estimated_completion_at": "2026-01-02T03:16:05.678Z", + "downloads": 1, + "updated_at": "2026-01-02T03:04:05.678Z" + } } ], "created_at": "2026-01-02T03:04:05.678Z", "updated_at": "2026-01-02T03:04:05.678Z", - "approved_at": "2026-01-02T03:04:05.678Z" + "approved_at": "2026-01-02T03:04:05.678Z", + "download": { + "phase": "downloading", + "percent": 43, + "bytes_total": 4294967296, + "bytes_left": 2448131358, + "estimated_completion_at": "2026-01-02T03:16:05.678Z", + "downloads": 1, + "updated_at": "2026-01-02T03:04:05.678Z" + } } ], "page": { diff --git a/contracts/api/v2/fixtures/request_status_ok.json b/contracts/api/v2/fixtures/request_status_ok.json index 78f8bb5a01..a1bb72355b 100644 --- a/contracts/api/v2/fixtures/request_status_ok.json +++ b/contracts/api/v2/fixtures/request_status_ok.json @@ -1,7 +1,11 @@ { - "revision": "9f99fe02e4707e64e2d61ce7d8682b809713f2cad154b46064cc7c16e0b26d61", + "revision": "dc6458db8af9354952b69baa1805dcb12a4cee23a40517a0dea2c3f02e441bb2", "state": "available", "allowed": true, "requests_enabled": true, - "rating_restrictions_enforced": true + "rating_restrictions_enforced": true, + "follow_supported": true, + "season_requests_supported": true, + "missing_seasons_requestable": false, + "download_progress_supported": true } diff --git a/contracts/api/v2/openapi.json b/contracts/api/v2/openapi.json index bcc4304263..6f759a3167 100644 --- a/contracts/api/v2/openapi.json +++ b/contracts/api/v2/openapi.json @@ -11306,6 +11306,10 @@ "description": "Opaque revision of this document", "type": "string" }, + "routing": { + "description": "Whether request routing is available: the routing rules under /admin/request-routes and the Standard/Advanced routing mode under /admin/request-routing", + "type": "boolean" + }, "state": { "description": "Support and configuration state, not health", "enum": [ @@ -11320,12 +11324,243 @@ "required": [ "available", "guarded_configuration", + "routing", "revision", "state", "allowed" ], "type": "object" }, + "AdminRequestCounts": { + "additionalProperties": false, + "properties": { + "done": { + "description": "Completed requests, and those closed by a decline or cancellation", + "examples": [ + 42 + ], + "format": "int64", + "type": "integer" + }, + "failed": { + "description": "Failed requests; Retry sends them again", + "examples": [ + 1 + ], + "format": "int64", + "type": "integer" + }, + "in_progress": { + "description": "Approved requests on their way to the library", + "examples": [ + 5 + ], + "format": "int64", + "type": "integer" + }, + "needs_approval": { + "description": "Pending requests waiting for an admin", + "examples": [ + 3 + ], + "format": "int64", + "type": "integer" + } + }, + "required": [ + "needs_approval", + "in_progress", + "failed", + "done" + ], + "type": "object" + }, + "AdminRequestEvent": { + "additionalProperties": false, + "properties": { + "actor_user_id": { + "description": "The account that acted; absent for the server itself", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + }, + "actor_username": { + "description": "The acting account's username, while the account exists", + "examples": [ + "admin" + ], + "type": "string" + }, + "created_at": { + "description": "RFC 3339 instant in UTC with millisecond precision", + "format": "date-time", + "pattern": "^(?:[1-9][0-9]{3}|0[1-9][0-9]{2}|00[1-9][0-9]|000[2-9])-", + "patternDescription": "a non-zero RFC 3339 instant", + "type": "string" + }, + "id": { + "description": "Opaque identifier", + "examples": [ + "981" + ], + "minLength": 1, + "type": "string" + }, + "message": { + "description": "A reason or error that came with the event", + "examples": [ + "auto approved" + ], + "type": "string" + }, + "type": { + "description": "What happened: created, approved, retried, submit_deferred, available_in_library, status_\u003cstatus\u003e or outcome_\u003coutcome\u003e; clients show unknown types as they are", + "examples": [ + "approved" + ], + "type": "string" + } + }, + "required": [ + "id", + "type", + "created_at" + ], + "type": "object" + }, + "AdminRequestEventCollection": { + "additionalProperties": false, + "properties": { + "items": { + "description": "The page's items; empty, never null", + "items": { + "$ref": "#/components/schemas/AdminRequestEvent" + }, + "type": "array" + }, + "page": { + "$ref": "#/components/schemas/PageInfo", + "description": "Cursor state; absent for bounded unpaginated collections" + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "AdminRequestGroupLimit": { + "additionalProperties": false, + "properties": { + "approval_mode": { + "description": "inherit uses the server-wide approval setting", + "enum": [ + "inherit", + "manual", + "auto" + ], + "examples": [ + "manual" + ], + "type": "string" + }, + "group_id": { + "description": "Opaque identifier", + "examples": [ + "2" + ], + "minLength": 1, + "type": "string" + }, + "limit_mode": { + "description": "inherit uses the server-wide limit; custom uses max_requests per window_days", + "enum": [ + "inherit", + "custom", + "unlimited" + ], + "examples": [ + "custom" + ], + "type": "string" + }, + "max_requests": { + "examples": [ + 10 + ], + "format": "int64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "window_days": { + "examples": [ + 7 + ], + "format": "int64", + "minimum": 1, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "group_id", + "limit_mode", + "max_requests", + "window_days", + "approval_mode" + ], + "type": "object" + }, + "AdminRequestGroupLimitBody": { + "additionalProperties": false, + "properties": { + "approval_mode": { + "enum": [ + "inherit", + "manual", + "auto" + ], + "type": "string" + }, + "limit_mode": { + "enum": [ + "inherit", + "custom", + "unlimited" + ], + "type": "string" + }, + "max_requests": { + "format": "int64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "window_days": { + "format": "int64", + "minimum": 1, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "limit_mode", + "max_requests", + "window_days", + "approval_mode" + ], + "type": "object" + }, "AdminRequestIntegration": { "additionalProperties": false, "properties": { @@ -11563,6 +11798,634 @@ }, "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 Everything else: it has no conditions, comes last and cannot be deleted; with no 4K server it makes no 4K copy", + "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 anime (true) or not (false): Japanese animation, and titles TMDB tags anime or an AniDB-based list names", + "type": "boolean" + }, + "company_ids": { + "description": "TMDB production company IDs (movies)", + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array" + }, + "exclude_company_ids": { + "description": "Match movies from none of these TMDB companies", + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array" + }, + "exclude_genre_ids": { + "description": "Match titles with none of these TMDB genre IDs", + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array" + }, + "exclude_keyword_ids": { + "description": "Match titles with none of these TMDB keyword IDs", + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array" + }, + "exclude_network_ids": { + "description": "Match series on none of these TMDB networks", + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array" + }, + "exclude_origin_countries": { + "description": "Match titles from none of these ISO 3166-1 countries", + "items": { + "type": "string" + }, + "type": "array" + }, + "exclude_original_languages": { + "description": "Match titles whose original language is none of these ISO 639-1 codes", + "examples": [ + [ + "en" + ] + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "exclude_requester_user_ids": { + "description": "Match requests from none of these accounts", + "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" + }, + "max_content_rating": { + "description": "Match titles whose rating is at most this one, by minimum age: the US rating, or the title's own country's when it has none; a title with neither does not match", + "examples": [ + "PG" + ], + "type": "string" + }, + "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": { + "description": "Japanese animation, or a title TMDB tags anime or an AniDB-based list names", + "type": "boolean" + }, + "company_ids": { + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array" + }, + "content_rating": { + "description": "The title's US rating, or its own country's prefixed with the country code (JP:PG12) when it has none; absent when TMDB has neither", + "examples": [ + "TV-14" + ], + "type": "string" + }, + "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; without it, rules for certain accounts do not match", + "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" + }, + "rules": { + "description": "Every route of the media type in evaluation order, with what it did", + "items": { + "$ref": "#/components/schemas/AdminRequestRoutePreviewRule" + }, + "type": "array" + }, + "tiers": { + "items": { + "$ref": "#/components/schemas/AdminRequestRoutePreviewTier" + }, + "type": "array" + } + }, + "required": [ + "facts", + "tiers", + "rules" + ], + "type": "object" + }, + "AdminRequestRoutePreviewRule": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "hd": { + "description": "What the route did for the HD copy", + "enum": [ + "sends", + "skips", + "passes", + "no_match", + "already_decided" + ], + "type": "string" + }, + "is_fallback": { + "type": "boolean" + }, + "route_id": { + "type": "string" + }, + "route_name": { + "type": "string" + }, + "uhd": { + "description": "What the route did for the 4K copy", + "enum": [ + "sends", + "skips", + "passes", + "no_match", + "already_decided" + ], + "type": "string" + }, + "unmet_conditions": { + "description": "The conditions the title fails, by field name (e.g. genre_ids); empty when it matches", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "route_id", + "route_name", + "is_fallback", + "enabled", + "unmet_conditions", + "hd", + "uhd" + ], + "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" + }, + "AdminRequestRouteTitle": { + "additionalProperties": false, + "properties": { + "media_type": { + "enum": [ + "movie", + "series" + ], + "type": "string" + }, + "poster_path": { + "description": "TMDB image path", + "type": "string" + }, + "title": { + "examples": [ + "Spirited Away" + ], + "type": "string" + }, + "tmdb_id": { + "description": "TMDB identifier (external, not a Silo ID)", + "examples": [ + 129 + ], + "format": "int64", + "type": "integer" + }, + "year": { + "examples": [ + 2001 + ], + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "media_type", + "title" + ], + "type": "object" + }, + "AdminRequestRouteTitleCollection": { + "additionalProperties": false, + "properties": { + "items": { + "description": "The page's items; empty, never null", + "items": { + "$ref": "#/components/schemas/AdminRequestRouteTitle" + }, + "type": "array" + }, + "page": { + "$ref": "#/components/schemas/PageInfo", + "description": "Cursor state; absent for bounded unpaginated collections" + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "AdminRequestRouting": { + "additionalProperties": false, + "properties": { + "mode": { + "description": "standard sends each media type to its one server, and 4K copies to its one server marked 4K, with each server's own settings; the routing rules are kept but paused. advanced routes with the rules.", + "enum": [ + "standard", + "advanced" + ], + "type": "string" + }, + "standard": { + "description": "Where Standard sends each media type that has a server; empty when Standard cannot be used", + "items": { + "$ref": "#/components/schemas/AdminRequestStandardDestination" + }, + "type": "array" + }, + "standard_unavailable_reason": { + "description": "Why Standard cannot be used (a media type has more than one server of a kind); absent when it can. Adding or enabling such a server turns Advanced on.", + "type": "string" + } + }, + "required": [ + "mode", + "standard" + ], + "type": "object" + }, + "AdminRequestRoutingUpdateInputBody": { + "additionalProperties": false, + "properties": { + "mode": { + "enum": [ + "standard", + "advanced" + ], + "type": "string" + } + }, + "required": [ + "mode" + ], + "type": "object" + }, "AdminRequestSettings": { "additionalProperties": false, "properties": { @@ -11595,6 +12458,30 @@ ], "type": "object" }, + "AdminRequestStandardDestination": { + "additionalProperties": false, + "properties": { + "hd_integration_id": { + "description": "The media type's one server that is not marked 4K; absent when it has none", + "type": "string" + }, + "media_type": { + "enum": [ + "movie", + "series" + ], + "type": "string" + }, + "uhd_integration_id": { + "description": "The media type's one server marked 4K; absent when it has none, and then there is no 4K copy", + "type": "string" + } + }, + "required": [ + "media_type" + ], + "type": "object" + }, "AdminRequestUserLimit": { "additionalProperties": false, "properties": { @@ -19072,6 +19959,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": { @@ -23763,6 +24670,17 @@ "description": "Whether the current principal may use the capability", "type": "boolean" }, + "download_progress_supported": { + "description": "Whether the server reports download progress (download on requests, their targets, and the title detail's request state). Whether a given request has any depends on its download server's request plugin.", + "type": "boolean" + }, + "follow_supported": { + "type": "boolean" + }, + "missing_seasons_requestable": { + "description": "Whether a series already in the library can be requested for the seasons it is missing. False while a download server that takes series uses a request plugin that cannot fetch individual seasons, so such a series stays already_available.", + "type": "boolean" + }, "rating_restrictions_enforced": { "type": "boolean" }, @@ -23773,6 +24691,9 @@ "description": "Opaque revision of this document", "type": "string" }, + "season_requests_supported": { + "type": "boolean" + }, "state": { "description": "Support and configuration state, not health", "enum": [ @@ -23787,6 +24708,10 @@ "required": [ "requests_enabled", "rating_restrictions_enforced", + "follow_supported", + "season_requests_supported", + "missing_seasons_requestable", + "download_progress_supported", "revision", "state", "allowed" @@ -27615,10 +28540,16 @@ "patternDescription": "a non-zero RFC 3339 instant", "type": "string" }, + "download": { + "$ref": "#/components/schemas/RequestDownload", + "description": "How far the request's downloads are over all its servers (1080p and 4K together), while any reports them: bytes summed, the phase that needs the most attention, the latest estimate, and the oldest report's time" + }, "external_id": { + "description": "Admins only: the integration's own identifier", "type": "string" }, "external_status": { + "description": "Admins only: the status as the download server reports it", "type": "string" }, "id": { @@ -27636,6 +28567,7 @@ "type": "string" }, "integration_kind": { + "description": "Admins only: the download server's kind", "examples": [ "radarr" ], @@ -27645,6 +28577,7 @@ "type": "boolean" }, "last_error": { + "description": "Admins only: why the last submission to a download server failed. It can name servers and routing rules", "type": "string" }, "library_content_id": { @@ -27665,6 +28598,10 @@ ], "type": "string" }, + "outcome_reason": { + "description": "Why the request was declined or withdrawn, when a reason was given", + "type": "string" + }, "overview": { "type": "string" }, @@ -27694,6 +28631,28 @@ "minLength": 1, "type": "string" }, + "season_progress": { + "description": "Series season requests: each requested season's episodes, once the series is in the library; empty otherwise", + "items": { + "$ref": "#/components/schemas/RequestSeasonProgress" + }, + "type": "array" + }, + "seasons": { + "description": "Series: the requested season numbers; empty means the whole series (requests made through v1 or before season requests)", + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array" + }, + "state": { + "description": "The one state to show a user: pending, approved, processing, partially_available (some requested seasons are in the library), available (in the library), declined, cancelled or failed", + "examples": [ + "pending" + ], + "type": "string" + }, "status": { "description": "pending, approved, queued, downloading, completed", "examples": [ @@ -27753,6 +28712,9 @@ "title", "status", "outcome", + "state", + "seasons", + "season_progress", "is_anime", "targets", "created_at", @@ -27810,6 +28772,21 @@ "description": "TMDB image path", "type": "string" }, + "seasons": { + "description": "Series only: the season numbers to request, starting at 1 (a season below 1 is refused). Omitted: every aired season not yet complete in the library", + "examples": [ + [ + 2, + 3 + ] + ], + "items": { + "format": "int64", + "type": "integer" + }, + "maxItems": 200, + "type": "array" + }, "title": { "examples": [ "Heat" @@ -35361,6 +36338,81 @@ }, "type": "object" }, + "RequestDownload": { + "additionalProperties": false, + "properties": { + "bytes_left": { + "description": "Bytes still to download; present whenever bytes_total is", + "examples": [ + 2448131358 + ], + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "bytes_total": { + "description": "Size of the downloads in bytes; absent while unknown", + "examples": [ + 4294967296 + ], + "format": "int64", + "minimum": 1, + "type": "integer" + }, + "downloads": { + "description": "Distinct downloads in flight; a season pack counts once", + "examples": [ + 1 + ], + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "estimated_completion_at": { + "description": "When the download server expects the downloads to finish; absent when it cannot tell", + "examples": [ + "2026-01-02T03:16:05.000Z" + ], + "format": "date-time", + "pattern": "^(?:[1-9][0-9]{3}|0[1-9][0-9]{2}|00[1-9][0-9]|000[2-9])-", + "patternDescription": "a non-zero RFC 3339 instant", + "type": "string" + }, + "percent": { + "description": "How much has downloaded, rounded down; absent while the size is unknown", + "examples": [ + 43 + ], + "format": "int64", + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + "phase": { + "description": "queued, downloading, paused, stalled, importing or import_blocked. More values may be added: read an unknown one as downloading, without a percentage", + "examples": [ + "downloading" + ], + "type": "string" + }, + "updated_at": { + "description": "When the server last heard from the download server. A client may hide figures older than about ten minutes", + "examples": [ + "2026-01-02T03:04:05.000Z" + ], + "format": "date-time", + "pattern": "^(?:[1-9][0-9]{3}|0[1-9][0-9]{2}|00[1-9][0-9]|000[2-9])-", + "patternDescription": "a non-zero RFC 3339 instant", + "type": "string" + } + }, + "required": [ + "phase", + "downloads", + "updated_at" + ], + "type": "object" + }, "RequestMediaCastMember": { "additionalProperties": false, "properties": { @@ -35520,6 +36572,13 @@ "format": "int64", "type": "integer" }, + "seasons": { + "description": "Series: the regular seasons (specials excluded) with library availability and request coverage; empty for movies", + "items": { + "$ref": "#/components/schemas/RequestMediaSeason" + }, + "type": "array" + }, "status": { "description": "TMDB release status", "examples": [ @@ -35576,7 +36635,8 @@ "creators", "recommendations", "availability", - "request" + "request", + "seasons" ], "type": "object" }, @@ -35707,9 +36767,80 @@ ], "type": "object" }, + "RequestMediaSeason": { + "additionalProperties": false, + "properties": { + "air_date": { + "description": "Calendar date, YYYY-MM-DD", + "examples": [ + "2025-01-17" + ], + "type": "string" + }, + "availability": { + "description": "Whether every aired episode is in the library", + "enum": [ + "missing", + "partial", + "available" + ], + "examples": [ + "partial" + ], + "type": "string" + }, + "episode_count": { + "description": "Episodes TMDB lists for the season, aired or not", + "examples": [ + 10 + ], + "format": "int64", + "type": "integer" + }, + "name": { + "examples": [ + "Season 2" + ], + "type": "string" + }, + "poster_path": { + "description": "TMDB image path", + "type": "string" + }, + "requested": { + "description": "The title's active request covers this season", + "type": "boolean" + }, + "season_number": { + "examples": [ + 2 + ], + "format": "int64", + "type": "integer" + } + }, + "required": [ + "season_number", + "episode_count", + "availability", + "requested" + ], + "type": "object" + }, "RequestMediaState": { "additionalProperties": false, "properties": { + "download": { + "$ref": "#/components/schemas/RequestDownload", + "description": "How far the active request's downloads are, while its download server reports them. Only the title detail (getRequestMediaDetail) carries it" + }, + "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": [ @@ -35732,6 +36863,20 @@ ], "type": "boolean" }, + "requested_by_viewer": { + "description": "Whether the viewing profile made the active request, so there is nothing to follow", + "examples": [ + false + ], + "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": [ @@ -35741,7 +36886,43 @@ } }, "required": [ - "requestable" + "requestable", + "following", + "requested_by_viewer" + ], + "type": "object" + }, + "RequestSeasonProgress": { + "additionalProperties": false, + "properties": { + "episodes_aired": { + "description": "Aired episodes by the library's own metadata; 0 when it has no air dates yet", + "examples": [ + 10 + ], + "format": "int64", + "type": "integer" + }, + "episodes_available": { + "description": "Episodes with a file in an enabled library", + "examples": [ + 4 + ], + "format": "int64", + "type": "integer" + }, + "season_number": { + "examples": [ + 2 + ], + "format": "int64", + "type": "integer" + } + }, + "required": [ + "season_number", + "episodes_aired", + "episodes_available" ], "type": "object" }, @@ -35758,11 +36939,16 @@ "patternDescription": "a non-zero RFC 3339 instant", "type": "string" }, + "download": { + "$ref": "#/components/schemas/RequestDownload", + "description": "How far this target's downloads are, while its download server reports them" + }, "external_id": { - "description": "The integration's own identifier", + "description": "Admins only: the integration's own identifier", "type": "string" }, "external_status": { + "description": "Admins only: the status as the download server reports it", "type": "string" }, "id": { @@ -35774,12 +36960,15 @@ "type": "string" }, "instance_name": { + "description": "Admins only: the download server's name", "type": "string" }, "integration_id": { + "description": "Admins only: the download server holding this target", "type": "string" }, "integration_kind": { + "description": "Admins only: the download server's kind", "examples": [ "radarr" ], @@ -35789,6 +36978,7 @@ "type": "boolean" }, "last_error": { + "description": "Admins only: why the download server failed this target", "type": "string" }, "quality": { @@ -35805,6 +36995,10 @@ "minLength": 1, "type": "string" }, + "route_name": { + "description": "Admins only: the routing rule that sent this target to its server, as named when it was sent", + "type": "string" + }, "status": { "examples": [ "queued" @@ -73952,7 +75146,2247 @@ }, "description": "Not Acceptable" }, - "412": { + "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/capabilities": { + "get": { + "operationId": "getAdminMarkerCapabilities", + "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/AdminMarkerCapabilities" + } + } + }, + "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 marker analysis supported by this build, such as local movie credits. Support does not promise that marker settings or a library allow analysis.", + "tags": [ + "admin-catalog" + ], + "x-silo-class": "acting_admin", + "x-silo-conditional": 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": [ + "network-access" + ], + "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "natural_idempotent", + "x-silo-service-backed": true + } + }, + "/api/v2/admin/network-access/{provider}/disconnect": { + "post": { + "operationId": "disconnectNetworkAccess", + "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 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": [ + "network-access" + ], + "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "natural_idempotent", + "x-silo-service-backed": true + } + }, + "/api/v2/admin/network-access/{provider}/status": { + "get": { + "operationId": "getAdminNetworkAccessStatus", + "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" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NetworkAccessStatus" + } + } + }, + "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 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": [ + "network-access" + ], + "x-silo-class": "acting_admin", + "x-silo-service-backed": true + } + }, + "/api/v2/admin/node-sessions": { + "get": { + "operationId": "listAdminNodeSessions", + "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": "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", + "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminNodeSessionsOutputBody" + } + } + }, + "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 best-effort Redis observations, not authoritative playback sessions. Each page enumerates current records; expired or unreadable values may be absent.", + "tags": [ + "admin" + ], + "x-silo-class": "acting_admin", + "x-silo-service-backed": true + } + }, + "/api/v2/admin/nodes": { + "get": { + "operationId": "listAdminNodes", + "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": "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": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionAdminNode" + } + } + }, + "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": "Page configured nodes and their last stored observations; no worker probe is performed.", + "tags": [ + "admin-nodes" + ], + "x-silo-class": "acting_admin", + "x-silo-service-backed": true + }, + "post": { + "operationId": "createAdminNode", + "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" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminNodeCreateBody" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminNode" + } + } + }, + "description": "Created", + "headers": { + "ETag": { + "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" + }, + "408": { "content": { "application/problem+json": { "schema": { @@ -73960,15 +77394,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": { @@ -74016,27 +77472,20 @@ "bearerAuth": [] } ], - "summary": "Discover whether the administrator log stream handshake is served.", + "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-observability" + "admin-nodes" ], "x-silo-class": "acting_admin", - "x-silo-conditional": true, + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "unique_constraint", "x-silo-service-backed": true } }, - "/api/v2/admin/markers/capabilities": { - "get": { - "operationId": "getAdminMarkerCapabilities", + "/api/v2/admin/nodes/force-reload": { + "post": { + "operationId": "forceReloadAdminNodes", "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", @@ -74058,13 +77507,6 @@ ], "type": "string" } - }, - { - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } } ], "responses": { @@ -74072,35 +77514,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminMarkerCapabilities" + "$ref": "#/components/schemas/AdminNodeReloadOutputBody" } } }, - "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": "OK" }, "400": { "content": { @@ -74152,24 +77570,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": { @@ -74216,18 +77616,37 @@ "bearerAuth": [] } ], - "summary": "Discover marker analysis supported by this build, such as local movie credits. Support does not promise that marker settings or a library allow analysis.", + "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-conditional": true + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", + "x-silo-service-backed": true } }, - "/api/v2/admin/markers/files/{fileId}/history": { - "get": { - "operationId": "listAdminFileMarkerHistory", + "/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", @@ -74251,42 +77670,19 @@ } }, { - "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": "id", "required": true, "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, + "maxLength": 10, + "pattern": "^[1-9][0-9]*$", "type": "string" } } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminMarkerHistory" - } - } - }, - "description": "OK" + "204": { + "description": "No Content" }, "400": { "content": { @@ -74338,6 +77734,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": { @@ -74348,6 +77762,16 @@ }, "description": "Unprocessable Entity" }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Required" + }, "429": { "content": { "application/problem+json": { @@ -74384,18 +77808,36 @@ "bearerAuth": [] } ], - "summary": "Read bounded recent marker edit history.", + "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-demo-restricted": true, + "x-silo-guarded": true, + "x-silo-retry-safety": "durable_dispatch", "x-silo-service-backed": true - } - }, - "/api/v2/admin/markers/history": { - "get": { - "operationId": "listAdminMarkerHistory", + }, + "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", @@ -74419,28 +77861,44 @@ } }, { - "explode": false, - "in": "query", - "name": "limit", + "in": "path", + "name": "id", + "required": true, "schema": { - "default": 25, - "format": "int64", - "maximum": 100, - "minimum": 1, - "type": "integer" + "maxLength": 10, + "pattern": "^[1-9][0-9]*$", + "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminNodeUpdateBody" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminMarkerHistory" + "$ref": "#/components/schemas/AdminNode" } } }, - "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": { @@ -74492,7 +77950,7 @@ }, "description": "Not Acceptable" }, - "422": { + "408": { "content": { "application/problem+json": { "schema": { @@ -74500,9 +77958,9 @@ } } }, - "description": "Unprocessable Entity" + "description": "Request Timeout" }, - "429": { + "409": { "content": { "application/problem+json": { "schema": { @@ -74510,9 +77968,9 @@ } } }, - "description": "Too Many Requests" + "description": "Conflict" }, - "500": { + "412": { "content": { "application/problem+json": { "schema": { @@ -74520,113 +77978,17 @@ } } }, - "description": "Internal Server Error" - }, - "503": { - "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": "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": { + "413": { "content": { "application/problem+json": { "schema": { @@ -74634,9 +77996,9 @@ } } }, - "description": "Forbidden" + "description": "Request Entity Too Large" }, - "404": { + "415": { "content": { "application/problem+json": { "schema": { @@ -74644,9 +78006,9 @@ } } }, - "description": "Not Found" + "description": "Unsupported Media Type" }, - "406": { + "422": { "content": { "application/problem+json": { "schema": { @@ -74654,9 +78016,9 @@ } } }, - "description": "Not Acceptable" + "description": "Unprocessable Entity" }, - "422": { + "428": { "content": { "application/problem+json": { "schema": { @@ -74664,7 +78026,7 @@ } } }, - "description": "Unprocessable Entity" + "description": "Precondition Required" }, "429": { "content": { @@ -74702,17 +78064,20 @@ "bearerAuth": [] } ], - "summary": "Read bounded recent marker edit history.", + "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": [ - "admin-catalog" + "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/markers/providers": { - "get": { - "operationId": "listAdminMarkerProviders", + "/api/v2/admin/nodes/{id}/check": { + "post": { + "operationId": "checkAdminNode", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -74735,6 +78100,16 @@ ], "type": "string" } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "maxLength": 10, + "pattern": "^[1-9][0-9]*$", + "type": "string" + } } ], "responses": { @@ -74742,7 +78117,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminMarkerProviders" + "$ref": "#/components/schemas/AdminNodeHealth" } } }, @@ -74844,17 +78219,19 @@ "bearerAuth": [] } ], - "summary": "Manage registered marker provider configuration.", + "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": [ - "admin-catalog" + "admin-nodes" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true } }, - "/api/v2/admin/markers/providers/{provider}": { - "put": { - "operationId": "updateAdminMarkerProvider", + "/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.", @@ -74880,31 +78257,21 @@ }, { "in": "path", - "name": "provider", + "name": "id", "required": true, "schema": { - "maxLength": 512, - "minLength": 1, + "maxLength": 10, + "pattern": "^[1-9][0-9]*$", "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" } } }, @@ -74960,36 +78327,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": { @@ -75036,18 +78373,19 @@ "bearerAuth": [] } ], - "summary": "Manage registered marker provider configuration.", + "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": [ - "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": { + "/api/v2/admin/nodes/{id}/reprobe": { "post": { - "operationId": "validateAdminMarkerProvider", + "operationId": "reprobeAdminNode", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -75073,11 +78411,11 @@ }, { "in": "path", - "name": "provider", + "name": "id", "required": true, "schema": { - "maxLength": 512, - "minLength": 1, + "maxLength": 10, + "pattern": "^[1-9][0-9]*$", "type": "string" } } @@ -75087,7 +78425,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminMarkerProviderValidation" + "$ref": "#/components/schemas/AdminNodeReprobe" } } }, @@ -75189,18 +78527,19 @@ "bearerAuth": [] } ], - "summary": "Manage registered marker provider configuration.", + "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-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/network-access/{provider}/connect": { + "/api/v2/admin/notifications/discord/test": { "post": { - "operationId": "connectNetworkAccess", + "operationId": "testAdminDiscordNotification", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -75223,38 +78562,18 @@ ], "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": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NetworkAccessStatus" + "$ref": "#/components/schemas/AdminNotificationDiscordTestResult" } } }, - "description": "Accepted" + "description": "OK" }, "400": { "content": { @@ -75306,36 +78625,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": { @@ -75382,19 +78671,19 @@ "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": "Verify the stored Discord bot credential without sending a message.", "tags": [ - "network-access" + "admin" ], "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/network-access/{provider}/disconnect": { + "/api/v2/admin/notifications/push/apple/test": { "post": { - "operationId": "disconnectNetworkAccess", + "operationId": "testAdminApplePushNotification", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -75417,38 +78706,28 @@ ], "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" + "$ref": "#/components/schemas/AdminNotificationPushTestInputBody" } } - } + }, + "required": true }, "responses": { - "202": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NetworkAccessStatus" + "$ref": "#/components/schemas/AdminNotificationPushTestResult" } } }, - "description": "Accepted" + "description": "OK" }, "400": { "content": { @@ -75576,19 +78855,19 @@ "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": "Dispatch one test push notification and report its current delivery outcome.", "tags": [ - "network-access" + "admin" ], "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/network-access/{provider}/status": { - "get": { - "operationId": "getAdminNetworkAccessStatus", + "/api/v2/admin/notifications/push/fcm/test": { + "post": { + "operationId": "testAdminAndroidPushNotification", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -75611,25 +78890,24 @@ ], "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/AdminNotificationPushTestInputBody" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NetworkAccessStatus" + "$ref": "#/components/schemas/AdminNotificationPushTestResult" } } }, @@ -75685,6 +78963,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": { @@ -75731,17 +79039,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": "Dispatch one test push notification and report its current delivery outcome.", "tags": [ - "network-access" + "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/node-sessions": { - "get": { - "operationId": "listAdminNodeSessions", + "/api/v2/admin/notifications/push/relay": { + "delete": { + "operationId": "clearAdminNotificationRelay", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -75764,58 +79074,11 @@ ], "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" - } - }, - { - "description": "Opaque identifier", - "explode": false, - "in": "query", - "name": "node_id", - "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - } } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminNodeSessionsOutputBody" - } - } - }, - "description": "OK" + "204": { + "description": "No Content" }, "400": { "content": { @@ -75913,17 +79176,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": "Clear the local push relay credential.", "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/nodes": { - "get": { - "operationId": "listAdminNodes", + "/api/v2/admin/notifications/push/relay/register": { + "post": { + "operationId": "registerAdminNotificationRelay", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -75946,40 +79211,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" - } - }, - { - "explode": false, - "in": "query", - "name": "cursor", - "schema": { - "maxLength": 8192, - "type": "string" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationRelayRegisterInputBody" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionAdminNode" + "$ref": "#/components/schemas/NotificationRelayRegistration" } } }, @@ -76035,6 +79284,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": { @@ -76081,15 +79360,19 @@ "bearerAuth": [] } ], - "summary": "Page configured nodes and their last stored observations; no worker probe is performed.", + "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-retry-safety": "non_retryable", "x-silo-service-backed": true - }, - "post": { - "operationId": "createAdminNode", + } + }, + "/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.", @@ -76112,35 +79395,43 @@ ], "type": "string" } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminNodeCreateBody" - } + }, + { + "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 - }, + { + "explode": false, + "in": "query", + "name": "cursor", + "schema": { + "type": "string" + } + } + ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminNode" + "$ref": "#/components/schemas/CollectionNotificationServerChannel" } } }, - "description": "Created", - "headers": { - "ETag": { - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "400": { "content": { @@ -76192,46 +79483,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": { @@ -76278,19 +79529,15 @@ "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": "List server notification channels.", "tags": [ - "admin-nodes" + "admin" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "unique_constraint", "x-silo-service-backed": true - } - }, - "/api/v2/admin/nodes/force-reload": { + }, "post": { - "operationId": "forceReloadAdminNodes", + "operationId": "createAdminNotificationServerChannel", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -76315,16 +79562,26 @@ } } ], + "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": { @@ -76376,6 +79633,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": { @@ -76422,9 +79709,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": "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, @@ -76432,27 +79719,10 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/nodes/{id}": { + "/api/v2/admin/notifications/server-channels/{id}": { "delete": { - "operationId": "deleteAdminNode", + "operationId": "deleteAdminNotificationServerChannel", "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", @@ -76480,8 +79750,6 @@ "name": "id", "required": true, "schema": { - "maxLength": 10, - "pattern": "^[1-9][0-9]*$", "type": "string" } } @@ -76540,24 +79808,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": { @@ -76568,16 +79818,6 @@ }, "description": "Unprocessable Entity" }, - "428": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Required" - }, "429": { "content": { "application/problem+json": { @@ -76614,36 +79854,18 @@ "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": "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-guarded": true, - "x-silo-retry-safety": "durable_dispatch", + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true }, "put": { - "operationId": "updateAdminNode", + "operationId": "updateAdminNotificationServerChannel", "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", @@ -76671,8 +79893,6 @@ "name": "id", "required": true, "schema": { - "maxLength": 10, - "pattern": "^[1-9][0-9]*$", "type": "string" } } @@ -76681,7 +79901,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminNodeUpdateBody" + "$ref": "#/components/schemas/NotificationServerChannelUpdateInputBody" } } }, @@ -76692,19 +79912,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminNode" + "$ref": "#/components/schemas/NotificationServerChannel" } } }, - "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": { @@ -76766,34 +79978,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": { @@ -76824,16 +80008,6 @@ }, "description": "Unprocessable Entity" }, - "428": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Required" - }, "429": { "content": { "application/problem+json": { @@ -76870,20 +80044,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": "Update channel configuration and atomically reset dispatch state on URL replacement or re-enabling. Never replay an uncertain update.", "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": { + "/api/v2/admin/notifications/server-channels/{id}/rotate-secret": { "post": { - "operationId": "checkAdminNode", + "operationId": "rotateAdminNotificationServerChannelSecret", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -76912,8 +80085,6 @@ "name": "id", "required": true, "schema": { - "maxLength": 10, - "pattern": "^[1-9][0-9]*$", "type": "string" } } @@ -76923,11 +80094,18 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminNodeHealth" + "$ref": "#/components/schemas/NotificationWebhookSecretOutputBody" } } }, - "description": "OK" + "description": "OK", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -77025,19 +80203,19 @@ "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": "Replace the generic server channel signing secret and reveal it once. Never replay an uncertain rotation.", "tags": [ - "admin-nodes" + "admin" ], "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/nodes/{id}/force-reload": { + "/api/v2/admin/notifications/server-channels/{id}/test": { "post": { - "operationId": "forceReloadAdminNode", + "operationId": "testAdminNotificationServerChannel", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -77062,12 +80240,16 @@ } }, { + "description": "Opaque identifier", "in": "path", "name": "id", "required": true, "schema": { - "maxLength": 10, - "pattern": "^[1-9][0-9]*$", + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, "type": "string" } } @@ -77077,7 +80259,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminNodeReloadOutputBody" + "$ref": "#/components/schemas/NotificationDestinationTestResult" } } }, @@ -77179,9 +80361,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": "Send one synchronous sample to a server notification channel.", "tags": [ - "admin-nodes" + "admin" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, @@ -77189,9 +80371,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/nodes/{id}/reprobe": { - "post": { - "operationId": "reprobeAdminNode", + "/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.", @@ -77216,22 +80398,36 @@ } }, { + "description": "Opaque identifier", "in": "path", "name": "id", "required": true, "schema": { - "maxLength": 10, - "pattern": "^[1-9][0-9]*$", + "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/AdminNodeReprobe" + "$ref": "#/components/schemas/Person" } } }, @@ -77287,6 +80483,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": { @@ -77333,9 +80559,9 @@ "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": "Apply a partial person metadata update.", "tags": [ - "admin-nodes" + "admin-catalog" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, @@ -77343,9 +80569,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/notifications/discord/test": { + "/api/v2/admin/people/{id}/refresh": { "post": { - "operationId": "testAdminDiscordNotification", + "operationId": "refreshAdminPerson", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -77368,6 +80594,20 @@ ], "type": "string" } + }, + { + "description": "Person identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Person identifier", + "examples": [ + "7" + ], + "minLength": 1, + "type": "string" + } } ], "responses": { @@ -77375,7 +80615,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminNotificationDiscordTestResult" + "$ref": "#/components/schemas/Person" } } }, @@ -77477,9 +80717,9 @@ "bearerAuth": [] } ], - "summary": "Verify the stored Discord bot credential without sending a message.", + "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, @@ -77487,9 +80727,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/notifications/push/apple/test": { - "post": { - "operationId": "testAdminApplePushNotification", + "/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.", @@ -77512,24 +80752,94 @@ ], "type": "string" } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminNotificationPushTestInputBody" - } + }, + { + "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": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminNotificationPushTestResult" + "$ref": "#/components/schemas/AdminPlaybackHistoryCollection" } } }, @@ -77585,36 +80895,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": { @@ -77661,20 +80941,26 @@ "bearerAuth": [] } ], - "summary": "Dispatch one test push notification and report its current delivery outcome.", + "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/push/fcm/test": { - "post": { - "operationId": "testAdminAndroidPushNotification", + "/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", @@ -77696,28 +80982,49 @@ ], "type": "string" } + }, + { + "in": "header", + "name": "If-None-Match", + "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/AdminPlaybackRoutingCapabilities" } } }, - "description": "OK" + "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": { @@ -77769,17 +81076,7 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { + "412": { "content": { "application/problem+json": { "schema": { @@ -77787,17 +81084,15 @@ } } }, - "description": "Request Entity Too Large" - }, - "415": { - "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": "Unsupported Media Type" + } }, "422": { "content": { @@ -77845,19 +81140,17 @@ "bearerAuth": [] } ], - "summary": "Dispatch one test push notification and report its current delivery outcome.", + "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": "non_retryable", - "x-silo-service-backed": true + "x-silo-conditional": true } }, - "/api/v2/admin/notifications/push/relay": { - "delete": { - "operationId": "clearAdminNotificationRelay", + "/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.", @@ -77880,11 +81173,44 @@ ], "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": { - "204": { - "description": "No Content" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionAdminPluginCatalogEntry" + } + } + }, + "description": "OK" }, "400": { "content": { @@ -77982,20 +81308,26 @@ "bearerAuth": [] } ], - "summary": "Clear the local push relay credential.", + "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/push/relay/register": { - "post": { - "operationId": "registerAdminNotificationRelay", + "/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", @@ -78017,28 +81349,44 @@ ], "type": "string" } + }, + { + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotificationRelayRegisterInputBody" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationRelayRegistration" + "$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" + } + } + } + }, + "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": { @@ -78090,17 +81438,7 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { + "412": { "content": { "application/problem+json": { "schema": { @@ -78108,17 +81446,15 @@ } } }, - "description": "Request Entity Too Large" - }, - "415": { - "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": "Unsupported Media Type" + } }, "422": { "content": { @@ -78166,78 +81502,85 @@ "bearerAuth": [] } ], - "summary": "Register or rotate the configured push relay credential.", + "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": { - "get": { - "operationId": "listAdminNotificationServerChannels", + }, + "put": { + "operationId": "updateAdminPluginCatalogSettings", "parameters": [ { - "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", + "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": "X-Profile-Id", + "name": "If-Match", + "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", + "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": "X-Profile-Token", + "name": "If-None-Match", "schema": { - "examples": [ - "pvt_5f3a9c1e7b2d4e8fa0c6" - ], "type": "string" } }, { - "description": "Page size; default 50, maximum 200", - "explode": false, - "in": "query", - "name": "limit", + "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": { - "default": 50, - "description": "Page size; default 50, maximum 200", "examples": [ - 50 + "1" ], - "format": "int64", - "maximum": 200, - "minimum": 1, - "type": "integer" + "type": "string" } }, { - "explode": false, - "in": "query", - "name": "cursor", + "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" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPluginCatalogSettings" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionNotificationServerChannel" + "$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": { @@ -78289,6 +81632,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": { @@ -78299,6 +81690,16 @@ }, "description": "Unprocessable Entity" }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Required" + }, "429": { "content": { "application/problem+json": { @@ -78335,15 +81736,20 @@ "bearerAuth": [] } ], - "summary": "List server notification channels.", + "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-guarded": true, + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true - }, - "post": { - "operationId": "createAdminNotificationServerChannel", + } + }, + "/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.", @@ -78368,26 +81774,16 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotificationServerChannelCreateInputBody" - } - } - }, - "required": true - }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationDestinationCreated" + "$ref": "#/components/schemas/AdminPluginCatalogStatus" } } }, - "description": "Created" + "description": "OK" }, "400": { "content": { @@ -78439,36 +81835,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": { @@ -78515,19 +81881,17 @@ "bearerAuth": [] } ], - "summary": "Create a server notification channel and reveal its signing secret once.", + "summary": "Read plugin catalog counts and update availability separately from editable configuration.", "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}": { - "delete": { - "operationId": "deleteAdminNotificationServerChannel", + "/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.", @@ -78552,17 +81916,42 @@ } }, { - "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" } } ], "responses": { - "204": { - "description": "No Content" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionAdminPluginInstallation" + } + } + }, + "description": "OK" }, "400": { "content": { @@ -78660,17 +82049,15 @@ "bearerAuth": [] } ], - "summary": "Delete the exact server notification channel and its row-local delivery bookkeeping. Does not recall already-dispatched provider work.", + "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" + "admin-plugins" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true }, - "put": { - "operationId": "updateAdminNotificationServerChannel", + "post": { + "operationId": "createAdminPluginInstallation", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -78693,36 +82080,28 @@ ], "type": "string" } - }, - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationServerChannelUpdateInputBody" + "$ref": "#/components/schemas/AdminPluginInstallCreate" } } }, "required": true }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationServerChannel" + "$ref": "#/components/schemas/AdminPluginInstallation" } } }, - "description": "OK" + "description": "Created" }, "400": { "content": { @@ -78850,9 +82229,9 @@ "bearerAuth": [] } ], - "summary": "Update channel configuration and atomically reset dispatch state on URL replacement or re-enabling. Never replay an uncertain update.", + "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, @@ -78860,9 +82239,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/notifications/server-channels/{id}/rotate-secret": { - "post": { - "operationId": "rotateAdminNotificationServerChannelSecret", + "/api/v2/admin/plugins/installations/{id}": { + "delete": { + "operationId": "deleteAdminPluginInstallation", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -78887,31 +82266,24 @@ } }, { + "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/NotificationWebhookSecretOutputBody" - } - } - }, - "description": "OK", - "headers": { - "Cache-Control": { - "schema": { - "type": "string" - } - } - } + "204": { + "description": "No Content" }, "400": { "content": { @@ -78963,6 +82335,16 @@ }, "description": "Not Acceptable" }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, "422": { "content": { "application/problem+json": { @@ -79009,19 +82391,17 @@ "bearerAuth": [] } ], - "summary": "Replace the generic server channel signing secret and reveal it once. Never replay an uncertain rotation.", + "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" + "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}/test": { - "post": { - "operationId": "testAdminNotificationServerChannel", + }, + "put": { + "operationId": "updateAdminPluginInstallation", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -79056,16 +82436,27 @@ "1" ], "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/NotificationDestinationTestResult" + "$ref": "#/components/schemas/AdminPluginInstallation" } } }, @@ -79121,6 +82512,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": { @@ -79167,19 +82598,19 @@ "bearerAuth": [] } ], - "summary": "Send one synchronous sample to a server notification channel.", + "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" + "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/people/{id}": { - "patch": { - "operationId": "updateAdminPerson", + "/api/v2/admin/plugins/installations/{id}/auth-binding": { + "put": { + "operationId": "updateAdminPluginAuthBinding", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -79214,6 +82645,7 @@ "1" ], "minLength": 1, + "pattern": "^[1-9][0-9]*$", "type": "string" } } @@ -79222,22 +82654,23 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPersonUpdate" + "$ref": "#/components/schemas/AdminPluginAuthBindingWrite" } } }, "required": true }, "responses": { - "200": { - "content": { - "application/json": { + "204": { + "description": "No Content", + "headers": { + "X-Silo-Restart-Required": { "schema": { - "$ref": "#/components/schemas/Person" + "description": "Always true: bindings load at server start", + "type": "string" } } - }, - "description": "OK" + } }, "400": { "content": { @@ -79299,6 +82732,16 @@ }, "description": "Request Timeout" }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, "413": { "content": { "application/problem+json": { @@ -79365,19 +82808,19 @@ "bearerAuth": [] } ], - "summary": "Apply a partial person metadata update.", + "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-catalog" + "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/people/{id}/refresh": { - "post": { - "operationId": "refreshAdminPerson", + "/api/v2/admin/plugins/installations/{id}/config": { + "put": { + "operationId": "updateAdminPluginInstallationConfig", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -79402,30 +82845,34 @@ } }, { - "description": "Person identifier", + "description": "Opaque identifier", "in": "path", "name": "id", "required": true, "schema": { - "description": "Person identifier", + "description": "Opaque identifier", "examples": [ - "7" + "1" ], "minLength": 1, + "pattern": "^[1-9][0-9]*$", "type": "string" } } ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Person" - } + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPluginConfigWrite" } - }, - "description": "OK" + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" }, "400": { "content": { @@ -79477,6 +82924,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": { @@ -79523,19 +83010,19 @@ "bearerAuth": [] } ], - "summary": "Wait for a provider refresh and return the updated person.", + "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-catalog" + "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/playback-history": { - "get": { - "operationId": "listAdminPlaybackHistory", + "/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.", @@ -79560,92 +83047,37 @@ } }, { - "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", + "description": "Opaque identifier", + "in": "path", + "name": "id", + "required": true, "schema": { - "description": "Only attempts by this login account", + "description": "Opaque identifier", "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" - ], + "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/AdminPlaybackHistoryCollection" + "$ref": "#/components/schemas/AdminPluginConnectionCheck" } } }, @@ -79701,6 +83133,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": { @@ -79747,26 +83219,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": "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" + "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}/restart": { + "post": { + "operationId": "restartAdminPluginInstallation", "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", @@ -79790,9 +83256,17 @@ } }, { - "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" } } @@ -79802,35 +83276,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPlaybackRoutingCapabilities" + "$ref": "#/components/schemas/AdminPluginInstallation" } } }, - "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": "OK" }, "400": { "content": { @@ -79882,7 +83332,7 @@ }, "description": "Not Acceptable" }, - "412": { + "409": { "content": { "application/problem+json": { "schema": { @@ -79890,15 +83340,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": { @@ -79946,17 +83388,19 @@ "bearerAuth": [] } ], - "summary": "Read the stable routing configuration vocabulary, independently of available worker capacity.", + "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-settings" + "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/catalog": { - "get": { - "operationId": "listAdminPluginCatalog", + "/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.", @@ -79981,38 +83425,47 @@ } }, { - "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" + "minLength": 1, + "pattern": "^[1-9][0-9]*$", + "type": "string" } }, { - "explode": false, - "in": "query", - "name": "cursor", + "in": "path", + "name": "capability_id", + "required": true, "schema": { - "maxLength": 8192, + "maxLength": 256, + "minLength": 1, "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPluginTaskBindingWrite" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionAdminPluginCatalogEntry" + "$ref": "#/components/schemas/AdminPluginTaskBindingResult" } } }, @@ -80068,6 +83521,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": { @@ -80114,26 +83607,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": "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": "natural_idempotent", "x-silo-service-backed": true } }, - "/api/v2/admin/plugins/catalog-settings": { - "get": { - "operationId": "getAdminPluginCatalogSettings", + "/api/v2/admin/plugins/installations/{id}/update": { + "post": { + "operationId": "applyAdminPluginUpdate", "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", @@ -80157,9 +83644,17 @@ } }, { - "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" } } @@ -80169,30 +83664,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginCatalogSettings" + "$ref": "#/components/schemas/AdminPluginInstallation" } } }, - "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": { @@ -80244,7 +83720,7 @@ }, "description": "Not Acceptable" }, - "412": { + "409": { "content": { "application/problem+json": { "schema": { @@ -80252,15 +83728,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": { @@ -80308,85 +83776,79 @@ "bearerAuth": [] } ], - "summary": "Read canonical plugin catalog configuration and its validator.", + "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" ], "x-silo-class": "acting_admin", - "x-silo-conditional": true, + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - }, - "put": { - "operationId": "updateAdminPluginCatalogSettings", + } + }, + "/api/v2/admin/plugins/repositories": { + "get": { + "operationId": "listAdminPluginRepositories", "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. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", - "name": "If-Match", - "required": true, + "name": "X-Profile-Id", "schema": { + "examples": [ + "1" + ], "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.", + "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": "If-None-Match", + "name": "X-Profile-Token", "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], "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", + "description": "Page size; default 50, maximum 200", + "explode": false, + "in": "query", + "name": "limit", "schema": { + "default": 50, + "description": "Page size; default 50, maximum 200", "examples": [ - "1" + 50 ], - "type": "string" + "format": "int64", + "maximum": 200, + "minimum": 1, + "type": "integer" } }, { - "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", + "explode": false, + "in": "query", + "name": "cursor", "schema": { - "examples": [ - "pvt_5f3a9c1e7b2d4e8fa0c6" - ], + "maxLength": 8192, "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminPluginCatalogSettings" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginCatalogSettings" + "$ref": "#/components/schemas/CollectionAdminPluginRepository" } } }, - "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": { @@ -80438,54 +83900,6 @@ }, "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": { @@ -80496,16 +83910,6 @@ }, "description": "Unprocessable Entity" }, - "428": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Required" - }, "429": { "content": { "application/problem+json": { @@ -80542,20 +83946,15 @@ "bearerAuth": [] } ], - "summary": "Atomically replace captured catalog configuration and reconcile managed repositories.", + "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-guarded": true, - "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true - } - }, - "/api/v2/admin/plugins/catalog-status": { - "get": { - "operationId": "getAdminPluginCatalogStatus", + }, + "post": { + "operationId": "createAdminPluginRepository", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -80580,16 +83979,26 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPluginRepositoryCreateBody" + } + } + }, + "required": true + }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginCatalogStatus" + "$ref": "#/components/schemas/AdminPluginRepository" } } }, - "description": "OK" + "description": "Created" }, "400": { "content": { @@ -80641,6 +84050,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": { @@ -80687,17 +84126,19 @@ "bearerAuth": [] } ], - "summary": "Read plugin catalog counts and update availability separately from editable configuration.", + "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": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/plugins/installations": { - "get": { - "operationId": "listAdminPluginInstallations", + "/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.", @@ -80722,42 +84163,19 @@ } }, { - "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": "id", + "required": true, "schema": { - "maxLength": 8192, + "maxLength": 19, + "pattern": "^[1-9][0-9]*$", "type": "string" } } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CollectionAdminPluginInstallation" - } - } - }, - "description": "OK" + "204": { + "description": "No Content" }, "400": { "content": { @@ -80855,15 +84273,17 @@ "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": "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": "non_retryable", "x-silo-service-backed": true }, - "post": { - "operationId": "createAdminPluginInstallation", + "put": { + "operationId": "updateAdminPluginRepository", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -80886,28 +84306,38 @@ ], "type": "string" } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "maxLength": 19, + "pattern": "^[1-9][0-9]*$", + "type": "string" + } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginInstallCreate" + "$ref": "#/components/schemas/AdminPluginRepositoryUpdateBody" } } }, "required": true }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginInstallation" + "$ref": "#/components/schemas/AdminPluginRepository" } } }, - "description": "Created" + "description": "OK" }, "400": { "content": { @@ -81035,7 +84465,7 @@ "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": "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" ], @@ -81045,9 +84475,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/plugins/installations/{id}": { - "delete": { - "operationId": "deleteAdminPluginInstallation", + "/api/v2/admin/plugins/uploads": { + "post": { + "operationId": "uploadAdminPluginInstallation", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -81070,26 +84500,33 @@ ], "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": { - "204": { - "description": "No Content" + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPluginInstallation" + } + } + }, + "description": "Created" }, "400": { "content": { @@ -81141,7 +84578,7 @@ }, "description": "Not Acceptable" }, - "409": { + "408": { "content": { "application/problem+json": { "schema": { @@ -81149,7 +84586,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": { @@ -81197,7 +84654,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 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" ], @@ -81205,9 +84662,11 @@ "x-silo-demo-restricted": true, "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - }, - "put": { - "operationId": "updateAdminPluginInstallation", + } + }, + "/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.", @@ -81230,43 +84689,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/AdminPluginInstallationUpdate" + "$ref": "#/components/schemas/AdminPluginChunkedUploadCreate" } } }, "required": true }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginInstallation" + "$ref": "#/components/schemas/AdminPluginUploadSession" } } }, - "description": "OK" + "description": "Created" }, "400": { "content": { @@ -81328,16 +84772,6 @@ }, "description": "Request Timeout" }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" - }, "413": { "content": { "application/problem+json": { @@ -81404,19 +84838,19 @@ "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": "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": "natural_idempotent", + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/plugins/installations/{id}/auth-binding": { - "put": { - "operationId": "updateAdminPluginAuthBinding", + "/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.", @@ -81441,42 +84875,19 @@ } }, { - "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" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminPluginAuthBindingWrite" - } - } - }, - "required": true - }, "responses": { "204": { - "description": "No Content", - "headers": { - "X-Silo-Restart-Required": { - "schema": { - "description": "Always true: bindings load at server start", - "type": "string" - } - } - } + "description": "No Content" }, "400": { "content": { @@ -81528,46 +84939,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": { @@ -81614,7 +84985,7 @@ "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": "Discard a process-local upload session and its spooled bytes; an absent or expired session is already gone and answers 204.", "tags": [ "admin-plugins" ], @@ -81624,9 +84995,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/plugins/installations/{id}/config": { + "/api/v2/admin/plugins/uploads/chunked/{upload_id}/chunks/{chunk_index}": { "put": { - "operationId": "updateAdminPluginInstallationConfig", + "operationId": "putAdminPluginUploadChunk", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -81651,34 +85022,48 @@ } }, { - "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/json": { + "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/AdminPluginConfigWrite" + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" } } }, "required": true }, "responses": { - "204": { - "description": "No Content" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPluginUploadSession" + } + } + }, + "description": "OK" }, "400": { "content": { @@ -81816,7 +85201,7 @@ "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": "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" ], @@ -81826,9 +85211,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/plugins/installations/{id}/config/test": { + "/api/v2/admin/plugins/uploads/chunked/{upload_id}/complete": { "post": { - "operationId": "testAdminPluginInstallationConfig", + "operationId": "completeAdminPluginUpload", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -81853,41 +85238,26 @@ } }, { - "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" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminPluginConfigWrite" - } - } - }, - "required": true - }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginConnectionCheck" + "$ref": "#/components/schemas/AdminPluginInstallation" } } }, - "description": "OK" + "description": "Created" }, "400": { "content": { @@ -81939,16 +85309,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, "409": { "content": { "application/problem+json": { @@ -81959,26 +85319,6 @@ }, "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": { @@ -82025,7 +85365,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": "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" ], @@ -82035,9 +85375,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/plugins/installations/{id}/restart": { - "post": { - "operationId": "restartAdminPluginInstallation", + "/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.", @@ -82061,18 +85401,75 @@ "type": "string" } }, + { + "explode": false, + "in": "query", + "name": "decision_name", + "schema": { + "type": "string" + } + }, { "description": "Opaque identifier", - "in": "path", - "name": "id", - "required": true, + "explode": false, + "in": "query", + "name": "user_id", "schema": { "description": "Opaque identifier", "examples": [ "1" ], "minLength": 1, - "pattern": "^[1-9][0-9]*$", + "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" } } @@ -82082,7 +85479,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginInstallation" + "$ref": "#/components/schemas/CollectionAdminPolicyDecision" } } }, @@ -82138,16 +85535,6 @@ }, "description": "Not Acceptable" }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" - }, "422": { "content": { "application/problem+json": { @@ -82194,19 +85581,17 @@ "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": "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": "natural_idempotent", "x-silo-service-backed": true } }, - "/api/v2/admin/plugins/installations/{id}/task-bindings/{capability_id}": { - "put": { - "operationId": "updateAdminPluginTaskBinding", + "/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.", @@ -82241,37 +85626,30 @@ "1" ], "minLength": 1, - "pattern": "^[1-9][0-9]*$", "type": "string" } }, { - "in": "path", - "name": "capability_id", - "required": true, + "in": "header", + "name": "If-Match", + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "If-None-Match", "schema": { - "maxLength": 256, - "minLength": 1, "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminPluginTaskBindingWrite" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginTaskBindingResult" + "$ref": "#/components/schemas/AdminPolicyDecision" } } }, @@ -82327,46 +85705,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": { @@ -82413,19 +85751,17 @@ "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": "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": "natural_idempotent", "x-silo-service-backed": true } }, - "/api/v2/admin/plugins/installations/{id}/update": { - "post": { - "operationId": "applyAdminPluginUpdate", + "/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.", @@ -82450,17 +85786,22 @@ } }, { - "description": "Opaque identifier", - "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": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "pattern": "^[1-9][0-9]*$", "type": "string" } } @@ -82470,7 +85811,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginInstallation" + "$ref": "#/components/schemas/CollectionAdminPolicyDocument" } } }, @@ -82526,16 +85867,6 @@ }, "description": "Not Acceptable" }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" - }, "422": { "content": { "application/problem+json": { @@ -82582,19 +85913,15 @@ "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": "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/repositories": { - "get": { - "operationId": "listAdminPluginRepositories", + }, + "post": { + "operationId": "createAdminPolicyDocument", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -82617,44 +85944,40 @@ ], "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" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPolicyDocumentCreate" + } + } + }, + "required": true + }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionAdminPluginRepository" + "$ref": "#/components/schemas/AdminPolicyDocument" } } }, - "description": "OK" + "description": "Created", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + }, + "Location": { + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -82706,6 +86029,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": { @@ -82752,16 +86105,37 @@ "bearerAuth": [] } ], - "summary": "Read stored repository configuration without remote catalog fetches. Every page enumerates the full stored list; continuation is live, not a snapshot.", + "summary": "Create a policy document without an active version.", "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 - }, - "post": { - "operationId": "createAdminPluginRepository", + } + }, + "/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", @@ -82783,28 +86157,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/AdminPluginRepositoryCreateBody" - } - } - }, - "required": true - }, "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminPluginRepository" - } - } - }, - "description": "Created" + "204": { + "description": "No Content" }, "400": { "content": { @@ -82856,7 +86227,7 @@ }, "description": "Not Acceptable" }, - "408": { + "412": { "content": { "application/problem+json": { "schema": { @@ -82864,19 +86235,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": { @@ -82884,9 +86253,9 @@ } } }, - "description": "Unsupported Media Type" + "description": "Unprocessable Entity" }, - "422": { + "428": { "content": { "application/problem+json": { "schema": { @@ -82894,7 +86263,7 @@ } } }, - "description": "Unprocessable Entity" + "description": "Precondition Required" }, "429": { "content": { @@ -82932,19 +86301,18 @@ "bearerAuth": [] } ], - "summary": "Create one stored repository configuration. No remote fetch, installation, replay identity or automatic retry; uncertain completion must be reconciled explicitly.", + "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/repositories/{id}": { - "delete": { - "operationId": "deleteAdminPluginRepository", + }, + "get": { + "operationId": "getAdminPolicyDocument", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -82969,19 +86337,51 @@ } }, { + "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/AdminPolicySnapshot" + } + } + }, + "description": "OK", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -83079,18 +86479,33 @@ "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 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": "non_retryable", "x-silo-service-backed": true }, - "put": { - "operationId": "updateAdminPluginRepository", + "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", @@ -83114,12 +86529,16 @@ } }, { + "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" } } @@ -83128,7 +86547,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginRepositoryUpdateBody" + "$ref": "#/components/schemas/AdminPolicyEnabled" } } }, @@ -83139,11 +86558,19 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginRepository" + "$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": { @@ -83205,6 +86632,24 @@ }, "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": { @@ -83235,6 +86680,16 @@ }, "description": "Unprocessable Entity" }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Required" + }, "429": { "content": { "application/problem+json": { @@ -83271,20 +86726,38 @@ "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": "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-guarded": true, "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/plugins/uploads": { - "post": { - "operationId": "uploadAdminPluginInstallation", + "/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", @@ -83306,33 +86779,50 @@ ], "type": "string" } + }, + { + "description": "Opaque identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } } ], "requestBody": { "content": { - "multipart/form-data": { - "encoding": { - "archive": { - "contentType": "application/zip, application/octet-stream" - } - }, + "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginUploadForm" + "$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": { @@ -83394,6 +86884,24 @@ }, "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": { @@ -83424,6 +86932,16 @@ }, "description": "Unprocessable Entity" }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Required" + }, "429": { "content": { "application/problem+json": { @@ -83460,19 +86978,20 @@ "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": "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/plugins/uploads/chunked": { - "post": { - "operationId": "createAdminPluginUpload", + "/api/v2/admin/policy/documents/{id}/versions": { + "get": { + "operationId": "listAdminPolicyVersions", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -83495,28 +87014,52 @@ ], "type": "string" } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminPluginChunkedUploadCreate" - } + }, + { + "description": "Opaque identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" } }, - "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": { + "type": "string" + } + } + ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginUploadSession" + "$ref": "#/components/schemas/CollectionAdminPolicyVersion" } } }, - "description": "Created" + "description": "OK" }, "400": { "content": { @@ -83568,36 +87111,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": { @@ -83644,19 +87157,15 @@ "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": "List immutable version metadata.", "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/chunked/{upload_id}": { - "delete": { - "operationId": "cancelAdminPluginUpload", + }, + "post": { + "operationId": "createAdminPolicyVersion", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -83681,19 +87190,47 @@ } }, { + "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/AdminPolicyVersionCreate" + } + } + }, + "required": true + }, "responses": { - "204": { - "description": "No Content" + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPolicyVersion" + } + } + }, + "description": "Created", + "headers": { + "Location": { + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -83745,6 +87282,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": { @@ -83791,19 +87358,19 @@ "bearerAuth": [] } ], - "summary": "Discard a process-local upload session and its spooled bytes; an absent or expired session is already gone and answers 204.", + "summary": "Save an immutable draft, including drafts that fail compilation.", "tags": [ - "admin-plugins" + "admin-policy" ], "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/uploads/chunked/{upload_id}/chunks/{chunk_index}": { - "put": { - "operationId": "putAdminPluginUploadChunk", + "/api/v2/admin/policy/documents/{id}/versions/{version}": { + "get": { + "operationId": "getAdminPolicyVersion", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -83828,44 +87395,40 @@ } }, { + "description": "Opaque identifier", "in": "path", - "name": "upload_id", + "name": "id", "required": true, "schema": { - "maxLength": 128, + "description": "Opaque identifier", + "examples": [ + "1" + ], "minLength": 1, "type": "string" } }, { + "description": "Opaque identifier", "in": "path", - "name": "chunk_index", + "name": "version", "required": true, "schema": { - "format": "int64", - "minimum": 0, - "type": "integer" + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" } } ], - "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/AdminPluginUploadSession" + "$ref": "#/components/schemas/AdminPolicyVersion" } } }, @@ -83921,46 +87484,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": { @@ -84007,19 +87530,17 @@ "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": "Read an immutable version by its opaque ID, including source.", "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}/complete": { + "/api/v2/admin/policy/simulate": { "post": { - "operationId": "completeAdminPluginUpload", + "operationId": "simulateAdminPolicy", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -84042,28 +87563,28 @@ ], "type": "string" } - }, - { - "in": "path", - "name": "upload_id", - "required": true, - "schema": { - "maxLength": 128, - "minLength": 1, - "type": "string" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPolicySimulation" + } + } + }, + "required": true + }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPluginInstallation" + "$ref": "#/components/schemas/AdminPolicySimulationResult" } } }, - "description": "Created" + "description": "OK" }, "400": { "content": { @@ -84115,7 +87636,7 @@ }, "description": "Not Acceptable" }, - "409": { + "408": { "content": { "application/problem+json": { "schema": { @@ -84123,7 +87644,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": { @@ -84171,9 +87712,9 @@ "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": "Evaluate policy input without changing the running policy.", "tags": [ - "admin-plugins" + "admin-policy" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, @@ -84181,9 +87722,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/policy/decisions": { - "get": { - "operationId": "listAdminPolicyDecisions", + "/api/v2/admin/policy/validate": { + "post": { + "operationId": "validateAdminPolicy", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -84206,86 +87747,24 @@ ], "type": "string" } - }, - { - "explode": false, - "in": "query", - "name": "decision_name", - "schema": { - "type": "string" - } - }, - { - "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" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPolicySource" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionAdminPolicyDecision" + "$ref": "#/components/schemas/AdminPolicyValidation" } } }, @@ -84341,6 +87820,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": { @@ -84387,17 +87896,19 @@ "bearerAuth": [] } ], - "summary": "List policy decisions with cursor pagination.", + "summary": "Compile policy source without persisting it.", "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/decisions/{id}": { + "/api/v2/admin/policy/vendor": { "get": { - "operationId": "getAdminPolicyDecision", + "operationId": "listAdminPolicyVendor", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -84420,34 +87931,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": { @@ -84455,7 +87938,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPolicyDecision" + "$ref": "#/components/schemas/CollectionAdminPolicyVendor" } } }, @@ -84557,7 +88040,7 @@ "bearerAuth": [] } ], - "summary": "Read one policy decision and retained samples.", + "summary": "Read embedded vendor policy sources.", "tags": [ "admin-policy" ], @@ -84565,10 +88048,18 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/policy/documents": { + "/api/v2/admin/rate-limits/config": { "get": { - "operationId": "listAdminPolicyDocuments", + "operationId": "getAdminRateLimitConfig", "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", @@ -84592,21 +88083,8 @@ } }, { - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "default": 50, - "format": "int64", - "maximum": 200, - "minimum": 1, - "type": "integer" - } - }, - { - "explode": false, - "in": "query", - "name": "cursor", + "in": "header", + "name": "If-None-Match", "schema": { "type": "string" } @@ -84617,11 +88095,30 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionAdminPolicyDocument" + "$ref": "#/components/schemas/AdminRateLimitConfig" } } }, - "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" + } + } + } + }, + "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": { @@ -84673,6 +88170,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": { @@ -84719,16 +88234,34 @@ "bearerAuth": [] } ], - "summary": "List saved policy documents.", + "summary": "Read desired rate-limit settings independently of process runtime observations.", "tags": [ - "admin-policy" + "admin-settings" ], "x-silo-class": "acting_admin", + "x-silo-conditional": true, "x-silo-service-backed": true }, - "post": { - "operationId": "createAdminPolicyDocument", + "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.", + "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", @@ -84756,34 +88289,22 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPolicyDocumentCreate" + "$ref": "#/components/schemas/AdminRateLimitUpdate" } } }, "required": true }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPolicyDocument" + "$ref": "#/components/schemas/AdminRateLimitUpdateResult" } } }, - "description": "Created", - "headers": { - "ETag": { - "schema": { - "type": "string" - } - }, - "Location": { - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "400": { "content": { @@ -84845,6 +88366,24 @@ }, "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": { @@ -84875,6 +88414,16 @@ }, "description": "Unprocessable Entity" }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Required" + }, "429": { "content": { "application/problem+json": { @@ -84911,37 +88460,21 @@ "bearerAuth": [] } ], - "summary": "Create a policy document without an active version.", + "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-retry-safety": "non_retryable", + "x-silo-guarded": true, + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true } }, - "/api/v2/admin/policy/documents/{id}": { - "delete": { - "operationId": "deleteAdminPolicyDocument", + "/api/v2/admin/rate-limits/status": { + "get": { + "operationId": "getAdminRateLimitStatus", "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", @@ -84963,25 +88496,18 @@ ], "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/AdminRateLimitStatus" + } + } + }, + "description": "OK" }, "400": { "content": { @@ -85033,24 +88559,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": { @@ -85061,16 +88569,6 @@ }, "description": "Unprocessable Entity" }, - "428": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Required" - }, "429": { "content": { "application/problem+json": { @@ -85107,18 +88605,17 @@ "bearerAuth": [] } ], - "summary": "Delete an inactive document using its captured validator.", + "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-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": "getAdminPolicyDocument", + "operationId": "getAdminRecommendationsStatus", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -85141,34 +88638,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": { @@ -85176,18 +88645,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPolicySnapshot" + "$ref": "#/components/schemas/AdminRecommendationsStatus" } } }, - "description": "OK", - "headers": { - "ETag": { - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "400": { "content": { @@ -85285,33 +88747,18 @@ "bearerAuth": [] } ], - "summary": "Read a canonical document, active source, and document validator.", + "summary": "Read persisted counts and this process's running flags.", "tags": [ - "admin-policy" + "admin-recommendations" ], "x-silo-class": "acting_admin", "x-silo-service-backed": true - }, - "patch": { - "operationId": "setAdminPolicyEnabled", + } + }, + "/api/v2/admin/recommendations/trigger/cowatch": { + "post": { + "operationId": "triggerAdminRecommendationCowatch", "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", @@ -85333,50 +88780,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/AdminPolicyEnabled" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPolicyApplyResult" + "$ref": "#/components/schemas/AdminRecommendationStarted" } } }, - "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": { @@ -85428,7 +88843,7 @@ }, "description": "Not Acceptable" }, - "408": { + "422": { "content": { "application/problem+json": { "schema": { @@ -85436,9 +88851,9 @@ } } }, - "description": "Request Timeout" + "description": "Unprocessable Entity" }, - "412": { + "429": { "content": { "application/problem+json": { "schema": { @@ -85446,17 +88861,83 @@ } } }, - "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": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/Problem" } } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Start process-local recommendation work without a durable job receipt.", + "tags": [ + "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/recommendations/trigger/embeddings": { + "post": { + "operationId": "triggerAdminRecommendationEmbeddings", + "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" } }, - "413": { + { + "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/AdminRecommendationStarted" + } + } + }, + "description": "OK" + }, + "400": { "content": { "application/problem+json": { "schema": { @@ -85464,9 +88945,9 @@ } } }, - "description": "Request Entity Too Large" + "description": "Bad Request" }, - "415": { + "401": { "content": { "application/problem+json": { "schema": { @@ -85474,9 +88955,9 @@ } } }, - "description": "Unsupported Media Type" + "description": "Unauthorized" }, - "422": { + "403": { "content": { "application/problem+json": { "schema": { @@ -85484,9 +88965,9 @@ } } }, - "description": "Unprocessable Entity" + "description": "Forbidden" }, - "428": { + "404": { "content": { "application/problem+json": { "schema": { @@ -85494,7 +88975,27 @@ } } }, - "description": "Precondition Required" + "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": { @@ -85532,38 +89033,20 @@ "bearerAuth": [] } ], - "summary": "Set enabled state and report persisted and local application outcomes.", + "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-guarded": true, "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/policy/documents/{id}/active-version": { - "put": { - "operationId": "activateAdminPolicyVersion", + "/api/v2/admin/recommendations/trigger/recommendations": { + "post": { + "operationId": "triggerAdminRecommendationRefresh", "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", @@ -85585,50 +89068,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/AdminPolicyActivation" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPolicyApplyResult" + "$ref": "#/components/schemas/AdminRecommendationStarted" } } }, - "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": { @@ -85680,7 +89131,7 @@ }, "description": "Not Acceptable" }, - "408": { + "422": { "content": { "application/problem+json": { "schema": { @@ -85688,9 +89139,9 @@ } } }, - "description": "Request Timeout" + "description": "Unprocessable Entity" }, - "412": { + "429": { "content": { "application/problem+json": { "schema": { @@ -85698,17 +89149,83 @@ } } }, - "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": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/Problem" } } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Start process-local recommendation work without a durable job receipt.", + "tags": [ + "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/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.", + "in": "header", + "name": "X-Profile-Id", + "schema": { + "examples": [ + "1" + ], + "type": "string" } }, - "413": { + { + "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/AdminRecommendationStarted" + } + } + }, + "description": "OK" + }, + "400": { "content": { "application/problem+json": { "schema": { @@ -85716,9 +89233,9 @@ } } }, - "description": "Request Entity Too Large" + "description": "Bad Request" }, - "415": { + "401": { "content": { "application/problem+json": { "schema": { @@ -85726,9 +89243,9 @@ } } }, - "description": "Unsupported Media Type" + "description": "Unauthorized" }, - "422": { + "403": { "content": { "application/problem+json": { "schema": { @@ -85736,9 +89253,9 @@ } } }, - "description": "Unprocessable Entity" + "description": "Forbidden" }, - "428": { + "404": { "content": { "application/problem+json": { "schema": { @@ -85746,7 +89263,27 @@ } } }, - "description": "Precondition Required" + "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": { @@ -85784,20 +89321,19 @@ "bearerAuth": [] } ], - "summary": "Set the active version using the captured document validator.", + "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-guarded": true, "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/policy/documents/{id}/versions": { + "/api/v2/admin/request-groups/{group_id}/limit": { "get": { - "operationId": "listAdminPolicyVersions", + "operationId": "getAdminRequestGroupLimit", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -85822,36 +89358,17 @@ } }, { - "description": "Opaque identifier", + "description": "The access group", "in": "path", - "name": "id", + "name": "group_id", "required": true, "schema": { - "description": "Opaque identifier", + "description": "The access group", "examples": [ - "1" + "2" ], "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": { + "pattern": "^[1-9][0-9]*$", "type": "string" } } @@ -85861,11 +89378,18 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionAdminPolicyVersion" + "$ref": "#/components/schemas/AdminRequestGroupLimit" } } }, - "description": "OK" + "description": "OK", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -85963,16 +89487,33 @@ "bearerAuth": [] } ], - "summary": "List immutable version metadata.", + "summary": "Get an access group's request approval and limit.", "tags": [ - "admin-policy" + "admin" ], "x-silo-class": "acting_admin", "x-silo-service-backed": true }, - "post": { - "operationId": "createAdminPolicyVersion", + "put": { + "operationId": "updateAdminRequestGroupLimit", "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", @@ -85996,16 +89537,17 @@ } }, { - "description": "Opaque identifier", + "description": "The access group", "in": "path", - "name": "id", + "name": "group_id", "required": true, "schema": { - "description": "Opaque identifier", + "description": "The access group", "examples": [ - "1" + "2" ], "minLength": 1, + "pattern": "^[1-9][0-9]*$", "type": "string" } } @@ -86014,24 +89556,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPolicyVersionCreate" + "$ref": "#/components/schemas/AdminRequestGroupLimitBody" } } }, "required": true }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPolicyVersion" + "$ref": "#/components/schemas/AdminRequestGroupLimit" } } }, - "description": "Created", + "description": "OK", "headers": { - "Location": { + "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" } @@ -86098,6 +89641,24 @@ }, "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": { @@ -86128,6 +89689,16 @@ }, "description": "Unprocessable Entity" }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Required" + }, "429": { "content": { "application/problem+json": { @@ -86164,19 +89735,20 @@ "bearerAuth": [] } ], - "summary": "Save an immutable draft, including drafts that fail compilation.", + "summary": "Replace an access group's request approval and limit.", "tags": [ - "admin-policy" + "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/policy/documents/{id}/versions/{version}": { + "/api/v2/admin/request-integrations": { "get": { - "operationId": "getAdminPolicyVersion", + "operationId": "listRequestIntegrations", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -86201,30 +89773,32 @@ } }, { - "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, - "type": "string" + "format": "int64", + "maximum": 200, + "minimum": 1, + "type": "integer" } }, { - "description": "Opaque identifier", - "in": "path", - "name": "version", - "required": true, + "description": "Opaque cursor from page.next_cursor", + "explode": false, + "in": "query", + "name": "cursor", "schema": { - "description": "Opaque identifier", + "description": "Opaque cursor from page.next_cursor", "examples": [ - "1" + "eyJpIjo1MH0" ], - "minLength": 1, "type": "string" } } @@ -86234,7 +89808,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPolicyVersion" + "$ref": "#/components/schemas/CollectionAdminRequestIntegration" } } }, @@ -86336,17 +89910,15 @@ "bearerAuth": [] } ], - "summary": "Read an immutable version by its opaque ID, including source.", + "summary": "Manage media requests and their configuration.", "tags": [ - "admin-policy" + "admin" ], "x-silo-class": "acting_admin", "x-silo-service-backed": true - } - }, - "/api/v2/admin/policy/simulate": { + }, "post": { - "operationId": "simulateAdminPolicy", + "operationId": "createRequestIntegration", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -86375,22 +89947,34 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPolicySimulation" + "$ref": "#/components/schemas/AdminRequestIntegrationBody" } } }, "required": true }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminPolicySimulationResult" + "$ref": "#/components/schemas/AdminRequestIntegration" } } }, - "description": "OK" + "description": "Created", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + }, + "Location": { + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -86518,9 +90102,9 @@ "bearerAuth": [] } ], - "summary": "Evaluate policy input without changing the running policy.", + "summary": "Manage media requests and their configuration.", "tags": [ - "admin-policy" + "admin" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, @@ -86528,10 +90112,27 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/policy/validate": { - "post": { - "operationId": "validateAdminPolicy", + "/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", @@ -86553,28 +90154,25 @@ ], "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/AdminPolicySource" - } - } - }, - "required": true - }, "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminPolicyValidation" - } - } - }, - "description": "OK" + "204": { + "description": "No Content" }, "400": { "content": { @@ -86626,7 +90224,7 @@ }, "description": "Not Acceptable" }, - "408": { + "412": { "content": { "application/problem+json": { "schema": { @@ -86634,19 +90232,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": { @@ -86654,9 +90250,9 @@ } } }, - "description": "Unsupported Media Type" + "description": "Unprocessable Entity" }, - "422": { + "428": { "content": { "application/problem+json": { "schema": { @@ -86664,7 +90260,7 @@ } } }, - "description": "Unprocessable Entity" + "description": "Precondition Required" }, "429": { "content": { @@ -86702,19 +90298,18 @@ "bearerAuth": [] } ], - "summary": "Compile policy source without persisting it.", + "summary": "Manage media requests and their configuration.", "tags": [ - "admin-policy" + "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/policy/vendor": { + }, "get": { - "operationId": "listAdminPolicyVendor", + "operationId": "getRequestIntegration", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -86737,6 +90332,20 @@ ], "type": "string" } + }, + { + "description": "The request", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "The request", + "examples": [ + "1834729" + ], + "minLength": 1, + "type": "string" + } } ], "responses": { @@ -86744,11 +90353,18 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionAdminPolicyVendor" + "$ref": "#/components/schemas/AdminRequestIntegration" } } }, - "description": "OK" + "description": "OK", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -86846,22 +90462,29 @@ "bearerAuth": [] } ], - "summary": "Read embedded vendor policy sources.", + "summary": "Manage media requests and their configuration.", "tags": [ - "admin-policy" + "admin" ], "x-silo-class": "acting_admin", "x-silo-service-backed": true - } - }, - "/api/v2/admin/rate-limits/config": { - "get": { - "operationId": "getAdminRateLimitConfig", + }, + "put": { + "operationId": "updateRequestIntegration", "parameters": [ { - "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", + "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" } @@ -86889,19 +90512,36 @@ } }, { - "in": "header", - "name": "If-None-Match", + "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/AdminRateLimitConfig" + "$ref": "#/components/schemas/AdminRequestIntegration" } } }, @@ -86915,17 +90555,6 @@ } } }, - "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": { @@ -86976,6 +90605,16 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, "412": { "content": { "application/problem+json": { @@ -86994,6 +90633,26 @@ } } }, + "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": { @@ -87004,6 +90663,16 @@ }, "description": "Unprocessable Entity" }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Required" + }, "429": { "content": { "application/problem+json": { @@ -87040,34 +90709,21 @@ "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-demo-restricted": true, + "x-silo-guarded": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - }, - "patch": { - "operationId": "updateAdminRateLimitConfig", + } + }, + "/api/v2/admin/request-integrations/{id}/options": { + "post": { + "operationId": "loadRequestIntegrationOptions", "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", @@ -87089,13 +90745,27 @@ ], "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/AdminRateLimitUpdate" + "$ref": "#/components/schemas/AdminRequestOptionsInputBody" } } }, @@ -87106,7 +90776,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRateLimitUpdateResult" + "$ref": "#/components/schemas/AdminRequestOptions" } } }, @@ -87172,24 +90842,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": { @@ -87220,16 +90872,6 @@ }, "description": "Unprocessable Entity" }, - "428": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Required" - }, "429": { "content": { "application/problem+json": { @@ -87266,20 +90908,19 @@ "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": { + "/api/v2/admin/request-routes": { "get": { - "operationId": "getAdminRateLimitStatus", + "operationId": "listRequestRoutes", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -87309,7 +90950,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRateLimitStatus" + "$ref": "#/components/schemas/CollectionAdminRequestRoute" } } }, @@ -87365,6 +91006,16 @@ }, "description": "Not Acceptable" }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, "422": { "content": { "application/problem+json": { @@ -87411,17 +91062,15 @@ "bearerAuth": [] } ], - "summary": "Read process-local limiter backend and configured Redis availability; no reachability probe.", + "summary": "List the request routing rules, in evaluation order per media type.", "tags": [ - "admin-settings" + "admin" ], "x-silo-class": "acting_admin", "x-silo-service-backed": true - } - }, - "/api/v2/admin/recommendations/status": { - "get": { - "operationId": "getAdminRecommendationsStatus", + }, + "post": { + "operationId": "createRequestRoute", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -87446,16 +91095,38 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminRequestRouteBody" + } + } + }, + "required": true + }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRecommendationsStatus" + "$ref": "#/components/schemas/AdminRequestRoute" } } }, - "description": "OK" + "description": "Created", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + }, + "Location": { + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -87507,6 +91178,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": { @@ -87553,17 +91264,19 @@ "bearerAuth": [] } ], - "summary": "Read persisted counts and this process's running flags.", + "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, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/recommendations/trigger/cowatch": { + "/api/v2/admin/request-routes/order": { "post": { - "operationId": "triggerAdminRecommendationCowatch", + "operationId": "reorderRequestRoutes", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -87588,12 +91301,22 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminRequestRouteReorderInputBody" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRecommendationStarted" + "$ref": "#/components/schemas/CollectionAdminRequestRoute" } } }, @@ -87649,6 +91372,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": { @@ -87695,9 +91458,9 @@ "bearerAuth": [] } ], - "summary": "Start process-local recommendation work without a durable job receipt.", + "summary": "Set the evaluation order of a media type's routing rules.", "tags": [ - "admin-recommendations" + "admin" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, @@ -87705,9 +91468,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/recommendations/trigger/embeddings": { + "/api/v2/admin/request-routes/preview": { "post": { - "operationId": "triggerAdminRecommendationEmbeddings", + "operationId": "previewRequestRoute", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -87732,12 +91495,22 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminRequestRoutePreviewInputBody" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRecommendationStarted" + "$ref": "#/components/schemas/AdminRequestRoutePreviewOutputBody" } } }, @@ -87793,6 +91566,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": { @@ -87839,19 +91652,18 @@ "bearerAuth": [] } ], - "summary": "Start process-local recommendation work without a durable job receipt.", + "summary": "Show which server each quality tier of a title would go to.", "tags": [ - "admin-recommendations" + "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/recommendations/trigger/recommendations": { - "post": { - "operationId": "triggerAdminRecommendationRefresh", + "/api/v2/admin/request-routes/titles": { + "get": { + "operationId": "searchRequestRouteTitles", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -87874,6 +91686,35 @@ ], "type": "string" } + }, + { + "explode": false, + "in": "query", + "name": "media_type", + "required": true, + "schema": { + "enum": [ + "movie", + "series" + ], + "type": "string" + } + }, + { + "description": "Title to search TMDB for", + "explode": false, + "in": "query", + "name": "q", + "required": true, + "schema": { + "description": "Title to search TMDB for", + "examples": [ + "spirited away" + ], + "maxLength": 200, + "minLength": 1, + "type": "string" + } } ], "responses": { @@ -87881,7 +91722,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRecommendationStarted" + "$ref": "#/components/schemas/AdminRequestRouteTitleCollection" } } }, @@ -87937,6 +91778,16 @@ }, "description": "Not Acceptable" }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, "422": { "content": { "application/problem+json": { @@ -87983,20 +91834,35 @@ "bearerAuth": [] } ], - "summary": "Start process-local recommendation work without a durable job receipt.", + "summary": "Search TMDB for titles to try the routing rules on; works while requests are turned off.", "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", + "/api/v2/admin/request-routes/{id}": { + "delete": { + "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.", + "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", @@ -88018,18 +91884,25 @@ ], "type": "string" } + }, + { + "description": "The route", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "The route", + "examples": [ + "fallback-movie" + ], + "minLength": 1, + "type": "string" + } } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminRecommendationStarted" - } - } - }, - "description": "OK" + "204": { + "description": "No Content" }, "400": { "content": { @@ -88081,6 +91954,34 @@ }, "description": "Not Acceptable" }, + "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" + } + } + } + }, "422": { "content": { "application/problem+json": { @@ -88091,6 +91992,16 @@ }, "description": "Unprocessable Entity" }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Required" + }, "429": { "content": { "application/problem+json": { @@ -88127,19 +92038,18 @@ "bearerAuth": [] } ], - "summary": "Start process-local recommendation work without a durable job receipt.", + "summary": "Delete a request routing rule.", "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/request-integrations": { + }, "get": { - "operationId": "listRequestIntegrations", + "operationId": "getRequestRoute", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -88164,32 +92074,16 @@ } }, { - "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", + "description": "The route", + "in": "path", + "name": "id", + "required": true, "schema": { - "description": "Opaque cursor from page.next_cursor", + "description": "The route", "examples": [ - "eyJpIjo1MH0" + "fallback-movie" ], + "minLength": 1, "type": "string" } } @@ -88199,11 +92093,18 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionAdminRequestIntegration" + "$ref": "#/components/schemas/AdminRequestRoute" } } }, - "description": "OK" + "description": "OK", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -88255,6 +92156,16 @@ }, "description": "Not Acceptable" }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, "422": { "content": { "application/problem+json": { @@ -88301,16 +92212,33 @@ "bearerAuth": [] } ], - "summary": "Manage media requests and their configuration.", + "summary": "Get one request routing rule.", "tags": [ "admin" ], "x-silo-class": "acting_admin", "x-silo-service-backed": true }, - "post": { - "operationId": "createRequestIntegration", + "put": { + "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.", + "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", @@ -88332,35 +92260,45 @@ ], "type": "string" } + }, + { + "description": "The route", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "The route", + "examples": [ + "fallback-movie" + ], + "minLength": 1, + "type": "string" + } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRequestIntegrationBody" + "$ref": "#/components/schemas/AdminRequestRouteBody" } } }, "required": true }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRequestIntegration" + "$ref": "#/components/schemas/AdminRequestRoute" } } }, - "description": "Created", + "description": "OK", "headers": { "ETag": { - "schema": { - "type": "string" - } - }, - "Location": { + "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" } @@ -88427,37 +92365,7 @@ }, "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": { + "409": { "content": { "application/problem+json": { "schema": { @@ -88465,9 +92373,9 @@ } } }, - "description": "Too Many Requests" + "description": "Conflict" }, - "500": { + "412": { "content": { "application/problem+json": { "schema": { @@ -88475,137 +92383,17 @@ } } }, - "description": "Internal Server Error" - }, - "503": { - "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": "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-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", - "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" } - } - ], - "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": { + "413": { "content": { "application/problem+json": { "schema": { @@ -88613,9 +92401,9 @@ } } }, - "description": "Not Acceptable" + "description": "Request Entity Too Large" }, - "412": { + "415": { "content": { "application/problem+json": { "schema": { @@ -88623,15 +92411,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": "Unsupported Media Type" }, "422": { "content": { @@ -88689,7 +92469,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" ], @@ -88698,9 +92478,11 @@ "x-silo-guarded": true, "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - }, + } + }, + "/api/v2/admin/request-routing": { "get": { - "operationId": "getRequestIntegration", + "operationId": "getRequestRouting", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -88723,20 +92505,6 @@ ], "type": "string" } - }, - { - "description": "The request", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "The request", - "examples": [ - "1834729" - ], - "minLength": 1, - "type": "string" - } } ], "responses": { @@ -88744,7 +92512,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRequestIntegration" + "$ref": "#/components/schemas/AdminRequestRouting" } } }, @@ -88807,6 +92575,16 @@ }, "description": "Not Acceptable" }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, "422": { "content": { "application/problem+json": { @@ -88853,7 +92631,7 @@ "bearerAuth": [] } ], - "summary": "Manage media requests and their configuration.", + "summary": "Get the request routing mode and where Standard routing would send each media type.", "tags": [ "admin" ], @@ -88861,7 +92639,7 @@ "x-silo-service-backed": true }, "put": { - "operationId": "updateRequestIntegration", + "operationId": "updateRequestRouting", "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.", @@ -88901,27 +92679,13 @@ ], "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" + "$ref": "#/components/schemas/AdminRequestRoutingUpdateInputBody" } } }, @@ -88932,7 +92696,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRequestIntegration" + "$ref": "#/components/schemas/AdminRequestRouting" } } }, @@ -89006,6 +92770,16 @@ }, "description": "Request Timeout" }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, "412": { "content": { "application/problem+json": { @@ -89100,7 +92874,7 @@ "bearerAuth": [] } ], - "summary": "Manage media requests and their configuration.", + "summary": "Switch request routing between Standard and Advanced; Standard is refused while a media type has more than one server of a kind.", "tags": [ "admin" ], @@ -89111,9 +92885,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/request-integrations/{id}/options": { - "post": { - "operationId": "loadRequestIntegrationOptions", + "/api/v2/admin/request-settings": { + "get": { + "operationId": "getAdminRequestSettings", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -89136,18 +92910,168 @@ ], "type": "string" } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminRequestSettings" + } + } + }, + "description": "OK", + "headers": { + "ETag": { + "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" + }, + "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": [ { - "description": "The request", - "in": "path", - "name": "id", + "bearerAuth": [] + } + ], + "summary": "Manage media requests and their configuration.", + "tags": [ + "admin" + ], + "x-silo-class": "acting_admin", + "x-silo-service-backed": true + }, + "put": { + "operationId": "updateAdminRequestSettings", + "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": { - "description": "The request", + "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", + "name": "X-Profile-Id", + "schema": { "examples": [ - "1834729" + "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" ], - "minLength": 1, "type": "string" } } @@ -89156,7 +93080,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRequestOptionsInputBody" + "$ref": "#/components/schemas/AdminRequestSettings" } } }, @@ -89167,11 +93091,19 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRequestOptions" + "$ref": "#/components/schemas/AdminRequestSettings" } } }, - "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": { @@ -89233,6 +93165,24 @@ }, "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": { @@ -89263,6 +93213,16 @@ }, "description": "Unprocessable Entity" }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Required" + }, "429": { "content": { "application/problem+json": { @@ -89305,13 +93265,14 @@ ], "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/request-settings": { + "/api/v2/admin/request-users/{user_id}/limit": { "get": { - "operationId": "getAdminRequestSettings", + "operationId": "getAdminRequestUserLimit", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -89334,6 +93295,21 @@ ], "type": "string" } + }, + { + "description": "Opaque identifier", + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "pattern": "^[1-9][0-9]*$", + "type": "string" + } } ], "responses": { @@ -89341,7 +93317,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRequestSettings" + "$ref": "#/components/schemas/AdminRequestUserLimit" } } }, @@ -89458,7 +93434,7 @@ "x-silo-service-backed": true }, "put": { - "operationId": "updateAdminRequestSettings", + "operationId": "updateAdminRequestUserLimit", "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.", @@ -89498,13 +93474,28 @@ ], "type": "string" } + }, + { + "description": "Opaque identifier", + "in": "path", + "name": "user_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/AdminRequestSettings" + "$ref": "#/components/schemas/AdminRequestLimitBody" } } }, @@ -89515,7 +93506,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRequestSettings" + "$ref": "#/components/schemas/AdminRequestUserLimit" } } }, @@ -89694,9 +93685,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/request-users/{user_id}/limit": { + "/api/v2/admin/requests": { "get": { - "operationId": "getAdminRequestUserLimit", + "operationId": "listAdminRequests", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -89721,17 +93712,135 @@ } }, { - "description": "Opaque identifier", - "in": "path", - "name": "user_id", - "required": true, + "description": "Only requests in this status", + "explode": false, + "in": "query", + "name": "status", "schema": { - "description": "Opaque identifier", + "description": "Only requests in this status", + "enum": [ + "pending", + "approved", + "queued", + "downloading", + "completed" + ], "examples": [ - "1" + "pending" + ], + "type": "string" + } + }, + { + "description": "Only requests with this outcome", + "explode": false, + "in": "query", + "name": "outcome", + "schema": { + "description": "Only requests with this outcome", + "enum": [ + "active", + "declined", + "cancelled", + "failed" + ], + "examples": [ + "active" + ], + "type": "string" + } + }, + { + "description": "Only requests in this queue view: needs_approval (pending), in_progress (approved, queued or downloading), failed, or done (completed, or closed by a decline or cancellation)", + "explode": false, + "in": "query", + "name": "view", + "schema": { + "description": "Only requests in this queue view: needs_approval (pending), in_progress (approved, queued or downloading), failed, or done (completed, or closed by a decline or cancellation)", + "enum": [ + "needs_approval", + "in_progress", + "failed", + "done" + ], + "examples": [ + "needs_approval" + ], + "type": "string" + } + }, + { + "description": "Only requests whose title contains this text, or whose TMDB ID equals it", + "explode": false, + "in": "query", + "name": "q", + "schema": { + "description": "Only requests whose title contains this text, or whose TMDB ID equals it", + "examples": [ + "severance" + ], + "maxLength": 200, + "type": "string" + } + }, + { + "description": "Only requests for this media type", + "explode": false, + "in": "query", + "name": "media_type", + "schema": { + "description": "Only requests for this media type", + "enum": [ + "movie", + "series" + ], + "examples": [ + "series" + ], + "type": "string" + } + }, + { + "description": "Only requests made by this account; at most 2147483647", + "explode": false, + "in": "query", + "name": "requested_by_user_id", + "schema": { + "description": "Only requests made by this account; at most 2147483647", + "examples": [ + "7" + ], + "pattern": "^[1-9][0-9]{0,9}$", + "type": "string" + } + }, + { + "description": "Page size; default 50, maximum 50", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 50, + "description": "Page size; default 50, maximum 50", + "examples": [ + 50 + ], + "format": "int64", + "maximum": 50, + "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": [ + "eyJvIjo1MH0" ], - "minLength": 1, - "pattern": "^[1-9][0-9]*$", "type": "string" } } @@ -89741,18 +93850,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRequestUserLimit" + "$ref": "#/components/schemas/MediaRequestCollection" } } }, - "description": "OK", - "headers": { - "ETag": { - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "400": { "content": { @@ -89856,23 +93958,16 @@ ], "x-silo-class": "acting_admin", "x-silo-service-backed": true - }, - "put": { - "operationId": "updateAdminRequestUserLimit", + } + }, + "/api/v2/admin/requests/capabilities": { + "get": { + "operationId": "getAdminRequestCapabilities", "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" } @@ -89900,41 +93995,39 @@ } }, { - "description": "Opaque identifier", - "in": "path", - "name": "user_id", - "required": true, + "in": "header", + "name": "If-None-Match", "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "pattern": "^[1-9][0-9]*$", "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminRequestLimitBody" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRequestUserLimit" + "$ref": "#/components/schemas/AdminRequestCapabilitiesOutputBody" } } }, "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.", @@ -89994,16 +94087,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, "412": { "content": { "application/problem+json": { @@ -90022,26 +94105,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": { @@ -90052,16 +94115,6 @@ }, "description": "Unprocessable Entity" }, - "428": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Required" - }, "429": { "content": { "application/problem+json": { @@ -90103,15 +94156,13 @@ "admin" ], "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/requests": { + "/api/v2/admin/requests/counts": { "get": { - "operationId": "listAdminRequests", + "operationId": "getAdminRequestCounts", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -90134,75 +94185,6 @@ ], "type": "string" } - }, - { - "description": "Only requests in this status", - "explode": false, - "in": "query", - "name": "status", - "schema": { - "description": "Only requests in this status", - "enum": [ - "pending", - "approved", - "queued", - "downloading", - "completed" - ], - "examples": [ - "pending" - ], - "type": "string" - } - }, - { - "description": "Only requests with this outcome", - "explode": false, - "in": "query", - "name": "outcome", - "schema": { - "description": "Only requests with this outcome", - "enum": [ - "active", - "declined", - "cancelled", - "failed" - ], - "examples": [ - "active" - ], - "type": "string" - } - }, - { - "description": "Page size; default 50, maximum 50", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "default": 50, - "description": "Page size; default 50, maximum 50", - "examples": [ - 50 - ], - "format": "int64", - "maximum": 50, - "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": [ - "eyJvIjo1MH0" - ], - "type": "string" - } } ], "responses": { @@ -90210,7 +94192,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MediaRequestCollection" + "$ref": "#/components/schemas/AdminRequestCounts" } } }, @@ -90312,7 +94294,7 @@ "bearerAuth": [] } ], - "summary": "Manage media requests and their configuration.", + "summary": "Count the requests in each admin queue view.", "tags": [ "admin" ], @@ -90320,18 +94302,10 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/requests/capabilities": { - "get": { - "operationId": "getAdminRequestCapabilities", + "/api/v2/admin/requests/{id}/approve": { + "post": { + "operationId": "adminApproveRequest", "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", @@ -90355,47 +94329,40 @@ } }, { - "in": "header", - "name": "If-None-Match", + "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/AdminRequestActionInputBody" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRequestCapabilitiesOutputBody" + "$ref": "#/components/schemas/MediaRequest" } } }, - "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": "OK" }, "400": { "content": { @@ -90447,7 +94414,7 @@ }, "description": "Not Acceptable" }, - "412": { + "408": { "content": { "application/problem+json": { "schema": { @@ -90455,15 +94422,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": { @@ -90516,13 +94495,14 @@ "admin" ], "x-silo-class": "acting_admin", - "x-silo-conditional": true, + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/requests/{id}/approve": { + "/api/v2/admin/requests/{id}/cancel": { "post": { - "operationId": "adminApproveRequest", + "operationId": "adminCancelRequest", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -90718,9 +94698,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/requests/{id}/cancel": { + "/api/v2/admin/requests/{id}/decline": { "post": { - "operationId": "adminCancelRequest", + "operationId": "adminDeclineRequest", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -90916,9 +94896,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/requests/{id}/decline": { - "post": { - "operationId": "adminDeclineRequest", + "/api/v2/admin/requests/{id}/events": { + "get": { + "operationId": "listAdminRequestEvents", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -90957,22 +94937,12 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminRequestActionInputBody" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MediaRequest" + "$ref": "#/components/schemas/AdminRequestEventCollection" } } }, @@ -91028,36 +94998,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": { @@ -91104,13 +95044,11 @@ "bearerAuth": [] } ], - "summary": "Manage media requests and their configuration.", + "summary": "List a request's history, newest first (at most 200 entries).", "tags": [ "admin" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, @@ -162772,7 +166710,160 @@ "bearerAuth": [] } ], - "summary": "Browse a studio's movies.", + "summary": "Browse a studio's movies.", + "tags": [ + "requests" + ], + "x-silo-class": "profile_scoped", + "x-silo-service-backed": true + } + }, + "/api/v2/requests/discover/genres": { + "get": { + "operationId": "listDiscoverGenres", + "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" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DiscoverBrandCollection" + } + } + }, + "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": "List the browsable genres.", "tags": [ "requests" ], @@ -162780,9 +166871,9 @@ "x-silo-service-backed": true } }, - "/api/v2/requests/discover/genres": { + "/api/v2/requests/discover/networks": { "get": { - "operationId": "listDiscoverGenres", + "operationId": "listDiscoverNetworks", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -162925,7 +167016,7 @@ "bearerAuth": [] } ], - "summary": "List the browsable genres.", + "summary": "List the browsable networks.", "tags": [ "requests" ], @@ -162933,9 +167024,9 @@ "x-silo-service-backed": true } }, - "/api/v2/requests/discover/networks": { + "/api/v2/requests/discover/studios": { "get": { - "operationId": "listDiscoverNetworks", + "operationId": "listDiscoverStudios", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -163078,7 +167169,7 @@ "bearerAuth": [] } ], - "summary": "List the browsable networks.", + "summary": "List the browsable studios.", "tags": [ "requests" ], @@ -163086,9 +167177,9 @@ "x-silo-service-backed": true } }, - "/api/v2/requests/discover/studios": { + "/api/v2/requests/discover/{section}": { "get": { - "operationId": "listDiscoverStudios", + "operationId": "getDiscoverSection", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -163112,6 +167203,43 @@ ], "type": "string" } + }, + { + "description": "The discovery row", + "in": "path", + "name": "section", + "required": true, + "schema": { + "description": "The discovery row", + "enum": [ + "trending_movies", + "trending_series", + "popular_movies", + "popular_series", + "upcoming_movies", + "on_air_series" + ], + "examples": [ + "trending_movies" + ], + "type": "string" + } + }, + { + "description": "Provider result page, 1-based", + "explode": false, + "in": "query", + "name": "page", + "schema": { + "default": 1, + "description": "Provider result page, 1-based", + "examples": [ + 1 + ], + "format": "int64", + "minimum": 1, + "type": "integer" + } } ], "responses": { @@ -163119,7 +167247,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DiscoverBrandCollection" + "$ref": "#/components/schemas/DiscoverSection" } } }, @@ -163231,7 +167359,7 @@ "bearerAuth": [] } ], - "summary": "List the browsable studios.", + "summary": "Get one page of a discovery row.", "tags": [ "requests" ], @@ -163239,9 +167367,9 @@ "x-silo-service-backed": true } }, - "/api/v2/requests/discover/{section}": { - "get": { - "operationId": "getDiscoverSection", + "/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.", @@ -163267,36 +167395,199 @@ } }, { - "description": "The discovery row", + "description": "The media type", "in": "path", - "name": "section", + "name": "media_type", "required": true, "schema": { - "description": "The discovery row", + "description": "The media type", "enum": [ - "trending_movies", - "trending_series", - "popular_movies", - "popular_series", - "upcoming_movies", - "on_air_series" + "movie", + "series" ], "examples": [ - "trending_movies" + "movie" ], "type": "string" } }, { - "description": "Provider result page, 1-based", - "explode": false, - "in": "query", - "name": "page", + "description": "TMDB identifier (external, not a Silo ID)", + "in": "path", + "name": "tmdb_id", + "required": true, "schema": { - "default": 1, - "description": "Provider result page, 1-based", + "description": "TMDB identifier (external, not a Silo ID)", "examples": [ - 1 + 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, @@ -163309,7 +167600,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DiscoverSection" + "$ref": "#/components/schemas/RequestMediaState" } } }, @@ -163421,11 +167712,13 @@ "bearerAuth": [] } ], - "summary": "Get one page of a discovery row.", + "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 } }, diff --git a/docs/architecture/admin-settings-ux.md b/docs/architecture/admin-settings-ux.md index 947628cc75..04a8153ee4 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,16 @@ 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 starts on Standard, which needs +no setup (each request goes to the server for its type) and shows only a line +per media type; the rules appear under Advanced, which a second server of a +kind turns on. The Standard/Advanced switch, each routing rule and each media +type's Everything else carry their own validators and save as the admin goes; +the save pill covers only the general request settings. 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/api-contract.md b/docs/architecture/api-contract.md index a1dfa378ee..9ed8e9194a 100644 --- a/docs/architecture/api-contract.md +++ b/docs/architecture/api-contract.md @@ -1813,6 +1813,25 @@ 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 series request can name its seasons (`seasons` on `POST /api/v2/requests`); +series detail lists the regular seasons with availability and request coverage; +requests carry `seasons`, `season_progress` and the `partially_available` state; +`GET /api/v2/requests/status` advertises `season_requests_supported`. + +While a request downloads, requests, their targets and the title detail's +request state carry `download` (phase, percent, bytes, estimated completion, +and when the server last heard from the download server), and +`GET /api/v2/requests/status` advertises `download_progress_supported`. The +phase is an open set: clients render an unknown one like `downloading`. See +[Media requests](media-requests.md#download-progress). + +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. @@ -2003,10 +2022,33 @@ have no request-administration consumers; the bundled web migrates these workflo Jellyfin compatibility has no corresponding administration contract. Moderation uses signed `(created_at, id)` cursors scoped to the administrator, -profile and filters. Integration lists return bounded ID-ordered pages over the +profile and filters. The v2 queue adds filters v1 never had: a `view` +(`needs_approval`, `in_progress`, `failed`, `done`), a title or TMDB ID search +(`q`), `media_type` and `requested_by_user_id`. Two v2-only reads serve the queue: +`GET /admin/requests/counts` counts each view, and +`GET /admin/requests/{id}/events` returns a request's history, newest first and +bounded to 200 entries. An access group's request approval and limit +(`/admin/request-groups/{group_id}/limit`) is v2-only and guarded by `If-Match` +like an account's; a group with none saved reads as revision zero. 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 and, rule by rule, why, plus an admin title search +(`GET /admin/request-routes/titles`) for trying titles while requests are off. 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. +`GET`/`PUT /admin/request-routing` reads and switches the routing mode +(`standard` or `advanced`) with `If-Match` on its revision; the read also says +where Standard sends each media type, or why it cannot be used, and switching +to Standard is refused with a validation problem while it cannot. The `routing` +field of `getAdminRequestCapabilities` reports whether the server offers both +the routing rule operations and the routing mode operations. + 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 @@ -2024,6 +2066,24 @@ base URL. Plugin validation happens before the storage transaction; an interveni edit still fails the final comparison instead of overwriting it. A failed plugin validation uses structured v2 problem errors for the web's inline field messages. +A v2 integration's `base_url` is normalized before option loading and before +create or update: `http://` is assumed when no scheme is given and a trailing +slash is dropped, so the saved address is the one the options probe used. An +address with credentials, a query or a fragment, or another scheme, is a +`validation_failed` problem on `body.base_url`. When `POST +/admin/request-integrations/{id}/options` fails, the host answers with its own +sentences and never echoes the plugin's upstream text. What the admin must fix +is a `validation_failed` problem on `body.base_url` (wrong address, missing URL +base, https on an http port) or `body.api_key_ref` (missing or rejected key). A +message the plugin wrote as gRPC `InvalidArgument` or `FailedPrecondition` is +the problem detail. A server that cannot be reached stays `dependency_unavailable`, +with a detail naming the cause when known (nothing listening, unknown host, +timeout, rejected certificate). A plugin may return a single `service_kind` +option naming the service it found; the Sonarr and Radarr plugin does, and the +web uses it to set the server type. The frozen v1 routes keep their behavior: +they pass and store the address as submitted, and a failed v1 probe the host +classified still answers 500. + All mutations remain non-retryable after an uncertain response. Approve, retry and option loading retain their owning service behavior and may invoke a plugin; they have no new accepted-job or durable-dispatch guarantee. Configuration guards prevent diff --git a/docs/architecture/media-requests.md b/docs/architecture/media-requests.md new file mode 100644 index 0000000000..0118170c0c --- /dev/null +++ b/docs/architecture/media-requests.md @@ -0,0 +1,529 @@ +# 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`, and the +download refresh pass is `refresh_request_downloads` beside it. + +## 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. + +## Routing facts + +Creating a request reads the title's TMDB detail once, after the cheap refusals +(a movie already in the library, a title 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, rating, and whether +it is anime. IDs rather than names, because names follow the configured TMDB +language. + +Anime means Japanese animation (`internal/requests/anime.go`). TMDB's anime +keyword alone misses about one anime series in eight and one film in three, so +a title also counts when TMDB files it as Animation in Japanese or from Japan, +or when an AniDB-based list names it (a series also needs to be in Japanese or +from Japan, since the list names some Western series and a series' anime flag +can set Sonarr's series type). The list is Kometa's Anime-IDs, matched +on the TVDB series ID (series) or IMDb ID TMDB reports; the "Refresh Anime +List" task (`internal/animeids`) downloads it daily into `anime_ids`, one +server at a time under a lease, keeping the stored copy when a download fails +or looks truncated. The request path only reads the table, and a failed +lookup counts as not listed. AniDB also lists Chinese and Korean animation, +which counts only when TMDB itself tags it anime; an admin routes it with a +genre and language rule instead. + +The rating is the US one when the title has one. A title never rated in the +US falls back to its own country's (the first origin country TMDB rated it +in, strictest where it rated it more than once), stored with the country as a +prefix ("JP:PG12") so `access.Normalize` reads its age on that country's +scale; so does a title rated only "NR" in the US. The parental-control path +keeps to the US rating. + +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, and content rating ("at most PG"). Every +set condition must hold; a list matches any of its values, and each list has an +exclude form that matches when the title has none of them ("original language +is not English"). A title with no rating (US or its own country's) never +matches a rating condition, as a parental ceiling treats it. Ratings compare by +their own minimum ages ("TV-Y7 or lower" does not take TV-PG), and a title TMDB +had not rated yet is asked about again a day later. The rating is captured with +the other facts from the detail TMDB already returns; a request captured before +the rating was gets it at submission, only when a route checks ratings. + +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`, and so +does Everything else with no 4K server: "no 4K copy" means the same on a rule +and on the fallback. The admin preview explains a decision route by route: the +conditions each failed and what it did per tier (sent, skipped, passed on, did +not match, came after the tier was decided). + +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, and it cannot be switched to the type the route's media type cannot use +(Sonarr for movies, Radarr for series) or stop taking that media type. A chosen +server that is disabled, not set up (no installation, no key), of the wrong type +or not taking the media 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` (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 +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. +The first Radarr (Sonarr) server added becomes Everything else for movies +(series) in the same transaction, unless another enabled server already takes +that media type (another of the kind, or a Seerr connection) or the new one is +flagged 4K; a migration did the same for installs with exactly one usable +server of a kind, so a single-server setup needs no routing. Deleting the last server of a kind removes that Everything else with +it when no rule routes the media type; otherwise the delete is refused. +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. +A Radarr or Sonarr takes one version only: 4K versions go only to a server +marked 4K (the plugin's `is_4k` switch, or its older `is_default_4k`), HD +versions only to one that is not. Saving a route that breaks this is refused, +and so, under Advanced, is changing a server's 4K switch while a route sends it +the other version. A server of another plugin (Seerr) takes either version. +Changing a server's type or media types while a route sends it a media type it +would no longer take is refused too. A route save and a server save each check +the other again with the server row locked, so two admins saving at once +cannot leave a route pointing at a server that no longer fits. + +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, unless no enabled route +of its media type has a condition (Everything else alone decides), when it +goes without them. Standard needs only the anime fact, so without TMDB it +routes on the anime flag stored with the request. + +### Standard and Advanced + +`request_routing` holds the routing mode (`internal/requests/routing_mode.go`). +Advanced routes with the rules above. Standard pauses the rules (they stay +stored and apply again under Advanced) and sends each media type to its one +enabled server that is not marked 4K, and its 4K copies to its one enabled +server marked 4K (the Sonarr/Radarr plugin's `is_4k` switch), with each +server's own settings: Everything else's overrides do not apply. Anime series +(see "Routing facts") go to the same servers with Sonarr's anime series type, +as Seerr sends them. With no server +marked 4K there is no 4K copy, even with `force_dual_quality`. A media type +whose server is another plugin (Seerr) keeps that plugin's own routing. Targets +record the route as "Standard". + +Standard needs at most one normal and one 4K enabled server per media type, +counting other plugins that take it. When one of them is a plugin that picks +its own server (Seerr), both must be connections of that one plugin: Standard +hands such a plugin the whole request, so a 4K Radarr beside it would never be +used. Switching to Standard is refused +otherwise, and adding or enabling a server that breaks the rule turns Advanced +on in the same transaction, with Everything else given the servers Standard was +using where it has none, so requests keep going where they went. Only Radarr +and Sonarr servers that still take the media type after the save are carried +over; a media type another plugin routed stays with it. When that media type +has no rule and another plugin installation would now also take it, the save +is refused: with no rule, the first connection by name would get its requests. +The admin switches to Advanced and sets Everything else first. Switching to +Advanced by hand carries servers over the same way. Standard lets a server's +4K switch change even while a paused route sends it the other version, since +those routes cannot be edited there; turning Advanced on, either way, first +clears every route destination whose server no longer fits its version, so that +version falls through to Everything else and its carried-over server. Nothing switches back to Standard on its own. Every +server write and mode switch takes one advisory lock first, so a server added +while Standard is being turned on cannot leave Standard on with two servers of +a kind; Standard read with two servers anyway routes with the rules. Under +Standard, Everything else does not keep a server from being deleted (the +reference is cleared); a paused rule still does. The migration put installs on +Advanced when Standard would send a request elsewhere (an enabled rule, two +servers of a kind, or an Everything else with overrides, a 4K server, an HD +server other than the media type's one Radarr or Sonarr, or no 4K server while +one marked 4K exists) and everyone else on Standard. + +## 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 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 + +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. Only the attempt holding the current +lease can do this: one that outlived its lease while another server claimed the +request leaves the newer claim alone. After `maxSubmitAttempts` the request +is marked `failed` for an admin to retry, under the same lease check. + +A successful attempt records its targets under that check too, in one +transaction with the status they imply. An attempt that outlived its lease while +the request was withdrawn, completed from the library, or claimed again drops +its result and leaves the request as it finds it. The router call itself carries +no idempotency key, so a service the stale call reached may still hold the title. + +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 makes the set look smaller than it +is, so in either case every failed target is kept for an admin to see. If +nothing is left to send, the remaining targets decide the status. + +## Season requests + +A series request names the seasons it wants (`seasons`). Season numbers start +at 1: a request naming season 0 (specials) or a negative number is refused with +`validation_failed`, never read as naming none. When the requester names none, the server asks for every aired regular season (TMDB's seasons that +have started airing, specials excluded) that is not complete in the library. +Requests from before season requests, and every v1 request, have no seasons: +they mean the whole series and keep the old rule that any episode in the +library fulfills them. A series with no aired season yet, and not in the +library, is requested whole unless the requester names seasons. + +The request sent to a router plugin names the same seasons +(`RequestDescriptor.seasons`, empty for the whole series). A plugin whose +manifest declares `request_router.supports_seasons` acquires only those +seasons: it adds them to a series its download server already tracks and +leaves the other seasons alone, and a repeated request converges. Any other +plugin, including every one built before the flag existed, ignores the seasons +and takes the whole series. The host reads the flag from the capability +metadata stored at install, so checking it never launches the plugin. + +A series in the library can be requested for the seasons it lacks (aired and +incomplete, or not aired yet) when no download server takes series, in which +case the library fulfills the request, or when every enabled download server +that takes series is bound to a plugin that declares `supports_seasons`. A +server on any other plugin would add the whole series again: refused by a +server that has it, every season downloaded by one that does not. So with such +a server, a series in the library stays `already_available`, as before season +requests. `missing_seasons_requestable` on the feature status reports the same +answer. Only the series detail applies this: search, discover and +recommendation cards report any series in the library as available. + +Submission applies the rule per request. A season request for a series outside +the library goes to its server as usual. One for a series in the library goes +only where the servers chosen for it take seasons: without routing rules every +series server, with rules the servers the rules choose for the title, or every +server a series rule sends to while TMDB cannot supply its routing facts. +Otherwise it waits for the library, as when it was made before a server that +cannot take seasons was set up. One whose seasons are already complete in the +library when it is approved is not sent; the reconcile pass completes it. If routing facts read after the submission +claim choose a server that cannot, that tier is not sent and the attempt fails +with a message naming the server. + +A season is complete when every aired episode of it has a file in an enabled +library, judged by the library's own provider metadata, so no external service +is involved. An episode has a file when one is linked to it +(`episode_libraries`) or when a multi-episode file of its season spans it (the +file links to its first episode only). Only aired episodes count toward an aired +one, so an episode present ahead of its air date cannot stand in for a missing +one. A season whose episodes have no air dates yet counts as complete once any +episode of it is present. + +A season request is fulfilled, completed by the reconcile pass and notified, +when every requested season is complete; while only some are, its state is +`partially_available`. Once the download server reports the request done, a +season also counts when any episode of it is present: an episode the server +cannot find would otherwise hold the request and its notification open for +good. A season with no episode in the library never counts, since it may not +have aired yet. A library whose metadata provider numbers seasons differently +from TMDB (absolute or TVDB order) can therefore leave a season request +waiting; the fulfilled-notification pass stamps each request it checks without +notifying, so such requests rotate behind newer ones rather than starve them. + +The series detail lists each regular season with its availability (`missing`, +`partial`, `available`) and whether the active request covers it. A title still +has at most one open request: a second profile that wants the same series while +a request is open follows it. + +## 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); +the insert itself checks for the open request and holds a share lock on its +row until the follow commits, so a follow cannot land just after the request +was declined, cancelled or completed, and miss that transition's follow +cleanup. + +A follow belongs to a profile and the request that was open when it was made +(`media_request_follows`, keyed by account, profile id and request, since +profile ids repeat across accounts). A series can have completed requests still +waiting for the library beside a newer open request for other seasons; each +request's notification goes to its own follows, and a profile can follow each +of them. Unfollowing a title removes the profile's follows on all of them. A follow +survives its request failing: the title's next request takes over the follows +of a failed request, or one its requester replaced. Declining or cancelling a +request clears its 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 the request's followers, 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; each +recipient's delivery is deduplicated, so a retry tells only those not yet told. +The server-channel announcement has no such guard, so it goes out once, after +the request is stamped as notified, from the pass whose stamp took. + +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 +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 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. 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. + +A second task, `refresh_request_downloads`, runs every minute between reconcile +passes (see [Download progress](#download-progress)). It asks only about +targets in `downloading` that already have download progress and whose plugin +declares `reports_download_progress`, and applies what the plugin answers +through the same code as reconcile: status transitions and download progress. +It submits nothing, checks no library presence and sends no notification; those +stay with the reconcile pass. `queued` targets stay on the five-minute cadence +on purpose: a requested but unreleased movie can sit `queued` for months, and +polling it every minute would multiply calls to the download server for +nothing. A scheduled run is skipped, without a task history entry, while no +downloading target has progress. + +Two advisory locks keep the passes apart. The reconcile lock lets one server +run each reconcile pass; the others skip. The request target write lock keeps +the two passes from writing the same target at once. A reconcile pass takes it +after its own lock, on the same database session so the pass keeps the rest of +the connection pool for its own work, and waits up to two minutes for a +refresh pass that holds it, so a refresh never makes a reconcile pass skip; a +wait that runs out fails the pass. A refresh pass only tries the write lock, so it runs on one server at +a time and skips while a reconcile pass runs anywhere in the cluster. A refresh +pass also stops after 90 seconds, cutting the plugin call in flight and leaving +the requests it has not reached for the next pass, so it always ends inside the +reconcile pass's wait, even while a download server stops answering and every +call to it runs to the router's 60-second deadline. + +## Download progress + +While a target downloads, its router plugin can report how far along it is in +`CheckStatus` (`TargetStatus.progress`): a phase, the size and bytes left summed +over the target's distinct downloads (a season pack counts once), the latest +estimated completion, and the number of downloads. A plugin declares that it +does with `request_router.reports_download_progress` in its manifest, which the +host reads from the stored capability metadata without launching the plugin. +The host keeps progress only from a declaring plugin; any other plugin has none +and keeps the five-minute cadence. + +The phase is `queued`, `downloading`, `paused`, `stalled`, `importing` or +`import_blocked`. The host reads an unknown or empty phase as `downloading`, +and a report with neither a phase nor a size as none. The set is open: clients +must render an unknown phase like `downloading`, without a percentage. When +several downloads or targets are combined, the phase follows +`import_blocked` > `stalled` > `downloading` > `importing` > `paused` > +`queued`, so a download that needs attention shows, and otherwise +`downloading` wins while anything still downloads. + +Progress lives on the target row (`download_*` columns on +`media_request_targets`), so any node serves the latest value and a node dying +loses nothing. `download_updated_at` records when the server last heard from +the plugin. A progress write touches only those columns, and only while the +target is `queued` or `downloading`, so a late report cannot give a finished +target progress again. It does not move the target's `updated_at`, which dates +status changes for the stalled-target backstop, recompute the request's status, +or add to its history. When the server's own state moves without changing the +target's status (an import that stalls), the pass records the new raw +`external_status` the same way, so the raw status and the progress shown +beside it agree. Progress is cleared when a target completes or fails, +and when the plugin stops reporting any for a live target (the download left +the queue). A pass writes nothing for a target that has no progress and +reports none. A target with progress that a pass asks about without getting +its status back (the plugin skipped a server that errored or no longer has the +title, or the call failed) keeps its progress, since one missed answer is +usually a blip, until the last report is 15 minutes old; the next unanswered +pass then clears it, so a frozen figure stops showing and clients stop polling +for it. + +The reconcile pass records a download's first progress, so progress appears +within one reconcile pass of a download starting. From then on the refresh +task refreshes it every minute, taking requests by their downloading target +with progress asked about longest ago (`download_checked_at`), at most 200 a +minute. A target counts as asked about whether or not its server answered, so +one that stops answering takes its turn and moves to the back instead of +heading every batch. A target leaves the refresh task as soon as its progress +clears. A downloading target without progress stays with the reconcile pass +however long it waits: a plugin can report a title downloading with nothing in +its download queue for it (Seerr keeps media at Processing until a release +arrives), and such targets must not crowd out the ones that are downloading. +The refresh task also clears, without a call, the progress of a target whose +server is gone, disabled or unusable, or whose plugin no longer declares +progress, since nothing will refresh it. + +The v2 API carries `download` on each request target, on the request itself, +and on the title detail's request state. The request's figure combines its live +targets (1080p and 4K together): bytes and downloads summed, the phase by the +order above, the latest estimate, and the oldest report's time, so a client +that hides figures older than about ten minutes hides a partly stale one too. +Its size is unknown while any live target's is, including a live target that +has reported no progress yet. `percent` is +`floor((total - left) * 100 / total)`, and the byte counts and percent are +absent while the size is unknown. Only the title detail +(`GET /api/v2/requests/detail/{media_type}/{tmdb_id}`) fills the request +state's `download`, with one targets query for an active request that is queued +or downloading; search and discovery do not load targets per result. It +carries no requester identity, so anyone who can see the title's request state +sees it. `GET /api/v2/requests/status` advertises +`download_progress_supported`. v1 does not carry progress. Release names, +indexers, download clients, paths and queue messages never reach a client. +Clients poll every 30 seconds while anything they show has `download`, and stop +polling otherwise. The TMDB client keeps a title's detail for two minutes, so +title pages polling a download share one TMDB fetch per server however many are +open. + +## Re-requesting a failed title + +Creating a request deletes the requester's own failed requests for the same +title inside the insert transaction, so the re-request replaces them. The quota +is checked only there, under the requester's advisory lock, and no failed +request counts against it (see [Who can request](#who-can-request)). Other +accounts' failed requests are left alone as those users' history. Retrying one +of them after someone else has requested the title answers +`ErrAlreadyRequested`, since only one active request per title may exist. + +## What a requester sees + +The v2 request operations a profile calls (`createRequest`, `listMyRequests`, +`getRequest` and `cancelRequest`) give a viewer who is not an admin the request +without its download server details: the request's `integration_kind`, +`external_id`, `external_status` and `last_error`, and each target's +`integration_id`, `integration_kind`, `instance_name`, `external_id`, +`external_status`, `route_name` and `last_error`. These name the admin's +download servers and routing rules and carry the servers' raw statuses and +errors, none of which a requester can act on. The request's `last_error` is +written for the admin who fixes the submission: it can name a server or a +routing rule, or pass on a plugin's own error text. A requester still sees the +request's state and `outcome_reason`, and each target's quality, status and +`download`. An admin sees every field, on those operations and on the +`/api/v2/admin/requests` operations. The frozen `/api/v1` request routes still +return them to everyone. + +## Admin queue + +The admin queue groups requests by what an admin does next, from status and +outcome alone so the database can filter and count them: needs approval +(pending), in progress (approved, queued or downloading), failed, and done +(completed, or closed by a decline or cancellation). An admin can retry a +failed request or close it, which moves it to done (v2 only; the v1 cancel +still refuses a failed request). A closed request stays closed: a target that +reports later updates only itself. A request's +history is its `media_request_events` rows. Target updates record the +request's status or outcome only when it changes, so neither a reconcile pass +nor a second target repeats an entry. + +## Who can request + +Whether an account may request, whether its requests need approval, and how +many it may make resolve in layers: the account's own settings, then its +access group's (`request_group_limits`), then the server-wide request +settings. A layer set to inherit defers to the next; admins are never capped +by a group. An account is blocked in one of two places only: requests turned +off server-wide, or the requests switch on the account or its access group +(`requests_allowed`). The older `blocked` limit and approval modes on an +account are still honored when an API client writes them, but the migration +that added group limits moved existing ones onto the account's switch and no +editor offers them. + +The quota counts the requests an account made in the window, except those +declined or failed: those give their slot back. A cancelled request keeps +counting, or requesting and cancelling could repeat without limit. The one +exception is a failed request an admin closes from the queue: it keeps its +submission error and the slot its failure returned. Every other cancel clears +`last_error`, so withdrawing a request that is backing off after a failed +attempt still counts. The store +checks the quota under the requester's advisory lock, so concurrent creates +cannot both take the last slot. diff --git a/docs/architecture/notifications.md b/docs/architecture/notifications.md index e1487a80cc..c216364527 100644 --- a/docs/architecture/notifications.md +++ b/docs/architecture/notifications.md @@ -118,12 +118,21 @@ 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. 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 -the inserts idempotent, and the per-webhook `notify_requests` flag gates the -webhook channel for them. +booleans. Partial unique indexes make the inserts idempotent: +`request.fulfilled` per `(user_id, profile_id, request_id)`, because a +follower on another account can share the requester's profile id (every +account from before profiles has a `default` profile), and approved/declined, +which only reach the requester, per `(profile_id, request_id, type)`. An +operational delivery's webhook, web push and mobile push targets are the +recipient profile's on the recipient's account. The per-webhook +`notify_requests` flag gates the webhook channel for them. Approval is the one transition whose two destinations disagree. Server channels see `request.approved` for every approval; the requester only gets a diff --git a/go.mod b/go.mod index 70d1472190..bf0511ae62 100644 --- a/go.mod +++ b/go.mod @@ -129,7 +129,7 @@ require ( ) require ( - github.com/Silo-Server/silo-plugin-sdk v0.17.0 + github.com/Silo-Server/silo-plugin-sdk v0.19.0 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect diff --git a/go.sum b/go.sum index e0070d84aa..0d726fca83 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,8 @@ github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0g github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= -github.com/Silo-Server/silo-plugin-sdk v0.17.0 h1:dHA4UbObMVIzAaLQC4clq1bSWuPaim88yptIRR5/f/M= -github.com/Silo-Server/silo-plugin-sdk v0.17.0/go.mod h1:abwsCEKuPAAgeAqpNGbwoaut2eQlC/Kj97u89Vvg9qM= +github.com/Silo-Server/silo-plugin-sdk v0.19.0 h1:LWYI9x6OxBr8d+IxsHjJpcABpjvx/lA4UsDD13ynlAg= +github.com/Silo-Server/silo-plugin-sdk v0.19.0/go.mod h1:abwsCEKuPAAgeAqpNGbwoaut2eQlC/Kj97u89Vvg9qM= github.com/TwiN/go-color v1.4.1 h1:mqG0P/KBgHKVqmtL5ye7K0/Gr4l6hTksPgTgMk3mUzc= github.com/TwiN/go-color v1.4.1/go.mod h1:WcPf/jtiW95WBIsEeY1Lc/b8aaWoiqQpu5cf8WFxu+s= github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4= diff --git a/internal/animeids/animeids.go b/internal/animeids/animeids.go new file mode 100644 index 0000000000..700fb2ffe2 --- /dev/null +++ b/internal/animeids/animeids.go @@ -0,0 +1,300 @@ +// Package animeids keeps a copy of an AniDB-based anime list, by the TVDB and +// IMDb IDs TMDB also reports, so request routing can tell anime TMDB does not +// tag as such. The list is Kometa's Anime-IDs (MIT), rebuilt daily from AniDB +// and the Anime-Lists mappings. A scheduled task replaces the stored copy; the +// request path only reads it, so a slow or unreachable GitHub never holds a +// request up. +package animeids + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// SourceURL is the published list. +const SourceURL = "https://raw.githubusercontent.com/Kometa-Team/Anime-IDs/master/anime_ids.json" + +const ( + sourceHost = "raw.githubusercontent.com" + // maxBody bounds the download; the list is under 2 MB today. + maxBody = 16 << 20 + // minEntries is the fewest listed IDs a download must hold to replace + // the stored copy; fewer means a truncated or broken file. The list + // yields about 6,000 today. + minEntries = 3000 + // lease bounds how long one server's claim keeps the others from + // downloading; a crashed server's claim expires with it. + lease = 10 * time.Minute +) + +// Store answers whether the list names a title. +type Store struct { + pool *pgxpool.Pool +} + +// NewStore reads the stored list. +func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } + +// Listed reports whether the list names a title: a series by its TVDB or IMDb +// ID, a movie by its IMDb ID. TVDB numbers movies apart from series, so a +// movie's TVDB ID is not compared. An empty list names nothing. +func (s *Store) Listed(ctx context.Context, movie bool, tvdbID int, imdbID string) (bool, error) { + imdbID = strings.TrimSpace(imdbID) + tvdb := "" + if tvdbID > 0 && !movie { + tvdb = strconv.Itoa(tvdbID) + } + if tvdb == "" && imdbID == "" { + return false, nil + } + var listed bool + err := s.pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM anime_ids + WHERE (source = 'tvdb' AND external_id = $1) OR (source = 'imdb' AND external_id = $2) + )`, tvdb, imdbID).Scan(&listed) + if err != nil { + return false, fmt.Errorf("look up anime list: %w", err) + } + return listed, nil +} + +// Result is what one refresh did. +type Result struct { + // Skipped: another server holds the refresh. + Skipped bool `json:"skipped,omitempty"` + // Unchanged: the published list has not changed since the last copy. + Unchanged bool `json:"unchanged,omitempty"` + Entries int `json:"entries,omitempty"` +} + +// Refresher downloads the list and replaces the stored copy. +type Refresher struct { + pool *pgxpool.Pool + client *http.Client + url string +} + +// NewRefresher downloads from SourceURL. +func NewRefresher(pool *pgxpool.Pool) *Refresher { + return &Refresher{pool: pool, client: newClient(), url: SourceURL} +} + +// newClient only follows redirects to the list's own host. +func newClient() *http.Client { + return &http.Client{ + Timeout: 60 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return errors.New("anime list: too many redirects") + } + if req.URL.Scheme != "https" || req.URL.Hostname() != sourceHost { + return fmt.Errorf("anime list: refusing redirect to %s", req.URL.Redacted()) + } + return nil + }, + } +} + +// Refresh downloads the list and replaces the stored copy, unless another +// server is already doing so or the list has not changed. A failed download +// or a broken file keeps the stored copy and records why. +func (r *Refresher) Refresh(ctx context.Context) (Result, error) { + c, claimed, err := r.claim(ctx) + if err != nil || !claimed { + return Result{Skipped: !claimed}, err + } + ids, newETag, unchanged, err := r.download(ctx, c.etag) + if err == nil && unchanged { + return Result{Unchanged: true}, r.finish(ctx, c, "unchanged", "") + } + if err == nil { + err = r.replace(ctx, c, ids, newETag) + } + if err != nil { + if recordErr := r.finish(context.WithoutCancel(ctx), c, "error", err.Error()); recordErr != nil { + return Result{}, errors.Join(err, recordErr) + } + return Result{}, err + } + return Result{Entries: len(ids)}, nil +} + +// claimToken is this server's claim: when it was taken, which is what the +// server's later writes must still find, and the ETag to send. +type claimToken struct { + at time.Time + etag string +} + +// errClaimLost means another server took the refresh over after this one's +// lease ran out; this one's result is dropped. +var errClaimLost = errors.New("anime list refresh: another server took the refresh over") + +// claim takes the refresh for this server. The stored ETag is only sent when +// rows are stored, so a list that lost its rows is downloaded again. +func (r *Refresher) claim(ctx context.Context) (claimToken, bool, error) { + var c claimToken + err := r.pool.QueryRow(ctx, ` + INSERT INTO anime_ids_refresh (id, last_attempt_at, last_status, last_error) + VALUES (true, clock_timestamp(), 'refreshing', '') + ON CONFLICT (id) DO UPDATE SET + last_attempt_at = EXCLUDED.last_attempt_at, + last_status = 'refreshing', + last_error = '' + WHERE anime_ids_refresh.last_status <> 'refreshing' + OR anime_ids_refresh.last_attempt_at <= clock_timestamp() - ($1::bigint * INTERVAL '1 millisecond') + RETURNING last_attempt_at, CASE WHEN EXISTS (SELECT 1 FROM anime_ids) THEN etag ELSE '' END`, + lease.Milliseconds()).Scan(&c.at, &c.etag) + if errors.Is(err, pgx.ErrNoRows) { + return claimToken{}, false, nil + } + if err != nil { + return claimToken{}, false, fmt.Errorf("claim anime list refresh: %w", err) + } + return c, true, nil +} + +// finish records a refresh that stored nothing new, if the claim is still +// this server's. +func (r *Refresher) finish(ctx context.Context, c claimToken, status, message string) error { + _, err := r.pool.Exec(ctx, ` + UPDATE anime_ids_refresh SET last_status = $1, last_error = $2, + refreshed_at = CASE WHEN $1 = 'unchanged' THEN clock_timestamp() ELSE refreshed_at END + WHERE id AND last_attempt_at = $3`, status, message, c.at) + if err != nil { + return fmt.Errorf("record anime list refresh: %w", err) + } + return nil +} + +func (r *Refresher) download(ctx context.Context, etag string) (ids []listedID, newETag string, unchanged bool, err error) { + u, err := url.Parse(r.url) + if err != nil { + return nil, "", false, fmt.Errorf("anime list url: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, "", false, err + } + req.Header.Set("User-Agent", "silo-anime-ids") + if etag != "" { + req.Header.Set("If-None-Match", etag) + } + resp, err := r.client.Do(req) + if err != nil { + return nil, "", false, fmt.Errorf("download anime list: %w", err) + } + defer func() { _ = resp.Body.Close() }() + switch resp.StatusCode { + case http.StatusNotModified: + return nil, etag, true, nil + case http.StatusOK: + default: + return nil, "", false, fmt.Errorf("download anime list: %s", resp.Status) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody+1)) + if err != nil { + return nil, "", false, fmt.Errorf("download anime list: %w", err) + } + if len(body) > maxBody { + return nil, "", false, errors.New("download anime list: larger than expected") + } + ids, err = parse(body) + if err != nil { + return nil, "", false, err + } + return ids, resp.Header.Get("ETag"), false, nil +} + +// The sources a listed ID comes from, as the anime_ids table stores them. +const ( + sourceTVDB = "tvdb" + sourceIMDb = "imdb" +) + +type listedID struct { + source, id string +} + +// parse reads the list: AniDB IDs keyed to their TVDB series ID and IMDb IDs +// (several, comma-separated, for a multi-part film). +func parse(body []byte) ([]listedID, error) { + var raw map[string]struct { + TVDBID *int `json:"tvdb_id"` + IMDbID string `json:"imdb_id"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("read anime list: %w", err) + } + seen := map[listedID]bool{} + var out []listedID + add := func(id listedID) { + if id.id != "" && !seen[id] { + seen[id] = true + out = append(out, id) + } + } + for _, entry := range raw { + if entry.TVDBID != nil && *entry.TVDBID > 0 { + add(listedID{sourceTVDB, strconv.Itoa(*entry.TVDBID)}) + } + for imdb := range strings.SplitSeq(entry.IMDbID, ",") { + if imdb = strings.TrimSpace(imdb); strings.HasPrefix(imdb, "tt") { + add(listedID{sourceIMDb, imdb}) + } + } + } + if len(out) < minEntries { + return nil, fmt.Errorf("read anime list: only %d IDs, expected at least %d", len(out), minEntries) + } + return out, nil +} + +// replace swaps the stored copy for the new one in one transaction, so a +// reader sees the old list or the new one, never a partial one. +func (r *Refresher) replace(ctx context.Context, c claimToken, ids []listedID, etag string) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + // Lock the claim row: a server taking over after this one's lease ran + // out waits, then finds its claim replaced by this one's result. + var one int + err = tx.QueryRow(ctx, `SELECT 1 FROM anime_ids_refresh WHERE id AND last_attempt_at = $1 FOR UPDATE`, c.at).Scan(&one) + if errors.Is(err, pgx.ErrNoRows) { + return errClaimLost + } + if err != nil { + return fmt.Errorf("replace anime list: %w", err) + } + if _, err := tx.Exec(ctx, `DELETE FROM anime_ids`); err != nil { + return fmt.Errorf("replace anime list: %w", err) + } + rows := make([][]any, len(ids)) + for i, id := range ids { + rows[i] = []any{id.source, id.id} + } + if _, err := tx.CopyFrom(ctx, pgx.Identifier{"anime_ids"}, []string{"source", "external_id"}, pgx.CopyFromRows(rows)); err != nil { + return fmt.Errorf("replace anime list: %w", err) + } + if _, err := tx.Exec(ctx, ` + UPDATE anime_ids_refresh SET last_status = 'ok', last_error = '', etag = $1, + entry_count = $2, refreshed_at = clock_timestamp() + WHERE id`, etag, len(ids)); err != nil { + return fmt.Errorf("record anime list refresh: %w", err) + } + return tx.Commit(ctx) +} diff --git a/internal/animeids/animeids_test.go b/internal/animeids/animeids_test.go new file mode 100644 index 0000000000..9e1361cbb6 --- /dev/null +++ b/internal/animeids/animeids_test.go @@ -0,0 +1,234 @@ +package animeids + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// realEntries are verbatim entries from Kometa's published anime_ids.json, +// keyed by AniDB ID: a series by TVDB ID only, one with an IMDb ID too, one +// whose IMDb IDs name the parts of a multi-part film, and one mapped to +// neither. +const realEntries = `{ + "1": {"tvdb_id": 72025, "tvdb_season": 1, "tvdb_epoffset": 0, "mal_id": 290, "anilist_id": 290}, + "11": {"tvdb_id": 70900, "tvdb_season": 0, "tvdb_epoffset": 2, "imdb_id": "tt7941838", "mal_id": 821, "anilist_id": 821}, + "4772": {"tvdb_id": 75411, "tvdb_season": 0, "tvdb_epoffset": 1, "imdb_id": "tt0936323,tt0936320", "mal_id": 1719, "anilist_id": 1719}, + "159": {"tvdb_epoffset": 0} +}` + +// realIDs is how many distinct IDs realEntries holds. +const realIDs = 6 + +// list builds a published list: the real entries plus n made-up series. +func list(n int) []byte { + raw := map[string]map[string]any{} + if err := json.Unmarshal([]byte(realEntries), &raw); err != nil { + panic(err) + } + for i := 1; i <= n; i++ { + raw[fmt.Sprint("filler-", i)] = map[string]any{"tvdb_id": 1000 + i, "mal_id": i} + } + raw["null"] = map[string]any{"tvdb_id": nil} + body, _ := json.Marshal(raw) + return body +} + +func TestParse(t *testing.T) { + ids, err := parse(list(minEntries)) + if err != nil { + t.Fatal(err) + } + got := map[listedID]bool{} + for _, id := range ids { + got[id] = true + } + if len(ids) != minEntries+realIDs { + t.Fatalf("parsed %d IDs, want %d", len(ids), minEntries+realIDs) + } + for _, want := range []listedID{ + {"tvdb", "72025"}, {"tvdb", "70900"}, {"tvdb", "75411"}, {"tvdb", "1001"}, + {"imdb", "tt7941838"}, {"imdb", "tt0936323"}, {"imdb", "tt0936320"}, + } { + if !got[want] { + t.Errorf("missing %v: want every series by TVDB ID and each part of a film by IMDb ID", want) + } + } + if _, err := parse(list(10)); err == nil || !strings.Contains(err.Error(), "only") { + t.Fatalf("a short list: %v, want it refused", err) + } + if _, err := parse([]byte("rate limited")); err == nil { + t.Fatal("parsed a page that is not the list") + } +} + +func testPool(t *testing.T) *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("anime_ids_%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{"anime_ids", "anime_ids_refresh"} { + if _, err := pool.Exec(t.Context(), `CREATE TABLE `+table+` (LIKE public.`+table+` INCLUDING ALL)`); err != nil { + t.Fatal(err) + } + } + return pool +} + +func TestRefreshDatabase(t *testing.T) { + pool := testPool(t) + ctx := t.Context() + body := list(minEntries) + var serve atomic.Value + serve.Store("ok") + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + switch serve.Load() { + case "down": + http.Error(w, "down", http.StatusBadGateway) + case "broken": + _, _ = w.Write(list(5)) + default: + if r.Header.Get("If-None-Match") == `"v1"` { + w.WriteHeader(http.StatusNotModified) + return + } + w.Header().Set("ETag", `"v1"`) + _, _ = w.Write(body) + } + })) + defer server.Close() + refresher := &Refresher{pool: pool, client: server.Client(), url: server.URL} + store := NewStore(pool) + + got, err := refresher.Refresh(ctx) + if err != nil || got.Entries != minEntries+realIDs { + t.Fatalf("first refresh = %+v, %v", got, err) + } + for _, tc := range []struct { + movie bool + tvdb int + imdb string + want bool + }{ + {false, 1001, "", true}, + {true, 1001, "", false}, // a movie's TVDB ID is another numbering + {true, 0, "tt0936320", true}, + {false, 0, "tt0936320", true}, + {false, 42, "tt9999999", false}, + {false, 0, "", false}, + } { + if listed, err := store.Listed(ctx, tc.movie, tc.tvdb, tc.imdb); err != nil || listed != tc.want { + t.Fatalf("Listed(%v, %d, %q) = %v, %v; want %v", tc.movie, tc.tvdb, tc.imdb, listed, err, tc.want) + } + } + + // The same list again is not downloaded again. + if got, err := refresher.Refresh(ctx); err != nil || !got.Unchanged { + t.Fatalf("unchanged refresh = %+v, %v", got, err) + } + + // A failed or broken download keeps the stored list and says why. + for _, mode := range []string{"down", "broken"} { + serve.Store(mode) + if _, err := pool.Exec(ctx, `UPDATE anime_ids_refresh SET etag = ''`); err != nil { + t.Fatal(err) + } + if _, err := refresher.Refresh(ctx); err == nil { + t.Fatalf("%s: refresh succeeded", mode) + } + var status, message string + var count int + _ = pool.QueryRow(ctx, `SELECT last_status, last_error FROM anime_ids_refresh`).Scan(&status, &message) + _ = pool.QueryRow(ctx, `SELECT count(*) FROM anime_ids`).Scan(&count) + if status != "error" || message == "" || count != minEntries+realIDs { + t.Fatalf("%s: status %q %q, %d IDs; want the error recorded and the list kept", mode, status, message, count) + } + } + + // A list that lost its rows is downloaded again, not answered 304. + serve.Store("ok") + if _, err := pool.Exec(ctx, `UPDATE anime_ids_refresh SET etag = '"v1"'; DELETE FROM anime_ids`); err != nil { + t.Fatal(err) + } + if got, err := refresher.Refresh(ctx); err != nil || got.Unchanged || got.Entries != minEntries+realIDs { + t.Fatalf("refresh of an emptied list = %+v, %v; want it downloaded", got, err) + } + + // A server whose lease ran out and was taken over drops its result. + c, claimed, err := refresher.claim(ctx) + if err != nil || !claimed { + t.Fatal(claimed, err) + } + if _, err := pool.Exec(ctx, `UPDATE anime_ids_refresh SET last_attempt_at = clock_timestamp()`); err != nil { + t.Fatal(err) + } + if err := refresher.replace(ctx, c, []listedID{{"tvdb", "1"}}, "stale"); !errors.Is(err, errClaimLost) { + t.Fatalf("replace after a takeover: %v, want errClaimLost", err) + } + if _, err := pool.Exec(ctx, `UPDATE anime_ids_refresh SET last_status = 'ok'`); err != nil { + t.Fatal(err) + } + + // A refresh another server holds keeps this one out until its lease ends. + serve.Store("ok") + if _, err := pool.Exec(ctx, `UPDATE anime_ids_refresh SET last_status = 'refreshing', last_attempt_at = now()`); err != nil { + t.Fatal(err) + } + before := requests.Load() + if got, err := refresher.Refresh(ctx); err != nil || !got.Skipped || requests.Load() != before { + t.Fatalf("held refresh = %+v, %v; want it skipped without a download", got, err) + } + if _, err := pool.Exec(ctx, `UPDATE anime_ids_refresh SET last_attempt_at = now() - interval '11 minutes'`); err != nil { + t.Fatal(err) + } + if got, err := refresher.Refresh(ctx); err != nil || got.Skipped { + t.Fatalf("expired claim = %+v, %v; want it taken over", got, err) + } +} + +func TestRedirectsStayOnTheListHost(t *testing.T) { + client := newClient() + req, _ := http.NewRequest(http.MethodGet, "https://example.com/x", nil) + if err := client.CheckRedirect(req, []*http.Request{{}}); err == nil { + t.Fatal("followed a redirect off the list host") + } + req, _ = http.NewRequest(http.MethodGet, "https://"+sourceHost+"/x", nil) + if err := client.CheckRedirect(req, []*http.Request{{}}); err != nil { + t.Fatal(err) + } +} diff --git a/internal/api/handlers/requests.go b/internal/api/handlers/requests.go index 4f68da5b09..cb344de4be 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 { @@ -235,9 +238,22 @@ func (h *RequestsHandler) HandleGetDetail(w http.ResponseWriter, r *http.Request writeRequestServiceError(w, err) return } + wholeSeriesRequestState(detail) writeJSON(w, http.StatusOK, detail) } +// wholeSeriesRequestState keeps v1 on the rule from before season requests, +// which v1 can neither name nor show: a series in the library is not +// requestable, even with seasons missing. An active request still wins, as it +// does for every title. +func wholeSeriesRequestState(detail *mediarequests.MediaDetail) { + if detail == nil || detail.MediaType != mediarequests.MediaTypeSeries || + detail.Availability != mediarequests.AvailabilityAvailable || detail.Request.Status != "" { + return + } + detail.Request = mediarequests.RequestState{Requestable: false, Reason: "already_available"} +} + func (h *RequestsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) { viewer, ok := requestViewer(w, r, true) if !ok { @@ -248,6 +264,7 @@ func (h *RequestsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") return } + input.WholeSeries = true req, err := h.service.CreateRequest(r.Context(), viewer, input) if err != nil { writeRequestServiceError(w, err) @@ -494,6 +511,14 @@ func (h *RequestsHandler) HandleLoadIntegrationOptions(w http.ResponseWriter, r } options, err := h.service.LoadIntegrationOptions(r.Context(), viewer, integration) if err != nil { + // v1 is frozen: the host's classified probe answers are a v2 feature, + // so this route keeps answering those with its original 500. A + // validation error the router returned itself keeps its v1 400. + var probe *mediarequests.ProbeValidationError + if errors.As(err, &probe) { + writeError(w, http.StatusInternalServerError, "internal_error", "Request operation failed") + return + } writeRequestServiceError(w, err) return } diff --git a/internal/api/handlers/requests_test.go b/internal/api/handlers/requests_test.go index 91521673c8..69d6f9fa9a 100644 --- a/internal/api/handlers/requests_test.go +++ b/internal/api/handlers/requests_test.go @@ -3,6 +3,7 @@ package handlers import ( "context" "encoding/json" + "io" "net/http" "net/http/httptest" "strings" @@ -20,6 +21,7 @@ type fakeRequestService struct { listNetworksFn func() ([]mediarequests.DiscoverBrandCard, error) listGenresFn func() ([]mediarequests.DiscoverBrandCard, error) browseFn func(kind, slug string, mediaType mediarequests.MediaType, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error) + loadOptionsErr error } func (f *fakeRequestService) ListStudios(context.Context, mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error) { @@ -55,6 +57,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 } @@ -144,7 +154,38 @@ func (f *fakeRequestService) DeleteIntegration(context.Context, mediarequests.Vi } func (f *fakeRequestService) LoadIntegrationOptions(context.Context, mediarequests.Viewer, mediarequests.Integration) (map[string][]mediarequests.RouterOption, error) { - return nil, nil + return nil, f.loadOptionsErr +} + +// The v1 options route keeps its original answer to a failed probe: the +// field errors the service now returns are for v2 only. +func TestHandleLoadIntegrationOptionsKeepsV1FailureShape(t *testing.T) { + for _, err := range []error{ + &mediarequests.ProbeValidationError{ValidationError: &mediarequests.ValidationError{FieldErrors: map[string]string{"api_key_ref": "The server rejected this API key."}}}, + &mediarequests.IntegrationUnreachableError{Detail: "Nothing answered at that address. Check the host and port."}, + } { + h := NewRequestsHandler(&fakeRequestService{loadOptionsErr: err}) + rec := httptest.NewRecorder() + req := authedRequest("POST", "/api/v1/admin/request-integrations/new/options") + req.Body = io.NopCloser(strings.NewReader(`{"base_url":"10.0.0.5:8989"}`)) + h.HandleLoadIntegrationOptions(rec, req) + if rec.Code != http.StatusInternalServerError || !strings.Contains(rec.Body.String(), "internal_error") { + t.Fatalf("%T: status = %d body = %s, want the v1 500", err, rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "API key") || strings.Contains(rec.Body.String(), "Nothing answered") { + t.Fatalf("%T: v1 body carries the v2 detail: %s", err, rec.Body.String()) + } + } + + // A validation error the router returned itself keeps v1's 400. + h := NewRequestsHandler(&fakeRequestService{loadOptionsErr: &mediarequests.ValidationError{FormError: "api key rejected"}}) + rec := httptest.NewRecorder() + req := authedRequest("POST", "/api/v1/admin/request-integrations/new/options") + req.Body = io.NopCloser(strings.NewReader(`{"base_url":"http://10.0.0.5:8989"}`)) + h.HandleLoadIntegrationOptions(rec, req) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "validation_failed") { + t.Fatalf("router validation error: status = %d body = %s, want the v1 400", rec.Code, rec.Body.String()) + } } func authedRequest(method, target string) *http.Request { @@ -251,3 +292,27 @@ func TestHandleBrowseGenreRequiresMediaType(t *testing.T) { t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) } } + +func TestWholeSeriesRequestStateKeepsV1Rule(t *testing.T) { + partial := &mediarequests.MediaDetail{MediaType: mediarequests.MediaTypeSeries, + Availability: mediarequests.AvailabilityAvailable, Request: mediarequests.RequestState{Requestable: true}} + wholeSeriesRequestState(partial) + if partial.Request.Requestable || partial.Request.Reason != "already_available" { + t.Fatalf("series in the library = %+v, want already_available", partial.Request) + } + + requested := &mediarequests.MediaDetail{MediaType: mediarequests.MediaTypeSeries, + Availability: mediarequests.AvailabilityAvailable, + Request: mediarequests.RequestState{Status: mediarequests.StatusQueued, Reason: "already_requested"}} + wholeSeriesRequestState(requested) + if requested.Request.Reason != "already_requested" { + t.Fatalf("active request = %+v, want it kept", requested.Request) + } + + missing := &mediarequests.MediaDetail{MediaType: mediarequests.MediaTypeSeries, + Availability: mediarequests.AvailabilityMissing, Request: mediarequests.RequestState{Requestable: true}} + wholeSeriesRequestState(missing) + if !missing.Request.Requestable { + t.Fatalf("series not in the library = %+v, want requestable", missing.Request) + } +} diff --git a/internal/api/requests_wiring.go b/internal/api/requests_wiring.go index 7e03d343b1..9bca586b26 100644 --- a/internal/api/requests_wiring.go +++ b/internal/api/requests_wiring.go @@ -23,6 +23,22 @@ func (a PluginRequestRouterAdapter) RequestRouterClient(ctx context.Context, ins return a.Svc.RequestRouterClient(ctx, installationID, capabilityID) } +// RouterFeatures reads the optional request_router.v1 features the +// capability's stored manifest declares, without launching the plugin. +func (a PluginRequestRouterAdapter) RouterFeatures(ctx context.Context, installationID int, capabilityID string) (mediarequests.RouterFeatures, error) { + if a.Svc == nil { + return mediarequests.RouterFeatures{}, errors.New("request router plugin service is not configured") + } + descriptor, err := a.Svc.RequestRouterDescriptor(ctx, installationID, capabilityID) + if err != nil { + return mediarequests.RouterFeatures{}, err + } + return mediarequests.RouterFeatures{ + SupportsSeasons: descriptor.GetSupportsSeasons(), + ReportsDownloadProgress: descriptor.GetReportsDownloadProgress(), + }, nil +} + // AttachRequestRouter wires the plugin-backed router provider onto a requests // service. Both the HTTP handler and the reconcile task call this so the wiring // lives in one place. With either dependency absent (e.g. a build without the diff --git a/internal/api/router.go b/internal/api/router.go index 3e994e78ab..be18ccd998 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -23,6 +23,7 @@ import ( "github.com/Silo-Server/silo-server/internal/adminjob" "github.com/Silo-Server/silo-server/internal/ai/jobrunner" "github.com/Silo-Server/silo-server/internal/ai/llm" + "github.com/Silo-Server/silo-server/internal/animeids" "github.com/Silo-Server/silo-server/internal/api/handlers" apimw "github.com/Silo-Server/silo-server/internal/api/middleware" "github.com/Silo-Server/silo-server/internal/apiv2" @@ -879,6 +880,7 @@ func newChiRouter(deps Dependencies) chi.Router { mediarequests.NewCatalogPresence(itemRepo, providerIDRepo), ) AttachRequestRouter(requestSvc, deps.PluginService) + requestSvc.SetAnimeIndex(animeids.NewStore(deps.DB)) requestSvc.SetGroupPolicyProvider(accessGroupStore) if userRepo != nil { requestSvc.SetUserRepository(userRepo) diff --git a/internal/api/testdata/media_routes.txt b/internal/api/testdata/media_routes.txt index 5c592f2541..ac9b32d43d 100644 --- a/internal/api/testdata/media_routes.txt +++ b/internal/api/testdata/media_routes.txt @@ -251,21 +251,35 @@ POST /api/v2/admin/recommendations/trigger/cowatch non-media POST /api/v2/admin/recommendations/trigger/embeddings non-media POST /api/v2/admin/recommendations/trigger/recommendations non-media POST /api/v2/admin/recommendations/trigger/taste-profiles non-media +GET /api/v2/admin/request-groups/{group_id}/limit non-media +PUT /api/v2/admin/request-groups/{group_id}/limit non-media GET /api/v2/admin/request-integrations non-media POST /api/v2/admin/request-integrations non-media 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 +GET /api/v2/admin/request-routes/titles 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-routing non-media +PUT /api/v2/admin/request-routing 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 PUT /api/v2/admin/request-users/{user_id}/limit non-media GET /api/v2/admin/requests non-media GET /api/v2/admin/requests/capabilities non-media +GET /api/v2/admin/requests/counts non-media POST /api/v2/admin/requests/{id}/approve non-media POST /api/v2/admin/requests/{id}/cancel non-media POST /api/v2/admin/requests/{id}/decline non-media +GET /api/v2/admin/requests/{id}/events non-media POST /api/v2/admin/requests/{id}/retry non-media GET /api/v2/admin/sections non-media POST /api/v2/admin/sections non-media @@ -683,6 +697,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 @@ -1417,21 +1433,35 @@ POST /api/v2/admin/recommendations/trigger/cowatch non-media POST /api/v2/admin/recommendations/trigger/embeddings non-media POST /api/v2/admin/recommendations/trigger/recommendations non-media POST /api/v2/admin/recommendations/trigger/taste-profiles non-media +GET /api/v2/admin/request-groups/{group_id}/limit non-media +PUT /api/v2/admin/request-groups/{group_id}/limit non-media GET /api/v2/admin/request-integrations non-media POST /api/v2/admin/request-integrations non-media 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 +GET /api/v2/admin/request-routes/titles 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-routing non-media +PUT /api/v2/admin/request-routing 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 PUT /api/v2/admin/request-users/{user_id}/limit non-media GET /api/v2/admin/requests non-media GET /api/v2/admin/requests/capabilities non-media +GET /api/v2/admin/requests/counts non-media POST /api/v2/admin/requests/{id}/approve non-media POST /api/v2/admin/requests/{id}/cancel non-media POST /api/v2/admin/requests/{id}/decline non-media +GET /api/v2/admin/requests/{id}/events non-media POST /api/v2/admin/requests/{id}/retry non-media GET /api/v2/admin/sections non-media POST /api/v2/admin/sections non-media @@ -1849,6 +1879,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/admin_request_groups.go b/internal/apiv2/admin_request_groups.go new file mode 100644 index 0000000000..04a0784bf1 --- /dev/null +++ b/internal/apiv2/admin_request_groups.go @@ -0,0 +1,138 @@ +package apiv2 + +import ( + "context" + "errors" + "net/http" + "strconv" + + mediarequests "github.com/Silo-Server/silo-server/internal/requests" +) + +const ( + opGetAdminRequestGroupLimit = "getAdminRequestGroupLimit" + opUpdateAdminRequestGroupLimit = "updateAdminRequestGroupLimit" +) + +var adminRequestGroupOperationIDs = []string{opGetAdminRequestGroupLimit, opUpdateAdminRequestGroupLimit} + +// adminRequestGroups is the access-group slice of the request service. +type adminRequestGroups interface { + GetGroupLimit(context.Context, mediarequests.Viewer, int64) (*mediarequests.GroupLimit, error) + UpsertGroupLimitConditional(context.Context, mediarequests.Viewer, mediarequests.GroupLimit, int64) (*mediarequests.GroupLimit, error) +} + +// AdminRequestGroupLimit is an access group's request approval and quota. Its +// members' own limits win over it, and it wins over the server-wide settings. +type AdminRequestGroupLimit struct { + GroupID ID `json:"group_id" example:"2"` + LimitMode string `json:"limit_mode" enum:"inherit,custom,unlimited" doc:"inherit uses the server-wide limit; custom uses max_requests per window_days" example:"custom"` + MaxRequests *int `json:"max_requests" nullable:"true" minimum:"0" example:"10"` + WindowDays *int `json:"window_days" nullable:"true" minimum:"1" example:"7"` + ApprovalMode string `json:"approval_mode" enum:"inherit,manual,auto" doc:"inherit uses the server-wide approval setting" example:"manual"` +} + +type AdminRequestGroupLimitBody struct { + LimitMode string `json:"limit_mode" enum:"inherit,custom,unlimited"` + MaxRequests *int `json:"max_requests" nullable:"true" minimum:"0"` + WindowDays *int `json:"window_days" nullable:"true" minimum:"1"` + ApprovalMode string `json:"approval_mode" enum:"inherit,manual,auto"` +} + +type AdminRequestGroupInput struct { + GroupID ID `path:"group_id" pattern:"^[1-9][0-9]*$" doc:"The access group" example:"2"` +} + +type AdminRequestGroupLimitInput struct { + AdminRequestGroupInput + IfMatch string `header:"If-Match"` + IfNoneMatch string `header:"If-None-Match"` + Body AdminRequestGroupLimitBody +} + +type AdminRequestGroupLimitOutput struct { + ETag string `header:"ETag"` + Body AdminRequestGroupLimit +} + +func registerAdminRequestGroups(reg *Registry) { + op := func(method, id, summary string, guard bool) Operation { + o := Operation{Operation: humaOp(method, Prefix+"/admin/request-groups/{group_id}/limit", id, "admin", summary), Class: ClassActingAdmin, DemoRestricted: isMutatingMethod(method), ServiceBacked: true, Guarded: guard} + o.Errors = []int{http.StatusNotFound} + if method != http.MethodGet { + o.RetrySafety = RetrySafetyNonRetryable + } + return o + } + Register(reg, op(http.MethodGet, opGetAdminRequestGroupLimit, "Get an access group's request approval and limit.", false), reg.getAdminRequestGroupLimit) + Register(reg, op(http.MethodPut, opUpdateAdminRequestGroupLimit, "Replace an access group's request approval and limit.", true), reg.updateAdminRequestGroupLimit) +} + +func (reg *Registry) adminRequestGroupService() (adminRequestGroups, *Problem) { + s, ok := reg.deps.AdminRequests.(adminRequestGroups) + if !ok { + return nil, unavailable("request access by group") + } + return s, nil +} + +func adminGroupLimitOf(r *mediarequests.GroupLimit) AdminRequestGroupLimit { + return AdminRequestGroupLimit{IDFromInt(r.GroupID), string(r.LimitMode), r.MaxRequests, r.WindowDays, string(r.ApprovalMode)} +} + +func adminRequestGroupID(in AdminRequestGroupInput) (int64, *Problem) { + id, err := strconv.ParseInt(string(in.GroupID), 10, 64) + if err != nil || id <= 0 { + return 0, NewProblem(TypeValidationFailed, "Invalid access group ID.") + } + return id, nil +} + +func (reg *Registry) getAdminRequestGroupLimit(ctx context.Context, in *AdminRequestGroupInput) (*AdminRequestGroupLimitOutput, error) { + s, p := reg.adminRequestGroupService() + if p != nil { + return nil, p + } + id, p := adminRequestGroupID(*in) + if p != nil { + return nil, p + } + r, err := s.GetGroupLimit(ctx, adminRequestViewer(ctx), id) + if err != nil { + return nil, requestProblem(err) + } + return &AdminRequestGroupLimitOutput{ETag: adminRequestTag(ctx, "group-limit", string(in.GroupID), r.Revision).String(), Body: adminGroupLimitOf(r)}, nil +} + +func (reg *Registry) updateAdminRequestGroupLimit(ctx context.Context, in *AdminRequestGroupLimitInput) (*AdminRequestGroupLimitOutput, error) { + s, p := reg.adminRequestGroupService() + if p != nil { + return nil, p + } + id, p := adminRequestGroupID(in.AdminRequestGroupInput) + if p != nil { + return nil, p + } + v := adminRequestViewer(ctx) + r, err := s.GetGroupLimit(ctx, v, id) + if err != nil { + return nil, requestProblem(err) + } + rev, p := adminRequestGuard(AdminRequestPreconditions{in.IfMatch, in.IfNoneMatch}, adminRequestTag(ctx, "group-limit", string(in.GroupID), r.Revision), r.Revision) + if p != nil { + return nil, p + } + b := in.Body + r, err = s.UpsertGroupLimitConditional(ctx, v, mediarequests.GroupLimit{GroupID: id, LimitMode: mediarequests.LimitMode(b.LimitMode), MaxRequests: b.MaxRequests, WindowDays: b.WindowDays, ApprovalMode: mediarequests.ApprovalMode(b.ApprovalMode)}, rev) + if errors.Is(err, mediarequests.ErrStaleRevision) { + current, e := reg.getAdminRequestGroupLimit(ctx, &in.AdminRequestGroupInput) + if e != nil { + return nil, e + } + return nil, NewProblem(TypePreconditionFailed, "The access group's request limit changed; reload before saving.").WithHeader("ETag", current.ETag) + } + if err != nil { + return nil, requestProblem(err) + } + return &AdminRequestGroupLimitOutput{ETag: adminRequestTag(ctx, "group-limit", string(in.GroupID), r.Revision).String(), Body: adminGroupLimitOf(r)}, nil +} diff --git a/internal/apiv2/admin_request_groups_test.go b/internal/apiv2/admin_request_groups_test.go new file mode 100644 index 0000000000..bc1dd6deb0 --- /dev/null +++ b/internal/apiv2/admin_request_groups_test.go @@ -0,0 +1,65 @@ +package apiv2 + +import ( + "context" + "net/http" + "testing" + + mediarequests "github.com/Silo-Server/silo-server/internal/requests" +) + +// fakeGroupAdmin adds access-group request limits to the admin request fake. +type fakeGroupAdmin struct { + *fakeAdminRequests + limit mediarequests.GroupLimit +} + +func (f *fakeGroupAdmin) GetGroupLimit(_ context.Context, _ mediarequests.Viewer, id int64) (*mediarequests.GroupLimit, error) { + if id != 2 { + return nil, mediarequests.ErrNotFound + } + out := f.limit + return &out, nil +} + +func (f *fakeGroupAdmin) UpsertGroupLimitConditional(_ context.Context, _ mediarequests.Viewer, in mediarequests.GroupLimit, expected int64) (*mediarequests.GroupLimit, error) { + if expected != -1 && expected != f.limit.Revision { + return nil, mediarequests.ErrStaleRevision + } + f.writes++ + in.Revision = f.limit.Revision + 1 + f.limit = in + out := in + return &out, nil +} + +func TestAdminRequestGroupLimit(t *testing.T) { + f := &fakeGroupAdmin{fakeAdminRequests: fixtureAdminRequests(), + limit: mediarequests.GroupLimit{GroupID: 2, LimitMode: mediarequests.LimitModeInherit, ApprovalMode: mediarequests.ApprovalModeInherit}} + deps := requestDeps(fixtureRequests()) + deps.AdminRequests = f + h := NewHandler(deps) + path := Prefix + "/admin/request-groups/2/limit" + body := `{"limit_mode":"custom","max_requests":3,"window_days":14,"approval_mode":"manual"}` + + requireProblem(t, do(t, h, http.MethodGet, path, "", requestOwner), TypePermissionDenied) + requireProblem(t, do(t, h, http.MethodGet, Prefix+"/admin/request-groups/9/limit", "", actingRequestAdmin), TypeNotFound) + read := do(t, h, http.MethodGet, path, "", actingRequestAdmin) + var limit AdminRequestGroupLimit + decodeBody(t, read.Body, &limit) + if read.Code != 200 || limit.LimitMode != "inherit" || read.Header().Get("ETag") == "" { + t.Fatal(read.Code, read.Body.String()) + } + + requireProblem(t, do(t, h, http.MethodPut, path, body, actingRequestAdmin), TypePreconditionRequired) + requireProblem(t, do(t, h, http.MethodPut, path, `{"limit_mode":"blocked","approval_mode":"inherit"}`, with(actingRequestAdmin, "If-Match", read.Header().Get("ETag"))), TypeValidationFailed) + saved := do(t, h, http.MethodPut, path, body, with(actingRequestAdmin, "If-Match", read.Header().Get("ETag"))) + if saved.Code != 200 || f.limit.LimitMode != mediarequests.LimitModeCustom || *f.limit.MaxRequests != 3 || f.limit.ApprovalMode != mediarequests.ApprovalModeManual { + t.Fatalf("%d %s limit=%+v", saved.Code, saved.Body.String(), f.limit) + } + stale := do(t, h, http.MethodPut, path, body, with(actingRequestAdmin, "If-Match", read.Header().Get("ETag"))) + requireProblem(t, stale, TypePreconditionFailed) + if f.writes != 1 { + t.Fatalf("writes = %d, want 1", f.writes) + } +} diff --git a/internal/apiv2/admin_request_queue.go b/internal/apiv2/admin_request_queue.go new file mode 100644 index 0000000000..0f6269059b --- /dev/null +++ b/internal/apiv2/admin_request_queue.go @@ -0,0 +1,142 @@ +package apiv2 + +import ( + "context" + "net/http" + "strconv" + "strings" + + mediarequests "github.com/Silo-Server/silo-server/internal/requests" +) + +const ( + opGetAdminRequestCounts = "getAdminRequestCounts" + opListAdminRequestEvents = "listAdminRequestEvents" +) + +var adminRequestQueueOperationIDs = []string{opGetAdminRequestCounts, opListAdminRequestEvents} + +// adminRequestQueue is the queue slice of the request service: view counts +// and request history. +type adminRequestQueue interface { + CountAdminViews(context.Context, mediarequests.Viewer) (mediarequests.AdminViewCounts, error) + ListRequestEvents(context.Context, mediarequests.Viewer, string) ([]mediarequests.RequestEvent, error) +} + +// adminRequestCloser lets the admin cancel also close a failed request. +type adminRequestCloser interface { + AdminCancel(context.Context, mediarequests.Viewer, string, string) (*mediarequests.Request, error) +} + +// AdminMediaRequestListInput filters the admin request queue. +type AdminMediaRequestListInput struct { + Status string `query:"status" enum:"pending,approved,queued,downloading,completed" doc:"Only requests in this status" example:"pending"` + Outcome string `query:"outcome" enum:"active,declined,cancelled,failed" doc:"Only requests with this outcome" example:"active"` //nolint:misspell // the store's spelling + View string `query:"view" enum:"needs_approval,in_progress,failed,done" doc:"Only requests in this queue view: needs_approval (pending), in_progress (approved, queued or downloading), failed, or done (completed, or closed by a decline or cancellation)" example:"needs_approval"` + Q string `query:"q" maxLength:"200" doc:"Only requests whose title contains this text, or whose TMDB ID equals it" example:"severance"` + MediaType string `query:"media_type" enum:"movie,series" doc:"Only requests for this media type" example:"series"` + RequestedByUserID string `query:"requested_by_user_id" pattern:"^[1-9][0-9]{0,9}$" doc:"Only requests made by this account; at most 2147483647" example:"7"` + Limit int `query:"limit" minimum:"1" maximum:"50" default:"50" doc:"Page size; default 50, maximum 50" example:"50"` + Cursor string `query:"cursor" doc:"Opaque cursor from page.next_cursor" example:"eyJvIjo1MH0"` +} + +// filterKey binds a cursor to the filters it was issued for. +func (in *AdminMediaRequestListInput) filterKey() string { + return strings.Join([]string{in.Status, in.Outcome, in.View, in.Q, in.MediaType, in.RequestedByUserID}, "|") +} + +// AdminRequestCounts counts the requests in each queue view. +type AdminRequestCounts struct { + NeedsApproval int `json:"needs_approval" doc:"Pending requests waiting for an admin" example:"3"` + InProgress int `json:"in_progress" doc:"Approved requests on their way to the library" example:"5"` + Failed int `json:"failed" doc:"Failed requests; Retry sends them again" example:"1"` + Done int `json:"done" doc:"Completed requests, and those closed by a decline or cancellation" example:"42"` +} + +type AdminRequestCountsOutput struct { + Body AdminRequestCounts +} + +// AdminRequestEvent is one entry of a request's history. +type AdminRequestEvent struct { + ID ID `json:"id" example:"981"` + Type string `json:"type" doc:"What happened: created, approved, retried, submit_deferred, available_in_library, status_ or outcome_; clients show unknown types as they are" example:"approved"` + ActorUserID ID `json:"actor_user_id,omitempty" doc:"The account that acted; absent for the server itself" example:"1"` + ActorUsername string `json:"actor_username,omitempty" doc:"The acting account's username, while the account exists" example:"admin"` + Message string `json:"message,omitempty" doc:"A reason or error that came with the event" example:"auto approved"` + CreatedAt Instant `json:"created_at"` +} + +type AdminRequestEventCollection struct { + Collection[AdminRequestEvent] +} + +type AdminRequestEventsOutput struct { + Body AdminRequestEventCollection +} + +func registerAdminRequestQueue(reg *Registry) { + op := func(path, id, summary string) Operation { + o := Operation{Operation: humaOp(http.MethodGet, Prefix+path, id, "admin", summary), Class: ClassActingAdmin, ServiceBacked: true} + o.Errors = []int{http.StatusNotFound} + return o + } + Register(reg, op("/admin/requests/counts", opGetAdminRequestCounts, "Count the requests in each admin queue view."), reg.getAdminRequestCounts) + Register(reg, op("/admin/requests/{id}/events", opListAdminRequestEvents, "List a request's history, newest first (at most 200 entries)."), reg.listAdminRequestEvents) +} + +func (reg *Registry) adminRequestQueueService() (adminRequestQueue, *Problem) { + s, ok := reg.deps.AdminRequests.(adminRequestQueue) + if !ok { + return nil, unavailable("request queue") + } + return s, nil +} + +func (reg *Registry) getAdminRequestCounts(ctx context.Context, _ *struct{}) (*AdminRequestCountsOutput, error) { + s, p := reg.adminRequestQueueService() + if p != nil { + return nil, p + } + c, err := s.CountAdminViews(ctx, adminRequestViewer(ctx)) + if err != nil { + return nil, requestProblem(err) + } + return &AdminRequestCountsOutput{Body: AdminRequestCounts{NeedsApproval: c.NeedsApproval, InProgress: c.InProgress, Failed: c.Failed, Done: c.Done}}, nil +} + +func (reg *Registry) listAdminRequestEvents(ctx context.Context, in *MediaRequestGetInput) (*AdminRequestEventsOutput, error) { + s, p := reg.adminRequestQueueService() + if p != nil { + return nil, p + } + events, err := s.ListRequestEvents(ctx, adminRequestViewer(ctx), string(in.ID)) + if err != nil { + return nil, requestProblem(err) + } + items := make([]AdminRequestEvent, 0, len(events)) + for _, e := range events { + item := AdminRequestEvent{ID: IDFromInt(e.ID), Type: e.EventType, ActorUsername: e.ActorUsername, Message: e.Message, CreatedAt: NewInstant(e.CreatedAt)} + if e.ActorUserID != nil { + item.ActorUserID = IDFromInt(int64(*e.ActorUserID)) + } + items = append(items, item) + } + return &AdminRequestEventsOutput{Body: AdminRequestEventCollection{Collection: Paginated(items, "")}}, nil +} + +// adminListFilter turns the queue's query into the store filter. +func adminListFilter(in *AdminMediaRequestListInput) (mediarequests.ListFilter, *Problem) { + filter := mediarequests.ListFilter{ + Status: mediarequests.Status(in.Status), Outcome: mediarequests.Outcome(in.Outcome), + View: mediarequests.AdminView(in.View), Query: in.Q, MediaType: mediarequests.MediaType(in.MediaType), + } + if in.RequestedByUserID != "" { + id, err := strconv.ParseInt(in.RequestedByUserID, 10, 32) + if err != nil { + return filter, NewProblem(TypeValidationFailed, "Invalid account ID.") + } + filter.RequestedByUserID = int(id) + } + return filter, nil +} diff --git a/internal/apiv2/admin_request_queue_test.go b/internal/apiv2/admin_request_queue_test.go new file mode 100644 index 0000000000..6aebca34b8 --- /dev/null +++ b/internal/apiv2/admin_request_queue_test.go @@ -0,0 +1,76 @@ +package apiv2 + +import ( + "context" + "net/http" + "testing" + "time" + + mediarequests "github.com/Silo-Server/silo-server/internal/requests" +) + +// fakeQueueAdmin adds view counts and request history to the admin request +// fake. +type fakeQueueAdmin struct { + *fakeAdminRequests + eventsFor string +} + +func (f *fakeQueueAdmin) CountAdminViews(_ context.Context, v mediarequests.Viewer) (mediarequests.AdminViewCounts, error) { + f.viewer = v + return mediarequests.AdminViewCounts{NeedsApproval: 3, InProgress: 2, Failed: 1, Done: 9}, nil +} + +func (f *fakeQueueAdmin) ListRequestEvents(_ context.Context, _ mediarequests.Viewer, id string) ([]mediarequests.RequestEvent, error) { + if id != "r-1" { + return nil, mediarequests.ErrNotFound + } + f.eventsFor = id + actor := 7 + at := time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC) + return []mediarequests.RequestEvent{ + {ID: 2, RequestID: id, EventType: "outcome_declined", ActorUserID: &actor, ActorUsername: "admin", Message: "Not this month", CreatedAt: at}, + {ID: 1, RequestID: id, EventType: "created", CreatedAt: at.Add(-time.Hour)}, + }, nil +} + +func queueAdminHandler(f *fakeQueueAdmin) http.Handler { + deps := requestDeps(fixtureRequests()) + deps.AdminRequests = f + return NewHandler(deps) +} + +func TestAdminRequestQueueFiltersCountsAndHistory(t *testing.T) { + f := &fakeQueueAdmin{fakeAdminRequests: fixtureAdminRequests()} + h := queueAdminHandler(f) + + list := do(t, h, http.MethodGet, Prefix+"/admin/requests?view=failed&q=dune&media_type=movie&requested_by_user_id=7", "", actingRequestAdmin) + if list.Code != 200 { + t.Fatal(list.Code, list.Body.String()) + } + if got := f.filter; got.View != mediarequests.AdminViewFailed || got.Query != "dune" || got.MediaType != mediarequests.MediaTypeMovie || got.RequestedByUserID != 7 { + t.Fatalf("filter = %+v", got) + } + requireProblem(t, do(t, h, http.MethodGet, Prefix+"/admin/requests?view=someday", "", actingRequestAdmin), TypeValidationFailed) + requireProblem(t, do(t, h, http.MethodGet, Prefix+"/admin/requests?requested_by_user_id=0", "", actingRequestAdmin), TypeValidationFailed) + requireProblem(t, do(t, h, http.MethodGet, Prefix+"/admin/requests?requested_by_user_id=2147483648", "", actingRequestAdmin), TypeValidationFailed) + + requireProblem(t, do(t, h, http.MethodGet, Prefix+"/admin/requests/counts", "", requestOwner), TypePermissionDenied) + var counts AdminRequestCounts + rec := do(t, h, http.MethodGet, Prefix+"/admin/requests/counts", "", actingRequestAdmin) + decodeBody(t, rec.Body, &counts) + if rec.Code != 200 || counts != (AdminRequestCounts{NeedsApproval: 3, InProgress: 2, Failed: 1, Done: 9}) { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } + + var history struct { + Items []AdminRequestEvent `json:"items"` + } + rec = do(t, h, http.MethodGet, Prefix+"/admin/requests/r-1/events", "", actingRequestAdmin) + decodeBody(t, rec.Body, &history) + if rec.Code != 200 || len(history.Items) != 2 || history.Items[0].Type != "outcome_declined" || + history.Items[0].ActorUserID != "7" || history.Items[0].ActorUsername != "admin" || history.Items[1].ActorUserID != "" { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } + requireProblem(t, do(t, h, http.MethodGet, Prefix+"/admin/requests/missing/events", "", actingRequestAdmin), TypeNotFound) +} diff --git a/internal/apiv2/admin_request_routes.go b/internal/apiv2/admin_request_routes.go new file mode 100644 index 0000000000..3c43df8523 --- /dev/null +++ b/internal/apiv2/admin_request_routes.go @@ -0,0 +1,531 @@ +package apiv2 + +import ( + "context" + "errors" + "net/http" + "strconv" + + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" + 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" + opSearchRequestRouteTitles = "searchRequestRouteTitles" + opGetRequestRouting = "getRequestRouting" + opUpdateRequestRouting = "updateRequestRouting" +) + +var adminRequestRouteOperationIDs = []string{opListRequestRoutes, opGetRequestRoute, opCreateRequestRoute, + opUpdateRequestRoute, opDeleteRequestRoute, opReorderRequestRoutes, opPreviewRequestRoute, opSearchRequestRouteTitles, + opGetRequestRouting, opUpdateRequestRouting} + +// 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) + SearchRouteTitles(context.Context, mediarequests.Viewer, mediarequests.MediaType, string) ([]tmdb.MediaResult, error) + GetRoutingOverview(context.Context, mediarequests.Viewer) (*mediarequests.RoutingOverview, error) + UpdateRoutingModeConditional(context.Context, mediarequests.Viewer, mediarequests.RoutingMode, int64) (*mediarequests.RoutingOverview, error) +} + +// AdminRequestRouting is how requests find their server. +type AdminRequestRouting struct { + Mode string `json:"mode" enum:"standard,advanced" doc:"standard sends each media type to its one server, and 4K copies to its one server marked 4K, with each server's own settings; the routing rules are kept but paused. advanced routes with the rules."` + // Standard and StandardUnavailableReason describe Standard whichever + // mode is on, so a client can show what switching would do. + Standard []AdminRequestStandardDestination `json:"standard" doc:"Where Standard sends each media type that has a server; empty when Standard cannot be used"` + StandardUnavailableReason string `json:"standard_unavailable_reason,omitempty" doc:"Why Standard cannot be used (a media type has more than one server of a kind); absent when it can. Adding or enabling such a server turns Advanced on."` +} + +// AdminRequestStandardDestination is where Standard sends one media type. +type AdminRequestStandardDestination struct { + MediaType string `json:"media_type" enum:"movie,series"` + HDIntegrationID string `json:"hd_integration_id,omitempty" doc:"The media type's one server that is not marked 4K; absent when it has none"` + UHDIntegrationID string `json:"uhd_integration_id,omitempty" doc:"The media type's one server marked 4K; absent when it has none, and then there is no 4K copy"` +} + +type AdminRequestRoutingOutput struct { + ETag string `header:"ETag"` + Body AdminRequestRouting +} +type AdminRequestRoutingUpdateInput struct { + IfMatch string `header:"If-Match"` + IfNoneMatch string `header:"If-None-Match"` + Body struct { + Mode string `json:"mode" enum:"standard,advanced"` + } +} + +// AdminRequestRouteConditions narrow a route. Every set field must match; a +// list matches when the title has any of its values, and an exclude list when +// it has none of them. Its fields mirror the service's, in the same order. +type AdminRequestRouteConditions struct { + Anime *bool `json:"anime,omitempty" doc:"Match anime (true) or not (false): Japanese animation, and titles TMDB tags anime or an AniDB-based list names"` + 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"` + + ExcludeGenreIDs []int `json:"exclude_genre_ids,omitempty" doc:"Match titles with none of these TMDB genre IDs"` + ExcludeKeywordIDs []int `json:"exclude_keyword_ids,omitempty" doc:"Match titles with none of these TMDB keyword IDs"` + ExcludeOriginalLanguages []string `json:"exclude_original_languages,omitempty" doc:"Match titles whose original language is none of these ISO 639-1 codes" example:"[\"en\"]"` + ExcludeOriginCountries []string `json:"exclude_origin_countries,omitempty" doc:"Match titles from none of these ISO 3166-1 countries"` + ExcludeNetworkIDs []int `json:"exclude_network_ids,omitempty" doc:"Match series on none of these TMDB networks"` + ExcludeCompanyIDs []int `json:"exclude_company_ids,omitempty" doc:"Match movies from none of these TMDB companies"` + ExcludeRequesterUserIDs []int `json:"exclude_requester_user_ids,omitempty" doc:"Match requests from none of these accounts"` + MaxContentRating string `json:"max_content_rating,omitempty" doc:"Match titles whose rating is at most this one, by minimum age: the US rating, or the title's own country's when it has none; a title with neither does not match" example:"PG"` +} + +// 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 Everything else: it has no conditions, comes last and cannot be deleted; with no 4K server it makes no 4K copy"` + 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; without it, rules for certain accounts do not match"` + } +} + +// 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" doc:"Japanese animation, or a title TMDB tags anime or an AniDB-based list names"` + ContentRating string `json:"content_rating,omitempty" doc:"The title's US rating, or its own country's prefixed with the country code (JP:PG12) when it has none; absent when TMDB has neither" example:"TV-14"` +} + +// AdminRequestRoutePreviewRule is what one route did in a preview. +type AdminRequestRoutePreviewRule struct { + RouteID string `json:"route_id"` + RouteName string `json:"route_name"` + IsFallback bool `json:"is_fallback"` + Enabled bool `json:"enabled"` + Unmet []string `json:"unmet_conditions" doc:"The conditions the title fails, by field name (e.g. genre_ids); empty when it matches"` + HD string `json:"hd" enum:"sends,skips,passes,no_match,already_decided" doc:"What the route did for the HD copy"` + UHD string `json:"uhd" enum:"sends,skips,passes,no_match,already_decided" doc:"What the route did for the 4K copy"` +} + +// AdminRequestRouteTitle is a title the admin can try the rules on. +type AdminRequestRouteTitle struct { + TMDBID int `json:"tmdb_id" doc:"TMDB identifier (external, not a Silo ID)" example:"129"` + MediaType string `json:"media_type" enum:"movie,series"` + Title string `json:"title" example:"Spirited Away"` + Year int `json:"year,omitempty" example:"2001"` + PosterPath string `json:"poster_path,omitempty" doc:"TMDB image path"` +} + +type AdminRequestRouteTitleSearchInput struct { + MediaType string `query:"media_type" enum:"movie,series" required:"true"` + Q string `query:"q" minLength:"1" maxLength:"200" required:"true" doc:"Title to search TMDB for" example:"spirited away"` +} + +type AdminRequestRouteTitleCollection struct { + Collection[AdminRequestRouteTitle] +} + +type AdminRequestRouteTitleCollectionOutput struct { + Body AdminRequestRouteTitleCollection +} + +// 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"` + Rules []AdminRequestRoutePreviewRule `json:"rules" doc:"Every route of the media type in evaluation order, with what it did"` + } +} + +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) + Register(reg, op(http.MethodGet, "/admin/request-routes/titles", opSearchRequestRouteTitles, "Search TMDB for titles to try the routing rules on; works while requests are turned off.", false), reg.searchAdminRequestRouteTitles) + Register(reg, op(http.MethodGet, "/admin/request-routing", opGetRequestRouting, "Get the request routing mode and where Standard routing would send each media type.", false), reg.getAdminRequestRouting) + Register(reg, op(http.MethodPut, "/admin/request-routing", opUpdateRequestRouting, "Switch request routing between Standard and Advanced; Standard is refused while a media type has more than one server of a kind.", true), reg.updateAdminRequestRouting) +} + +func adminRequestRoutingOf(o mediarequests.RoutingOverview) AdminRequestRouting { + out := AdminRequestRouting{Mode: string(o.Mode), Standard: []AdminRequestStandardDestination{}, StandardUnavailableReason: o.StandardBlocker} + for _, d := range o.Standard { + out.Standard = append(out.Standard, AdminRequestStandardDestination{MediaType: string(d.MediaType), HDIntegrationID: d.HDIntegrationID, UHDIntegrationID: d.UHDIntegrationID}) + } + return out +} + +func routingTag(ctx context.Context, o mediarequests.RoutingOverview) EntityTag { + return adminRequestTag(ctx, "routing", "global", o.Revision) +} + +func (reg *Registry) getAdminRequestRouting(ctx context.Context, _ *struct{}) (*AdminRequestRoutingOutput, error) { + s, p := reg.adminRequestRouteService() + if p != nil { + return nil, p + } + o, err := s.GetRoutingOverview(ctx, adminRequestViewer(ctx)) + if err != nil { + return nil, requestProblem(err) + } + return &AdminRequestRoutingOutput{ETag: routingTag(ctx, *o).String(), Body: adminRequestRoutingOf(*o)}, nil +} + +func (reg *Registry) updateAdminRequestRouting(ctx context.Context, in *AdminRequestRoutingUpdateInput) (*AdminRequestRoutingOutput, error) { + s, p := reg.adminRequestRouteService() + if p != nil { + return nil, p + } + v := adminRequestViewer(ctx) + current, err := s.GetRoutingOverview(ctx, v) + if err != nil { + return nil, requestProblem(err) + } + rev, p := adminRequestGuard(AdminRequestPreconditions{in.IfMatch, in.IfNoneMatch}, routingTag(ctx, *current), current.Revision) + if p != nil { + return nil, p + } + o, err := s.UpdateRoutingModeConditional(ctx, v, mediarequests.RoutingMode(in.Body.Mode), rev) + if errors.Is(err, mediarequests.ErrStaleRevision) { + latest, e := s.GetRoutingOverview(ctx, v) + if e != nil { + return nil, requestProblem(e) + } + return nil, NewProblem(TypePreconditionFailed, "Request routing changed; reload before saving.").WithHeader("ETag", routingTag(ctx, *latest).String()) + } + if err != nil { + return nil, requestProblem(err) + } + return &AdminRequestRoutingOutput{ETag: routingTag(ctx, *o).String(), Body: adminRequestRoutingOf(*o)}, nil +} + +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(c), + 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(c), + 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, + } + if f.ContentRating != nil { + out.Body.Facts.ContentRating = *f.ContentRating + } + out.Body.Rules = make([]AdminRequestRoutePreviewRule, 0, len(preview.Rules)) + for _, rule := range preview.Rules { + out.Body.Rules = append(out.Body.Rules, AdminRequestRoutePreviewRule{ + RouteID: rule.Route.ID, RouteName: rule.Route.Name, IsFallback: rule.Route.IsFallback, Enabled: rule.Route.Enabled, + Unmet: NonNil(rule.Unmet), HD: string(rule.Steps[mediarequests.Quality1080p]), UHD: string(rule.Steps[mediarequests.Quality2160p]), + }) + } + 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 +} + +func (reg *Registry) searchAdminRequestRouteTitles(ctx context.Context, in *AdminRequestRouteTitleSearchInput) (*AdminRequestRouteTitleCollectionOutput, error) { + s, p := reg.adminRequestRouteService() + if p != nil { + return nil, p + } + results, err := s.SearchRouteTitles(ctx, adminRequestViewer(ctx), mediarequests.MediaType(in.MediaType), in.Q) + if err != nil { + return nil, requestProblem(err) + } + items := make([]AdminRequestRouteTitle, 0, len(results)) + for _, r := range results { + items = append(items, AdminRequestRouteTitle{TMDBID: r.ID, MediaType: in.MediaType, Title: r.Title, Year: r.Year, PosterPath: r.PosterPath}) + } + return &AdminRequestRouteTitleCollectionOutput{Body: AdminRequestRouteTitleCollection{Collection: Paginated(items, "")}}, 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..1c705e6743 --- /dev/null +++ b/internal/apiv2/admin_request_routes_test.go @@ -0,0 +1,284 @@ +package apiv2 + +import ( + "context" + "net/http" + "strings" + "testing" + + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" + 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 + searched string + routing mediarequests.RoutingOverview +} + +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 (f *fakeRouteAdmin) SearchRouteTitles(_ context.Context, _ mediarequests.Viewer, mediaType mediarequests.MediaType, q string) ([]tmdb.MediaResult, error) { + f.searched = q + return []tmdb.MediaResult{{ID: 129, MediaType: string(mediaType), Title: "Spirited Away", Year: 2001}}, nil +} + +func (f *fakeRouteAdmin) GetRoutingOverview(context.Context, mediarequests.Viewer) (*mediarequests.RoutingOverview, error) { + o := f.routing + return &o, nil +} + +func (f *fakeRouteAdmin) UpdateRoutingModeConditional(_ context.Context, _ mediarequests.Viewer, mode mediarequests.RoutingMode, expected int64) (*mediarequests.RoutingOverview, error) { + if expected != -1 && expected != f.routing.Revision { + return nil, mediarequests.ErrStaleRevision + } + if mode == mediarequests.RoutingStandard && f.routing.StandardBlocker != "" { + return nil, &mediarequests.ValidationError{FieldErrors: map[string]string{"mode": f.routing.StandardBlocker}} + } + f.writes++ + f.routing.Mode, f.routing.Revision = mode, f.routing.Revision+1 + o := f.routing + return &o, 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."}, + }, + Rules: []mediarequests.RouteTrace{ + {Route: mediarequests.Route{ID: "r-kids", Name: "Kids", Enabled: true}, Unmet: []string{"genre_ids", "max_content_rating"}, + Steps: map[mediarequests.Quality]mediarequests.RouteStep{mediarequests.Quality1080p: mediarequests.RouteStepNoMatch, mediarequests.Quality2160p: mediarequests.RouteStepNoMatch}}, + {Route: mediarequests.Route{ID: "r-anime", Name: "Anime", Enabled: true}, + Steps: map[mediarequests.Quality]mediarequests.RouteStep{mediarequests.Quality1080p: mediarequests.RouteStepSends, mediarequests.Quality2160p: mediarequests.RouteStepPasses}}, + }, + } + f.preview.Facts.ContentRating = new("TV-14") + var preview struct { + Facts AdminRequestRouteFacts `json:"facts"` + Tiers []AdminRequestRoutePreviewTier `json:"tiers"` + Rules []AdminRequestRoutePreviewRule `json:"rules"` + } + 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 == "" || preview.Facts.ContentRating != "TV-14" || + len(preview.Rules) != 2 || len(preview.Rules[0].Unmet) != 2 || preview.Rules[1].HD != "sends" || preview.Rules[1].UHD != "passes" { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } + + var titles struct { + Items []AdminRequestRouteTitle `json:"items"` + } + requireProblem(t, do(t, h, http.MethodGet, base+"/titles?media_type=movie&q=spirited", "", requestOwner), TypePermissionDenied) + rec = do(t, h, http.MethodGet, base+"/titles?media_type=movie&q=spirited", "", actingRequestAdmin) + decodeBody(t, rec.Body, &titles) + if rec.Code != 200 || len(titles.Items) != 1 || titles.Items[0].TMDBID != 129 || f.searched != "spirited" { + 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) +} + +func TestAdminRequestRoutingMode(t *testing.T) { + f := fixtureRouteAdmin() + f.routing = mediarequests.RoutingOverview{ + RoutingSettings: mediarequests.RoutingSettings{Mode: mediarequests.RoutingStandard, Revision: 3}, + Standard: []mediarequests.StandardDestination{{MediaType: mediarequests.MediaTypeMovie, HDIntegrationID: "radarr", UHDIntegrationID: "radarr-4k"}}, + } + h := routeAdminHandler(f) + path := Prefix + "/admin/request-routing" + + requireProblem(t, do(t, h, http.MethodGet, path, "", requestOwner), TypePermissionDenied) + var got AdminRequestRouting + read := do(t, h, http.MethodGet, path, "", actingRequestAdmin) + decodeBody(t, read.Body, &got) + tag := read.Header().Get("ETag") + if read.Code != 200 || tag == "" || got.Mode != "standard" || len(got.Standard) != 1 || + got.Standard[0].UHDIntegrationID != "radarr-4k" || got.StandardUnavailableReason != "" { + t.Fatalf("%d %s", read.Code, read.Body.String()) + } + + requireProblem(t, do(t, h, http.MethodPut, path, `{"mode":"advanced"}`, actingRequestAdmin), TypePreconditionRequired) + requireProblem(t, do(t, h, http.MethodPut, path, `{"mode":"advanced"}`, with(actingRequestAdmin, "If-Match", `"stale"`)), TypePreconditionFailed) + requireProblem(t, do(t, h, http.MethodPut, path, `{"mode":"sideways"}`, with(actingRequestAdmin, "If-Match", tag)), TypeValidationFailed) + if f.writes != 0 { + t.Fatal("a refused switch took effect") + } + saved := do(t, h, http.MethodPut, path, `{"mode":"advanced"}`, with(actingRequestAdmin, "If-Match", tag)) + decodeBody(t, saved.Body, &got) + if saved.Code != 200 || got.Mode != "advanced" || saved.Header().Get("ETag") == tag { + t.Fatalf("%d %s", saved.Code, saved.Body.String()) + } + + f.routing.Standard, f.routing.StandardBlocker = nil, "Movies can go to more than one server (A, B)." + current := do(t, h, http.MethodGet, path, "", actingRequestAdmin) + decodeBody(t, current.Body, &got) + if got.Standard == nil || len(got.Standard) != 0 || got.StandardUnavailableReason == "" { + t.Fatalf("blocked overview = %s", current.Body.String()) + } + requireProblem(t, do(t, h, http.MethodPut, path, `{"mode":"standard"}`, with(actingRequestAdmin, "If-Match", current.Header().Get("ETag"))), TypeValidationFailed) +} + +// Clients detect routing through the admin request capability document rather +// than by probing the route operations. +func TestAdminRequestCapabilitiesAdvertiseRouting(t *testing.T) { + for _, tc := range []struct { + name string + h http.Handler + want string + }{ + {"with routing", routeAdminHandler(fixtureRouteAdmin()), `"routing":true`}, + {"without routing", adminRequestsHandler(fixtureAdminRequests()), `"routing":false`}, + } { + r := do(t, tc.h, http.MethodGet, Prefix+"/admin/requests/capabilities", "", actingRequestAdmin) + if r.Code != http.StatusOK || !strings.Contains(r.Body.String(), tc.want) { + t.Fatalf("%s: %d %s, want %s", tc.name, r.Code, r.Body.String(), tc.want) + } + } +} diff --git a/internal/apiv2/admin_requests.go b/internal/apiv2/admin_requests.go index 3c638c2af7..eebffd2266 100644 --- a/internal/apiv2/admin_requests.go +++ b/internal/apiv2/admin_requests.go @@ -174,6 +174,7 @@ type AdminRequestCapabilitiesOutputBody struct { Capability Available bool `json:"available"` GuardedConfiguration bool `json:"guarded_configuration"` + Routing bool `json:"routing" doc:"Whether request routing is available: the routing rules under /admin/request-routes and the Standard/Advanced routing mode under /admin/request-routing"` } func adminRequestViewer(ctx context.Context) mediarequests.Viewer { @@ -217,9 +218,10 @@ func registerAdminRequests(reg *Registry) { out := new(AdminRequestCapabilitiesOutput) out.Body.Available = reg.deps.AdminRequests != nil _, out.Body.GuardedConfiguration = reg.deps.AdminRequests.(guardedAdminRequests) + _, out.Body.Routing = reg.deps.AdminRequests.(adminRequestRoutes) return out, nil }) - Register(reg, op(http.MethodGet, "/admin/requests", opListAdminRequests, false), func(ctx context.Context, in *MediaRequestListInput) (*MediaRequestCollectionOutput, error) { + Register(reg, op(http.MethodGet, "/admin/requests", opListAdminRequests, false), func(ctx context.Context, in *AdminMediaRequestListInput) (*MediaRequestCollectionOutput, error) { return reg.listAdminRequests(ctx, cursors, in) }) for _, action := range []string{adminActionApprove, adminActionDecline, adminActionCancel, adminActionRetry} { @@ -237,14 +239,18 @@ func registerAdminRequests(reg *Registry) { case adminActionDecline: r, err = s.Decline(ctx, v, string(in.ID), in.Body.Reason) case adminActionCancel: - r, err = s.Cancel(ctx, v, string(in.ID), in.Body.Reason) + if closer, ok := s.(adminRequestCloser); ok { + r, err = closer.AdminCancel(ctx, v, string(in.ID), in.Body.Reason) + } else { + r, err = s.Cancel(ctx, v, string(in.ID), in.Body.Reason) + } case adminActionRetry: r, err = s.Retry(ctx, v, string(in.ID)) } if err != nil { return nil, requestProblem(err) } - return &MediaRequestOutput{Body: mediaRequestOf(r)}, nil + return &MediaRequestOutput{Body: mediaRequestOf(r, v)}, nil }) } Register(reg, op(http.MethodGet, "/admin/request-settings", opGetAdminRequestSettings, false), reg.getAdminRequestSettings) @@ -263,13 +269,13 @@ func registerAdminRequests(reg *Registry) { Register(reg, op(http.MethodPost, "/admin/request-integrations/{id}/options", opLoadRequestIntegrationOptions, false), reg.loadAdminRequestOptions) } -func (reg *Registry) listAdminRequests(ctx context.Context, cursors *Cursors, in *MediaRequestListInput) (*MediaRequestCollectionOutput, error) { +func (reg *Registry) listAdminRequests(ctx context.Context, cursors *Cursors, in *AdminMediaRequestListInput) (*MediaRequestCollectionOutput, error) { s, p := reg.adminRequestService() if p != nil { return nil, p } v := adminRequestViewer(ctx) - scope := CursorScope{OperationID: opListAdminRequests, Security: strconv.Itoa(v.UserID) + "/" + v.ProfileID, Filter: in.Status + "|" + in.Outcome, Sort: adminRequestSort, Tiebreaker: "id"} + scope := CursorScope{OperationID: opListAdminRequests, Security: strconv.Itoa(v.UserID) + "/" + v.ProfileID, Filter: in.filterKey(), Sort: adminRequestSort, Tiebreaker: "id"} var before *mediarequests.RequestPageKey if in.Cursor != "" { before = new(mediarequests.RequestPageKey) @@ -280,7 +286,12 @@ func (reg *Registry) listAdminRequests(ctx context.Context, cursors *Cursors, in return nil, NewProblem(TypeInvalidCursor, "The cursor position is invalid.") } } - rows, err := s.ListAdmin(ctx, v, mediarequests.ListFilter{Status: mediarequests.Status(in.Status), Outcome: mediarequests.Outcome(in.Outcome), Limit: in.Limit + 1, Before: before}) + filter, p := adminListFilter(in) + if p != nil { + return nil, p + } + filter.Limit, filter.Before = in.Limit+1, before + rows, err := s.ListAdmin(ctx, v, filter) if err != nil { return nil, requestProblem(err) } @@ -295,7 +306,7 @@ func (reg *Registry) listAdminRequests(ctx context.Context, cursors *Cursors, in } items := make([]MediaRequest, 0, len(rows)) for _, r := range rows { - items = append(items, mediaRequestOf(r)) + items = append(items, mediaRequestOf(r, v)) } return &MediaRequestCollectionOutput{Body: MediaRequestCollection{Collection: Paginated(items, next)}}, nil } @@ -420,12 +431,31 @@ func adminIntegrationOf(r mediarequests.Integration) AdminRequestIntegration { } return AdminRequestIntegration{ID: ID(r.ID), Name: r.Name, CapabilityID: r.CapabilityID, InstallationID: install, SupportedMediaTypes: types, PluginConfig: config, Enabled: r.Enabled, BaseURL: r.BaseURL, HasAPIKey: strings.TrimSpace(r.APIKeyRef) != "", LastCheckAt: checked, LastCheckStatus: r.LastCheckStatus, LastCheckError: r.LastCheckError, UpdatedAt: NewInstant(r.UpdatedAt)} } + +// adminRequestBaseURL is the address a v2 probe or save sends on: http:// +// assumed and no trailing slash, so the probe and the saved row agree. A blank +// address stays blank; the service then uses the saved one. +func adminRequestBaseURL(raw string) (string, *Problem) { + if strings.TrimSpace(raw) == "" { + return raw, nil + } + normalized, err := mediarequests.NormalizeIntegrationBaseURL(raw) + if err != nil { + return "", requestProblem(err) + } + return normalized, nil +} + func (b AdminRequestIntegrationBody) domain() (mediarequests.Integration, *Problem) { id, err := strconv.Atoi(string(b.InstallationID)) if err != nil || id <= 0 { return mediarequests.Integration{}, NewProblem(TypeValidationFailed, "Invalid installation ID.") } - return mediarequests.Integration{Name: b.Name, CapabilityID: b.CapabilityID, InstallationID: &id, SupportedMediaTypes: b.SupportedMediaTypes, PluginConfig: b.PluginConfig, Enabled: b.Enabled, BaseURL: b.BaseURL, APIKeyRef: b.APIKey}, nil + baseURL, p := adminRequestBaseURL(b.BaseURL) + if p != nil { + return mediarequests.Integration{}, p + } + return mediarequests.Integration{Name: b.Name, CapabilityID: b.CapabilityID, InstallationID: &id, SupportedMediaTypes: b.SupportedMediaTypes, PluginConfig: b.PluginConfig, Enabled: b.Enabled, BaseURL: baseURL, APIKeyRef: b.APIKey}, nil } func (reg *Registry) listAdminRequestIntegrations(ctx context.Context, cursors *Cursors, in *CursorListInput) (*AdminRequestIntegrationCollectionOutput, error) { s, p := reg.adminRequestService() @@ -564,7 +594,11 @@ func (reg *Registry) loadAdminRequestOptions(ctx context.Context, in *AdminReque } install = &id } - options, err := s.LoadIntegrationOptions(ctx, adminRequestViewer(ctx), mediarequests.Integration{ID: string(in.ID), Name: b.Name, CapabilityID: b.CapabilityID, InstallationID: install, BaseURL: b.BaseURL, APIKeyRef: b.APIKey, PluginConfig: b.PluginConfig}) + baseURL, p := adminRequestBaseURL(b.BaseURL) + if p != nil { + return nil, p + } + options, err := s.LoadIntegrationOptions(ctx, adminRequestViewer(ctx), mediarequests.Integration{ID: string(in.ID), Name: b.Name, CapabilityID: b.CapabilityID, InstallationID: install, BaseURL: baseURL, APIKeyRef: b.APIKey, PluginConfig: b.PluginConfig}) if err != nil { return nil, requestProblem(err) } diff --git a/internal/apiv2/admin_requests_test.go b/internal/apiv2/admin_requests_test.go index 73b37ecadc..adbf765c49 100644 --- a/internal/apiv2/admin_requests_test.go +++ b/internal/apiv2/admin_requests_test.go @@ -21,6 +21,10 @@ type fakeAdminRequests struct { stale bool filter mediarequests.ListFilter action, reason, requestID string + probedBaseURL string + // failed gives every returned request the errors a failed submission + // leaves (withSubmissionErrors). + failed bool } func fixtureAdminRequests() *fakeAdminRequests { @@ -101,11 +105,12 @@ func (f *fakeAdminRequests) DeleteIntegrationConditional(_ context.Context, v me } func (f *fakeAdminRequests) LoadIntegrationOptions(_ context.Context, v mediarequests.Viewer, r mediarequests.Integration) (map[string][]mediarequests.RouterOption, error) { f.viewer = v + f.probedBaseURL = r.BaseURL if r.APIKeyRef == "bad" { return nil, &mediarequests.ValidationError{FieldErrors: map[string]string{"api_key_ref": "invalid key"}} } if r.APIKeyRef == "unreachable" { - return nil, fmt.Errorf("%w: dial tcp: connect: connection refused", mediarequests.ErrIntegrationUnreachable) + return nil, &mediarequests.IntegrationUnreachableError{Detail: "Nothing answered at that address. Check the host and port.", Err: fmt.Errorf("dial tcp: connect: connection refused")} } return map[string][]mediarequests.RouterOption{}, nil } @@ -116,14 +121,19 @@ func TestAdminRequestOptionsUnreachableIntegration(t *testing.T) { h := adminRequestsHandler(fixtureAdminRequests()) rec := do(t, h, http.MethodPost, Prefix+"/admin/request-integrations/new/options", `{"api_key_ref":"unreachable"}`, actingRequestAdmin) requireProblem(t, rec, TypeDependencyUnavailable) - if strings.Contains(rec.Body.String(), "connection refused") { - t.Fatal("upstream failure detail leaked") + if !strings.Contains(rec.Body.String(), "Nothing answered at that address. Check the host and port.") { + t.Fatalf("body = %s, want the classified detail", rec.Body.String()) + } + for _, leaked := range []string{"connection refused", "dial tcp"} { + if strings.Contains(rec.Body.String(), leaked) { + t.Fatalf("upstream failure detail %q leaked: %s", leaked, rec.Body.String()) + } } } func (f *fakeAdminRequests) ListAdmin(_ context.Context, v mediarequests.Viewer, filter mediarequests.ListFilter) ([]*mediarequests.Request, error) { f.viewer = v f.filter = filter - rows := []*mediarequests.Request{fixtureMediaRequest("r-3", 3), fixtureMediaRequest("r-2", 2), fixtureMediaRequest("r-1", 1)} + rows := []*mediarequests.Request{f.request("r-3", 3), f.request("r-2", 2), f.request("r-1", 1)} out := []*mediarequests.Request{} for _, r := range rows { if filter.Before != nil && r.ID >= filter.Before.ID { @@ -140,7 +150,14 @@ func (f *fakeAdminRequests) moderate(v mediarequests.Viewer, action, id, reason f.viewer = v f.action, f.requestID, f.reason = action, id, reason f.writes++ - return fixtureMediaRequest(id, 1), nil + return f.request(id, 1), nil +} +func (f *fakeAdminRequests) request(id string, tmdbID int) *mediarequests.Request { + r := fixtureMediaRequest(id, tmdbID) + if f.failed { + withSubmissionErrors(r) + } + return r } func (f *fakeAdminRequests) Approve(_ context.Context, v mediarequests.Viewer, id string) (*mediarequests.Request, error) { return f.moderate(v, "approve", id, "") @@ -238,6 +255,47 @@ func TestAdminRequestIntegrationSecretsAndGuard(t *testing.T) { t.Fatal(created.Code, created.Body.String()) } } + +// The v2 probe sends a scheme-less address with http:// in front and refuses +// one it could never reach as a field error, without asking the plugin. +func TestAdminRequestOptionsNormalizesBaseURL(t *testing.T) { + f := fixtureAdminRequests() + h := adminRequestsHandler(f) + path := Prefix + "/admin/request-integrations/new/options" + if rec := do(t, h, http.MethodPost, path, `{"base_url":"10.0.0.5:8989/","api_key_ref":"k"}`, actingRequestAdmin); rec.Code != 200 { + t.Fatal(rec.Code, rec.Body.String()) + } + if f.probedBaseURL != "http://10.0.0.5:8989" { + t.Fatalf("probed base URL = %q", f.probedBaseURL) + } + f.probedBaseURL = "unset" + rec := do(t, h, http.MethodPost, path, `{"base_url":"ftp://10.0.0.5","api_key_ref":"k"}`, actingRequestAdmin) + requireProblem(t, rec, TypeValidationFailed) + if !strings.Contains(rec.Body.String(), `"body.base_url"`) || f.probedBaseURL != "unset" { + t.Fatal(rec.Body.String(), f.probedBaseURL) + } +} + +// A v2 save stores the address the options probe used, and refuses one it +// could never probe as a field error. +func TestAdminRequestIntegrationSaveNormalizesBaseURL(t *testing.T) { + f := fixtureAdminRequests() + h := adminRequestsHandler(f) + body := strings.Replace(requestIntegrationBody, `"https://router.example.test"`, `"10.0.0.5:8989/"`, 1) + created := do(t, h, http.MethodPost, Prefix+"/admin/request-integrations", body, actingRequestAdmin) + if created.Code != 201 || !strings.Contains(created.Body.String(), `"base_url":"http://10.0.0.5:8989"`) { + t.Fatal(created.Code, created.Body.String()) + } + bad := strings.Replace(requestIntegrationBody, `"https://router.example.test"`, `"ftp://10.0.0.5"`, 1) + refused := do(t, h, http.MethodPost, Prefix+"/admin/request-integrations", bad, actingRequestAdmin) + requireProblem(t, refused, TypeValidationFailed) + if !strings.Contains(refused.Body.String(), `"body.base_url"`) { + t.Fatal(refused.Body.String()) + } + if f.writes != 1 { + t.Fatalf("writes = %d, want only the valid create", f.writes) + } +} func TestAdminRequestLimitsModerationAndOptions(t *testing.T) { f := fixtureAdminRequests() h := adminRequestsHandler(f) @@ -271,6 +329,31 @@ func TestAdminRequestLimitsModerationAndOptions(t *testing.T) { t.Fatalf("validation %+v", p) } } + +// The admin request operations carry every download server detail. +func TestAdminRequestsCarryDownloadServerDetails(t *testing.T) { + f := fixtureAdminRequests() + f.failed = true + h := adminRequestsHandler(f) + rec := do(t, h, http.MethodGet, Prefix+"/admin/requests", "", actingRequestAdmin) + var page struct { + Items []map[string]any `json:"items"` + } + decodeBody(t, rec.Body, &page) + if rec.Code != http.StatusOK || len(page.Items) != 3 { + t.Fatalf("listAdminRequests: %d %s", rec.Code, rec.Body.String()) + } + for _, item := range page.Items { + if targets, _ := item["targets"].([]any); len(targets) == 0 { + t.Fatalf("listAdminRequests: no targets in %v", item) + } + requireAdminMembers(t, "listAdminRequests", item, adminRequestMembers, adminTargetMembers) + } + for _, action := range []string{"approve", "decline", "cancel", "retry"} { + got := requireTargets(t, action, do(t, h, http.MethodPost, Prefix+"/admin/requests/r-1/"+action, `{}`, actingRequestAdmin)) + requireAdminMembers(t, action, got, adminRequestMembers, adminTargetMembers) + } +} func TestAdminRequestCursorBoundaries(t *testing.T) { f := fixtureAdminRequests() h := adminRequestsHandler(f) diff --git a/internal/apiv2/document.go b/internal/apiv2/document.go index 3fb6f89451..da1b72400c 100644 --- a/internal/apiv2/document.go +++ b/internal/apiv2/document.go @@ -683,6 +683,9 @@ func registerAll(reg *Registry) { registerRecommendations(reg) registerRequests(reg) registerAdminRequests(reg) + registerAdminRequestRoutes(reg) + registerAdminRequestQueue(reg) + registerAdminRequestGroups(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 826f66755c..9cede85694 100644 --- a/internal/apiv2/document_test.go +++ b/internal/apiv2/document_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "reflect" + "slices" "strconv" "strings" "testing" @@ -304,7 +305,7 @@ func TestGeneratedDocumentStatuses(t *testing.T) { for _, id := range append(requestOperationIDs, requestLifecycleOperationIDs...) { profileToken[id] = true } - for _, id := range adminRequestOperationIDs { + for _, id := range slices.Concat(adminRequestOperationIDs, adminRequestRouteOperationIDs, adminRequestQueueOperationIDs, adminRequestGroupOperationIDs) { profileToken[id] = true } expect[opCreateRequest] = map[int]bool{http.StatusCreated: true, http.StatusConflict: true, http.StatusTooManyRequests: true, http.StatusNotFound: true} diff --git a/internal/apiv2/request_downloads_test.go b/internal/apiv2/request_downloads_test.go new file mode 100644 index 0000000000..ed0fd84cb5 --- /dev/null +++ b/internal/apiv2/request_downloads_test.go @@ -0,0 +1,143 @@ +package apiv2 + +import ( + "encoding/json" + "math" + "net/http" + "strings" + "testing" + "time" + + mediarequests "github.com/Silo-Server/silo-server/internal/requests" +) + +func TestRequestDownloadOf(t *testing.T) { + updated := fixedTime() + eta := updated.Add(10 * time.Minute) + progress := func(total, left int64) *mediarequests.DownloadProgress { + return &mediarequests.DownloadProgress{Phase: mediarequests.DownloadPhaseDownloading, BytesTotal: total, BytesLeft: left, Downloads: 1, UpdatedAt: updated} + } + for _, tc := range []struct { + name string + total, left int64 + want int + }{ + {"just started", 1000, 1000, 0}, + {"rounded down", 3, 1, 66}, + {"almost done", 1000, 1, 99}, + {"done", 1000, 0, 100}, + {"left above total", 1000, 5000, 0}, + {"negative left", 1000, -5, 100}, + {"huge", math.MaxInt64, math.MaxInt64 / 2, 50}, + } { + t.Run(tc.name, func(t *testing.T) { + got := requestDownloadOf(progress(tc.total, tc.left)) + if got.Percent == nil || *got.Percent != tc.want { + t.Fatalf("percent = %v, want %d", got.Percent, tc.want) + } + if got.BytesTotal == nil || *got.BytesTotal != tc.total || got.BytesLeft == nil || *got.BytesLeft < 0 || *got.BytesLeft > tc.total { + t.Fatalf("bytes = %v/%v, want the total and a left within it", got.BytesTotal, got.BytesLeft) + } + }) + } + + if requestDownloadOf(nil) != nil { + t.Fatal("no progress must map to no download") + } + unknown := requestDownloadOf(&mediarequests.DownloadProgress{Phase: mediarequests.DownloadPhaseQueued, Downloads: -1, UpdatedAt: updated, EstimatedCompletion: &eta}) + if unknown.Percent != nil || unknown.BytesTotal != nil || unknown.BytesLeft != nil { + t.Fatalf("unknown size: %+v, want no percent and no byte counts", unknown) + } + if unknown.Phase != "queued" || unknown.Downloads != 0 || unknown.EstimatedCompletionAt == nil || !unknown.EstimatedCompletionAt.Equal(eta) || !unknown.UpdatedAt.Equal(updated) { + t.Fatalf("unknown size: %+v", unknown) + } + raw, err := json.Marshal(requestDownloadOf(progress(0, 0))) + if err != nil { + t.Fatal(err) + } + for _, field := range []string{"percent", "bytes_total", "bytes_left", "estimated_completion_at"} { + if strings.Contains(string(raw), `"`+field+`"`) { + t.Fatalf("%s: %s must be omitted", raw, field) + } + } +} + +func TestRequestResponsesCarryDownload(t *testing.T) { + h := newTestHandler(t, requestDeps(fixtureRequests())) + type download struct { + Phase string `json:"phase"` + Percent *int `json:"percent"` + BytesTotal *int64 `json:"bytes_total"` + BytesLeft *int64 `json:"bytes_left"` + EstimatedCompletionAt string `json:"estimated_completion_at"` + Downloads int `json:"downloads"` + UpdatedAt string `json:"updated_at"` + } + var got struct { + Download *download `json:"download"` + Targets []struct { + Download *download `json:"download"` + } `json:"targets"` + } + rec := do(t, h, http.MethodGet, "/api/v2/requests/r-1", "", requestOwner) + decodeBody(t, rec.Body, &got) + if rec.Code != 200 || got.Download == nil || len(got.Targets) != 1 || got.Targets[0].Download == nil { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } + for _, d := range []*download{got.Download, got.Targets[0].Download} { + if d.Phase != "downloading" || d.Percent == nil || *d.Percent != 43 || d.BytesTotal == nil || *d.BytesTotal != 4294967296 || + d.BytesLeft == nil || *d.BytesLeft != 2448131358 || d.Downloads != 1 || + d.EstimatedCompletionAt != "2026-01-02T03:16:05.678Z" || d.UpdatedAt != "2026-01-02T03:04:05.678Z" { + t.Fatalf("download = %+v in %s", d, rec.Body.String()) + } + } + + // A request without progress carries no download at all. + rec = do(t, h, http.MethodGet, "/api/v2/requests/r-2", "", requestOwner) + if rec.Code != 200 || strings.Contains(rec.Body.String(), `"download"`) { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } +} + +func TestRequestMediaDetailCarriesDownload(t *testing.T) { + svc := fixtureRequests() + h := newTestHandler(t, requestDeps(svc)) + rec := do(t, h, http.MethodGet, "/api/v2/requests/detail/movie/949", "", requestOwner) + if rec.Code != 200 || strings.Contains(rec.Body.String(), `"download"`) { + t.Fatalf("without progress: %d %s", rec.Code, rec.Body.String()) + } + + svc.detailDownload = fixtureDownload() + rec = do(t, h, http.MethodGet, "/api/v2/requests/detail/movie/949", "", requestOwner) + var got struct { + Request struct { + Download *struct { + Phase string `json:"phase"` + Percent *int `json:"percent"` + } `json:"download"` + } `json:"request"` + Recommendations []struct { + Request map[string]any `json:"request"` + } `json:"recommendations"` + } + decodeBody(t, rec.Body, &got) + if rec.Code != 200 || got.Request.Download == nil || got.Request.Download.Phase != "downloading" || + got.Request.Download.Percent == nil || *got.Request.Download.Percent != 43 { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } + if len(got.Recommendations) != 1 || got.Recommendations[0].Request["download"] != nil { + t.Fatalf("recommendations = %+v, want no download on them", got.Recommendations) + } +} + +func TestRequestStatusAdvertisesDownloadProgress(t *testing.T) { + h := lifecycleHandler(&fakeLifecycle{}, &fakeWatchLifecycle{}) + rec := do(t, h, http.MethodGet, Prefix+"/requests/status", "", requestOwner) + var got struct { + DownloadProgressSupported *bool `json:"download_progress_supported"` + } + decodeBody(t, rec.Body, &got) + if rec.Code != 200 || got.DownloadProgressSupported == nil || !*got.DownloadProgressSupported { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/apiv2/request_lifecycle.go b/internal/apiv2/request_lifecycle.go index 01a6f964d5..6ba0f69473 100644 --- a/internal/apiv2/request_lifecycle.go +++ b/internal/apiv2/request_lifecycle.go @@ -37,6 +37,18 @@ 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"` + // SeasonRequestsSupported advertises seasons on createRequest, the season + // list on series detail, and season progress on requests. + SeasonRequestsSupported bool `json:"season_requests_supported"` + // MissingSeasonsRequestable reports whether a series already in the + // library can be requested for its missing seasons. + MissingSeasonsRequestable bool `json:"missing_seasons_requestable" doc:"Whether a series already in the library can be requested for the seasons it is missing. False while a download server that takes series uses a request plugin that cannot fetch individual seasons, so such a series stays already_available."` + // DownloadProgressSupported advertises download on requests, their + // targets, and the title detail's request state. + DownloadProgressSupported bool `json:"download_progress_supported" doc:"Whether the server reports download progress (download on requests, their targets, and the title detail's request state). Whether a given request has any depends on its download server's request plugin."` } type RequestFeatureStatusOutput struct { Status int @@ -176,17 +188,18 @@ 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, SeasonRequestsSupported: true, MissingSeasonsRequestable: status.MissingSeasonsRequestable, DownloadProgressSupported: 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 { return nil, unavailable("requests") } - result, err := requests.Cancel(ctx, lifecycleViewer(ctx), string(in.ID), in.Body.Reason) + viewer := lifecycleViewer(ctx) + result, err := requests.Cancel(ctx, viewer, string(in.ID), in.Body.Reason) if err != nil { return nil, requestProblem(err) } - return &MediaRequestOutput{Body: mediaRequestOf(result)}, nil + return &MediaRequestOutput{Body: mediaRequestOf(result, viewer)}, nil }) scope := func(ctx context.Context) (int, string, error) { if providers == nil { diff --git a/internal/apiv2/requests.go b/internal/apiv2/requests.go index 367f9be6fc..39e4151e7e 100644 --- a/internal/apiv2/requests.go +++ b/internal/apiv2/requests.go @@ -4,6 +4,7 @@ import ( "cmp" "context" "errors" + "math/bits" "net/http" "slices" "strconv" @@ -23,9 +24,28 @@ 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"` + 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"` + // Download is filled on the title detail only: search and discovery do + // not load each result's targets. + Download *RequestDownload `json:"download,omitempty" doc:"How far the active request's downloads are, while its download server reports them. Only the title detail (getRequestMediaDetail) carries it"` +} + +// RequestDownload is how far downloads are, as the download server last +// reported them. +type RequestDownload struct { + Phase string `json:"phase" doc:"queued, downloading, paused, stalled, importing or import_blocked. More values may be added: read an unknown one as downloading, without a percentage" example:"downloading"` + Percent *int `json:"percent,omitempty" minimum:"0" maximum:"100" doc:"How much has downloaded, rounded down; absent while the size is unknown" example:"43"` + BytesTotal *int64 `json:"bytes_total,omitempty" minimum:"1" doc:"Size of the downloads in bytes; absent while unknown" example:"4294967296"` + BytesLeft *int64 `json:"bytes_left,omitempty" minimum:"0" doc:"Bytes still to download; present whenever bytes_total is" example:"2448131358"` + EstimatedCompletionAt *Instant `json:"estimated_completion_at,omitempty" doc:"When the download server expects the downloads to finish; absent when it cannot tell" example:"2026-01-02T03:16:05.000Z"` + Downloads int `json:"downloads" minimum:"0" doc:"Distinct downloads in flight; a season pack counts once" example:"1"` + UpdatedAt Instant `json:"updated_at" doc:"When the server last heard from the download server. A client may hide figures older than about ten minutes" example:"2026-01-02T03:04:05.000Z"` } // RequestMediaResult is one discovery or search card. @@ -87,6 +107,25 @@ type RequestMediaDetail struct { Availability string `json:"availability" doc:"missing or available in this server's catalog" example:"missing"` LibraryContentID string `json:"library_content_id,omitempty" doc:"The catalog item when the media is available"` Request RequestMediaState `json:"request"` + Seasons []RequestMediaSeason `json:"seasons" doc:"Series: the regular seasons (specials excluded) with library availability and request coverage; empty for movies"` +} + +// RequestMediaSeason is one season of a series on its detail document. +type RequestMediaSeason struct { + SeasonNumber int `json:"season_number" example:"2"` + Name string `json:"name,omitempty" example:"Season 2"` + EpisodeCount int `json:"episode_count" doc:"Episodes TMDB lists for the season, aired or not" example:"10"` + AirDate string `json:"air_date,omitempty" doc:"Calendar date, YYYY-MM-DD" example:"2025-01-17"` + PosterPath string `json:"poster_path,omitempty" doc:"TMDB image path"` + Availability string `json:"availability" enum:"missing,partial,available" doc:"Whether every aired episode is in the library" example:"partial"` + Requested bool `json:"requested" doc:"The title's active request covers this season"` +} + +// RequestSeasonProgress is how far one requested season is. +type RequestSeasonProgress struct { + SeasonNumber int `json:"season_number" example:"2"` + EpisodesAired int `json:"episodes_aired" doc:"Aired episodes by the library's own metadata; 0 when it has no air dates yet" example:"10"` + EpisodesAvailable int `json:"episodes_available" doc:"Episodes with a file in an enabled library" example:"4"` } // RequestMediaPage is one TMDB result page. Search and browse page by the @@ -145,51 +184,60 @@ type DiscoverBrowsePage struct { } // RequestTarget is one fulfillment of a request against one integration -// instance at one quality. +// instance at one quality. The download server details are for admins only: +// see mediaRequestOf. type RequestTarget struct { ID ID `json:"id" example:"42"` RequestID ID `json:"request_id" example:"1834729"` - IntegrationID string `json:"integration_id,omitempty"` - IntegrationKind string `json:"integration_kind,omitempty" example:"radarr"` - InstanceName string `json:"instance_name,omitempty"` + IntegrationID string `json:"integration_id,omitempty" doc:"Admins only: the download server holding this target"` + IntegrationKind string `json:"integration_kind,omitempty" doc:"Admins only: the download server's kind" example:"radarr"` + InstanceName string `json:"instance_name,omitempty" doc:"Admins only: the download server's name"` Quality string `json:"quality" example:"1080p"` IsAnime bool `json:"is_anime"` - ExternalID string `json:"external_id,omitempty" doc:"The integration's own identifier"` - ExternalStatus string `json:"external_status,omitempty"` + ExternalID string `json:"external_id,omitempty" doc:"Admins only: the integration's own identifier"` + ExternalStatus string `json:"external_status,omitempty" doc:"Admins only: the status as the download server reports it"` Status string `json:"status" example:"queued"` - LastError string `json:"last_error,omitempty"` + LastError string `json:"last_error,omitempty" doc:"Admins only: why the download server failed this target"` + RouteName string `json:"route_name,omitempty" doc:"Admins only: 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"` + // Download is set while the target's router plugin reports progress. + Download *RequestDownload `json:"download,omitempty" doc:"How far this target's downloads are, while its download server reports them"` } // MediaRequest is one media request. type MediaRequest struct { - ID ID `json:"id" example:"1834729"` - Provider string `json:"provider" example:"tmdb"` - MediaType string `json:"media_type" doc:"movie or series" example:"movie"` - TMDBID int `json:"tmdb_id" doc:"TMDB identifier (external, not a Silo ID)" example:"949"` - TVDBID *int `json:"tvdb_id,omitempty" doc:"TVDB identifier (external, not a Silo ID)"` - IMDbID string `json:"imdb_id,omitempty" example:"tt0113277"` - Title string `json:"title" example:"Heat"` - Year *int `json:"year,omitempty" example:"1995"` - Overview string `json:"overview,omitempty"` - 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 - 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"` - IsAnime bool `json:"is_anime"` - Targets []RequestTarget `json:"targets" doc:"Empty, never null"` - ExternalID string `json:"external_id,omitempty"` - ExternalStatus string `json:"external_status,omitempty"` - LibraryContentID string `json:"library_content_id,omitempty" doc:"The catalog item once the media is in the library"` - LastError string `json:"last_error,omitempty"` - CreatedAt Instant `json:"created_at" example:"2026-01-02T03:04:05.000Z"` - UpdatedAt Instant `json:"updated_at" example:"2026-01-02T03:04:05.000Z"` - ApprovedAt *Instant `json:"approved_at,omitempty"` - CompletedAt *Instant `json:"completed_at,omitempty"` + ID ID `json:"id" example:"1834729"` + Provider string `json:"provider" example:"tmdb"` + MediaType string `json:"media_type" doc:"movie or series" example:"movie"` + TMDBID int `json:"tmdb_id" doc:"TMDB identifier (external, not a Silo ID)" example:"949"` + TVDBID *int `json:"tvdb_id,omitempty" doc:"TVDB identifier (external, not a Silo ID)"` + IMDbID string `json:"imdb_id,omitempty" example:"tt0113277"` + Title string `json:"title" example:"Heat"` + Year *int `json:"year,omitempty" example:"1995"` + Overview string `json:"overview,omitempty"` + 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 + State string `json:"state" doc:"The one state to show a user: pending, approved, processing, partially_available (some requested seasons are in the library), available (in the library), declined, cancelled or failed" example:"pending"` //nolint:misspell // the store's spelling + Seasons []int `json:"seasons" doc:"Series: the requested season numbers; empty means the whole series (requests made through v1 or before season requests)"` + SeasonProgress []RequestSeasonProgress `json:"season_progress" doc:"Series season requests: each requested season's episodes, once the series is in the library; empty otherwise"` + 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" doc:"Admins only: the download server's kind" example:"radarr"` + IsAnime bool `json:"is_anime"` + Targets []RequestTarget `json:"targets" doc:"Empty, never null"` + ExternalID string `json:"external_id,omitempty" doc:"Admins only: the integration's own identifier"` + ExternalStatus string `json:"external_status,omitempty" doc:"Admins only: the status as the download server reports it"` + LibraryContentID string `json:"library_content_id,omitempty" doc:"The catalog item once the media is in the library"` + LastError string `json:"last_error,omitempty" doc:"Admins only: why the last submission to a download server failed. It can name servers and routing rules"` + CreatedAt Instant `json:"created_at" example:"2026-01-02T03:04:05.000Z"` + UpdatedAt Instant `json:"updated_at" example:"2026-01-02T03:04:05.000Z"` + ApprovedAt *Instant `json:"approved_at,omitempty"` + CompletedAt *Instant `json:"completed_at,omitempty"` + Download *RequestDownload `json:"download,omitempty" doc:"How far the request's downloads are over all its servers (1080p and 4K together), while any reports them: bytes summed, the phase that needs the most attention, the latest estimate, and the oldest report's time"` } // MediaRequestOutput is a single-request response. @@ -214,6 +262,7 @@ type MediaRequestCreate struct { Overview string `json:"overview,omitempty"` PosterPath string `json:"poster_path,omitempty" doc:"TMDB image path"` BackdropPath string `json:"backdrop_path,omitempty" doc:"TMDB image path"` + Seasons []int `json:"seasons,omitempty" maxItems:"200" doc:"Series only: the season numbers to request, starting at 1 (a season below 1 is refused). Omitted: every aired season not yet complete in the library" example:"[2,3]"` } // MediaRequestCreateInput is the createRequest request. @@ -321,6 +370,8 @@ const ( opBrowseDiscoverGenre = "browseDiscoverGenre" opBrowseDiscoverNetwork = "browseDiscoverNetwork" opBrowseDiscoverStudio = "browseDiscoverStudio" + opFollowRequestMedia = "followRequestMedia" + opUnfollowRequestMedia = "unfollowRequestMedia" ) const requestsTag = "requests" @@ -331,6 +382,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 +463,19 @@ 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). + 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} @@ -453,11 +518,12 @@ func (reg *Registry) createRequest(ctx context.Context, in *MediaRequestCreateIn Overview: in.Body.Overview, PosterPath: in.Body.PosterPath, BackdropPath: in.Body.BackdropPath, + Seasons: in.Body.Seasons, }) if err != nil { return nil, requestProblem(err) } - return &MediaRequestOutput{Body: mediaRequestOf(req)}, nil + return &MediaRequestOutput{Body: mediaRequestOf(req, viewer)}, nil } // listMyRequests pages by the last emitted creation time and unique request ID. @@ -503,7 +569,7 @@ func (reg *Registry) listMyRequests(ctx context.Context, cursors *Cursors, in *M } items := make([]MediaRequest, 0, len(rows)) for _, r := range rows { - items = append(items, mediaRequestOf(r)) + items = append(items, mediaRequestOf(r, viewer)) } return &MediaRequestCollectionOutput{Body: MediaRequestCollection{Collection: Paginated(items, next)}}, nil } @@ -518,7 +584,7 @@ func (reg *Registry) getRequest(ctx context.Context, in *MediaRequestGetInput) ( if err != nil { return nil, requestProblem(err) } - return &MediaRequestOutput{Body: mediaRequestOf(req)}, nil + return &MediaRequestOutput{Body: mediaRequestOf(req, viewer)}, nil } // searchRequestMedia is v1 GET /requests/search. @@ -551,6 +617,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 +782,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): @@ -695,12 +791,21 @@ func requestProblem(err error) *Problem { case errors.Is(err, mediarequests.ErrInvalidState): return NewProblem(TypeConflict, "The request is not in a state that allows this action.") case errors.Is(err, mediarequests.ErrIntegrationUnreachable): + // Detail is a host-written sentence; the underlying cause stays out. + if unreachable, ok := errors.AsType[*mediarequests.IntegrationUnreachableError](err); ok && unreachable.Detail != "" { + return NewProblem(TypeDependencyUnavailable, unreachable.Detail) + } return NewProblem(TypeDependencyUnavailable, "The request integration could not be reached.") } return NewProblem(TypeInternalError, "An unexpected error occurred.") } -func mediaRequestOf(r *mediarequests.Request) MediaRequest { +// mediaRequestOf maps a request for the viewer. The download server details +// (which server and routing rule took each target, the server's own ids and +// raw statuses, and the submission and target errors, which can name servers +// and routing rules) go to an admin only. A requester keeps each target's +// quality, status and download progress. +func mediaRequestOf(r *mediarequests.Request, viewer mediarequests.Viewer) MediaRequest { out := MediaRequest{ ID: ID(r.ID), Provider: r.Provider, @@ -715,35 +820,79 @@ func mediaRequestOf(r *mediarequests.Request) MediaRequest { BackdropPath: r.BackdropPath, Status: string(r.Status), Outcome: string(r.Outcome), - IntegrationKind: r.IntegrationKind, + State: string(r.State()), + Seasons: NonNil(r.Seasons), + SeasonProgress: requestSeasonProgressOf(r.SeasonProgress), + OutcomeReason: r.OutcomeReason, IsAnime: r.IsAnime, Targets: make([]RequestTarget, 0, len(r.Targets)), - ExternalID: r.ExternalID, - ExternalStatus: r.ExternalStatus, LibraryContentID: r.LibraryContentID, - LastError: r.LastError, CreatedAt: NewInstant(r.CreatedAt), UpdatedAt: NewInstant(r.UpdatedAt), ApprovedAt: instantPtr(r.ApprovedAt), CompletedAt: instantPtr(r.CompletedAt), + Download: requestDownloadOf(r.Download()), + } + if viewer.IsAdmin { + out.IntegrationKind, out.ExternalID, out.ExternalStatus, out.LastError = r.IntegrationKind, r.ExternalID, r.ExternalStatus, r.LastError } if r.RequestedByUserID != 0 { out.RequestedByUserID = IDFromInt(int64(r.RequestedByUserID)) } out.RequestedByProfileID = ID(r.RequestedByProfileID) for _, t := range r.Targets { - out.Targets = append(out.Targets, RequestTarget{ - 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), - }) + target := RequestTarget{ + ID: IDFromInt(t.ID), RequestID: ID(t.RequestID), Quality: string(t.Quality), IsAnime: t.IsAnime, + Status: string(t.Status), CreatedAt: NewInstant(t.CreatedAt), UpdatedAt: NewInstant(t.UpdatedAt), + Download: requestDownloadOf(t.Download), + } + if viewer.IsAdmin { + target.IntegrationID, target.IntegrationKind, target.InstanceName = t.IntegrationID, t.IntegrationKind, t.InstanceName + target.ExternalID, target.ExternalStatus, target.LastError, target.RouteName = t.ExternalID, t.ExternalStatus, t.LastError, t.RouteName + } + out.Targets = append(out.Targets, target) + } + return out +} + +// requestDownloadOf maps download progress. The byte counts and percent are +// left out while the size is unknown (a total of 0). +func requestDownloadOf(d *mediarequests.DownloadProgress) *RequestDownload { + if d == nil { + return nil + } + out := &RequestDownload{ + Phase: string(d.Phase), + EstimatedCompletionAt: instantPtr(d.EstimatedCompletion), + Downloads: max(d.Downloads, 0), + UpdatedAt: NewInstant(d.UpdatedAt), + } + if d.BytesTotal > 0 { + total, left := d.BytesTotal, min(max(d.BytesLeft, 0), d.BytesTotal) + percent := downloadPercent(total, left) + out.BytesTotal, out.BytesLeft, out.Percent = &total, &left, &percent + } + return out +} + +// downloadPercent is floor((total-left)*100/total) for 0 <= left <= total and +// total > 0. The product is taken in 128 bits, so no total can overflow it. +func downloadPercent(total, left int64) int { + hi, lo := bits.Mul64(uint64(total-left), 100) + percent, _ := bits.Div64(hi, lo, uint64(total)) + return int(percent) +} + +func requestSeasonProgressOf(progress []mediarequests.SeasonProgress) []RequestSeasonProgress { + out := make([]RequestSeasonProgress, 0, len(progress)) + for _, p := range progress { + out = append(out, RequestSeasonProgress{SeasonNumber: p.Season, EpisodesAired: p.Aired, EpisodesAvailable: p.Have}) } return out } 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, State: string(s.State), Download: requestDownloadOf(s.Download)} } func requestMediaResultsOf(results []mediarequests.MediaResult) []RequestMediaResult { @@ -779,7 +928,19 @@ func requestMediaDetailOf(d *mediarequests.MediaDetail) RequestMediaDetail { Networks: NonNil(d.Networks), Cast: cast, Director: d.Director, Creators: NonNil(d.Creators), Recommendations: requestMediaResultsOf(d.Recommendations), Availability: string(d.Availability), LibraryContentID: d.LibraryContentID, Request: requestMediaStateOf(d.Request), + Seasons: requestMediaSeasonsOf(d.Seasons), + } +} + +func requestMediaSeasonsOf(seasons []mediarequests.RequestSeason) []RequestMediaSeason { + out := make([]RequestMediaSeason, 0, len(seasons)) + for _, s := range seasons { + out = append(out, RequestMediaSeason{ + SeasonNumber: s.Number, Name: s.Name, EpisodeCount: s.EpisodeCount, AirDate: s.AirDate, + PosterPath: s.PosterPath, Availability: string(s.Availability), Requested: s.Requested, + }) } + return out } func discoverSectionOf(s *mediarequests.DiscoverySection) DiscoverSection { diff --git a/internal/apiv2/requests_test.go b/internal/apiv2/requests_test.go index a50bf7dc68..697dec25b0 100644 --- a/internal/apiv2/requests_test.go +++ b/internal/apiv2/requests_test.go @@ -5,9 +5,11 @@ import ( "context" "encoding/json" "net/http" + "net/http/httptest" "slices" "strings" "testing" + "time" mediarequests "github.com/Silo-Server/silo-server/internal/requests" ) @@ -17,6 +19,8 @@ import ( type fakeRequests struct { requests []*mediarequests.Request err error + // detailDownload is the download progress GetDetail reports. + detailDownload *mediarequests.DownloadProgress lastViewer mediarequests.Viewer lastFilter mediarequests.ListFilter @@ -25,13 +29,20 @@ type fakeRequests struct { lastArgs []any } +// fixtureMediaRequest is an approved movie request with one queued target. +// The target names its download server and routing rule, which only an +// admin sees. func fixtureMediaRequest(id string, tmdbID int) *mediarequests.Request { year := 1995 approved := fixedTime() return &mediarequests.Request{ ID: id, Provider: "tmdb", MediaType: mediarequests.MediaTypeMovie, TMDBID: tmdbID, Title: "Heat", Year: &year, Status: mediarequests.StatusApproved, Outcome: mediarequests.OutcomeActive, RequestedByUserID: 1, RequestedByProfileID: "p-owner", - IntegrationKind: "radarr", Targets: []mediarequests.Target{{ID: 42, RequestID: id, Quality: mediarequests.Quality1080p, Status: mediarequests.StatusQueued, CreatedAt: fixedTime(), UpdatedAt: fixedTime()}}, + IntegrationKind: "radarr", Targets: []mediarequests.Target{{ + ID: 42, RequestID: id, IntegrationID: "integration-1", IntegrationKind: "radarr", InstanceName: "Radarr", + Quality: mediarequests.Quality1080p, ExternalID: "7", ExternalStatus: "queued", Status: mediarequests.StatusQueued, + RouteName: "Movies", CreatedAt: fixedTime(), UpdatedAt: fixedTime(), + }}, CreatedAt: fixedTime(), UpdatedAt: fixedTime(), ApprovedAt: &approved, } } @@ -78,7 +89,7 @@ func (f *fakeRequests) GetDetail(_ context.Context, viewer mediarequests.Viewer, MediaType: mediaType, TMDBID: tmdbID, IMDbID: "tt0113277", Title: "Heat", Year: 1995, Runtime: 170, Genres: []string{"Crime"}, Cast: []mediarequests.MediaCastMember{{Name: "Al Pacino", Character: "Vincent Hanna"}}, Director: "Michael Mann", Recommendations: []mediarequests.MediaResult{fixtureResult(950)}, Availability: mediarequests.AvailabilityAvailable, - LibraryContentID: "movie:heat-1995", Request: mediarequests.RequestState{Reason: "already_available"}, + LibraryContentID: "movie:heat-1995", Request: mediarequests.RequestState{Reason: "already_available", Download: f.detailDownload}, }, nil } @@ -188,6 +199,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 { @@ -201,7 +223,22 @@ func fixtureRequests() *fakeRequests { other.RequestedByUserID, other.RequestedByProfileID = 2, "p-primary" pending := fixtureMediaRequest("r-2", 950) pending.Status = mediarequests.StatusPending - return &fakeRequests{requests: []*mediarequests.Request{fixtureMediaRequest("r-1", 949), pending, other}} + // r-1 is downloading, so the fixtures show a request with download + // progress next to ones without. + downloading := fixtureMediaRequest("r-1", 949) + downloading.Status = mediarequests.StatusDownloading + downloading.Targets[0].Status = mediarequests.StatusDownloading + downloading.Targets[0].Download = fixtureDownload() + return &fakeRequests{requests: []*mediarequests.Request{downloading, pending, other}} +} + +// fixtureDownload is a 4 GiB download 43% of the way. +func fixtureDownload() *mediarequests.DownloadProgress { + eta := fixedTime().Add(12 * time.Minute) + return &mediarequests.DownloadProgress{ + Phase: mediarequests.DownloadPhaseDownloading, BytesTotal: 4294967296, BytesLeft: 2448131358, + EstimatedCompletion: &eta, Downloads: 1, UpdatedAt: fixedTime(), + } } func decodeBody(t *testing.T, rec interface{ String() string }, into any) { @@ -249,6 +286,14 @@ func TestCreateRequest(t *testing.T) { // An unknown member is refused. requireProblem(t, do(t, h, http.MethodPost, "/api/v2/requests", `{"media_type":"movie","tmdb_id":1,"title":"x","quality":"4k"}`, requestOwner), TypeValidationFailed) + // A season refused by the service names the seasons field. + svc.err = &mediarequests.ValidationError{FieldErrors: map[string]string{"seasons": "Season numbers start at 1."}} + p = requireProblem(t, do(t, h, http.MethodPost, "/api/v2/requests", `{"media_type":"series","tmdb_id":1399,"title":"x","seasons":[0]}`, requestOwner), TypeValidationFailed) + if len(p.Errors) != 1 || p.Errors[0].Location != "body.seasons" { + t.Fatalf("errors = %+v, want one at body.seasons", p.Errors) + } + svc.err = nil + // Service decisions render as problems. svc.err = mediarequests.QuotaError{Used: 5, Limit: 5, WindowDays: 7} rec = do(t, h, http.MethodPost, "/api/v2/requests", `{"media_type":"movie","tmdb_id":7,"title":"x"}`, requestOwner) @@ -338,6 +383,142 @@ func TestGetRequest(t *testing.T) { } } +// A request's download server details are for admins only: the servers and +// routing rules a request went to, the servers' own ids and raw statuses, and +// errors that can name them. +var ( + adminRequestMembers = []string{"integration_kind", "external_id", "external_status", "last_error"} + adminTargetMembers = []string{"integration_id", "integration_kind", "instance_name", "external_id", "external_status", "last_error", "route_name"} + // serverRequestMembers and serverTargetMembers are the ones + // fixtureMediaRequest fills; withSubmissionErrors fills the rest. + serverRequestMembers = []string{"integration_kind"} + serverTargetMembers = []string{"integration_id", "integration_kind", "instance_name", "external_id", "external_status", "route_name"} + // requesterTargetMembers are what every viewer gets on a target. + requesterTargetMembers = []string{"id", "request_id", "quality", "is_anime", "status", "created_at", "updated_at"} +) + +// withSubmissionErrors adds the rest of the admin details: the errors a failed +// submission leaves, which name a server and a routing rule, and the request's +// own server fields. +func withSubmissionErrors(r *mediarequests.Request) { + r.ExternalID, r.ExternalStatus = "3", "5" + r.LastError = `route "Movies" sends to "Radarr", which is disabled` + for i := range r.Targets { + r.Targets[i].LastError = `Post "http://radarr.lan:7878/api/v3/movie": connection refused` + } +} + +// requireAdminMembers checks that a request body carries exactly the wanted +// admin-only members, on the request and on each of its targets, and that its +// targets keep what a requester sees. +func requireAdminMembers(t *testing.T, label string, req map[string]any, wantRequest, wantTarget []string) { + t.Helper() + for _, m := range adminRequestMembers { + if _, got := req[m]; got != slices.Contains(wantRequest, m) { + t.Errorf("%s: request %s present = %v", label, m, got) + } + } + targets, _ := req["targets"].([]any) + for _, raw := range targets { + target, _ := raw.(map[string]any) + for _, m := range adminTargetMembers { + if _, got := target[m]; got != slices.Contains(wantTarget, m) { + t.Errorf("%s: target %s present = %v", label, m, got) + } + } + for _, m := range requesterTargetMembers { + if _, ok := target[m]; !ok { + t.Errorf("%s: target lost %s", label, m) + } + } + } +} + +// requireTargets decodes one request body and checks it has targets. +func requireTargets(t *testing.T, label string, rec *httptest.ResponseRecorder) map[string]any { + t.Helper() + if rec.Code != http.StatusOK { + t.Fatalf("%s: %d %s", label, rec.Code, rec.Body.String()) + } + var req map[string]any + decodeBody(t, rec.Body, &req) + if targets, _ := req["targets"].([]any); len(targets) == 0 { + t.Fatalf("%s: no targets in %s", label, rec.Body.String()) + } + return req +} + +// The profile-scoped request operations leave the download server details +// out for a requester and keep them for an admin. +func TestRequestDownloadServerDetailsAreForAdmins(t *testing.T) { + svc := fixtureRequests() + for _, r := range svc.requests { + withSubmissionErrors(r) + } + deps := requestDeps(svc) + deps.RequestLifecycle = &fakeLifecycle{} + h := newTestHandler(t, deps) + admin := with(bearer(adminToken), "X-Profile-Id", "p-primary") + + // A requester sees none of them, on any operation that answers with a + // request. + created := do(t, h, http.MethodPost, "/api/v2/requests", `{"media_type":"movie","tmdb_id":7,"title":"Heat"}`, requestOwner) + if created.Code != http.StatusCreated { + t.Fatalf("createRequest: %d %s", created.Code, created.Body.String()) + } + var body map[string]any + decodeBody(t, created.Body, &body) + requireAdminMembers(t, "createRequest", body, nil, nil) + + var mine struct { + Items []map[string]any `json:"items"` + } + rec := do(t, h, http.MethodGet, "/api/v2/requests/mine", "", requestOwner) + decodeBody(t, rec.Body, &mine) + if rec.Code != http.StatusOK || len(mine.Items) != 2 { + t.Fatalf("listMyRequests: %d %s", rec.Code, rec.Body.String()) + } + for _, item := range mine.Items { + requireAdminMembers(t, "listMyRequests", item, nil, nil) + } + got := requireTargets(t, "getRequest", do(t, h, http.MethodGet, "/api/v2/requests/r-1", "", requestOwner)) + requireAdminMembers(t, "getRequest", got, nil, nil) + target := got["targets"].([]any)[0].(map[string]any) + if target["quality"] != "1080p" || target["status"] != "downloading" || target["download"] == nil { + t.Fatalf("getRequest: target = %v, want its quality, status and download", target) + } + got = requireTargets(t, "cancelRequest", do(t, h, http.MethodPost, "/api/v2/requests/r-1/cancel", `{}`, requestOwner)) + requireAdminMembers(t, "cancelRequest", got, nil, nil) + + // An admin sees every one the request has on the same operations. The + // create and cancel fakes answer with a fresh fixture, which carries no + // errors and, once created, no targets. + created = do(t, h, http.MethodPost, "/api/v2/requests", `{"media_type":"movie","tmdb_id":8,"title":"Heat"}`, admin) + if created.Code != http.StatusCreated { + t.Fatalf("admin createRequest: %d %s", created.Code, created.Body.String()) + } + var adminBody map[string]any + decodeBody(t, created.Body, &adminBody) + requireAdminMembers(t, "admin createRequest", adminBody, serverRequestMembers, nil) + var adminMine struct { + Items []map[string]any `json:"items"` + } + rec = do(t, h, http.MethodGet, "/api/v2/requests/mine", "", admin) + decodeBody(t, rec.Body, &adminMine) + if rec.Code != http.StatusOK || len(adminMine.Items) != 1 { + t.Fatalf("admin listMyRequests: %d %s", rec.Code, rec.Body.String()) + } + requireAdminMembers(t, "admin listMyRequests", adminMine.Items[0], adminRequestMembers, adminTargetMembers) + got = requireTargets(t, "admin getRequest", do(t, h, http.MethodGet, "/api/v2/requests/r-1", "", admin)) + requireAdminMembers(t, "admin getRequest", got, adminRequestMembers, adminTargetMembers) + target = got["targets"].([]any)[0].(map[string]any) + if target["instance_name"] != "Radarr" || target["route_name"] != "Movies" || target["last_error"] != `Post "http://radarr.lan:7878/api/v3/movie": connection refused` { + t.Fatalf("admin getRequest: target = %v", target) + } + got = requireTargets(t, "admin cancelRequest", do(t, h, http.MethodPost, "/api/v2/requests/r-1/cancel", `{}`, admin)) + requireAdminMembers(t, "admin cancelRequest", got, serverRequestMembers, serverTargetMembers) +} + func TestSearchRequestMedia(t *testing.T) { svc := fixtureRequests() h := newTestHandler(t, requestDeps(svc)) @@ -512,3 +693,49 @@ 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) +} + +func TestCreateSeriesRequestPassesSeasons(t *testing.T) { + svc := fixtureRequests() + h := newTestHandler(t, requestDeps(svc)) + rec := do(t, h, http.MethodPost, "/api/v2/requests", `{"media_type":"series","tmdb_id":95396,"title":"Severance","seasons":[2,3]}`, requestOwner) + if rec.Code != http.StatusCreated { + t.Fatal(rec.Code, rec.Body.String()) + } + if !slices.Equal(svc.lastCreate.Seasons, []int{2, 3}) { + t.Fatalf("seasons passed = %v, want [2 3]", svc.lastCreate.Seasons) + } + var got struct { + Seasons []int `json:"seasons"` + SeasonProgress []any `json:"season_progress"` + } + decodeBody(t, rec.Body, &got) + if got.Seasons == nil || got.SeasonProgress == nil { + t.Fatalf("seasons fields must be arrays, never null: %s", rec.Body.String()) + } +} diff --git a/internal/apiv2/router.go b/internal/apiv2/router.go index 0ac42b8816..5a8e565818 100644 --- a/internal/apiv2/router.go +++ b/internal/apiv2/router.go @@ -1023,6 +1023,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/catalog/season_availability.go b/internal/catalog/season_availability.go new file mode 100644 index 0000000000..83fa43ebcb --- /dev/null +++ b/internal/catalog/season_availability.go @@ -0,0 +1,73 @@ +package catalog + +import ( + "context" + "fmt" +) + +// SeasonAvailability counts one season's episodes: how many have aired (by +// the provider's air dates), how many are dated to air later, and how many +// have a file in an enabled library, in all and among the aired ones. +type SeasonAvailability struct { + Aired int + Upcoming int + Have int + HaveAired int +} + +// SeriesSeasonAvailability returns per-season episode counts for each series, +// specials (season 0) excluded. An episode has a file when one in an enabled +// library is linked to it, or when a present multi-episode file of its season +// spans it: such a file links to its first episode only. +func (r *ItemRepository) SeriesSeasonAvailability(ctx context.Context, seriesContentIDs []string) (map[string]map[int]SeasonAvailability, error) { + out := map[string]map[int]SeasonAvailability{} + if len(seriesContentIDs) == 0 { + return out, nil + } + rows, err := r.pool.Query(ctx, ` + WITH present AS ( + SELECT el.episode_id AS content_id + FROM episode_libraries el + JOIN episodes e ON e.content_id = el.episode_id + JOIN media_folders f ON f.id = el.media_folder_id AND f.enabled + WHERE e.series_id = ANY($1) AND e.season_number > 0 + UNION + SELECT covered.content_id + FROM episodes first + JOIN media_files mf ON mf.episode_id = first.content_id + AND mf.missing_since IS NULL + AND mf.multi_episode_end > mf.multi_episode_start + JOIN media_folders f ON f.id = mf.media_folder_id AND f.enabled + JOIN episodes covered ON covered.series_id = first.series_id + AND covered.season_number = first.season_number + AND covered.episode_number BETWEEN mf.multi_episode_start AND mf.multi_episode_end + WHERE first.series_id = ANY($1) AND first.season_number > 0 + ) + SELECT e.series_id, e.season_number, + count(*) FILTER (WHERE e.air_date <= current_date), + count(*) FILTER (WHERE e.air_date > current_date), + count(p.content_id), + count(p.content_id) FILTER (WHERE e.air_date <= current_date) + FROM episodes e + LEFT JOIN present p ON p.content_id = e.content_id + WHERE e.series_id = ANY($1) AND e.season_number > 0 + GROUP BY e.series_id, e.season_number + `, seriesContentIDs) + if err != nil { + return nil, fmt.Errorf("series season availability: %w", err) + } + defer rows.Close() + for rows.Next() { + var series string + var season int + var a SeasonAvailability + if err := rows.Scan(&series, &season, &a.Aired, &a.Upcoming, &a.Have, &a.HaveAired); err != nil { + return nil, err + } + if out[series] == nil { + out[series] = map[int]SeasonAvailability{} + } + out[series][season] = a + } + return out, rows.Err() +} diff --git a/internal/catalog/season_availability_db_test.go b/internal/catalog/season_availability_db_test.go new file mode 100644 index 0000000000..ba082f2156 --- /dev/null +++ b/internal/catalog/season_availability_db_test.go @@ -0,0 +1,90 @@ +package catalog + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestSeriesSeasonAvailabilityDatabase(t *testing.T) { + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + ctx := t.Context() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + suffix := fmt.Sprint(time.Now().UnixNano()) + series := "series-season-test-" + suffix + enabled, disabled := 880000+int(time.Now().UnixNano()%9000), 0 + disabled = enabled + 1 + seed := func(stmt string, args ...any) { + t.Helper() + if _, err := pool.Exec(ctx, stmt, args...); err != nil { + t.Fatal(err) + } + } + seed(`INSERT INTO media_folders (id, type, name, enabled) VALUES ($1, 'tvshows', 'Season test', true), ($2, 'tvshows', 'Season test off', false)`, enabled, disabled) + seed(`INSERT INTO media_items (content_id, type, title) VALUES ($1, 'series', 'Season test')`, series) + seed(`INSERT INTO episodes (content_id, series_id, season_number, episode_number, air_date) + SELECT $1::text || v.suffix, $1::text, v.season, v.episode, v.aired + FROM (VALUES ('-s0e1', 0, 1, DATE '2020-01-01'), ('-s1e1', 1, 1, DATE '2020-01-01'), ('-s1e2', 1, 2, DATE '2020-01-08'), + ('-s2e1', 2, 1, DATE '2021-01-01'), ('-s2e2', 2, 2, DATE '2999-01-01'), ('-s3e1', 3, 1, NULL::date)) + AS v(suffix, season, episode, aired)`, series) + seed(`INSERT INTO episode_libraries (episode_id, media_folder_id) + VALUES ($1::text || '-s0e1', $2), ($1::text || '-s1e1', $2), ($1::text || '-s1e2', $2), ($1::text || '-s2e1', $3), ($1::text || '-s3e1', $2)`, + series, enabled, disabled) + // Season 4: one file spans e1-e2 and links to e1 only; e3 aired but is + // missing, and e4 is present ahead of its air date, so it must not stand + // in for e3. + seed(`INSERT INTO episodes (content_id, series_id, season_number, episode_number, air_date) + SELECT $1::text || v.suffix, $1::text, 4, v.episode, v.aired + FROM (VALUES ('-s4e1', 1, DATE '2022-01-01'), ('-s4e2', 2, DATE '2022-01-08'), ('-s4e3', 3, DATE '2022-01-15'), ('-s4e4', 4, DATE '2999-01-01')) + AS v(suffix, episode, aired)`, series) + seed(`INSERT INTO episode_libraries (episode_id, media_folder_id) VALUES ($1::text || '-s4e1', $2), ($1::text || '-s4e4', $2)`, series, enabled) + // Season 5 is dated but has not aired; one episode arrived early. + seed(`INSERT INTO episodes (content_id, series_id, season_number, episode_number, air_date) + SELECT $1::text || v.suffix, $1::text, 5, v.episode, v.aired + FROM (VALUES ('-s5e1', 1, DATE '2999-01-01'), ('-s5e2', 2, DATE '2999-01-08')) AS v(suffix, episode, aired)`, series) + seed(`INSERT INTO episode_libraries (episode_id, media_folder_id) VALUES ($1::text || '-s5e1', $2)`, series, enabled) + fileID := time.Now().UnixNano() + seed(`INSERT INTO media_files (id, media_folder_id, file_path, episode_id, multi_episode_start, multi_episode_end) + VALUES ($1, $2, $3, $4, 1, 2)`, fileID, enabled, "/season-test/"+suffix+"/S04E01-E02.mkv", series+"-s4e1") + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM media_files WHERE id = $1`, fileID) + _, _ = pool.Exec(context.Background(), `DELETE FROM episodes WHERE series_id = $1`, series) + _, _ = pool.Exec(context.Background(), `DELETE FROM media_items WHERE content_id = $1`, series) + _, _ = pool.Exec(context.Background(), `DELETE FROM media_folders WHERE id IN ($1, $2)`, enabled, disabled) + }) + + bySeries, err := NewItemRepository(pool).SeriesSeasonAvailability(ctx, []string{series, "series-season-test-absent-" + suffix}) + if err != nil { + t.Fatal(err) + } + if len(bySeries) != 1 { + t.Fatalf("series = %d, want only the one with episodes", len(bySeries)) + } + got := bySeries[series] + want := map[int]SeasonAvailability{ + 1: {Aired: 2, Have: 2, HaveAired: 2}, // complete + 2: {Aired: 1, Upcoming: 1, Have: 0, HaveAired: 0}, // its file is in a disabled library; e2 has not aired + 3: {Aired: 0, Have: 1, HaveAired: 0}, // no air dates yet + 4: {Aired: 3, Upcoming: 1, Have: 3, HaveAired: 2}, // e2 through the multi-episode file; e3 missing + 5: {Aired: 0, Upcoming: 2, Have: 1, HaveAired: 0}, // dated, not aired yet + } + if len(got) != len(want) { + t.Fatalf("seasons = %+v, want %+v (specials excluded)", got, want) + } + for season, w := range want { + if got[season] != w { + t.Errorf("season %d = %+v, want %+v", season, got[season], w) + } + } +} diff --git a/internal/contractledger/ledger_test.go b/internal/contractledger/ledger_test.go index 9bb02e11a7..e1b7f69b76 100644 --- a/internal/contractledger/ledger_test.go +++ b/internal/contractledger/ledger_test.go @@ -878,7 +878,12 @@ 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.", + "updateAdminRequestGroupLimit": "V2-only access-group request limit: the limit's revision from request_editor_revision_seq is its ETag; a group with none saved is revision zero.", + "updateRequestRouting": "V2-only request routing mode (Standard or Advanced): the mode's revision from request_editor_revision_seq is its ETag.", +} // TestGuardedOperationsAreMarkedIfMatch reconciles the v2 registry with the // ledger: every operation registered Guarded must have each legacy row that @@ -1209,6 +1214,15 @@ func TestRetrySafetyMismatchesFire(t *testing.T) { var mutationWithoutLegacyRow = map[string]string{ "redetectAdminItemMarkers": "V2-only choice of marker kinds to re-detect: v1 re-detected episode intros only, which redetectAdminEpisodeIntro keeps porting. Work is coalesced per item within the process, so a replay while it runs reports already_running; a later replay analyzes again, so it is non-retryable like the intro action.", "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.", + "updateAdminRequestGroupLimit": "V2-only access-group request limit replacement, guarded by If-Match on the limit's revision; a replay after success answers 412.", + "updateRequestRouting": "V2-only request routing mode switch, guarded by If-Match on the mode's revision; a replay after success answers 412.", + "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.", "importAdminTMDBList": "V2-only administrator import of a public TMDB list: v1 had no TMDB list source. Like the other imports it creates a new collection per call and is non-retryable.", "importTMDBListCollection": "V2-only personal import of a public TMDB list: v1 had no TMDB list source. Like the other imports it creates a new collection per call and is non-retryable.", "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/database/pglock/pglock.go b/internal/database/pglock/pglock.go index 9a41906358..56608c1e2d 100644 --- a/internal/database/pglock/pglock.go +++ b/internal/database/pglock/pglock.go @@ -11,9 +11,13 @@ package pglock import ( "context" + "errors" "fmt" + "slices" + "strconv" "time" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" ) @@ -30,6 +34,8 @@ const ArtworkReconcileLockKey int64 = 0x53494c4f535452 type Lock struct { conn *pgxpool.Conn key int64 + // also holds the keys AcquireAlso took on the same session. + also []int64 } // TryAcquire takes advisory lock key without blocking. It reports acquired @@ -58,6 +64,65 @@ func TryAcquire(ctx context.Context, pool *pgxpool.Pool, key int64) (*Lock, bool return &Lock{conn: conn, key: key}, true, nil } +// lockNotAvailable is the SQLSTATE PostgreSQL reports when lock_timeout ends a +// lock wait. +const lockNotAvailable = "55P03" + +// AcquireAlso takes advisory lock key on the session that already holds l, +// waiting up to wait for another session holding key to release it, or not +// at all when wait is zero or less. Work guarded by two locks then holds one +// pooled connection instead of two. It reports false with no error when the +// wait runs out; l keeps its own lock. Release unlocks every key l holds. +// +// PostgreSQL enforces the wait with a transaction-local lock_timeout, so a +// wait that runs out leaves the lock queue and the session clean. The lock +// itself is session-level and outlives that transaction. Any other failure +// closes the session, since PostgreSQL may have granted the lock just before +// reporting the error; that drops l's lock too, and Release becomes a no-op. +func (l *Lock) AcquireAlso(ctx context.Context, key int64, wait time.Duration) (bool, error) { + if l == nil || l.conn == nil { + return false, fmt.Errorf("acquiring advisory lock %d: no session holds a lock", key) + } + var locked bool + var err error + if wait <= 0 { + err = l.conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1)`, key).Scan(&locked) + } else { + locked, err = lockWithin(ctx, l.conn, key, wait) + } + if err != nil { + conn := l.conn + l.conn = nil + _ = conn.Hijack().Close(context.WithoutCancel(ctx)) + return false, fmt.Errorf("acquiring advisory lock %d: %w", key, err) + } + if locked { + l.also = append(l.also, key) + } + return locked, nil +} + +// lockWithin takes advisory lock key on conn, waiting up to wait. It reports +// false with no error when the wait runs out. After any error the session's +// lock state is unknown and conn must be closed, not reused. +func lockWithin(ctx context.Context, conn *pgxpool.Conn, key int64, wait time.Duration) (bool, error) { + tx, err := conn.Begin(ctx) + if err != nil { + return false, err + } + if _, err := tx.Exec(ctx, `SELECT set_config('lock_timeout', $1, true)`, strconv.FormatInt(max(wait.Milliseconds(), 1), 10)); err != nil { + return false, err + } + _, err = tx.Exec(ctx, `SELECT pg_advisory_lock($1)`, key) + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == lockNotAvailable { + return false, tx.Rollback(ctx) + } + if err != nil { + return false, err + } + return true, tx.Commit(ctx) +} + // Conn exposes the connection holding the lock. Work that must be serialized // against the lock may run on any connection; this is for callers that want to // keep it on the locked session. @@ -68,9 +133,10 @@ func (l *Lock) Conn() *pgxpool.Conn { return l.conn } -// Release unlocks and returns the connection to the pool. If the unlock fails +// Release unlocks every key the session holds (the lock's own and any +// AcquireAlso took) and returns the connection to the pool. If an unlock fails // or reports that the lock was not held, the connection is hijacked out of the -// pool and closed so the stranded lock dies with it. +// pool and closed so the stranded locks die with it. // // Release is idempotent and safe on a nil Lock. func (l *Lock) Release(ctx context.Context) error { @@ -83,17 +149,23 @@ func (l *Lock) Release(ctx context.Context) error { releaseCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), releaseTimeout) defer cancel() - var unlocked bool - err := conn.QueryRow(releaseCtx, `SELECT pg_advisory_unlock($1)`, l.key).Scan(&unlocked) - if err == nil && unlocked { - conn.Release() - return nil - } - - rawConn := conn.Hijack() - _ = rawConn.Close(context.WithoutCancel(ctx)) - if err != nil { - return fmt.Errorf("releasing advisory lock %d: %w", l.key, err) + // The keys AcquireAlso took, newest first, then the lock's own key. + keys := append(slices.Clone(l.also), l.key) + slices.Reverse(keys[:len(l.also)]) + l.also = nil + for _, key := range keys { + var unlocked bool + err := conn.QueryRow(releaseCtx, `SELECT pg_advisory_unlock($1)`, key).Scan(&unlocked) + if err == nil && unlocked { + continue + } + rawConn := conn.Hijack() + _ = rawConn.Close(context.WithoutCancel(ctx)) + if err != nil { + return fmt.Errorf("releasing advisory lock %d: %w", key, err) + } + return fmt.Errorf("releasing advisory lock %d: lock was not held", key) } - return fmt.Errorf("releasing advisory lock %d: lock was not held", l.key) + conn.Release() + return nil } diff --git a/internal/database/pglock/pglock_test.go b/internal/database/pglock/pglock_test.go index 9f7b3bf64c..59aed1d1c0 100644 --- a/internal/database/pglock/pglock_test.go +++ b/internal/database/pglock/pglock_test.go @@ -122,3 +122,155 @@ func TestTryAcquireNilPoolReportsNotAcquired(t *testing.T) { t.Fatalf("Release on nil lock: %v", err) } } + +// lockWaiters counts the sessions queued for advisory lock key. +func lockWaiters(t *testing.T, pool *pgxpool.Pool, key int64) int { + t.Helper() + var waiters int + err := pool.QueryRow(context.Background(), ` + SELECT count(*) FROM pg_locks + WHERE locktype = 'advisory' + AND NOT granted + AND ((classid::bigint << 32) | objid::bigint) = $1`, key).Scan(&waiters) + if err != nil { + t.Fatalf("inspect pg_locks: %v", err) + } + return waiters +} + +// pglockOtherTestKey is a second key for the tests that hold two locks. +const pglockOtherTestKey int64 = 0x70676C6F636B02 + +// AcquireAlso waits for another session's hold on the second key, then holds +// both keys on one session, and Release frees both. +func TestAcquireAlsoWaitsForTheHolder(t *testing.T) { + pool := testPool(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + other, acquired, err := TryAcquire(ctx, pool, pglockOtherTestKey) + if err != nil || !acquired { + t.Fatalf("TryAcquire(other) = (%v, %v), want acquired", acquired, err) + } + lock, acquired, err := TryAcquire(ctx, pool, pglockTestKey) + if err != nil || !acquired { + t.Fatalf("TryAcquire = (%v, %v), want acquired", acquired, err) + } + type result struct { + acquired bool + err error + } + done := make(chan result, 1) + go func() { + acquired, err := lock.AcquireAlso(ctx, pglockOtherTestKey, 20*time.Second) + done <- result{acquired, err} + }() + for lockWaiters(t, pool, pglockOtherTestKey) == 0 { + select { + case got := <-done: + t.Fatalf("AcquireAlso returned (%v, %v) while the key was held", got.acquired, got.err) + case <-ctx.Done(): + t.Fatal("AcquireAlso never queued for the key") + case <-time.After(10 * time.Millisecond): + } + } + if err := other.Release(ctx); err != nil { + t.Fatalf("Release(other): %v", err) + } + if got := <-done; got.err != nil || !got.acquired { + t.Fatalf("AcquireAlso = (%v, %v), want acquired once the holder released", got.acquired, got.err) + } + // Both keys outlive the transaction that set the wait, on one session. + var sessions int + if err := pool.QueryRow(ctx, ` + SELECT count(DISTINCT pid) FROM pg_locks + WHERE locktype = 'advisory' AND granted + AND ((classid::bigint << 32) | objid::bigint) = ANY($1)`, []int64{pglockTestKey, pglockOtherTestKey}).Scan(&sessions); err != nil { + t.Fatalf("inspect pg_locks: %v", err) + } + if !lockHeld(t, pool, pglockTestKey) || !lockHeld(t, pool, pglockOtherTestKey) || sessions != 1 { + t.Fatalf("held = %v and %v on %d sessions, want both keys on one session", + lockHeld(t, pool, pglockTestKey), lockHeld(t, pool, pglockOtherTestKey), sessions) + } + if err := lock.Release(ctx); err != nil { + t.Fatalf("Release: %v", err) + } + if lockHeld(t, pool, pglockTestKey) || lockHeld(t, pool, pglockOtherTestKey) { + t.Fatal("a key is still held after Release") + } +} + +// A wait that runs out reports not acquired, keeps the session's own lock, and +// leaves the session without the lock_timeout it set. +func TestAcquireAlsoGivesUpAfterTheWait(t *testing.T) { + pool := testPool(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + other, acquired, err := TryAcquire(ctx, pool, pglockOtherTestKey) + if err != nil || !acquired { + t.Fatalf("TryAcquire(other) = (%v, %v), want acquired", acquired, err) + } + defer func() { _ = other.Release(ctx) }() + lock, acquired, err := TryAcquire(ctx, pool, pglockTestKey) + if err != nil || !acquired { + t.Fatalf("TryAcquire = (%v, %v), want acquired", acquired, err) + } + defer func() { _ = lock.Release(ctx) }() + + if acquired, err := lock.AcquireAlso(ctx, pglockOtherTestKey, 100*time.Millisecond); err != nil || acquired { + t.Fatalf("AcquireAlso = (%v, %v), want (false, nil) after the wait", acquired, err) + } + if waiters := lockWaiters(t, pool, pglockOtherTestKey); waiters != 0 { + t.Fatalf("%d sessions still queued for the key, want none", waiters) + } + if !lockHeld(t, pool, pglockTestKey) { + t.Fatal("the session lost its own lock when the wait ran out") + } + var timeout string + if err := lock.Conn().QueryRow(ctx, `SHOW lock_timeout`).Scan(&timeout); err != nil { + t.Fatalf("read lock_timeout: %v", err) + } + if timeout != "0" { + t.Fatalf("session lock_timeout = %q, want the default 0", timeout) + } + if err := lock.Release(ctx); err != nil { + t.Fatalf("Release: %v", err) + } + if lockHeld(t, pool, pglockTestKey) { + t.Fatal("advisory lock still held after Release") + } +} + +// Without a wait, AcquireAlso only tries. +func TestAcquireAlsoWithoutAWaitOnlyTries(t *testing.T) { + pool := testPool(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + lock, acquired, err := TryAcquire(ctx, pool, pglockTestKey) + if err != nil || !acquired { + t.Fatalf("TryAcquire = (%v, %v), want acquired", acquired, err) + } + defer func() { _ = lock.Release(ctx) }() + other, acquired, err := TryAcquire(ctx, pool, pglockOtherTestKey) + if err != nil || !acquired { + t.Fatalf("TryAcquire(other) = (%v, %v), want acquired", acquired, err) + } + if acquired, err := lock.AcquireAlso(ctx, pglockOtherTestKey, 0); err != nil || acquired { + t.Fatalf("AcquireAlso while held = (%v, %v), want (false, nil)", acquired, err) + } + if err := other.Release(ctx); err != nil { + t.Fatalf("Release(other): %v", err) + } + if acquired, err := lock.AcquireAlso(ctx, pglockOtherTestKey, 0); err != nil || !acquired { + t.Fatalf("AcquireAlso once free = (%v, %v), want acquired", acquired, err) + } +} + +func TestAcquireAlsoWithoutALockFails(t *testing.T) { + var lock *Lock + if acquired, err := lock.AcquireAlso(context.Background(), pglockOtherTestKey, time.Minute); err == nil || acquired { + t.Fatalf("AcquireAlso on a nil Lock = (%v, %v), want an error", acquired, err) + } +} diff --git a/internal/metadata/tmdb/client.go b/internal/metadata/tmdb/client.go index 26495fcafc..062ce762f4 100644 --- a/internal/metadata/tmdb/client.go +++ b/internal/metadata/tmdb/client.go @@ -3,10 +3,12 @@ package tmdb import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" "net/url" + "slices" "sort" "strconv" "strings" @@ -29,9 +31,14 @@ const ( // never changes. The stale-window failure mode is safe — a title that // gains a cert stays hidden (fail-closed) for at most the TTL. certificationCacheTTL = 7 * 24 * time.Hour - // certificationFetchTimeout bounds the shared (caller-detached) - // singleflight fetch; see GetCertification. - certificationFetchTimeout = 30 * time.Second + // sharedFetchTimeout bounds a shared (caller-detached) singleflight + // fetch; see cachedCertification and GetMediaDetail. + sharedFetchTimeout = 30 * time.Second + // mediaDetailCacheTTL is deliberately short: a detail is large, so the + // cache holds only the titles being looked at right now. It is long enough + // that a title page polling a download's progress every 30 seconds, and + // everyone else viewing the title, share one fetch. + mediaDetailCacheTTL = 2 * time.Minute ) // Client is an HTTP client for the TMDB collection preset API surface. @@ -44,6 +51,7 @@ type Client struct { discoverPageCache *cache.TTLCache[*MediaPage] externalIDCache *cache.TTLCache[*ExternalIDs] certificationCache *cache.TTLCache[string] + mediaDetailCache *cache.TTLCache[*MediaDetail] cacheGroup singleflight.Group responseCacheTTL time.Duration } @@ -51,6 +59,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 == "" { @@ -65,6 +77,7 @@ func NewClient(apiKey string, rateLimit int) *Client { discoverPageCache: cache.NewTTLCache[*MediaPage](), externalIDCache: cache.NewTTLCache[*ExternalIDs](), certificationCache: cache.NewTTLCache[string](), + mediaDetailCache: cache.NewTTLCache[*MediaDetail](), responseCacheTTL: defaultResponseCacheTTL, } } @@ -91,6 +104,9 @@ func (c *Client) Close() { if c.certificationCache != nil { c.certificationCache.Close() } + if c.mediaDetailCache != nil { + c.mediaDetailCache.Close() + } } // doGet executes a GET request against the TMDB API with rate limiting, @@ -149,11 +165,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) @@ -877,29 +899,105 @@ func (c *Client) GetList(ctx context.Context, id, limit int) ([]CollectionResult // // Cast is sorted by TMDB billing order and capped at 24 entries to keep the // payload bounded. +// +// A detail is cached for mediaDetailCacheTTL, and concurrent callers for one +// title share a fetch. Each caller gets its own copy. func (c *Client) GetMediaDetail(ctx context.Context, mediaType string, id int) (*MediaDetail, error) { if id <= 0 { return nil, fmt.Errorf("tmdb: media id must be > 0 (got %d)", id) } - + var fetch func(context.Context) (*MediaDetail, error) switch mediaType { case "movie": - path := fmt.Sprintf("/movie/%d?append_to_response=credits,external_ids,recommendations,release_dates,keywords", id) - var resp movieDetailResponse - if err := c.doGet(ctx, path, &resp); err != nil { - return nil, err + fetch = func(fetchCtx context.Context) (*MediaDetail, error) { + path := fmt.Sprintf("/movie/%d?append_to_response=credits,external_ids,recommendations,release_dates,keywords", id) + var resp movieDetailResponse + if err := c.doGet(fetchCtx, path, &resp); err != nil { + return nil, err + } + return normalizeMovieDetail(&resp), nil } - return normalizeMovieDetail(&resp), nil case "series", "tv": - path := fmt.Sprintf("/tv/%d?append_to_response=credits,external_ids,recommendations,content_ratings,keywords", id) - var resp tvDetailResponse - if err := c.doGet(ctx, path, &resp); err != nil { - return nil, err + mediaType = "tv" + fetch = func(fetchCtx context.Context) (*MediaDetail, error) { + path := fmt.Sprintf("/tv/%d?append_to_response=credits,external_ids,recommendations,content_ratings,keywords", id) + var resp tvDetailResponse + if err := c.doGet(fetchCtx, path, &resp); err != nil { + return nil, err + } + return normalizeTVDetail(&resp), nil } - return normalizeTVDetail(&resp), nil default: return nil, fmt.Errorf("tmdb: invalid media type for detail: %q", mediaType) } + + cacheKey := "media_detail:" + mediaType + ":" + strconv.Itoa(id) + if c.mediaDetailCache != nil { + if cached, ok := c.mediaDetailCache.Get(cacheKey); ok { + return cloneMediaDetail(cached), nil + } + } + // DoChan + select for the same reason as cachedCertification: the shared + // fetch survives any one caller's disconnect, and each caller stops + // waiting on its own cancellation. + resultCh := c.cacheGroup.DoChan(cacheKey, func() (any, error) { + if c.mediaDetailCache != nil { + if cached, ok := c.mediaDetailCache.Get(cacheKey); ok { + return cached, nil + } + } + fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), sharedFetchTimeout) + defer cancel() + detail, err := fetch(fetchCtx) + if err != nil { + return nil, err + } + if c.mediaDetailCache != nil { + c.mediaDetailCache.Set(cacheKey, detail, mediaDetailCacheTTL) + } + return detail, nil + }) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case result := <-resultCh: + if result.Err != nil { + return nil, result.Err + } + detail, ok := result.Val.(*MediaDetail) + if !ok { + return nil, fmt.Errorf("tmdb: invalid cached media detail response") + } + return cloneMediaDetail(detail), nil + } +} + +// cloneMediaDetail copies a detail deeply enough that a caller changing its +// copy cannot change the cached one. +func cloneMediaDetail(detail *MediaDetail) *MediaDetail { + if detail == nil { + return nil + } + cloned := *detail + cloned.Genres = slices.Clone(detail.Genres) + if detail.Certifications != nil { + cloned.Certifications = make(map[string][]string, len(detail.Certifications)) + for country, certs := range detail.Certifications { + cloned.Certifications[country] = slices.Clone(certs) + } + } + cloned.ProductionCompanies = slices.Clone(detail.ProductionCompanies) + cloned.KeywordIDs = slices.Clone(detail.KeywordIDs) + cloned.GenreIDs = slices.Clone(detail.GenreIDs) + cloned.CompanyIDs = slices.Clone(detail.CompanyIDs) + cloned.NetworkIDs = slices.Clone(detail.NetworkIDs) + cloned.OriginCountries = slices.Clone(detail.OriginCountries) + cloned.Networks = slices.Clone(detail.Networks) + cloned.Seasons = slices.Clone(detail.Seasons) + cloned.Cast = slices.Clone(detail.Cast) + cloned.Creators = slices.Clone(detail.Creators) + cloned.Recommendations = slices.Clone(detail.Recommendations) + return &cloned } func normalizeMovieDetail(resp *movieDetailResponse) *MediaDetail { @@ -922,13 +1020,20 @@ func normalizeMovieDetail(resp *movieDetailResponse) *MediaDetail { Status: resp.Status, Homepage: resp.Homepage, ContentRating: pickMovieCertification(resp.ReleaseDates), + USCertification: pickUSMovieCertification(resp.ReleaseDates), + Certifications: movieCertifications(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 @@ -982,8 +1087,12 @@ func normalizeTVDetail(resp *tvDetailResponse) *MediaDetail { NumberOfSeasons: resp.NumberOfSeasons, NumberOfEpisodes: resp.NumberOfEpisodes, ContentRating: pickTVRating(resp.ContentRatings), + USCertification: pickUSTVRating(resp.ContentRatings), + Certifications: tvCertifications(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] @@ -992,6 +1101,21 @@ 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) + } + } + for _, season := range resp.Seasons { + if season.SeasonNumber < 0 { + continue + } + detail.Seasons = append(detail.Seasons, SeasonSummary{ + Number: season.SeasonNumber, + Name: strings.TrimSpace(season.Name), + EpisodeCount: season.EpisodeCount, + AirDate: season.AirDate, + PosterPath: season.PosterPath, + }) } if resp.ExternalIDs != nil { detail.IMDbID = resp.ExternalIDs.IMDbID @@ -1025,6 +1149,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 @@ -1228,7 +1362,8 @@ func cloneExternalIDs(ids *ExternalIDs) *ExternalIDs { // "TV-MA", ...), or "" when the title has no US certification. It uses the // dedicated release_dates / content_ratings sub-resources instead of the full // detail payload for the same reason GetExternalIDs does: the detail response -// is 100+ KB and uncached, while these are a country list of a few KB. +// is 100+ KB and cached only briefly, while these are a country list of a few +// KB. // // Unlike GetMediaDetail's display rating, this deliberately does NOT fall // back to another country's certification: the value feeds the US-scale @@ -1237,17 +1372,57 @@ func cloneExternalIDs(ids *ExternalIDs) *ExternalIDs { // ladder treats as fail-closed. mediaType accepts Silo-facing // "movie"/"series" plus TMDB-facing "tv". func (c *Client) GetCertification(ctx context.Context, mediaType string, id int) (string, error) { - var path string + path, err := certificationPath(mediaType, id) + if err != nil { + return "", err + } + return c.cachedCertification(ctx, "certification:"+path, func(fetchCtx context.Context) (string, error) { + return c.fetchCertification(fetchCtx, mediaType, path) + }) +} + +// GetCertifications returns every country's certifications for a title, +// keyed by ISO 3166-1 code, from the same small sub-resource and cache as +// GetCertification. Request routing reads a title's own country's rating +// from it when the title has no US one; the parental-control path keeps to +// GetCertification. +func (c *Client) GetCertifications(ctx context.Context, mediaType string, id int) (map[string][]string, error) { + path, err := certificationPath(mediaType, id) + if err != nil { + return nil, err + } + raw, err := c.cachedCertification(ctx, "certifications:"+path, func(fetchCtx context.Context) (string, error) { + certs, err := c.fetchCertifications(fetchCtx, mediaType, path) + if err != nil { + return "", err + } + encoded, err := json.Marshal(certs) + return string(encoded), err + }) + if err != nil { + return nil, err + } + var certs map[string][]string + if err := json.Unmarshal([]byte(raw), &certs); err != nil { + return nil, fmt.Errorf("tmdb: invalid cached certifications: %w", err) + } + return certs, nil +} + +func certificationPath(mediaType string, id int) (string, error) { switch mediaType { case "movie": - path = fmt.Sprintf("/movie/%d/release_dates", id) + return fmt.Sprintf("/movie/%d/release_dates", id), nil case "series", "tv": - path = fmt.Sprintf("/tv/%d/content_ratings", id) + return fmt.Sprintf("/tv/%d/content_ratings", id), nil default: return "", fmt.Errorf("tmdb: invalid media type: %q", mediaType) } +} - cacheKey := "certification:" + path +// cachedCertification fetches one certification value once across concurrent +// callers and caches it. +func (c *Client) cachedCertification(ctx context.Context, cacheKey string, fetch func(context.Context) (string, error)) (string, error) { if c.certificationCache != nil { if cached, ok := c.certificationCache.Get(cacheKey); ok { return cached, nil @@ -1264,9 +1439,9 @@ func (c *Client) GetCertification(ctx context.Context, mediaType string, id int) return cached, nil } } - fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), certificationFetchTimeout) + fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), sharedFetchTimeout) defer cancel() - cert, err := c.fetchCertification(fetchCtx, mediaType, path) + cert, err := fetch(fetchCtx) if err != nil { return nil, err } @@ -1293,6 +1468,75 @@ func (c *Client) GetCertification(ctx context.Context, mediaType string, id int) } } +func (c *Client) fetchCertifications(ctx context.Context, mediaType, path string) (map[string][]string, error) { + if mediaType == "movie" { + var resp releaseDatesResponse + if err := c.doGet(ctx, path, &resp); err != nil { + return nil, err + } + return movieCertifications(&resp), nil + } + var resp contentRatingsResponse + if err := c.doGet(ctx, path, &resp); err != nil { + return nil, err + } + return tvCertifications(&resp), nil +} + +// USCertificationFrom picks the US rating out of GetCertifications' answer +// the way GetCertification does: the strictest recognized movie +// certification, a series' first rating. +func USCertificationFrom(mediaType string, certs map[string][]string) string { + us := certs["US"] + if mediaType != "movie" { + if len(us) > 0 { + return us[0] + } + return "" + } + var picked string + pickedRank := -1 + for _, cert := range us { + if rank := usCertificationRank[strings.ToUpper(cert)]; rank > pickedRank || picked == "" { + picked, pickedRank = cert, rank + } + } + return picked +} + +// movieCertifications lists each country's non-empty certifications, in +// TMDB's order, without repeats. +func movieCertifications(rd *releaseDatesResponse) map[string][]string { + out := map[string][]string{} + if rd == nil { + return out + } + for _, country := range rd.Results { + code := strings.ToUpper(strings.TrimSpace(country.ISO3166)) + for _, entry := range country.ReleaseDates { + if cert := strings.TrimSpace(entry.Certification); code != "" && cert != "" && !slices.Contains(out[code], cert) { + out[code] = append(out[code], cert) + } + } + } + return out +} + +// tvCertifications lists each country's non-empty rating. +func tvCertifications(cr *contentRatingsResponse) map[string][]string { + out := map[string][]string{} + if cr == nil { + return out + } + for _, entry := range cr.Results { + code := strings.ToUpper(strings.TrimSpace(entry.ISO3166)) + if rating := strings.TrimSpace(entry.Rating); code != "" && rating != "" && !slices.Contains(out[code], rating) { + out[code] = append(out[code], rating) + } + } + return out +} + func (c *Client) fetchCertification(ctx context.Context, mediaType, path string) (string, error) { if mediaType == "movie" { var resp releaseDatesResponse diff --git a/internal/metadata/tmdb/client_test.go b/internal/metadata/tmdb/client_test.go index 5e70f3d0b3..08cadcd24c 100644 --- a/internal/metadata/tmdb/client_test.go +++ b/internal/metadata/tmdb/client_test.go @@ -2,10 +2,14 @@ package tmdb import ( "context" + "encoding/json" "errors" "net/http" "net/http/httptest" "reflect" + "slices" + "strings" + "sync" "sync/atomic" "testing" "time" @@ -1176,6 +1180,106 @@ func TestGetCertificationSingleflightsConcurrentCallers(t *testing.T) { } } +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) + } +} + +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) + } +} + +func TestGetCertificationsListsEveryCountry(t *testing.T) { + var calls int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/movie/129/release_dates": + _, _ = w.Write([]byte(`{"results":[ + {"iso_3166_1":"JP","release_dates":[{"certification":"G","type":3},{"certification":"","type":4},{"certification":"G","type":5}]}, + {"iso_3166_1":"de","release_dates":[{"certification":"6","type":3}]} + ]}`)) + case "/tv/209867/content_ratings": + _, _ = w.Write([]byte(`{"results":[{"iso_3166_1":"JP","rating":"PG12"},{"iso_3166_1":"KR","rating":""}]}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + client := NewClient("test-key", 1000) + client.SetBaseURL(server.URL) + + movie, err := client.GetCertifications(context.Background(), "movie", 129) + if err != nil || len(movie) != 2 || len(movie["JP"]) != 1 || movie["JP"][0] != "G" || movie["DE"][0] != "6" { + t.Fatalf("movie certifications = %v, %v", movie, err) + } + series, err := client.GetCertifications(context.Background(), "series", 209867) + if err != nil || len(series) != 1 || series["JP"][0] != "PG12" { + t.Fatalf("series certifications = %v, %v", series, err) + } + // Cached like GetCertification. + before := calls + if _, err := client.GetCertifications(context.Background(), "movie", 129); err != nil || calls != before { + t.Fatalf("second read made %d calls, err %v", calls-before, err) + } + // The detail carries the same map. + if got := movieCertifications(&releaseDatesResponse{Results: []releaseDatesCountryEntry{{ISO3166: "fr", ReleaseDates: []releaseDateEntry{{Certification: "U"}}}}}); got["FR"][0] != "U" { + t.Fatalf("detail certifications = %v", got) + } +} + +func TestUSCertificationFromMatchesGetCertification(t *testing.T) { + movie := map[string][]string{"US": {"NR", "PG", "R"}, "JP": {"G"}} + if got := USCertificationFrom("movie", movie); got != "R" { + t.Fatalf("movie = %q, want the strictest, R", got) + } + if got := USCertificationFrom("series", map[string][]string{"US": {"TV-14", "TV-MA"}}); got != "TV-14" { + t.Fatalf("series = %q, want the first", got) + } + if got := USCertificationFrom("movie", map[string][]string{"JP": {"G"}}); got != "" { + t.Fatalf("no US entry = %q", got) + } +} + func TestGetListPagesMixedEntriesInListOrder(t *testing.T) { var pages []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -1238,3 +1342,234 @@ func TestGetListRejectsNonPositiveID(t *testing.T) { t.Fatal("GetList(0) succeeded, want error") } } + +// A title page polls its detail while a request downloads, and several people +// may have it open: they share one fetch for the cache's TTL, each with a copy +// of its own. A failure is not cached. +func TestGetMediaDetailIsCachedBriefly(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/movie/129": + _, _ = w.Write([]byte(`{"id": 129, "title": "Spirited Away", "genres": [{"id": 16, "name": "Animation"}], + "recommendations": {"results": [{"id": 4935, "title": "Howl's Moving Castle"}]}}`)) + case "/tv/95396": + _, _ = w.Write([]byte(`{"id": 95396, "name": "Severance"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + client := NewClient("key", 40) + defer client.Close() + client.SetBaseURL(server.URL) + ctx := context.Background() + + first, err := client.GetMediaDetail(ctx, "movie", 129) + if err != nil { + t.Fatal(err) + } + first.Genres[0], first.Recommendations[0].Title = "changed", "changed" + second, err := client.GetMediaDetail(ctx, "movie", 129) + if err != nil { + t.Fatal(err) + } + if calls.Load() != 1 { + t.Fatalf("upstream calls = %d, want 1 while the detail is cached", calls.Load()) + } + if second.Genres[0] != "Animation" || second.Recommendations[0].Title != "Howl's Moving Castle" { + t.Fatalf("second detail = %+v; a caller's change reached the cache", second) + } + + // "series" and "tv" name the same title. + for _, mediaType := range []string{"series", "tv"} { + if detail, err := client.GetMediaDetail(ctx, mediaType, 95396); err != nil || detail.Title != "Severance" { + t.Fatalf("GetMediaDetail(%s) = %+v, %v", mediaType, detail, err) + } + } + if calls.Load() != 2 { + t.Fatalf("upstream calls = %d, want 2", calls.Load()) + } + + for range 2 { + if _, err := client.GetMediaDetail(ctx, "movie", 404); !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } + } + if calls.Load() != 4 { + t.Fatalf("upstream calls = %d, want each failure fetched again", calls.Load()) + } +} + +// blockedDetailServer answers /movie/129 once release is closed. started is +// closed when the first request arrives; calls counts every request. +func blockedDetailServer(t *testing.T) (client *Client, started <-chan struct{}, release func(), calls *atomic.Int32) { + t.Helper() + startedCh := make(chan struct{}) + releaseCh := make(chan struct{}) + var startOnce, releaseOnce sync.Once + calls = new(atomic.Int32) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + startOnce.Do(func() { close(startedCh) }) + <-releaseCh + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id": 129, "title": "Spirited Away"}`)) + })) + release = func() { releaseOnce.Do(func() { close(releaseCh) }) } + client = NewClient("key", 1000) + client.SetBaseURL(server.URL) + t.Cleanup(func() { + release() + server.Close() + client.Close() + }) + return client, startedCh, release, calls +} + +// waitingCtx closes waiting the first time its caller selects on Done. A +// follower does that only once it has joined the in-flight fetch. +type waitingCtx struct { + context.Context + once sync.Once + waiting chan struct{} +} + +func newWaitingCtx(parent context.Context) *waitingCtx { + return &waitingCtx{Context: parent, waiting: make(chan struct{})} +} + +func (c *waitingCtx) Done() <-chan struct{} { + c.once.Do(func() { close(c.waiting) }) + return c.Context.Done() +} + +type detailResult struct { + detail *MediaDetail + err error +} + +func getMediaDetailAsync(ctx context.Context, client *Client) <-chan detailResult { + out := make(chan detailResult, 1) + go func() { + detail, err := client.GetMediaDetail(ctx, "movie", 129) + out <- detailResult{detail, err} + }() + return out +} + +func awaitDetail(t *testing.T, who string, ch <-chan detailResult) detailResult { + t.Helper() + select { + case result := <-ch: + return result + case <-time.After(5 * time.Second): + t.Fatalf("%s is still blocked on the shared detail fetch", who) + return detailResult{} + } +} + +// The caller that starts a shared detail fetch may disconnect; everyone else +// waiting on the same title still gets it. +func TestGetMediaDetailSurvivesLeaderCancellation(t *testing.T) { + client, started, release, calls := blockedDetailServer(t) + + leaderCtx, cancelLeader := context.WithCancel(context.Background()) + defer cancelLeader() + leader := getMediaDetailAsync(leaderCtx, client) + <-started + + followerCtx := newWaitingCtx(context.Background()) + follower := getMediaDetailAsync(followerCtx, client) + select { + case <-followerCtx.waiting: + case <-time.After(5 * time.Second): + t.Fatal("follower never waited on its own context") + } + + cancelLeader() + if got := awaitDetail(t, "canceled leader", leader); !errors.Is(got.err, context.Canceled) { + t.Fatalf("leader err = %v, want context.Canceled", got.err) + } + release() + got := awaitDetail(t, "follower", follower) + if got.err != nil || got.detail.Title != "Spirited Away" { + t.Fatalf("follower = %+v, %v; want the detail after the leader canceled", got.detail, got.err) + } + if calls.Load() != 1 { + t.Fatalf("upstream calls = %d, want 1 shared fetch", calls.Load()) + } +} + +// A waiting caller that disconnects stops waiting at once, and the shared +// fetch still completes for the caller that started it. +func TestGetMediaDetailFollowerStopsWaitingOnCancel(t *testing.T) { + client, started, release, calls := blockedDetailServer(t) + + leader := getMediaDetailAsync(context.Background(), client) + <-started + + followerCtx, cancelFollower := context.WithCancel(context.Background()) + defer cancelFollower() + waiting := newWaitingCtx(followerCtx) + follower := getMediaDetailAsync(waiting, client) + select { + case <-waiting.waiting: + case <-time.After(5 * time.Second): + t.Fatal("follower never waited on its own context") + } + + cancelFollower() + if got := awaitDetail(t, "canceled follower", follower); !errors.Is(got.err, context.Canceled) { + t.Fatalf("follower err = %v, want context.Canceled", got.err) + } + release() + got := awaitDetail(t, "leader", leader) + if got.err != nil || got.detail.Title != "Spirited Away" { + t.Fatalf("leader = %+v, %v; want the detail after the follower canceled", got.detail, got.err) + } + if calls.Load() != 1 { + t.Fatalf("upstream calls = %d, want 1 shared fetch", calls.Load()) + } +} + +// Every slice and map a detail holds is copied, so no caller shares one with +// the cache; a field added later is caught here. +func TestCloneMediaDetailCopiesEveryReference(t *testing.T) { + var detail MediaDetail + v := reflect.ValueOf(&detail).Elem() + for i := range v.NumField() { + field := v.Field(i) + switch field.Kind() { + case reflect.Slice: + field.Set(reflect.MakeSlice(field.Type(), 1, 1)) + case reflect.Map: + m := reflect.MakeMap(field.Type()) + m.SetMapIndex(reflect.New(field.Type().Key()).Elem(), reflect.MakeSlice(field.Type().Elem(), 1, 1)) + field.Set(m) + case reflect.Pointer, reflect.Interface, reflect.Chan, reflect.Func: + t.Fatalf("MediaDetail.%s is a %s; teach cloneMediaDetail to copy it", v.Type().Field(i).Name, field.Kind()) + } + } + cloned := reflect.ValueOf(cloneMediaDetail(&detail)).Elem() + for i := range v.NumField() { + name := v.Type().Field(i).Name + switch v.Field(i).Kind() { + case reflect.Slice: + if cloned.Field(i).Pointer() == v.Field(i).Pointer() { + t.Errorf("MediaDetail.%s shares its backing array with the cached detail", name) + } + case reflect.Map: + if cloned.Field(i).Pointer() == v.Field(i).Pointer() { + t.Errorf("MediaDetail.%s shares its map with the cached detail", name) + } + for _, key := range v.Field(i).MapKeys() { + if cloned.Field(i).MapIndex(key).Pointer() == v.Field(i).MapIndex(key).Pointer() { + t.Errorf("MediaDetail.%s[%v] shares its slice with the cached detail", name, key) + } + } + } + } +} diff --git a/internal/metadata/tmdb/types.go b/internal/metadata/tmdb/types.go index ead0691ee2..a1980e7a3e 100644 --- a/internal/metadata/tmdb/types.go +++ b/internal/metadata/tmdb/types.go @@ -179,34 +179,50 @@ type apiError struct { // MediaType is Silo-facing: "movie" or "series". Series-specific fields are // zero-valued for movies and vice versa. type MediaDetail struct { - MediaType string - ID int - IMDbID string - TVDBID int - Title string - OriginalTitle string - Tagline string - Overview string - PosterPath string - BackdropPath string - ReleaseDate string - Year int - Runtime int - Genres []string - VoteAverage float64 - VoteCount int - Status string - Homepage string - ContentRating string + MediaType string + ID int + IMDbID string + TVDBID int + Title string + OriginalTitle string + Tagline string + Overview string + PosterPath string + BackdropPath string + ReleaseDate string + Year int + Runtime int + Genres []string + VoteAverage float64 + VoteCount int + Status string + Homepage string + ContentRating string + // USCertification is the US rating alone (the one GetCertification + // reports); ContentRating falls back to other countries for display. + USCertification string + // Certifications holds every country's certifications, keyed by ISO + // 3166-1 code, for readers that fall back to a title's own country. + Certifications map[string][]string 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 FirstAirDate string LastAirDate string Networks []string + // Seasons lists a series' seasons as TMDB knows them, specials (season + // 0) included. + Seasons []SeasonSummary Cast []MediaCastMember Director string @@ -225,6 +241,23 @@ type MediaCastMember struct { // genreEntry / companyEntry / networkEntry / personEntry mirror small object // shapes from the TMDB JSON. They're internal to the decode path. +// SeasonSummary is one season of a series. +type SeasonSummary struct { + Number int + Name string + EpisodeCount int + AirDate string + PosterPath string +} + +type seasonEntry struct { + SeasonNumber int `json:"season_number"` + Name string `json:"name"` + EpisodeCount int `json:"episode_count"` + AirDate string `json:"air_date"` + PosterPath string `json:"poster_path"` +} + type genreEntry struct { ID int `json:"id"` Name string `json:"name"` @@ -320,6 +353,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"` @@ -350,11 +384,13 @@ 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"` Homepage string `json:"homepage"` Networks []networkEntry `json:"networks"` + Seasons []seasonEntry `json:"seasons"` CreatedBy []personEntry `json:"created_by"` Credits *creditsResponse `json:"credits"` ExternalIDs *ExternalIDs `json:"external_ids"` 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/operational_dispatch.go b/internal/notifications/operational_dispatch.go index de48d1bd25..f8758bd10e 100644 --- a/internal/notifications/operational_dispatch.go +++ b/internal/notifications/operational_dispatch.go @@ -3,6 +3,7 @@ package notifications import ( "context" "fmt" + "slices" "github.com/oklog/ulid/v2" ) @@ -23,6 +24,11 @@ type OperationalDispatch struct { // them, because the retry workers recover pending outbox rows — then realtime // and channel dispatch run post-commit. Returns nil when the delivery deduped // away (the partial unique indexes make operational notices idempotent). +// +// Targets are the recipient profile's on the recipient's account: profile ids +// repeat across accounts (every account from before profiles has one named +// "default"), and a request.fulfilled for one account's "default" profile must +// not reach another account's devices or webhooks. func (s *System) DispatchOperational(ctx context.Context, delivery Delivery, opts OperationalDispatch) (*InsertedDelivery, error) { if s == nil { return nil, nil @@ -49,7 +55,7 @@ func (s *System) DispatchOperational(ctx context.Context, delivery Delivery, opt } attempts := make([]DeliveryAttempt, 0, 2) for _, hook := range hooksByProfile[delivery.ProfileID] { - if !opts.WebhookFilter(hook) { + if hook.UserID != delivery.UserID || !opts.WebhookFilter(hook) { continue } attempts = append(attempts, DeliveryAttempt{ @@ -69,6 +75,9 @@ func (s *System) DispatchOperational(ctx context.Context, delivery Delivery, opt } attempts := make([]DeliveryAttempt, 0, 2) for _, sub := range subsByProfile[delivery.ProfileID] { + if sub.UserID != delivery.UserID { + continue + } attempts = append(attempts, DeliveryAttempt{ ID: ulid.Make().String(), NotificationDeliveryID: row.ID, @@ -85,7 +94,10 @@ func (s *System) DispatchOperational(ctx context.Context, delivery Delivery, opt if err != nil { return nil, err } - attempts := newPushDeliveryAttempts(row.ID, devicesByProfile[delivery.ProfileID]) + devices := slices.DeleteFunc(devicesByProfile[delivery.ProfileID], func(device PushDevice) bool { + return device.UserID != delivery.UserID + }) + attempts := newPushDeliveryAttempts(row.ID, devices) if err := s.pushDeviceRepo.EnqueuePushAttempts(ctx, tx, attempts); err != nil { return nil, err } diff --git a/internal/notifications/request_notifier.go b/internal/notifications/request_notifier.go index ea535e8b44..8e64f06a06 100644 --- a/internal/notifications/request_notifier.go +++ b/internal/notifications/request_notifier.go @@ -11,10 +11,12 @@ import ( "github.com/oklog/ulid/v2" ) -// Request lifecycle delivery types posted to the requesting profile. Their -// reason_flags carry RequestFlags instead of reason booleans; partial unique -// indexes per (profile_id, request_id, type) make the inserts idempotent, and -// the per-webhook notify_requests flag gates the webhook channel. +// Request lifecycle delivery types posted to the requesting profile (and, for +// request.fulfilled, to followers). Their reason_flags carry RequestFlags +// instead of reason booleans; partial unique indexes make the inserts +// idempotent (request.fulfilled per account, profile and request; approved +// and declined per profile, request and type), and the per-webhook +// notify_requests flag gates the webhook channel. const ( // DeliveryTypeRequestFulfilled is the operational notice posted once the // requested media is present in the catalog @@ -28,6 +30,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 +46,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 +70,119 @@ 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 +} + +// notificationsEnabled reads the profile's master toggle. Notification +// preferences are keyed by profile id alone, so two accounts' legacy +// "default" profiles share one row, as they do for every notification type. +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 (account, 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)) + base := RequestFlags{RequestID: req.ID, TMDBID: req.TMDBID, MediaType: string(req.MediaType)} + // Legacy rows without attribution have no requester recipient. + // Profile ids repeat across accounts, so recipients are keyed by both. + told := map[requests.Follower]bool{} + if req.RequestedByProfileID != "" && req.RequestedByUserID > 0 { + requester := requests.Follower{UserID: req.RequestedByUserID, ProfileID: req.RequestedByProfileID} + told[requester] = true + if err := n.notifyFulfilledProfile(ctx, requester, contentID, base); err != nil { + return err + } + } + follower := base + follower.Follower = true + for _, recipient := range req.Followers { + if told[recipient] { + continue + } + told[recipient] = true + if err := n.notifyFulfilledProfile(ctx, recipient, contentID, follower); err != nil { + return err + } + } + return nil +} - if req.RequestedByProfileID == "" || req.RequestedByUserID <= 0 { - return nil // legacy rows without attribution have no recipient +// AnnounceFulfilled implements requests.FulfillmentNotifier: the server-channel +// post. The caller runs it once, after the request is stamped as notified, +// since a retried NotifyFulfilled would otherwise repeat the community +// announcement. It is not gated by anyone's personal preferences, and it is +// detached and best-effort. +func (n *RequestFulfillmentNotifier) AnnounceFulfilled(ctx context.Context, req requests.Request) { + if n == nil || n.backend == nil { + return } - prefs, err := n.system.Preferences.Get(ctx, req.RequestedByProfileID) + n.backend.PostServerChannelRequestEvent(ctx, ServerChannelEventRequestFulfilled, requestEventInfoFor(req)) +} + +// 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. @@ -226,7 +291,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/notifications/request_notifier_test.go b/internal/notifications/request_notifier_test.go index bc8f8a4fe2..d91fbde3b2 100644 --- a/internal/notifications/request_notifier_test.go +++ b/internal/notifications/request_notifier_test.go @@ -1,114 +1,186 @@ package notifications import ( - "bytes" "context" "log/slog" - "slices" - "strings" "testing" "github.com/Silo-Server/silo-server/internal/requests" ) -type recordedServerRequestEvent struct { - event string - info RequestEventInfo +type fakeFulfillmentBackend struct { + disabled map[string]bool + 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 { + 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))} - - notifier.RequestApproved(context.Background(), requests.Request{ - ID: "req-1", - MediaType: requests.MediaTypeMovie, - TMDBID: 550, - Title: "Fight Club", - RequestedByUserID: 7, - RequestedByProfileID: "profile-7", - }, origin) - - if len(backend.serverEvents) != 1 { - t.Fatalf("server events = %+v, want one approval event", backend.serverEvents) - } - 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) - } - return backend, logs.String() +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, + } +} + +func TestNotifyFulfilledTellsRequesterAndFollowers(t *testing.T) { + backend := &fakeFulfillmentBackend{disabled: map[string]bool{"muted": true}} + notifier := &RequestFulfillmentNotifier{backend: backend} + + 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) + } + if flags := parseRequestFlags(follower.ReasonFlags); flags.RequestID != "req-1" || flags.TMDBID != 949 { + t.Fatalf("follower flags = %+v, want the request identity", flags) + } + // The server-wide post waits for AnnounceFulfilled, which the caller runs + // once the request is stamped, so a retried delivery never repeats it. + if backend.channelPost != 0 { + t.Fatalf("channel posts after NotifyFulfilled = %d, want none", backend.channelPost) + } + notifier.AnnounceFulfilled(context.Background(), fulfilledRequest()) + if backend.channelPost != 1 { + t.Fatalf("channel posts after AnnounceFulfilled = %d, want 1", backend.channelPost) + } } -func TestRequestApprovedByAdminKeepsServerAndPersonalDelivery(t *testing.T) { - backend, _ := approve(t, requests.ApprovalOriginAdmin) +// Profile ids repeat across accounts, so a follower on another account whose +// profile id matches the requester's is still a separate recipient. +func TestNotifyFulfilledKeysRecipientsByAccount(t *testing.T) { + backend := &fakeFulfillmentBackend{} + notifier := &RequestFulfillmentNotifier{backend: backend} + req := fulfilledRequest(requests.Follower{UserID: 2, ProfileID: "default"}) + req.RequestedByProfileID = "default" - want := []string{DeliveryTypeRequestApproved} - if !slices.Equal(backend.personalDeliveries, want) { - t.Fatalf("personal deliveries = %v, want %v", backend.personalDeliveries, want) + if err := notifier.NotifyFulfilled(context.Background(), req, "movie-tmdb-949"); err != nil { + t.Fatalf("NotifyFulfilled: %v", err) + } + if len(backend.deliveries) != 2 || backend.deliveries[1].UserID != 2 || !parseRequestFlags(backend.deliveries[1].ReasonFlags).Follower { + t.Fatalf("deliveries = %+v, want the requester and the other account's follower", backend.deliveries) } } -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) + } + if got := requestLine(follower); got != followedTitleAvailable { + t.Fatalf("follower email line = %q", got) + } + if got := discordEmbedAuthorLine(follower); got != "Now available on Silo" { + t.Fatalf("follower Discord author = %q", 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")}, - } - 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) - } - }) +// Two accounts' legacy "default" profiles, one the requester and one a +// follower, each get exactly one request.fulfilled delivery, on their own +// account, and only their own account's devices are pushed. A second pass is +// deduped per account. +func TestNotifyFulfilledDeliversOncePerAccountForSharedProfileID(t *testing.T) { + p := inboxPageDB(t) + ctx := t.Context() + if _, err := p.Exec(ctx, ` + CREATE TABLE push_devices (LIKE public.push_devices INCLUDING ALL); + CREATE TABLE push_delivery_attempts (LIKE public.push_delivery_attempts INCLUDING ALL); + INSERT INTO push_devices + (id, user_id, profile_id, device_id, platform, provider, apns_environment, apns_topic, + apns_token_ciphertext, apns_token_hash, server_device_id, push_mode, enabled) + VALUES + ('device-account-1', 1, 'default', 'local-1', 'apple', 'silo_relay', 'sandbox', + 'org.siloserver.silo', 'ciphertext', 'hash-1', 'server-1', 'private_push', true), + ('device-account-2', 2, 'default', 'local-2', 'apple', 'silo_relay', 'sandbox', + 'org.siloserver.silo', 'ciphertext', 'hash-2', 'server-2', 'private_push', true)`); err != nil { + t.Fatalf("create push tables: %v", err) + } + system := &System{ + pool: p, + Settings: NewSettings(mapSettingReader{SettingApplePushDeliveryEnabled: "true"}), + Deliveries: NewDeliveryRepository(p), + Preferences: NewPreferencesRepository(p), + pushDeviceRepo: NewPushDeviceRepository(p), + dispatcher: NewMultiDispatcher(), + logger: slog.New(slog.DiscardHandler), + } + notifier := NewRequestFulfillmentNotifier(system) + req := fulfilledRequest(requests.Follower{UserID: 2, ProfileID: "default"}) + req.RequestedByProfileID = "default" + + for range 2 { + if err := notifier.NotifyFulfilled(ctx, req, "movie-tmdb-949"); err != nil { + t.Fatalf("NotifyFulfilled: %v", err) + } + } + + rows, err := p.Query(ctx, ` + SELECT d.id, d.user_id, d.reason_flags, a.push_device_id + FROM notification_deliveries d + LEFT JOIN push_delivery_attempts a ON a.notification_delivery_id = d.id + WHERE d.type = $1 + ORDER BY d.user_id, a.push_device_id`, DeliveryTypeRequestFulfilled) + if err != nil { + t.Fatalf("query deliveries: %v", err) + } + type got struct { + userID int + follower bool + device *string + } + var out []got + for rows.Next() { + var id string + var row got + var flags []byte + if err := rows.Scan(&id, &row.userID, &flags, &row.device); err != nil { + t.Fatalf("scan delivery: %v", err) + } + row.follower = parseRequestFlags(flags).Follower + out = append(out, row) + } + if err := rows.Err(); err != nil { + t.Fatalf("read deliveries: %v", err) + } + if len(out) != 2 { + t.Fatalf("deliveries with push attempts = %+v, want one per account", out) + } + for i, want := range []struct { + userID int + follower bool + device string + }{{1, false, "device-account-1"}, {2, true, "device-account-2"}} { + if out[i].userID != want.userID || out[i].follower != want.follower || out[i].device == nil || *out[i].device != want.device { + t.Fatalf("delivery %d = {user %d follower %v device %v}, want %+v", i, out[i].userID, out[i].follower, out[i].device, want) + } } } 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/plugins/request_router.go b/internal/plugins/request_router.go new file mode 100644 index 0000000000..1f8e83902b --- /dev/null +++ b/internal/plugins/request_router.go @@ -0,0 +1,40 @@ +package plugins + +import ( + "context" + "errors" + "fmt" + + pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" + "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginsdk/capability" +) + +// RequestRouterDescriptor returns the request_router.v1 descriptor an +// installation's manifest declares for capabilityID. It reads the capability +// metadata stored at install, so it never launches the plugin. A capability +// without a descriptor, as in every plugin built before the SDK carried one, +// or one the installation does not declare, yields an empty descriptor: every +// optional request-router feature off. +func (s *Service) RequestRouterDescriptor(ctx context.Context, installationID int, capabilityID string) (*pluginv1.RequestRouterDescriptor, error) { + if s == nil || s.installations == nil { + return nil, errors.New("plugin installations are not configured") + } + records, err := s.installations.ListCapabilities(ctx, installationID) + if err != nil { + return nil, err + } + for _, record := range records { + if record == nil || record.Type != capability.RequestRouter || record.ID != capabilityID { + continue + } + descriptor, err := DecodeCapability(record) + if err != nil { + return nil, fmt.Errorf("decode request router capability %q of installation %d: %w", capabilityID, installationID, err) + } + if router := descriptor.GetRequestRouter(); router != nil { + return router, nil + } + break + } + return &pluginv1.RequestRouterDescriptor{}, nil +} diff --git a/internal/plugins/request_router_test.go b/internal/plugins/request_router_test.go new file mode 100644 index 0000000000..d58945c8e6 --- /dev/null +++ b/internal/plugins/request_router_test.go @@ -0,0 +1,107 @@ +package plugins + +import ( + "context" + "testing" + "time" + + pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" + "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginsdk/capability" +) + +// The host reads supports_seasons and reports_download_progress from the +// capability metadata stored from the manifest, the way install records it. +func TestRequestRouterDescriptorReadsStoredManifestFlag(t *testing.T) { + records, err := CapabilityRecordsFromManifest(&pluginv1.PluginManifest{ + Capabilities: []*pluginv1.CapabilityDescriptor{ + {Type: capability.RequestRouter, Id: "arr", RequestRouter: &pluginv1.RequestRouterDescriptor{SupportsSeasons: true}}, + {Type: capability.RequestRouter, Id: "legacy"}, + {Type: capability.RequestRouter, Id: "progress", RequestRouter: &pluginv1.RequestRouterDescriptor{SupportsSeasons: true, ReportsDownloadProgress: true}}, + }, + }) + if err != nil { + t.Fatalf("CapabilityRecordsFromManifest: %v", err) + } + store := &fakeServiceInstallationStore{} + for i := range records { + store.listCapabilities = append(store.listCapabilities, &records[i]) + } + service := &Service{installations: store} + + for _, tc := range []struct { + capabilityID string + want bool + wantProgress bool + }{ + {"arr", true, false}, // built before reports_download_progress existed + {"legacy", false, false}, // built before the descriptor existed + {"missing", false, false}, // not declared at all + {"progress", true, true}, + } { + got, err := service.RequestRouterDescriptor(context.Background(), 5, tc.capabilityID) + if err != nil { + t.Fatalf("%s: RequestRouterDescriptor: %v", tc.capabilityID, err) + } + if got.GetSupportsSeasons() != tc.want { + t.Errorf("%s: supports_seasons = %v, want %v", tc.capabilityID, got.GetSupportsSeasons(), tc.want) + } + if got.GetReportsDownloadProgress() != tc.wantProgress { + t.Errorf("%s: reports_download_progress = %v, want %v", tc.capabilityID, got.GetReportsDownloadProgress(), tc.wantProgress) + } + } +} + +// Through the database: the flag survives the JSONB capability metadata, and +// replacing an installation's capabilities in place (a plugin upgraded from a +// build without the flag) turns it on. +func TestRequestRouterDescriptorFromStoredCapabilities(t *testing.T) { + pool := builtinGuardTestPool(t) + ctx := context.Background() + store := NewInstallationStore(pool) + + manifestCapabilities := func(supportsSeasons bool) []Capability { + descriptor := &pluginv1.CapabilityDescriptor{Type: capability.RequestRouter, Id: "arr"} + if supportsSeasons { + descriptor.RequestRouter = &pluginv1.RequestRouterDescriptor{SupportsSeasons: true} + } + records, err := CapabilityRecordsFromManifest(&pluginv1.PluginManifest{Capabilities: []*pluginv1.CapabilityDescriptor{descriptor}}) + if err != nil { + t.Fatalf("CapabilityRecordsFromManifest: %v", err) + } + return records + } + installation, err := store.Create(ctx, CreateInstallationInput{ + PluginID: "test.requests.seasons" + time.Now().UTC().Format("-20060102150405.000000000"), + Version: "0.1.6", + InstallPath: "/nonexistent/requests-seasons-test", + UpdatePolicy: "manual", + Capabilities: manifestCapabilities(false), + }) + if err != nil { + t.Fatalf("create installation: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM plugin_installations WHERE id = $1`, installation.ID) + }) + service := &Service{installations: store} + + before, err := service.RequestRouterDescriptor(ctx, installation.ID, "arr") + if err != nil { + t.Fatalf("RequestRouterDescriptor: %v", err) + } + if before.GetSupportsSeasons() { + t.Fatal("a build without the flag reads as supporting seasons") + } + + version := "0.1.7" + if err := store.Update(ctx, installation.ID, UpdateInstallationInput{Version: &version, Capabilities: manifestCapabilities(true)}); err != nil { + t.Fatalf("update installation: %v", err) + } + after, err := service.RequestRouterDescriptor(ctx, installation.ID, "arr") + if err != nil { + t.Fatalf("RequestRouterDescriptor: %v", err) + } + if !after.GetSupportsSeasons() { + t.Fatal("the upgraded build's supports_seasons was not read from stored metadata") + } +} diff --git a/internal/requests/admin_queue_test.go b/internal/requests/admin_queue_test.go new file mode 100644 index 0000000000..8a01a90799 --- /dev/null +++ b/internal/requests/admin_queue_test.go @@ -0,0 +1,218 @@ +package requests + +import ( + "context" + "errors" + "slices" + "testing" +) + +func TestAdminQueueFiltersDatabase(t *testing.T) { + repo, pool := lifecycleTestRepository(t) + ctx := t.Context() + insert := func(id string, userID, tmdbID int, title string, mediaType MediaType, status Status, outcome Outcome) { + t.Helper() + insertLifecycleRequest(t, repo, id, userID, tmdbID, StatusPending) + if _, err := pool.Exec(ctx, `UPDATE media_requests SET title = $2, media_type = $3, status = $4, outcome = $5 WHERE id = $1`, + id, title, mediaType, status, outcome); err != nil { + t.Fatal(err) + } + } + insert("pending", 1, 101, "Severance", MediaTypeSeries, StatusPending, OutcomeActive) + insert("approved", 2, 102, "Heat", MediaTypeMovie, StatusApproved, OutcomeActive) + insert("downloading", 1, 103, "100% Wolf", MediaTypeMovie, StatusDownloading, OutcomeActive) + insert("failed", 2, 104, "Dune", MediaTypeMovie, StatusQueued, OutcomeFailed) + insert("completed", 1, 105, "The Bear", MediaTypeSeries, StatusCompleted, OutcomeActive) + insert("declined", 2, 106, "Fight Club", MediaTypeMovie, StatusPending, OutcomeDeclined) + + ids := func(filter ListFilter) []string { + t.Helper() + reqs, err := repo.ListAdmin(ctx, filter) + if err != nil { + t.Fatal(err) + } + out := make([]string, 0, len(reqs)) + for _, r := range reqs { + out = append(out, r.ID) + } + slices.Sort(out) + return out + } + for _, tc := range []struct { + name string + filter ListFilter + want []string + }{ + {"needs approval", ListFilter{View: AdminViewNeedsApproval}, []string{"pending"}}, + {"in progress", ListFilter{View: AdminViewInProgress}, []string{"approved", "downloading"}}, + {"failed", ListFilter{View: AdminViewFailed}, []string{"failed"}}, + {"done", ListFilter{View: AdminViewDone}, []string{"completed", "declined"}}, + {"title search, any case", ListFilter{Query: "dUnE"}, []string{"failed"}}, + {"a percent sign is literal", ListFilter{Query: "100%"}, []string{"downloading"}}, + {"an underscore is literal", ListFilter{Query: "_"}, []string{}}, + {"a number also matches the TMDB id", ListFilter{Query: "105"}, []string{"completed"}}, + {"a number too big for a TMDB id is only a title search", ListFilter{Query: "99999999999"}, []string{}}, + {"media type", ListFilter{MediaType: MediaTypeSeries}, []string{"completed", "pending"}}, + {"requester and view", ListFilter{RequestedByUserID: 2, View: AdminViewDone}, []string{"declined"}}, + } { + if got := ids(tc.filter); !slices.Equal(got, tc.want) { + t.Errorf("%s: got %v, want %v", tc.name, got, tc.want) + } + } + + counts, err := repo.CountAdminViews(ctx) + if err != nil { + t.Fatal(err) + } + if counts != (AdminViewCounts{NeedsApproval: 1, InProgress: 2, Failed: 1, Done: 2}) { + t.Fatalf("counts = %+v", counts) + } +} + +func TestRequestHistoryAndTargetsDatabase(t *testing.T) { + repo, _ := lifecycleTestRepository(t) + ctx := t.Context() + insertLifecycleRequest(t, repo, "a", 1, 201, StatusPending) + insertLifecycleRequest(t, repo, "b", 1, 202, StatusPending) + if _, err := repo.SetStatus(ctx, "a", guardPending, StatusApproved, Viewer{UserID: 1, ProfileID: "profile"}); err != nil { + t.Fatal(err) + } + events, err := repo.ListEvents(ctx, "a", 10) + if err != nil { + t.Fatal(err) + } + if len(events) != 2 || events[0].EventType != "status_approved" || events[1].EventType != "created" { + t.Fatalf("events = %+v, want status_approved then created", events) + } + if events[0].ActorUserID == nil || *events[0].ActorUserID != 1 { + t.Fatalf("actor = %v, want account 1", events[0].ActorUserID) + } + + for _, target := range []Target{{RequestID: "a", Quality: "1080p", Status: StatusQueued}, {RequestID: "a", Quality: "2160p", Status: StatusQueued}, {RequestID: "b", Quality: "1080p", Status: StatusQueued}} { + if _, err := repo.CreateTarget(ctx, target); err != nil { + t.Fatal(err) + } + } + byRequest, err := repo.ListTargetsForRequests(ctx, []string{"a", "b", "missing"}) + if err != nil { + t.Fatal(err) + } + if len(byRequest["a"]) != 2 || len(byRequest["b"]) != 1 || len(byRequest["missing"]) != 0 { + t.Fatalf("targets = %+v", byRequest) + } +} + +func TestAdminQueueNeedsAnAdmin(t *testing.T) { + svc := newTestService(newFakeStore()) + member := testViewer(1) + if _, err := svc.CountAdminViews(context.Background(), member); !errors.Is(err, ErrForbidden) { + t.Fatalf("counts: err = %v, want ErrForbidden", err) + } + if _, err := svc.ListRequestEvents(context.Background(), member, "r1"); !errors.Is(err, ErrForbidden) { + t.Fatalf("events: err = %v, want ErrForbidden", err) + } +} + +func TestListAdminValidatesFilters(t *testing.T) { + store := newFakeStore() + svc := newTestService(store) + admin := Viewer{UserID: 1, ProfileID: "p", IsAdmin: true} + if _, err := svc.ListAdmin(context.Background(), admin, ListFilter{View: "someday"}); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("unknown view: err = %v, want ErrInvalidInput", err) + } + if _, err := svc.ListAdmin(context.Background(), admin, ListFilter{MediaType: "book"}); !errors.Is(err, ErrInvalidMediaType) { + t.Fatalf("unknown media type: err = %v, want ErrInvalidMediaType", err) + } + if _, err := svc.ListAdmin(context.Background(), admin, ListFilter{View: AdminViewFailed, Query: "dune", MediaType: "tv"}); err != nil { + t.Fatal(err) + } + if got := store.adminFilters[len(store.adminFilters)-1]; got.View != AdminViewFailed || got.Query != "dune" || got.MediaType != MediaTypeSeries { + t.Fatalf("filter = %+v", got) + } +} + +// A request's history records each aggregate change once, whatever the +// number of targets that caused it. +func TestHistoryRecordsEachChangeOnceDatabase(t *testing.T) { + repo, _ := lifecycleTestRepository(t) + ctx := t.Context() + insertLifecycleRequest(t, repo, "two", 1, 401, StatusApproved) + var ids []int64 + for _, quality := range []Quality{"1080p", "2160p"} { + target, err := repo.CreateTarget(ctx, Target{RequestID: "two", Quality: quality, Status: StatusQueued}) + if err != nil { + t.Fatal(err) + } + ids = append(ids, target.ID) + } + for _, id := range ids { + if _, err := repo.UpdateTargetStatus(ctx, id, StatusQueued, "", "", "", Viewer{}); err != nil { + t.Fatal(err) + } + } + if _, err := repo.UpdateTargetStatus(ctx, ids[0], StatusFailed, "", "", "boom", Viewer{}); err != nil { + t.Fatal(err) + } + if _, err := repo.UpdateTargetStatus(ctx, ids[1], StatusFailed, "", "", "boom", Viewer{}); err != nil { + t.Fatal(err) + } + events, err := repo.ListEvents(ctx, "two", 20) + if err != nil { + t.Fatal(err) + } + var types []string + for i := len(events) - 1; i >= 0; i-- { + types = append(types, events[i].EventType) + } + want := []string{"created", "approved", "status_queued", "outcome_failed"} + if !slices.Equal(types, want) { + t.Fatalf("history = %v, want %v", types, want) + } +} + +func TestAdminClosesAFailedRequest(t *testing.T) { + store := newFakeStore() + store.requests["f"] = &Request{ID: "f", MediaType: MediaTypeMovie, TMDBID: 1, Status: StatusQueued, Outcome: OutcomeFailed, RequestedByUserID: 2} + svc := newTestService(store) + + admin := Viewer{UserID: 1, ProfileID: "a", IsAdmin: true} + if _, err := svc.Cancel(context.Background(), Viewer{UserID: 2, ProfileID: "p"}, "f", ""); !errors.Is(err, ErrInvalidState) { + t.Fatalf("owner: err = %v, want ErrInvalidState", err) + } + // The frozen v1 cancel keeps refusing a failed request, for admins too. + if _, err := svc.Cancel(context.Background(), admin, "f", ""); !errors.Is(err, ErrInvalidState) { + t.Fatalf("v1 admin cancel: err = %v, want ErrInvalidState", err) + } + if _, err := svc.AdminCancel(context.Background(), Viewer{UserID: 2, ProfileID: "p"}, "f", ""); !errors.Is(err, ErrForbidden) { + t.Fatalf("member: err = %v, want ErrForbidden", err) + } + closed, err := svc.AdminCancel(context.Background(), admin, "f", "gave up") + if err != nil || closed.Outcome != OutcomeCancelled { + t.Fatalf("admin: %+v, %v; want the outcome closed", closed, err) + } +} + +// A target reporting after an admin closed its request leaves the request +// closed. +func TestClosedRequestStaysClosedDatabase(t *testing.T) { + repo, _ := lifecycleTestRepository(t) + ctx := t.Context() + insertLifecycleRequest(t, repo, "late", 1, 501, StatusApproved) + target, err := repo.CreateTarget(ctx, Target{RequestID: "late", Quality: "1080p", Status: StatusQueued}) + if err != nil { + t.Fatal(err) + } + if _, err := repo.UpdateTargetStatus(ctx, target.ID, StatusFailed, "", "", "boom", Viewer{}); err != nil { + t.Fatal(err) + } + if _, err := repo.SetOutcome(ctx, "late", guardFailed, OutcomeCancelled, Viewer{UserID: 1, ProfileID: "a"}, "closed"); err != nil { + t.Fatal(err) + } + after, err := repo.UpdateTargetStatus(ctx, target.ID, StatusDownloading, "", "", "", Viewer{}) + if err != nil { + t.Fatal(err) + } + if after.Outcome != OutcomeCancelled { + t.Fatalf("outcome = %s, want the request to stay closed", after.Outcome) + } +} diff --git a/internal/requests/anime.go b/internal/requests/anime.go index 809e49e544..ffa6402fad 100644 --- a/internal/requests/anime.go +++ b/internal/requests/anime.go @@ -1,15 +1,90 @@ package requests -// animeKeywordID is TMDB's "anime" keyword id. Matches Seerr's ANIME_KEYWORD_ID -// exactly (server/api/themoviedb/constants.ts). Detection is keyword-id only — -// no genre/language fallback — to mirror upstream behavior. +import ( + "context" + "log/slog" + "slices" + + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" +) + +// animeKeywordID is TMDB's "anime" keyword id, the one Seerr checks +// (server/api/themoviedb/constants.ts). const animeKeywordID = 210024 -func detectAnime(keywordIDs []int) bool { - for _, id := range keywordIDs { - if id == animeKeywordID { - return true - } +// animationGenreID is TMDB's Animation genre. +const animationGenreID = 16 + +// AnimeIndex answers whether an AniDB-based anime list names a title, by the +// TVDB and IMDb IDs TMDB reports. *animeids.Store implements it. +type AnimeIndex interface { + Listed(ctx context.Context, movie bool, tvdbID int, imdbID string) (bool, error) +} + +// SetAnimeIndex lets anime detection consult the anime list. +func (s *Service) SetAnimeIndex(index AnimeIndex) { s.animeIndex = index } + +// detectAnime decides whether a title is anime. Anime here means Japanese +// animation. TMDB's anime keyword alone misses about one anime series in +// eight and one film in three, so a title also counts when TMDB files it as +// Animation from Japan or in Japanese, or when the AniDB-based list names it. +// AniDB also catalogs Chinese and Korean animation, which only counts when +// TMDB itself tags it anime or it is a Japanese co-production; an admin +// routes it with a genre and language rule instead. +func detectAnime(detail *tmdb.MediaDetail, listed bool) bool { + if detail == nil { + return false + } + if slices.Contains(detail.KeywordIDs, animeKeywordID) { + return true + } + japanese := detail.OriginalLanguage == "ja" || slices.Contains(detail.OriginCountries, "JP") + if japanese && slices.Contains(detail.GenreIDs, animationGenreID) { + return true + } + if !listed { + return false + } + // A Japanese co-production still counts; Chinese or Korean animation by + // language or origin country does not on the list's word alone. + if !japanese && chineseOrKorean(detail) { + return false } - return false + // The list also names Western series AniDB catalogs (The Boondocks). A + // series' anime flag can set Sonarr's series type, and with it episode + // numbering, so a listed series also needs a Japanese signal; a film is + // only routed, so the list alone will do. + return detail.MediaType != string(MediaTypeSeries) || japanese +} + +// chineseOrKorean reports whether TMDB files a title in Chinese or Korean or +// as made in China or Korea. +func chineseOrKorean(detail *tmdb.MediaDetail) bool { + switch detail.OriginalLanguage { + case "zh", "cn", "ko": + return true + } + return slices.Contains(detail.OriginCountries, "CN") || slices.Contains(detail.OriginCountries, "KR") +} + +// animeListed asks the anime list about a title. The list only adds to TMDB's +// own signals, so a failed lookup counts as not listed rather than holding the +// request up. +func (s *Service) animeListed(ctx context.Context, detail *tmdb.MediaDetail) bool { + if s.animeIndex == nil || detail == nil { + return false + } + listed, err := s.animeIndex.Listed(ctx, detail.MediaType == string(MediaTypeMovie), detail.TVDBID, detail.IMDbID) + if err != nil { + slog.WarnContext(ctx, "requests: anime list lookup failed", "component", "requests", + "tmdb_id", detail.ID, "err", err) + return false + } + return listed +} + +// routingFacts reads a request's routing facts off a TMDB detail, asking the +// anime list first. +func (s *Service) routingFacts(ctx context.Context, detail *tmdb.MediaDetail) RoutingFacts { + return routingFactsFrom(detail, s.animeListed(ctx, detail), s.now()) } diff --git a/internal/requests/anime_test.go b/internal/requests/anime_test.go index cdc73a266c..90fb8dadb0 100644 --- a/internal/requests/anime_test.go +++ b/internal/requests/anime_test.go @@ -1,15 +1,97 @@ package requests -import "testing" +import ( + "context" + "errors" + "testing" + + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" +) func TestDetectAnime(t *testing.T) { - if !detectAnime([]int{99, animeKeywordID, 7}) { - t.Fatal("expected anime when keyword 210024 present") + for _, tc := range []struct { + name string + detail *tmdb.MediaDetail + listed bool + want bool + }{ + {"TMDB's anime keyword", &tmdb.MediaDetail{KeywordIDs: []int{99, animeKeywordID}}, false, true}, + {"Japanese-language animation", &tmdb.MediaDetail{GenreIDs: []int{animationGenreID}, OriginalLanguage: "ja"}, false, true}, + {"animation from Japan in another language", &tmdb.MediaDetail{GenreIDs: []int{animationGenreID}, OriginalLanguage: "en", OriginCountries: []string{"US", "JP"}}, false, true}, + {"Western animation", &tmdb.MediaDetail{GenreIDs: []int{animationGenreID}, OriginalLanguage: "en", OriginCountries: []string{"US"}}, false, false}, + {"Japanese live action", &tmdb.MediaDetail{GenreIDs: []int{18}, OriginalLanguage: "ja", OriginCountries: []string{"JP"}}, false, false}, + {"a film on the anime list", &tmdb.MediaDetail{MediaType: "movie", GenreIDs: []int{16}, OriginalLanguage: "en", OriginCountries: []string{"US"}}, true, true}, + {"a Western series on the anime list", &tmdb.MediaDetail{MediaType: "series", GenreIDs: []int{16}, OriginalLanguage: "en", OriginCountries: []string{"US"}}, true, false}, + {"a Japanese series on the list TMDB files under no genre", &tmdb.MediaDetail{MediaType: "series", OriginalLanguage: "ja", OriginCountries: []string{"JP"}}, true, true}, + {"Chinese animation on the anime list", &tmdb.MediaDetail{GenreIDs: []int{animationGenreID}, OriginalLanguage: "zh", OriginCountries: []string{"CN"}}, true, false}, + {"Korean animation on the anime list", &tmdb.MediaDetail{GenreIDs: []int{animationGenreID}, OriginalLanguage: "ko"}, true, false}, + {"a Chinese film in English on the anime list", &tmdb.MediaDetail{MediaType: "movie", GenreIDs: []int{animationGenreID}, OriginalLanguage: "en", OriginCountries: []string{"CN", "US"}}, true, false}, + {"a Korean film in English on the anime list", &tmdb.MediaDetail{MediaType: "movie", GenreIDs: []int{animationGenreID}, OriginalLanguage: "en", OriginCountries: []string{"KR"}}, true, false}, + {"a Japanese-Chinese series in Chinese on the anime list", &tmdb.MediaDetail{MediaType: "series", OriginalLanguage: "zh", OriginCountries: []string{"CN", "JP"}}, true, true}, + {"a Japanese-Korean film on the anime list", &tmdb.MediaDetail{MediaType: "movie", OriginalLanguage: "ko", OriginCountries: []string{"JP", "KR"}}, true, true}, + {"Chinese animation TMDB tags anime", &tmdb.MediaDetail{KeywordIDs: []int{animeKeywordID}, GenreIDs: []int{animationGenreID}, OriginalLanguage: "zh"}, false, true}, + {"no detail", nil, true, false}, + } { + if got := detectAnime(tc.detail, tc.listed); got != tc.want { + t.Errorf("%s: detectAnime = %v, want %v", tc.name, got, tc.want) + } } - if detectAnime([]int{99, 7}) { - t.Fatal("expected non-anime when keyword 210024 absent") +} + +type fakeAnimeIndex struct { + listed bool + err error + asked []any +} + +func (f *fakeAnimeIndex) Listed(_ context.Context, movie bool, tvdbID int, imdbID string) (bool, error) { + f.asked = append(f.asked, movie, tvdbID, imdbID) + return f.listed, f.err +} + +func TestRoutingFactsAskTheAnimeList(t *testing.T) { + svc := newTestService(newFakeStore()) + detail := &tmdb.MediaDetail{MediaType: "movie", ID: 5, IMDbID: "tt1", TVDBID: 9, OriginalLanguage: "en"} + if svc.routingFacts(context.Background(), detail).Anime { + t.Fatal("anime without a list or a TMDB signal") + } + index := &fakeAnimeIndex{listed: true} + svc.SetAnimeIndex(index) + if !svc.routingFacts(context.Background(), detail).Anime { + t.Fatal("a listed title is not anime") + } + if index.asked[0] != true || index.asked[1] != 9 || index.asked[2] != "tt1" { + t.Fatalf("asked the list %v", index.asked) + } + // A failed lookup never holds the request up; it is just not listed. + svc.SetAnimeIndex(&fakeAnimeIndex{err: errors.New("db down")}) + if facts := svc.routingFacts(context.Background(), detail); facts.Anime || !facts.Captured() { + t.Fatalf("facts = %+v, want captured and not anime", facts) + } +} + +func TestRoutingRating(t *testing.T) { + certs := map[string][]string{"JP": {"G", "PG12"}, "KR": {"15"}, "FR": {"U"}, "XX": {"weird"}} + for _, tc := range []struct { + name string + us string + origins []string + want string + }{ + {"the US rating wins", "PG-13", []string{"JP"}, "PG-13"}, + {"the own country's, strictest", "", []string{"JP"}, "JP:PG12"}, + {"the first origin country with a rating", "", []string{"CN", "KR"}, "KR:15"}, + {"a rating no scale reads", "", []string{"XX"}, ""}, + {"no rating anywhere", "", []string{"DE"}, ""}, + {"only NR in the US", "NR", []string{"JP"}, "JP:PG12"}, + {"only NR anywhere", "NR", []string{"DE"}, "NR"}, + } { + if got := routingRating(tc.us, certs, tc.origins); got != tc.want { + t.Errorf("%s: routingRating = %q, want %q", tc.name, got, tc.want) + } } - if detectAnime(nil) { - t.Fatal("expected non-anime for empty keywords") + jp := "JP:PG12" + if !ratingWithin(&jp, "PG-13") || ratingWithin(&jp, "PG") { + t.Fatal("a Japanese PG12 should be within PG-13 and above PG") } } 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/downloads.go b/internal/requests/downloads.go new file mode 100644 index 0000000000..e685b9aba3 --- /dev/null +++ b/internal/requests/downloads.go @@ -0,0 +1,225 @@ +package requests + +import ( + "context" + "errors" + "fmt" + "log/slog" + "slices" + "time" +) + +// staleDownloadProgress is how long a target's download progress outlives +// its last report once its server stops answering for it. A pass that asks +// about a target and gets no status back (the plugin skipped a server that +// errored or no longer has the title, or the call failed) clears progress +// older than this, so a frozen figure stops showing and clients stop polling +// for it. Until then the progress stays, since one missed answer is usually +// a blip. +const staleDownloadProgress = 15 * time.Minute + +// RefreshDownloads is the download refresh pass. For each active request with +// a downloading target that has download progress, least recently asked about +// first, it asks the plugins that declare reports_download_progress about +// those targets and applies the answer the way reconcile does: status +// transitions and download progress. It submits nothing, checks no library +// presence and sends no notification; the reconcile pass keeps those. +// +// Only a target with progress is polled. The reconcile pass records a +// download's first progress, and a target leaves this pass as soon as its +// progress clears, so a target whose plugin reports none (a legacy plugin, or +// one with nothing in its download queue for it) costs no call a minute. +// Queued targets stay on the reconcile cadence, since a title can sit queued +// for months before release. +// +// budget bounds the pass, zero or less leaving it to ctx: once it has run +// out, the call in flight is cut and no further request is asked about. The +// requests left over are the least recently asked about on the next pass, and +// the pass reports no error for them. +func (s *Service) RefreshDownloads(ctx context.Context, limit int, budget time.Duration) (DownloadRefreshResult, error) { + if s == nil || s.store == nil { + return DownloadRefreshResult{}, fmt.Errorf("request service is not configured") + } + if s.router == nil { + return DownloadRefreshResult{}, nil + } + if limit <= 0 || limit > 500 { + limit = 200 + } + checkCtx := ctx + if budget > 0 { + var cancel context.CancelFunc + checkCtx, cancel = context.WithTimeout(ctx, budget) + defer cancel() + } + reqs, err := s.store.ListDownloadingRequests(ctx, limit) + if err != nil || len(reqs) == 0 { + return DownloadRefreshResult{}, err + } + fc, err := s.newFulfillContext(ctx) + if err != nil { + return DownloadRefreshResult{}, err + } + ids := make([]string, 0, len(reqs)) + for _, req := range reqs { + ids = append(ids, req.ID) + } + byRequest, err := s.store.ListTargetsForRequests(ctx, ids) + if err != nil { + return DownloadRefreshResult{}, err + } + var result DownloadRefreshResult + for i, req := range reqs { + if err := ctx.Err(); err != nil { + return result, err + } + if checkCtx.Err() != nil { + slog.InfoContext(ctx, "request download refresh ran out of time; the rest wait for the next pass", "component", "requests", + "budget", budget, + "requests_left", len(reqs)-i, + ) + break + } + change, checked, err := s.refreshRequestDownloads(ctx, checkCtx, fc, *req, byRequest[req.ID]) + if checked { + result.Checked++ + } + if change != reconcileUnchanged { + result.Updated++ + } + if err != nil { + slog.WarnContext(ctx, "request download refresh failed", "component", "requests", + "request_id", req.ID, + "media_type", req.MediaType, + "tmdb_id", req.TMDBID, + "err", err, + ) + result.Errors++ + } + } + return result, nil +} + +// refreshRequestDownloads checks one request's downloading targets that have +// progress and whose plugin reports it, and applies what it answers. A target +// with progress that its server can no longer be asked about (gone, disabled +// or unusable), or whose plugin no longer declares progress, has the progress +// cleared: it is stale, and clearing it takes the target off this pass. +// checkCtx bounds only the plugin calls; the writes that apply their answers +// run on ctx, so a call the pass's budget cuts still moves its targets back in +// the rotation. A target whose plugin's features cannot be read is settled as +// unanswered, so it too moves back instead of heading every batch, and the +// request's other targets are still asked about. checked is false when no +// target was asked about. +func (s *Service) refreshRequestDownloads(ctx, checkCtx context.Context, fc *fulfillContext, req Request, targets []Target) (change reconcileChange, checked bool, err error) { + var polled []Target + var featureErr error + for _, t := range targets { + if t.Status != StatusDownloading || t.Download == nil { + continue + } + reports, err := s.targetReportsProgress(ctx, fc, req.MediaType, t) + if err != nil { + featureErr = errors.Join(featureErr, err) + if err := s.settleUnansweredDownload(ctx, t); err != nil { + return reconcileUnchanged, false, errors.Join(featureErr, err) + } + continue + } + if !reports { + if err := s.store.UpdateTargetDownload(ctx, t.ID, nil); err != nil { + return reconcileUnchanged, false, err + } + continue + } + polled = append(polled, t) + } + if len(polled) == 0 { + return reconcileUnchanged, false, featureErr + } + statuses, checkErr := s.checkTargetStatuses(checkCtx, req, polled, fc) + change, err = s.applyTargetStatuses(ctx, polled, statuses) + if err != nil { + return reconcileUnchanged, true, errors.Join(featureErr, err) + } + return change, true, errors.Join(featureErr, checkErr) +} + +// HasDownloadsToRefresh reports whether the download refresh pass has any +// work: an active request with a downloading target that has progress. +func (s *Service) HasDownloadsToRefresh(ctx context.Context) (bool, error) { + if s == nil || s.store == nil || s.router == nil { + return false, nil + } + reqs, err := s.store.ListDownloadingRequests(ctx, 1) + return len(reqs) > 0, err +} + +// settleUnansweredDownload handles a target that a pass asked about, or meant +// to, without getting its status back. A queued or downloading target with +// progress keeps it, marked as asked about so the download refresh pass takes +// the others first next time, until the progress is staleDownloadProgress +// old; then the progress is cleared. Any other target is not written. +func (s *Service) settleUnansweredDownload(ctx context.Context, t Target) error { + if t.Download == nil || (t.Status != StatusQueued && t.Status != StatusDownloading) { + return nil + } + if s.now().Sub(t.Download.UpdatedAt) > staleDownloadProgress { + return s.store.UpdateTargetDownload(ctx, t.ID, nil) + } + return s.store.MarkTargetDownloadChecked(ctx, t.ID) +} + +// targetReportsProgress reports whether the router capability that +// checkTargetStatuses would ask about a target declares +// reports_download_progress. A target it would skip reports none. +func (s *Service) targetReportsProgress(ctx context.Context, fc *fulfillContext, mediaType MediaType, t Target) (bool, error) { + var installationID int + var capabilityID string + if t.IntegrationID == "" { + conns, id, capability, err := s.resolveRouterConnections(ctx, fc, mediaType) + if err != nil || len(conns) == 0 { + return false, err + } + installationID, capabilityID = id, capability + } else { + in := integrationByID(fc, t.IntegrationID) + if !statusCheckable(in) { + return false, nil + } + installationID, capabilityID = *in.InstallationID, in.CapabilityID + } + features, err := s.routerFeatures(ctx, fc, installationID, capabilityID) + return features.ReportsDownloadProgress, err +} + +// keepsReportedProgress reports whether the progress in a router's statuses +// is recorded: only a plugin that declares reports_download_progress has its +// progress kept. The download refresh pass clears the progress of any other, +// so recording it would make it come and go between passes. Statuses without +// progress read no features, and when the declaration cannot be read the +// report stands. +func (s *Service) keepsReportedProgress(ctx context.Context, fc *fulfillContext, installationID int, capabilityID string, statuses []RouterTargetStatus) (bool, error) { + if !slices.ContainsFunc(statuses, func(st RouterTargetStatus) bool { return st.Progress != nil }) { + return true, nil + } + features, err := s.routerFeatures(ctx, fc, installationID, capabilityID) + if err != nil { + return true, err + } + return features.ReportsDownloadProgress, nil +} + +// activeRequestDownload loads an active request's targets for its download +// progress. Only a queued or downloading request can have any, so any other +// costs no query. +func (s *Service) activeRequestDownload(ctx context.Context, req *Request) (*DownloadProgress, error) { + if req == nil || req.Outcome != OutcomeActive || (req.Status != StatusQueued && req.Status != StatusDownloading) { + return nil, nil + } + targets, err := s.store.ListTargets(ctx, req.ID) + if err != nil { + return nil, err + } + return (&Request{Targets: targets}).Download(), nil +} diff --git a/internal/requests/downloads_test.go b/internal/requests/downloads_test.go new file mode 100644 index 0000000000..368c732cd0 --- /dev/null +++ b/internal/requests/downloads_test.go @@ -0,0 +1,1181 @@ +package requests + +import ( + "context" + "errors" + "fmt" + "slices" + "testing" + "time" + + pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" + "github.com/jackc/pgx/v5/pgxpool" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" +) + +func TestDownloadPhasePrecedence(t *testing.T) { + // Highest first; an unknown phase ranks as downloading. + order := []DownloadPhase{ + DownloadPhaseImportBlocked, DownloadPhaseStalled, DownloadPhaseDownloading, + DownloadPhaseImporting, DownloadPhasePaused, DownloadPhaseQueued, + } + for i := 1; i < len(order); i++ { + if downloadPhaseRank(order[i-1]) <= downloadPhaseRank(order[i]) { + t.Fatalf("%s must outrank %s", order[i-1], order[i]) + } + } + if downloadPhaseRank("seeding") != downloadPhaseRank(DownloadPhaseDownloading) { + t.Fatal("an unknown phase must rank as downloading") + } +} + +func TestRequestDownloadAggregatesLiveTargets(t *testing.T) { + early := time.Date(2026, 9, 28, 12, 0, 0, 0, time.UTC) + late := early.Add(time.Hour) + older, newer := early.Add(-2*time.Minute), early.Add(-time.Minute) + req := Request{Targets: []Target{ + {Quality: Quality1080p, Status: StatusDownloading, Download: &DownloadProgress{ + Phase: DownloadPhaseDownloading, BytesTotal: 4000, BytesLeft: 1000, EstimatedCompletion: &early, Downloads: 1, UpdatedAt: newer, + }}, + {Quality: Quality2160p, Status: StatusQueued, Download: &DownloadProgress{ + Phase: DownloadPhaseStalled, BytesTotal: 6000, BytesLeft: 6000, EstimatedCompletion: &late, Downloads: 2, UpdatedAt: older, + }}, + }} + got := req.Download() + if got == nil { + t.Fatal("Download() = nil, want the aggregate") + } + if got.Phase != DownloadPhaseStalled || got.BytesTotal != 10000 || got.BytesLeft != 7000 || got.Downloads != 3 { + t.Fatalf("aggregate = %+v, want stalled, 10000/7000 bytes, 3 downloads", got) + } + if got.EstimatedCompletion == nil || !got.EstimatedCompletion.Equal(late) || !got.UpdatedAt.Equal(older) { + t.Fatalf("aggregate = %+v, want the latest estimate and the oldest report", got) + } + // The aggregate is a copy: the targets keep their own figures. + if req.Targets[0].Download.BytesTotal != 4000 || req.Targets[0].Download.Phase != DownloadPhaseDownloading { + t.Fatalf("first target changed to %+v", req.Targets[0].Download) + } +} + +func TestRequestDownloadPhaseByPrecedence(t *testing.T) { + for _, tc := range []struct { + phases []DownloadPhase + want DownloadPhase + }{ + {[]DownloadPhase{DownloadPhaseQueued, DownloadPhaseImportBlocked}, DownloadPhaseImportBlocked}, + {[]DownloadPhase{DownloadPhaseDownloading, DownloadPhaseStalled}, DownloadPhaseStalled}, + {[]DownloadPhase{DownloadPhaseImporting, DownloadPhaseDownloading}, DownloadPhaseDownloading}, + {[]DownloadPhase{DownloadPhasePaused, DownloadPhaseImporting}, DownloadPhaseImporting}, + {[]DownloadPhase{DownloadPhaseQueued, DownloadPhasePaused}, DownloadPhasePaused}, + {[]DownloadPhase{DownloadPhaseQueued, DownloadPhaseQueued}, DownloadPhaseQueued}, + } { + req := Request{Targets: []Target{ + {Quality: Quality1080p, Status: StatusDownloading, Download: &DownloadProgress{Phase: tc.phases[0], BytesTotal: 1}}, + {Quality: Quality2160p, Status: StatusDownloading, Download: &DownloadProgress{Phase: tc.phases[1], BytesTotal: 1}}, + }} + if got := req.Download(); got == nil || got.Phase != tc.want { + t.Errorf("phases %v aggregate to %+v, want %s", tc.phases, got, tc.want) + } + } +} + +func TestRequestDownloadWithAnUnknownSizeHasNoTotal(t *testing.T) { + req := Request{Targets: []Target{ + {Quality: Quality1080p, Status: StatusDownloading, Download: &DownloadProgress{Phase: DownloadPhaseDownloading, BytesTotal: 4000, BytesLeft: 1000, Downloads: 1}}, + {Quality: Quality2160p, Status: StatusDownloading, Download: &DownloadProgress{Phase: DownloadPhaseQueued, Downloads: 1}}, + }} + got := req.Download() + if got == nil || got.BytesTotal != 0 || got.BytesLeft != 0 || got.Downloads != 2 { + t.Fatalf("aggregate = %+v, want no total while one size is unknown", got) + } +} + +// A live target that reports no progress yet (a 4K copy still waiting for a +// release, say) leaves the request's size unknown, so the request shows no +// percentage for its 1080p copy alone. +func TestRequestDownloadWithASilentLiveTargetHasNoTotal(t *testing.T) { + req := Request{Targets: []Target{ + {Quality: Quality1080p, Status: StatusDownloading, Download: &DownloadProgress{Phase: DownloadPhaseDownloading, BytesTotal: 4000, BytesLeft: 400, Downloads: 1}}, + {Quality: Quality2160p, Status: StatusQueued}, + }} + got := req.Download() + if got == nil || got.Phase != DownloadPhaseDownloading || got.BytesTotal != 0 || got.BytesLeft != 0 || got.Downloads != 1 { + t.Fatalf("aggregate = %+v, want downloading with no total while the 4K copy reports nothing", got) + } + // A finished target is not live and does not hide the total. + req.Targets[1].Status = StatusCompleted + if got := req.Download(); got == nil || got.BytesTotal != 4000 || got.BytesLeft != 400 { + t.Fatalf("aggregate = %+v, want the 1080p figures once the 4K copy is done", got) + } +} + +func TestRequestDownloadIgnoresFinishedTargetsAndTargetsWithoutProgress(t *testing.T) { + stale := &DownloadProgress{Phase: DownloadPhaseDownloading, BytesTotal: 100} + req := Request{Targets: []Target{ + {Quality: Quality1080p, Status: StatusCompleted, Download: stale}, + {Quality: Quality2160p, Status: StatusDownloading}, + }} + if got := req.Download(); got != nil { + t.Fatalf("Download() = %+v, want nil", got) + } + req.Targets = append(req.Targets, Target{Status: StatusFailed, Download: stale}) + if got := req.Download(); got != nil { + t.Fatalf("Download() = %+v, want nil", got) + } +} + +func TestDownloadProgressFromProto(t *testing.T) { + eta := time.Date(2026, 9, 28, 13, 0, 0, 0, time.UTC) + for _, tc := range []struct { + name string + in *pluginv1.DownloadProgress + want *DownloadProgress + }{ + {name: "unset", in: nil, want: nil}, + {name: "empty", in: &pluginv1.DownloadProgress{}, want: nil}, + {name: "no phase and no size", in: &pluginv1.DownloadProgress{Downloads: 1}, want: nil}, + { + name: "known phase", + in: &pluginv1.DownloadProgress{Phase: "import_blocked", BytesTotal: 100, BytesLeft: 0, Downloads: 1, EstimatedCompletion: timestamppb.New(eta)}, + want: &DownloadProgress{Phase: DownloadPhaseImportBlocked, BytesTotal: 100, Downloads: 1, EstimatedCompletion: &eta}, + }, + { + name: "empty phase with a size", + in: &pluginv1.DownloadProgress{BytesTotal: 100, BytesLeft: 40, Downloads: 1}, + want: &DownloadProgress{Phase: DownloadPhaseDownloading, BytesTotal: 100, BytesLeft: 40, Downloads: 1}, + }, + { + name: "unknown phase", + in: &pluginv1.DownloadProgress{Phase: "seeding", Downloads: 2}, + want: &DownloadProgress{Phase: DownloadPhaseDownloading, Downloads: 2}, + }, + { + name: "queued without a size", + in: &pluginv1.DownloadProgress{Phase: "queued", Downloads: 1}, + want: &DownloadProgress{Phase: DownloadPhaseQueued, Downloads: 1}, + }, + { + name: "clamped", + in: &pluginv1.DownloadProgress{Phase: "downloading", BytesTotal: 100, BytesLeft: 250, Downloads: -1}, + want: &DownloadProgress{Phase: DownloadPhaseDownloading, BytesTotal: 100, BytesLeft: 100}, + }, + { + name: "negative", + in: &pluginv1.DownloadProgress{Phase: "paused", BytesTotal: -5, BytesLeft: -5}, + want: &DownloadProgress{Phase: DownloadPhasePaused}, + }, + { + name: "invalid estimate", + in: &pluginv1.DownloadProgress{Phase: "downloading", BytesTotal: 10, EstimatedCompletion: ×tamppb.Timestamp{Nanos: -1}}, + want: &DownloadProgress{Phase: DownloadPhaseDownloading, BytesTotal: 10}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + got := downloadProgressFromProto(tc.in) + if !sameProgress(got, tc.want) { + t.Fatalf("downloadProgressFromProto() = %+v, want %+v", got, tc.want) + } + }) + } +} + +func TestPluginRouterProviderCheckStatusCarriesProgress(t *testing.T) { + eta := time.Date(2026, 9, 28, 13, 0, 0, 0, time.UTC) + fc := &fakeRouterClient{statuses: []*pluginv1.TargetStatus{ + {Quality: "1080p", ConnectionId: "c1", Status: "downloading", Progress: &pluginv1.DownloadProgress{ + Phase: "downloading", BytesTotal: 2000, BytesLeft: 500, Downloads: 1, EstimatedCompletion: timestamppb.New(eta), + }}, + {Quality: "2160p", ConnectionId: "c1", Status: "queued"}, + }} + out, err := NewPluginRouterProvider(fakeRouterResolver{c: fc}).CheckStatus(context.Background(), 1, "arr", + Request{MediaType: MediaTypeMovie, TMDBID: 42}, []RouterTargetRef{{Quality: Quality1080p, ConnectionID: "c1"}}, nil) + if err != nil { + t.Fatalf("CheckStatus: %v", err) + } + want := &DownloadProgress{Phase: DownloadPhaseDownloading, BytesTotal: 2000, BytesLeft: 500, Downloads: 1, EstimatedCompletion: &eta} + if len(out) != 2 || !sameProgress(out[0].Progress, want) || out[1].Progress != nil { + t.Fatalf("statuses = %+v, want progress on the first only", out) + } +} + +// sameProgress compares progress reports, ignoring UpdatedAt, which the +// store stamps. +func sameProgress(a, b *DownloadProgress) bool { + if a == nil || b == nil { + return a == b + } + if (a.EstimatedCompletion == nil) != (b.EstimatedCompletion == nil) || + (a.EstimatedCompletion != nil && !a.EstimatedCompletion.Equal(*b.EstimatedCompletion)) { + return false + } + return a.Phase == b.Phase && a.BytesTotal == b.BytesTotal && a.BytesLeft == b.BytesLeft && a.Downloads == b.Downloads +} + +// seedDownloadTarget stores an active request with one target on connection. +func seedDownloadTarget(t *testing.T, store *fakeStore, requestID, connection string, quality Quality, status Status, download *DownloadProgress) Target { + t.Helper() + if store.requests[requestID] == nil { + store.requests[requestID] = &Request{ID: requestID, MediaType: MediaTypeMovie, TMDBID: 550, Status: status, Outcome: OutcomeActive} + } + target, err := store.CreateTarget(context.Background(), Target{ + RequestID: requestID, IntegrationID: connection, Quality: quality, Status: status, ExternalID: "123", Download: download, + }) + if err != nil { + t.Fatalf("seed target: %v", err) + } + return target +} + +func onlyTarget(t *testing.T, store *fakeStore, requestID string) Target { + t.Helper() + targets, _ := store.ListTargets(context.Background(), requestID) + if len(targets) != 1 { + t.Fatalf("targets = %+v, want one", targets) + } + return targets[0] +} + +func TestApplyTargetStatusesWritesAndClearsProgress(t *testing.T) { + progress := &DownloadProgress{Phase: DownloadPhaseDownloading, BytesTotal: 1000, BytesLeft: 250, Downloads: 1} + earlier := &DownloadProgress{Phase: DownloadPhaseQueued, BytesTotal: 1000, BytesLeft: 1000, Downloads: 1} + for _, tc := range []struct { + name string + status Status + had *DownloadProgress + reported RouterTargetStatus + wantStatus Status + wantChange reconcileChange + wantWrites int + want *DownloadProgress + }{ + { + name: "progress without a status change", status: StatusDownloading, + reported: RouterTargetStatus{Status: StatusDownloading, Progress: progress}, + wantStatus: StatusDownloading, wantChange: reconcileUnchanged, wantWrites: 1, want: progress, + }, + { + name: "progress on the move to downloading", status: StatusQueued, had: earlier, + reported: RouterTargetStatus{Status: StatusDownloading, Progress: progress}, + wantStatus: StatusDownloading, wantChange: reconcileDownloading, wantWrites: 1, want: progress, + }, + { + name: "completion clears", status: StatusDownloading, had: earlier, + reported: RouterTargetStatus{Status: StatusCompleted, Progress: progress}, + wantStatus: StatusCompleted, wantChange: reconcileCompleted, + }, + { + name: "failure clears", status: StatusDownloading, had: earlier, + reported: RouterTargetStatus{Status: StatusFailed, Message: "no release"}, + wantStatus: StatusFailed, wantChange: reconcileFailed, + }, + { + name: "no progress clears what was reported", status: StatusDownloading, had: earlier, + reported: RouterTargetStatus{Status: StatusDownloading}, + wantStatus: StatusDownloading, wantChange: reconcileUnchanged, wantWrites: 1, + }, + { + name: "an idle target is not written", status: StatusQueued, + reported: RouterTargetStatus{Status: StatusQueued}, + wantStatus: StatusQueued, wantChange: reconcileUnchanged, + }, + { + name: "no status keeps the status and writes progress", status: StatusQueued, + reported: RouterTargetStatus{Progress: progress}, + wantStatus: StatusQueued, wantChange: reconcileUnchanged, wantWrites: 1, want: progress, + }, + } { + t.Run(tc.name, func(t *testing.T) { + store := newFakeStore() + target := seedDownloadTarget(t, store, "req-1", "router-1", Quality1080p, tc.status, tc.had) + service := newTestService(store) + reported := tc.reported + reported.Quality, reported.ConnectionID = Quality1080p, "router-1" + + targets, _ := store.ListTargets(context.Background(), "req-1") + change, err := service.applyTargetStatuses(context.Background(), targets, []RouterTargetStatus{ + reported, + // A status for a target the request does not have is ignored. + {Quality: Quality2160p, ConnectionID: "router-1", Status: StatusCompleted, Progress: progress}, + }) + if err != nil { + t.Fatalf("applyTargetStatuses: %v", err) + } + got := onlyTarget(t, store, "req-1") + if change != tc.wantChange || got.Status != tc.wantStatus { + t.Fatalf("change = %s, status = %s; want %s, %s", change, got.Status, tc.wantChange, tc.wantStatus) + } + if len(store.downloadWrites) != tc.wantWrites { + t.Fatalf("progress writes = %+v, want %d", store.downloadWrites, tc.wantWrites) + } + if tc.wantWrites > 0 && store.downloadWrites[0].targetID != target.ID { + t.Fatalf("wrote target %d, want %d", store.downloadWrites[0].targetID, target.ID) + } + if !sameProgress(got.Download, tc.want) { + t.Fatalf("stored progress = %+v, want %+v", got.Download, tc.want) + } + }) + } +} + +func TestApplyTargetStatusesReportsAProgressWriteFailure(t *testing.T) { + store := newFakeStore() + seedDownloadTarget(t, store, "req-1", "router-1", Quality1080p, StatusDownloading, nil) + store.downloadErr = errors.New("database unavailable") + targets, _ := store.ListTargets(context.Background(), "req-1") + _, err := newTestService(store).applyTargetStatuses(context.Background(), targets, []RouterTargetStatus{{ + Quality: Quality1080p, ConnectionID: "router-1", Status: StatusDownloading, + Progress: &DownloadProgress{Phase: DownloadPhaseDownloading, BytesTotal: 10}, + }}) + if !errors.Is(err, store.downloadErr) { + t.Fatalf("applyTargetStatuses err = %v, want the store's", err) + } +} + +// The reconcile pass records progress too, for queued targets and for a +// download's first report, which puts the target on the download refresh pass. +func TestReconcileRequestsRecordsDownloadProgress(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInst("router-1")} + seedDownloadTarget(t, store, "req-1", "router-1", Quality1080p, StatusQueued, nil) + store.candidates = []*Request{store.requests["req-1"]} + progress := &DownloadProgress{Phase: DownloadPhaseQueued, BytesTotal: 800, BytesLeft: 800, Downloads: 1} + service := newTestService(store) + service.SetRouterProvider(&fakeRouterProvider{progressCapable: map[int]bool{1: true}, statuses: []RouterTargetStatus{{ + Quality: Quality1080p, ConnectionID: "router-1", Status: StatusQueued, Progress: progress, + }}}) + + if _, err := service.ReconcileRequests(context.Background(), 100); err != nil { + t.Fatalf("ReconcileRequests: %v", err) + } + if got := onlyTarget(t, store, "req-1"); got.Status != StatusQueued || !sameProgress(got.Download, progress) { + t.Fatalf("target = %+v, want queued with the reported progress", got) + } + if len(store.statusUpdates) != 0 { + t.Fatalf("status updates = %v, want none for an unchanged status", store.statusUpdates) + } +} + +// Progress counts only from a plugin that declares reports_download_progress. +// The download refresh pass clears any other plugin's, so were reconcile to +// record it, it would come and go between the passes. +func TestReconcileRequestsIgnoresProgressFromUndeclaredPlugins(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInst("router-1")} + stale := &DownloadProgress{Phase: DownloadPhaseDownloading, BytesTotal: 800, BytesLeft: 100, Downloads: 1} + seedDownloadTarget(t, store, "req-1", "router-1", Quality1080p, StatusDownloading, stale) + store.candidates = []*Request{store.requests["req-1"]} + service := newTestService(store) + router := &fakeRouterProvider{statuses: []RouterTargetStatus{{ + Quality: Quality1080p, ConnectionID: "router-1", Status: StatusDownloading, + Progress: &DownloadProgress{Phase: DownloadPhaseDownloading, BytesTotal: 800, BytesLeft: 400, Downloads: 1}, + }}} + service.SetRouterProvider(router) + + result, err := service.ReconcileRequests(context.Background(), 100) + if err != nil || result.Errors != 0 { + t.Fatalf("ReconcileRequests = %+v, %v", result, err) + } + if got := onlyTarget(t, store, "req-1"); got.Status != StatusDownloading || got.Download != nil { + t.Fatalf("target = %+v, want downloading with its stale progress cleared", got) + } + + // When the declaration cannot be read, the report stands and the pass + // reports the error. + router.featuresErr = errors.New("capability metadata unavailable") + result, err = service.ReconcileRequests(context.Background(), 100) + if err != nil || result.Errors != 1 { + t.Fatalf("ReconcileRequests = %+v, %v; want one error", result, err) + } + if got := onlyTarget(t, store, "req-1"); got.Download == nil || got.Download.BytesLeft != 400 { + t.Fatalf("target = %+v, want the reported progress kept", got) + } +} + +// A server whose own state moves while the target's status does not (an +// import that stalls) has its raw status recorded beside the new progress, +// with no status write and so no history entry. An unchanged raw status is +// not written again. +func TestReconcileRequestsKeepsExternalStatusInStep(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInst("router-1")} + blocked := &DownloadProgress{Phase: DownloadPhaseImportBlocked, BytesTotal: 800, BytesLeft: 0, Downloads: 1} + target := seedDownloadTarget(t, store, "req-1", "router-1", Quality1080p, StatusDownloading, blocked) + store.targets["req-1"][0].ExternalStatus = "completed/importBlocked" + store.candidates = []*Request{store.requests["req-1"]} + service := newTestService(store) + stalled := &DownloadProgress{Phase: DownloadPhaseStalled, BytesTotal: 800, BytesLeft: 480, Downloads: 1} + service.SetRouterProvider(&fakeRouterProvider{progressCapable: map[int]bool{1: true}, statuses: []RouterTargetStatus{{ + Quality: Quality1080p, ConnectionID: "router-1", Status: StatusDownloading, ExternalStatus: "warning/downloading", Progress: stalled, + }}}) + + for pass := 1; pass <= 2; pass++ { + if _, err := service.ReconcileRequests(context.Background(), 100); err != nil { + t.Fatalf("pass %d: ReconcileRequests: %v", pass, err) + } + got := onlyTarget(t, store, "req-1") + if got.Status != StatusDownloading || got.ExternalStatus != "warning/downloading" || !sameProgress(got.Download, stalled) { + t.Fatalf("pass %d: target = %+v, want downloading, stalled, with the new raw status", pass, got) + } + if len(store.statusUpdates) != 0 { + t.Fatalf("pass %d: status updates = %v, want none for an unchanged status", pass, store.statusUpdates) + } + if want := []int64{target.ID}; !slices.Equal(store.externalStatusWrites, want) { + t.Fatalf("pass %d: external status writes = %v, want %v, once", pass, store.externalStatusWrites, want) + } + } +} + +// downloadRefreshService serves two download servers: router-1 on a plugin +// that reports progress, router-2 on one that does not. +func downloadRefreshService(store *fakeStore) (*Service, *fakeRouterProvider) { + store.integrations = []Integration{routerInstOn("router-1", 1), routerInstOn("router-2", 2)} + router := &fakeRouterProvider{progressCapable: map[int]bool{1: true}} + service := newTestService(store) + service.SetRouterProvider(router) + return service, router +} + +func TestRefreshDownloadsPollsOnlyDownloadingTargetsOfProgressPlugins(t *testing.T) { + store := newFakeStore() + service, router := downloadRefreshService(store) + earlier := &DownloadProgress{Phase: DownloadPhaseDownloading, BytesTotal: 1000, BytesLeft: 900, Downloads: 1} + // req-mixed: 1080p downloading on the reporting plugin, 4K downloading + // on the other. req-queued waits in the queue on the reporting plugin. + // req-legacy downloads only through the other plugin. + seedDownloadTarget(t, store, "req-mixed", "router-1", Quality1080p, StatusDownloading, earlier) + seedDownloadTarget(t, store, "req-mixed", "router-2", Quality2160p, StatusDownloading, nil) + seedDownloadTarget(t, store, "req-queued", "router-1", Quality1080p, StatusQueued, earlier) + seedDownloadTarget(t, store, "req-legacy", "router-2", Quality1080p, StatusDownloading, nil) + progress := &DownloadProgress{Phase: DownloadPhaseDownloading, BytesTotal: 1000, BytesLeft: 400, Downloads: 1} + router.statuses = []RouterTargetStatus{ + {Quality: Quality1080p, ConnectionID: "router-1", Status: StatusDownloading, Progress: progress}, + {Quality: Quality2160p, ConnectionID: "router-2", Status: StatusCompleted}, + } + presence := service.presence.(*fakePresence) + + result, err := service.RefreshDownloads(context.Background(), 200, 0) + if err != nil { + t.Fatalf("RefreshDownloads: %v", err) + } + if result != (DownloadRefreshResult{Checked: 1}) { + t.Fatalf("result = %+v, want one request checked", result) + } + if len(router.statusLog) != 1 || router.statusLog[0].installationID != 1 || + len(router.statusLog[0].refs) != 1 || router.statusLog[0].refs[0].Quality != Quality1080p { + t.Fatalf("status calls = %+v, want one call to installation 1 for the 1080p target", router.statusLog) + } + targets, _ := store.ListTargets(context.Background(), "req-mixed") + for _, target := range targets { + switch target.Quality { + case Quality1080p: + if !sameProgress(target.Download, progress) { + t.Fatalf("1080p progress = %+v, want %+v", target.Download, progress) + } + case Quality2160p: + // Not polled, so the completion reported for it was not applied. + if target.Status != StatusDownloading || target.Download != nil { + t.Fatalf("4K target = %+v, want it untouched", target) + } + } + } + if len(presence.got) != 0 || router.fulfillCalls != 0 { + t.Fatalf("presence lookups = %d, submissions = %d; want neither", len(presence.got), router.fulfillCalls) + } +} + +func TestRefreshDownloadsAppliesStatusTransitions(t *testing.T) { + store := newFakeStore() + service, router := downloadRefreshService(store) + // Were the pass to run the notification step, req-1 would be notified: + // it is in the library and not notified yet. + notifier := &fakeNotifier{} + service.SetFulfillmentNotifier(notifier) + service.presence.(*fakePresence).available = map[MediaType]map[int]bool{MediaTypeMovie: {550: true}} + store.unnotified = []string{"req-1"} + seedDownloadTarget(t, store, "req-1", "router-1", Quality1080p, StatusDownloading, + &DownloadProgress{Phase: DownloadPhaseImporting, BytesTotal: 10, Downloads: 1}) + router.statuses = []RouterTargetStatus{{Quality: Quality1080p, ConnectionID: "router-1", Status: StatusCompleted, ExternalStatus: "imported"}} + + result, err := service.RefreshDownloads(context.Background(), 200, 0) + if err != nil { + t.Fatalf("RefreshDownloads: %v", err) + } + if result != (DownloadRefreshResult{Checked: 1, Updated: 1}) { + t.Fatalf("result = %+v, want one request checked and updated", result) + } + if got := onlyTarget(t, store, "req-1"); got.Status != StatusCompleted || got.Download != nil { + t.Fatalf("target = %+v, want completed without progress", got) + } + if store.requests["req-1"].Status != StatusCompleted { + t.Fatalf("request status = %s, want completed", store.requests["req-1"].Status) + } + if len(notifier.requestIDs) != 0 || len(service.presence.(*fakePresence).got) != 0 { + t.Fatalf("notified %v after presence lookups %v; the reconcile pass owns both", notifier.requestIDs, service.presence.(*fakePresence).got) + } +} + +func TestRefreshDownloadsCountsPluginErrors(t *testing.T) { + store := newFakeStore() + service, router := downloadRefreshService(store) + seedDownloadTarget(t, store, "req-1", "router-1", Quality1080p, StatusDownloading, + &DownloadProgress{Phase: DownloadPhaseDownloading, BytesTotal: 10, Downloads: 1}) + router.statusErr = errors.New("plugin unavailable") + + result, err := service.RefreshDownloads(context.Background(), 200, 0) + if err != nil { + t.Fatalf("RefreshDownloads: %v", err) + } + if result != (DownloadRefreshResult{Checked: 1, Errors: 1}) { + t.Fatalf("result = %+v, want one request checked with an error", result) + } +} + +// A downloading target without progress is left to the reconcile pass, +// however long it stays that way: a plugin can report a title downloading with +// nothing in its download queue (Seerr keeps media at Processing for as long +// as it waits for a release), and polling it every minute would cost calls for +// nothing. A target whose progress stops leaves the pass the same way. +func TestRefreshDownloadsLeavesTargetsWithoutProgressToReconcile(t *testing.T) { + store := newFakeStore() + service, router := downloadRefreshService(store) + seedDownloadTarget(t, store, "req-idle", "router-1", Quality1080p, StatusDownloading, nil) + // req-live's 1080p has progress; its 4K, on the same plugin, has none. + seedDownloadTarget(t, store, "req-live", "router-1", Quality1080p, StatusDownloading, + &DownloadProgress{Phase: DownloadPhaseDownloading, BytesTotal: 1000, BytesLeft: 10, Downloads: 1}) + seedDownloadTarget(t, store, "req-live", "router-1", Quality2160p, StatusDownloading, nil) + // The plugin still calls every target downloading, with nothing in its + // queue. + router.statuses = []RouterTargetStatus{ + {Quality: Quality1080p, ConnectionID: "router-1", Status: StatusDownloading}, + {Quality: Quality2160p, ConnectionID: "router-1", Status: StatusDownloading}, + } + + result, err := service.RefreshDownloads(context.Background(), 200, 0) + if err != nil || result != (DownloadRefreshResult{Checked: 1}) { + t.Fatalf("RefreshDownloads = %+v, %v; want only req-live checked", result, err) + } + if len(router.statusLog) != 1 || len(router.statusLog[0].refs) != 1 || router.statusLog[0].refs[0].Quality != Quality1080p { + t.Fatalf("status calls = %+v, want one, for req-live's 1080p only", router.statusLog) + } + targets, _ := store.ListTargets(context.Background(), "req-live") + for _, target := range targets { + if target.Status != StatusDownloading || target.Download != nil { + t.Fatalf("req-live %s target = %+v, want downloading without progress", target.Quality, target) + } + } + + // Neither target is polled again until the reconcile pass records new + // progress, and a scheduled run has nothing to do. + result, err = service.RefreshDownloads(context.Background(), 200, 0) + if err != nil || result != (DownloadRefreshResult{}) || router.statusCalls != 1 { + t.Fatalf("second pass = %+v, %v with %d status calls; want nothing asked", result, err, router.statusCalls) + } + if has, err := service.HasDownloadsToRefresh(context.Background()); err != nil || has { + t.Fatalf("HasDownloadsToRefresh() = %v, %v; want false", has, err) + } +} + +// Progress the pass cannot refresh is stale: the pass clears it without a +// call, which takes the target off the pass. +func TestRefreshDownloadsClearsProgressItCannotRefresh(t *testing.T) { + store := newFakeStore() + service, router := downloadRefreshService(store) + disabled := routerInstOn("router-3", 1) + disabled.Enabled = false + noKey := routerInstOn("router-4", 1) + noKey.APIKeyRef = " " + store.integrations = append(store.integrations, disabled, noKey) + stale := &DownloadProgress{Phase: DownloadPhaseDownloading, BytesTotal: 1000, BytesLeft: 500, Downloads: 1} + for id, connection := range map[string]string{ + "req-undeclared": "router-2", // its plugin no longer declares progress + "req-disabled": "router-3", + "req-no-key": "router-4", + "req-gone": "router-9", // its server was deleted + } { + seedDownloadTarget(t, store, id, connection, Quality1080p, StatusDownloading, stale) + } + + result, err := service.RefreshDownloads(context.Background(), 200, 0) + if err != nil || result != (DownloadRefreshResult{}) { + t.Fatalf("RefreshDownloads = %+v, %v; want nothing checked", result, err) + } + if router.statusCalls != 0 { + t.Fatalf("status calls = %d, want none", router.statusCalls) + } + for _, id := range []string{"req-undeclared", "req-disabled", "req-no-key", "req-gone"} { + if got := onlyTarget(t, store, id); got.Status != StatusDownloading || got.Download != nil { + t.Fatalf("%s target = %+v, want downloading without progress", id, got) + } + } + if left, _ := store.ListDownloadingRequests(context.Background(), 200); len(left) != 0 { + t.Fatalf("requests left on the refresh pass = %d, want none", len(left)) + } +} + +// A target that gets no status back keeps its progress while it is fresh, +// since one missed answer is usually a blip, and loses it once it is stale, so +// a frozen figure stops showing and clients stop polling for it. That holds +// whether the plugin skipped the target (its server errored or no longer has +// the title) or the whole call failed, and in the reconcile pass too. +func TestUnansweredTargetsKeepProgressUntilItIsStale(t *testing.T) { + for _, tc := range []struct { + name string + age time.Duration + callFails bool + reconcile bool + wantKept bool + }{ + {name: "skipped, fresh", age: 2 * time.Minute, wantKept: true}, + {name: "skipped, stale", age: staleDownloadProgress + time.Minute}, + {name: "call failed, fresh", age: 2 * time.Minute, callFails: true, wantKept: true}, + {name: "call failed, stale", age: staleDownloadProgress + time.Minute, callFails: true}, + {name: "reconcile, fresh", age: 2 * time.Minute, reconcile: true, wantKept: true}, + {name: "reconcile, stale", age: staleDownloadProgress + time.Minute, reconcile: true}, + } { + t.Run(tc.name, func(t *testing.T) { + store := newFakeStore() + service, router := downloadRefreshService(store) + reported := service.now().Add(-tc.age) + progress := &DownloadProgress{Phase: DownloadPhaseDownloading, BytesTotal: 1000, BytesLeft: 400, Downloads: 1, UpdatedAt: reported} + target := seedDownloadTarget(t, store, "req-1", "router-1", Quality1080p, StatusDownloading, progress) + // The plugin answers for a target the request does not have. + router.statuses = []RouterTargetStatus{{Quality: Quality2160p, ConnectionID: "router-1", Status: StatusDownloading, Progress: progress}} + if tc.callFails { + router.statusErr = errors.New("context deadline exceeded") + } + + var err error + if tc.reconcile { + store.candidates = []*Request{store.requests["req-1"]} + _, err = service.ReconcileRequests(context.Background(), 100) + } else { + _, err = service.RefreshDownloads(context.Background(), 200, 0) + } + if err != nil { + t.Fatalf("pass: %v", err) + } + if router.statusCalls != 1 { + t.Fatalf("status calls = %d, want 1", router.statusCalls) + } + got := onlyTarget(t, store, "req-1") + if got.Status != StatusDownloading { + t.Fatalf("status = %s, want downloading", got.Status) + } + if tc.wantKept { + if !sameProgress(got.Download, progress) || !got.Download.UpdatedAt.Equal(reported) { + t.Fatalf("progress = %+v, want %+v as last reported", got.Download, progress) + } + if !slices.Equal(store.downloadChecks, []int64{target.ID}) || len(store.downloadWrites) != 0 { + t.Fatalf("checks = %v, writes = %+v; want the target marked asked about and nothing else", store.downloadChecks, store.downloadWrites) + } + return + } + if got.Download != nil { + t.Fatalf("progress = %+v, want stale progress cleared", got.Download) + } + if len(store.downloadChecks) != 0 { + t.Fatalf("checks = %v, want none once the progress is cleared", store.downloadChecks) + } + }) + } +} + +// A request whose server stops answering takes its turn and goes to the back +// instead of heading every batch, so the others still get refreshed. +func TestRefreshDownloadsRotatesPastATargetWithoutAnAnswer(t *testing.T) { + store := newFakeStore() + service, router := downloadRefreshService(store) + store.integrations = append(store.integrations, routerInstOn("router-3", 1)) + now := time.Now().UTC() + seedDownloadTarget(t, store, "req-silent", "router-1", Quality1080p, StatusDownloading, + &DownloadProgress{Phase: DownloadPhaseDownloading, BytesTotal: 1000, BytesLeft: 900, Downloads: 1, UpdatedAt: now.Add(-2 * time.Minute)}) + seedDownloadTarget(t, store, "req-healthy", "router-3", Quality1080p, StatusDownloading, + &DownloadProgress{Phase: DownloadPhaseDownloading, BytesTotal: 1000, BytesLeft: 900, Downloads: 1, UpdatedAt: now.Add(-time.Minute)}) + // router-1 has stopped reporting its target; router-3 answers. + progress := &DownloadProgress{Phase: DownloadPhaseDownloading, BytesTotal: 1000, BytesLeft: 300, Downloads: 1} + router.statuses = []RouterTargetStatus{{Quality: Quality1080p, ConnectionID: "router-3", Status: StatusDownloading, Progress: progress}} + + for range 2 { + if _, err := service.RefreshDownloads(context.Background(), 1, 0); err != nil { + t.Fatalf("RefreshDownloads: %v", err) + } + } + if len(router.statusLog) != 2 || router.statusLog[0].conns[0].ID != "router-1" || router.statusLog[1].conns[0].ID != "router-3" { + t.Fatalf("status calls = %+v, want router-1's request and then router-3's", router.statusLog) + } + if got := onlyTarget(t, store, "req-healthy"); !sameProgress(got.Download, progress) { + t.Fatalf("healthy progress = %+v, want %+v", got.Download, progress) + } +} + +// A target whose plugin's features cannot be read moves to the back of the +// rotation like one its server did not answer for, so a lasting failure does +// not keep the other downloads from being refreshed. +func TestRefreshDownloadsRotatesPastAFeatureReadFailure(t *testing.T) { + store := newFakeStore() + service, router := downloadRefreshService(store) + store.integrations = append(store.integrations, routerInstOn("router-3", 1)) + now := time.Now().UTC() + seedDownloadTarget(t, store, "req-first", "router-1", Quality1080p, StatusDownloading, + &DownloadProgress{Phase: DownloadPhaseDownloading, BytesTotal: 1000, BytesLeft: 900, Downloads: 1, UpdatedAt: now.Add(-2 * time.Minute)}) + seedDownloadTarget(t, store, "req-second", "router-3", Quality1080p, StatusDownloading, + &DownloadProgress{Phase: DownloadPhaseDownloading, BytesTotal: 1000, BytesLeft: 900, Downloads: 1, UpdatedAt: now.Add(-time.Minute)}) + router.featuresErr = errors.New("capability metadata unavailable") + + result, err := service.RefreshDownloads(context.Background(), 1, 0) + if err != nil || result.Errors != 1 { + t.Fatalf("RefreshDownloads = %+v, %v; want one error", result, err) + } + router.featuresErr = nil + if _, err := service.RefreshDownloads(context.Background(), 1, 0); err != nil { + t.Fatalf("RefreshDownloads: %v", err) + } + if len(router.statusLog) != 1 || router.statusLog[0].conns[0].ID != "router-3" { + t.Fatalf("status calls = %+v, want req-second's once req-first moved back", router.statusLog) + } +} + +// A pass ends within its budget even while a download server has stopped +// answering and each call to it would run to the router's deadline. The call +// in flight is cut, its target goes to the back of the rotation, and the +// requests left over wait for the next pass without failing this one. +func TestRefreshDownloadsStopsWhenItsBudgetRunsOut(t *testing.T) { + store := newFakeStore() + service, router := downloadRefreshService(store) + store.integrations = append(store.integrations, routerInstOn("router-3", 3)) + router.progressCapable[3] = true + router.statusHangFor = map[int]bool{1: true} + now := time.Now().UTC() + progress := func(age time.Duration) *DownloadProgress { + return &DownloadProgress{Phase: DownloadPhaseDownloading, BytesTotal: 1000, BytesLeft: 900, Downloads: 1, UpdatedAt: now.Add(-age)} + } + hungA := seedDownloadTarget(t, store, "req-hung-a", "router-1", Quality1080p, StatusDownloading, progress(3*time.Minute)) + seedDownloadTarget(t, store, "req-hung-b", "router-1", Quality1080p, StatusDownloading, progress(2*time.Minute)) + seedDownloadTarget(t, store, "req-healthy", "router-3", Quality1080p, StatusDownloading, progress(time.Minute)) + + type outcome struct { + result DownloadRefreshResult + err error + } + done := make(chan outcome, 1) + go func() { + result, err := service.RefreshDownloads(context.Background(), 200, 50*time.Millisecond) + done <- outcome{result, err} + }() + var got outcome + select { + case got = <-done: + case <-time.After(10 * time.Second): + t.Fatal("RefreshDownloads outlasted its budget while a server hung") + } + if got.err != nil || got.result != (DownloadRefreshResult{Checked: 1, Errors: 1}) { + t.Fatalf("RefreshDownloads = %+v, %v; want the one cut call and no error", got.result, got.err) + } + if router.statusCalls != 1 { + t.Fatalf("status calls = %d, want only the call the budget cut", router.statusCalls) + } + if !slices.Equal(store.downloadChecks, []int64{hungA.ID}) || onlyTarget(t, store, "req-hung-a").Download == nil { + t.Fatalf("checks = %v; want the cut request's target marked asked about, its progress kept", store.downloadChecks) + } + next, err := store.ListDownloadingRequests(context.Background(), 200) + if err != nil { + t.Fatal(err) + } + if ids, want := func() []string { + ids := make([]string, 0, len(next)) + for _, req := range next { + ids = append(ids, req.ID) + } + return ids + }(), []string{"req-hung-b", "req-healthy", "req-hung-a"}; !slices.Equal(ids, want) { + t.Fatalf("next pass order = %v, want %v", ids, want) + } +} + +func TestHasDownloadsToRefresh(t *testing.T) { + store := newFakeStore() + seedDownloadTarget(t, store, "req-1", "router-1", Quality1080p, StatusDownloading, + &DownloadProgress{Phase: DownloadPhaseDownloading, BytesTotal: 10, Downloads: 1}) + if has, err := newTestService(store).HasDownloadsToRefresh(context.Background()); err != nil || has { + t.Fatalf("without a router: %v, %v; want false", has, err) + } + + store = newFakeStore() + service, _ := downloadRefreshService(store) + seedDownloadTarget(t, store, "req-idle", "router-1", Quality1080p, StatusDownloading, nil) + seedDownloadTarget(t, store, "req-queued", "router-1", Quality1080p, StatusQueued, + &DownloadProgress{Phase: DownloadPhaseQueued, Downloads: 1}) + if has, err := service.HasDownloadsToRefresh(context.Background()); err != nil || has { + t.Fatalf("with no downloading target that has progress: %v, %v; want false", has, err) + } + seedDownloadTarget(t, store, "req-live", "router-1", Quality1080p, StatusDownloading, + &DownloadProgress{Phase: DownloadPhaseDownloading, BytesTotal: 10, Downloads: 1}) + if has, err := service.HasDownloadsToRefresh(context.Background()); err != nil || !has { + t.Fatalf("with a downloading target that has progress: %v, %v; want true", has, err) + } +} + +func TestRefreshDownloadsWithoutWorkMakesNoCalls(t *testing.T) { + store := newFakeStore() + seedDownloadTarget(t, store, "req-1", "router-1", Quality1080p, StatusDownloading, nil) + if result, err := newTestService(store).RefreshDownloads(context.Background(), 200, 0); err != nil || result != (DownloadRefreshResult{}) { + t.Fatalf("without a router: result = %+v, err = %v", result, err) + } + + store = newFakeStore() + service, router := downloadRefreshService(store) + store.listIntegrationsCalls = 0 + if result, err := service.RefreshDownloads(context.Background(), 200, 0); err != nil || result != (DownloadRefreshResult{}) { + t.Fatalf("with nothing downloading: result = %+v, err = %v", result, err) + } + if store.listIntegrationsCalls != 0 || router.statusCalls != 0 { + t.Fatalf("integrations reads = %d, status calls = %d; want none while nothing downloads", store.listIntegrationsCalls, router.statusCalls) + } +} + +func TestGetDetailCarriesTheActiveRequestDownload(t *testing.T) { + store := newFakeStore() + eta := time.Date(2026, 5, 24, 13, 0, 0, 0, time.UTC) + seedDownloadTarget(t, store, "req-1", "router-1", Quality1080p, StatusDownloading, + &DownloadProgress{Phase: DownloadPhaseDownloading, BytesTotal: 1000, BytesLeft: 250, Downloads: 1, EstimatedCompletion: &eta}) + active := store.requests["req-1"] + active.RequestedByUserID, active.RequestedByProfileID = 2, "profile-2" + store.active[MediaTypeMovie][550] = active + service := newTestServiceWithTMDB(store, &fakeTMDBClient{detail: &tmdb.MediaDetail{MediaType: "movie", ID: 550, Title: "Fight Club"}}) + + detail, err := service.GetDetail(context.Background(), testViewer(1), MediaTypeMovie, 550) + if err != nil { + t.Fatalf("GetDetail: %v", err) + } + want := &DownloadProgress{Phase: DownloadPhaseDownloading, BytesTotal: 1000, BytesLeft: 250, Downloads: 1, EstimatedCompletion: &eta} + if !sameProgress(detail.Request.Download, want) { + t.Fatalf("detail request download = %+v, want %+v", detail.Request.Download, want) + } + + // A request that has not reached a download server has none. + active.Status = StatusApproved + if detail, err = service.GetDetail(context.Background(), testViewer(1), MediaTypeMovie, 550); err != nil || detail.Request.Download != nil { + t.Fatalf("approved request: download = %+v, err = %v; want none", detail.Request.Download, err) + } +} + +// The raw status write changes nothing but external_status: not the date of +// the target's last status change, the request, or its history. A target that +// is no longer queued or downloading keeps what it has. +func TestUpdateTargetExternalStatusDatabase(t *testing.T) { + repo, pool := lifecycleTestRepository(t) + ctx := t.Context() + insertLifecycleRequest(t, repo, "req-ext", 1, 802, StatusDownloading) + target, err := repo.CreateTarget(ctx, Target{RequestID: "req-ext", Quality: Quality1080p, Status: StatusDownloading, ExternalStatus: "completed/importBlocked"}) + if err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `UPDATE media_request_targets SET updated_at = now() - interval '2 days' WHERE id = $1`, target.ID); err != nil { + t.Fatal(err) + } + read := func() (external string, updated time.Time, events int) { + t.Helper() + if err := pool.QueryRow(ctx, ` + SELECT t.external_status, t.updated_at, (SELECT count(*) FROM media_request_events e WHERE e.request_id = t.request_id) + FROM media_request_targets t WHERE t.id = $1`, target.ID).Scan(&external, &updated, &events); err != nil { + t.Fatal(err) + } + return external, updated, events + } + _, beforeUpdated, beforeEvents := read() + + if err := repo.UpdateTargetExternalStatus(ctx, target.ID, "warning/downloading"); err != nil { + t.Fatal(err) + } + if external, updated, events := read(); external != "warning/downloading" || !updated.Equal(beforeUpdated) || events != beforeEvents { + t.Fatalf("after write: external_status %q, updated_at %v (was %v), events %d (was %d); want the new status and nothing else changed", + external, updated, beforeUpdated, events, beforeEvents) + } + + if _, err := pool.Exec(ctx, `UPDATE media_request_targets SET status = 'completed' WHERE id = $1`, target.ID); err != nil { + t.Fatal(err) + } + if err := repo.UpdateTargetExternalStatus(ctx, target.ID, "queued"); err != nil { + t.Fatal(err) + } + if external, _, _ := read(); external != "warning/downloading" { + t.Fatalf("a completed target's external_status = %q, want it left alone", external) + } +} + +func TestUpdateTargetDownloadDatabase(t *testing.T) { + repo, pool := lifecycleTestRepository(t) + ctx := t.Context() + insertLifecycleRequest(t, repo, "req-dl", 1, 801, StatusDownloading) + target, err := repo.CreateTarget(ctx, Target{RequestID: "req-dl", Quality: Quality1080p, Status: StatusDownloading}) + if err != nil { + t.Fatal(err) + } + // Date the target's and the request's last change, and their history. + if _, err := pool.Exec(ctx, `UPDATE media_request_targets SET updated_at = now() - interval '2 days' WHERE id = $1`, target.ID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `UPDATE media_requests SET updated_at = now() - interval '2 days' WHERE id = 'req-dl'`); err != nil { + t.Fatal(err) + } + snapshot := func() (targetUpdated, requestUpdated time.Time, events int) { + t.Helper() + if err := pool.QueryRow(ctx, ` + SELECT t.updated_at, r.updated_at, (SELECT count(*) FROM media_request_events e WHERE e.request_id = r.id) + FROM media_request_targets t JOIN media_requests r ON r.id = t.request_id + WHERE t.id = $1`, target.ID).Scan(&targetUpdated, &requestUpdated, &events); err != nil { + t.Fatal(err) + } + return targetUpdated, requestUpdated, events + } + stored := func() *DownloadProgress { + t.Helper() + targets, err := repo.ListTargets(ctx, "req-dl") + if err != nil || len(targets) != 1 { + t.Fatalf("targets = %+v, err = %v", targets, err) + } + return targets[0].Download + } + beforeTarget, beforeRequest, beforeEvents := snapshot() + + eta := time.Now().Add(time.Hour).UTC().Truncate(time.Microsecond) + progress := &DownloadProgress{Phase: DownloadPhaseDownloading, BytesTotal: 5000, BytesLeft: 1200, Downloads: 2, EstimatedCompletion: &eta} + written := time.Now() + if err := repo.UpdateTargetDownload(ctx, target.ID, progress); err != nil { + t.Fatal(err) + } + got := stored() + if !sameProgress(got, progress) || got.UpdatedAt.Before(written.Add(-time.Minute)) { + t.Fatalf("stored progress = %+v, want %+v stamped now", got, progress) + } + // A report counts as asked about too, for the refresh rotation. + checkedAt := func() *time.Time { + t.Helper() + var at *time.Time + if err := pool.QueryRow(ctx, `SELECT download_checked_at FROM media_request_targets WHERE id = $1`, target.ID).Scan(&at); err != nil { + t.Fatal(err) + } + return at + } + if at := checkedAt(); at == nil || !at.Equal(got.UpdatedAt) { + t.Fatalf("download_checked_at = %v, want the report's %v", at, got.UpdatedAt) + } + if afterTarget, afterRequest, afterEvents := snapshot(); !afterTarget.Equal(beforeTarget) || !afterRequest.Equal(beforeRequest) || afterEvents != beforeEvents { + t.Fatalf("a progress write moved updated_at (target %v -> %v, request %v -> %v) or wrote history (%d -> %d)", + beforeTarget, afterTarget, beforeRequest, afterRequest, beforeEvents, afterEvents) + } + + // Nil clears it, again without touching the target or the request. + if err := repo.UpdateTargetDownload(ctx, target.ID, nil); err != nil { + t.Fatal(err) + } + if got := stored(); got != nil { + t.Fatalf("progress after clearing = %+v, want none", got) + } + if at := checkedAt(); at != nil { + t.Fatalf("download_checked_at after clearing = %v, want none", at) + } + if afterTarget, _, _ := snapshot(); !afterTarget.Equal(beforeTarget) { + t.Fatal("clearing progress moved the target's updated_at") + } + + // Completion clears it, and a late report cannot bring it back. + if err := repo.UpdateTargetDownload(ctx, target.ID, progress); err != nil { + t.Fatal(err) + } + if _, err := repo.UpdateTargetStatus(ctx, target.ID, StatusCompleted, "", "imported", "", Viewer{}); err != nil { + t.Fatal(err) + } + if got := stored(); got != nil { + t.Fatalf("progress after completion = %+v, want none", got) + } + if err := repo.UpdateTargetDownload(ctx, target.ID, progress); err != nil { + t.Fatal(err) + } + if got := stored(); got != nil { + t.Fatalf("a late report wrote %+v onto a completed target", got) + } +} + +func TestFailedTargetLosesDownloadProgressDatabase(t *testing.T) { + repo, _ := lifecycleTestRepository(t) + ctx := t.Context() + insertLifecycleRequest(t, repo, "req-fail", 1, 811, StatusQueued) + target, err := repo.CreateTarget(ctx, Target{RequestID: "req-fail", Quality: Quality1080p, Status: StatusQueued}) + if err != nil { + t.Fatal(err) + } + if err := repo.UpdateTargetDownload(ctx, target.ID, &DownloadProgress{Phase: DownloadPhaseStalled, BytesTotal: 10, BytesLeft: 10, Downloads: 1}); err != nil { + t.Fatal(err) + } + if _, err := repo.UpdateTargetStatus(ctx, target.ID, StatusFailed, "", "", "download failed", Viewer{}); err != nil { + t.Fatal(err) + } + targets, err := repo.ListTargets(ctx, "req-fail") + if err != nil || len(targets) != 1 || targets[0].Download != nil { + t.Fatalf("targets = %+v, err = %v; want the failed target without progress", targets, err) + } +} + +// addDownloadTestTarget stores a target for a DB test, with progress reported +// and asked about refreshedAgo ago, or without progress when refreshedAgo is +// empty. +func addDownloadTestTarget(t *testing.T, repo *Repository, pool *pgxpool.Pool, requestID string, quality Quality, status Status, refreshedAgo string) Target { + t.Helper() + ctx := t.Context() + target, err := repo.CreateTarget(ctx, Target{RequestID: requestID, Quality: quality, Status: status}) + if err != nil { + t.Fatal(err) + } + if refreshedAgo == "" { + return target + } + if _, err := pool.Exec(ctx, ` + UPDATE media_request_targets + SET download_phase = 'downloading', download_bytes_total = 1, download_bytes_left = 1, + download_count = 1, download_updated_at = now() - $2::interval, + download_checked_at = now() - $2::interval + WHERE id = $1`, target.ID, refreshedAgo); err != nil { + t.Fatal(err) + } + return target +} + +func requestIDs(reqs []*Request) []string { + ids := make([]string, 0, len(reqs)) + for _, req := range reqs { + ids = append(ids, req.ID) + } + return ids +} + +func TestListDownloadingRequestsOrderDatabase(t *testing.T) { + repo, pool := lifecycleTestRepository(t) + ctx := t.Context() + addTarget := func(requestID string, quality Quality, status Status, refreshedAgo string) { + t.Helper() + _ = addDownloadTestTarget(t, repo, pool, requestID, quality, status, refreshedAgo) + } + for i, id := range []string{"dl-a", "dl-b", "dl-c", "dl-d", "dl-e", "dl-f", "dl-g", "dl-h"} { + insertLifecycleRequest(t, repo, id, 1, 900+i, StatusDownloading) + } + addTarget("dl-a", Quality1080p, StatusDownloading, "1 minute") + addTarget("dl-b", Quality1080p, StatusDownloading, "") // no progress + addTarget("dl-c", Quality1080p, StatusQueued, "30 minutes") // nothing downloading + addTarget("dl-d", Quality1080p, StatusDownloading, "10 minutes") + // One recent target does not hide a stale sibling. + addTarget("dl-e", Quality1080p, StatusDownloading, "30 seconds") + addTarget("dl-e", Quality2160p, StatusDownloading, "5 minutes") + // A sibling without progress does not make the request look stale. + addTarget("dl-f", Quality1080p, StatusDownloading, "2 minutes") + addTarget("dl-f", Quality2160p, StatusDownloading, "") + addTarget("dl-g", Quality1080p, StatusDownloading, "20 minutes") + addTarget("dl-h", Quality1080p, StatusDownloading, "") // no progress + // A closed request is not refreshed. + if _, err := pool.Exec(ctx, `UPDATE media_requests SET outcome = 'cancelled' WHERE id = 'dl-g'`); err != nil { + t.Fatal(err) + } + + got, err := repo.ListDownloadingRequests(ctx, 10) + if err != nil { + t.Fatal(err) + } + if ids, want := requestIDs(got), []string{"dl-d", "dl-e", "dl-f", "dl-a"}; !slices.Equal(ids, want) { + t.Fatalf("downloading requests = %v, want %v", ids, want) + } + limited, err := repo.ListDownloadingRequests(ctx, 2) + if err != nil { + t.Fatal(err) + } + if ids, want := requestIDs(limited), []string{"dl-d", "dl-e"}; !slices.Equal(ids, want) { + t.Fatalf("limited = %v, want the two refreshed longest ago", ids) + } +} + +// Downloading targets that never report progress (a plugin that does not +// declare it, or one with nothing queued for them) cannot fill the batch and +// starve the targets the refresh pass exists for, however many there are. +func TestListDownloadingRequestsSkipsTargetsWithoutProgressDatabase(t *testing.T) { + repo, pool := lifecycleTestRepository(t) + ctx := t.Context() + const limit = 3 + for i := range 2 * limit { + id := fmt.Sprintf("dl-idle-%d", i) + insertLifecycleRequest(t, repo, id, 1, 950+i, StatusDownloading) + _ = addDownloadTestTarget(t, repo, pool, id, Quality1080p, StatusDownloading, "") + } + insertLifecycleRequest(t, repo, "dl-live-1", 1, 970, StatusDownloading) + _ = addDownloadTestTarget(t, repo, pool, "dl-live-1", Quality1080p, StatusDownloading, "1 minute") + insertLifecycleRequest(t, repo, "dl-live-2", 1, 971, StatusDownloading) + _ = addDownloadTestTarget(t, repo, pool, "dl-live-2", Quality1080p, StatusDownloading, "3 minutes") + + got, err := repo.ListDownloadingRequests(ctx, limit) + if err != nil { + t.Fatal(err) + } + if ids, want := requestIDs(got), []string{"dl-live-2", "dl-live-1"}; !slices.Equal(ids, want) { + t.Fatalf("downloading requests = %v, want %v", ids, want) + } +} + +// A target whose server stops answering takes its turn and goes to the back of +// the rotation, keeping its progress and when it was last heard from; it does +// not head every batch. +func TestMarkTargetDownloadCheckedRotatesDatabase(t *testing.T) { + repo, pool := lifecycleTestRepository(t) + ctx := t.Context() + insertLifecycleRequest(t, repo, "dl-silent", 1, 980, StatusDownloading) + silent := addDownloadTestTarget(t, repo, pool, "dl-silent", Quality1080p, StatusDownloading, "10 minutes") + insertLifecycleRequest(t, repo, "dl-healthy", 1, 981, StatusDownloading) + _ = addDownloadTestTarget(t, repo, pool, "dl-healthy", Quality1080p, StatusDownloading, "1 minute") + if _, err := pool.Exec(ctx, `UPDATE media_request_targets SET updated_at = now() - interval '2 days' WHERE id = $1`, silent.ID); err != nil { + t.Fatal(err) + } + read := func() (heard time.Time, updated time.Time, progress *DownloadProgress) { + t.Helper() + if err := pool.QueryRow(ctx, `SELECT download_updated_at, updated_at FROM media_request_targets WHERE id = $1`, silent.ID).Scan(&heard, &updated); err != nil { + t.Fatal(err) + } + targets, err := repo.ListTargets(ctx, "dl-silent") + if err != nil || len(targets) != 1 { + t.Fatalf("targets = %+v, err = %v", targets, err) + } + return heard, updated, targets[0].Download + } + heardBefore, updatedBefore, _ := read() + + if got, err := repo.ListDownloadingRequests(ctx, 10); err != nil || !slices.Equal(requestIDs(got), []string{"dl-silent", "dl-healthy"}) { + t.Fatalf("before = %v, %v; want the silent request first", requestIDs(got), err) + } + if err := repo.MarkTargetDownloadChecked(ctx, silent.ID); err != nil { + t.Fatal(err) + } + if got, err := repo.ListDownloadingRequests(ctx, 10); err != nil || !slices.Equal(requestIDs(got), []string{"dl-healthy", "dl-silent"}) { + t.Fatalf("after = %v, %v; want the silent request behind the healthy one", requestIDs(got), err) + } + heard, updated, progress := read() + if !heard.Equal(heardBefore) || !updated.Equal(updatedBefore) || progress == nil || progress.Phase != DownloadPhaseDownloading { + t.Fatalf("heard %v -> %v, updated_at %v -> %v, progress %+v; want all kept", heardBefore, heard, updatedBefore, updated, progress) + } + + // A target without progress, or finished, is not stamped. + bare := addDownloadTestTarget(t, repo, pool, "dl-healthy", Quality2160p, StatusDownloading, "") + if err := repo.MarkTargetDownloadChecked(ctx, bare.ID); err != nil { + t.Fatal(err) + } + if _, err := repo.UpdateTargetStatus(ctx, silent.ID, StatusCompleted, "", "imported", "", Viewer{}); err != nil { + t.Fatal(err) + } + if err := repo.MarkTargetDownloadChecked(ctx, silent.ID); err != nil { + t.Fatal(err) + } + var stamped int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM media_request_targets WHERE id = ANY($1) AND download_checked_at IS NOT NULL`, []int64{bare.ID, silent.ID}).Scan(&stamped); err != nil { + t.Fatal(err) + } + if stamped != 0 { + t.Fatalf("%d targets without live progress were stamped, want none", stamped) + } +} diff --git a/internal/requests/editor_concurrency.go b/internal/requests/editor_concurrency.go index d3fe74f233..71ed83e6e9 100644 --- a/internal/requests/editor_concurrency.go +++ b/internal/requests/editor_concurrency.go @@ -105,10 +105,21 @@ func (r *Repository) UpdateIntegrationConditional(ctx context.Context, in Integr return nil, err } defer func() { _ = tx.Rollback(ctx) }() + // The routing mode lock comes before the row lock, as when adding a server. + before, standard, err := r.standardBeforeSave(ctx, tx) + if err != nil { + return nil, err + } if err = lockRevision(ctx, tx, `SELECT revision FROM request_integrations WHERE id=$1 FOR UPDATE`, []any{in.ID}, expected, false); err != nil { return nil, err } + if err = ensureRoutesStillFit(ctx, tx, in, !standard); err != nil { + return nil, err + } out, err := r.updateIntegration(ctx, tx, in) + if err == nil && standard { + err = r.advanceIfStandardBroken(ctx, tx, before) + } if err != nil { return nil, err } @@ -123,6 +134,9 @@ func (r *Repository) DeleteIntegrationConditional(ctx context.Context, id string return err } defer func() { _ = tx.Rollback(ctx) }() + if err = lockRoutingMode(ctx, tx); err != nil { + return err + } if err = lockRevision(ctx, tx, `SELECT revision FROM request_integrations WHERE id=$1 FOR UPDATE`, []any{id}, expected, false); err != nil { return err } @@ -193,6 +207,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/editor_concurrency_test.go b/internal/requests/editor_concurrency_test.go index 6b3bf64a7c..0180202f8c 100644 --- a/internal/requests/editor_concurrency_test.go +++ b/internal/requests/editor_concurrency_test.go @@ -42,7 +42,7 @@ func editorTestRepository(t *testing.T) *Repository { t.Fatal(err) } t.Cleanup(pool.Close) - for _, table := range []string{"request_settings", "request_user_limits", "request_integrations"} { + for _, table := range []string{"request_settings", "request_user_limits", "request_integrations", "request_routing"} { var count int if err = admin.QueryRow(t.Context(), `SELECT count(*) FROM pg_trigger WHERE tgrelid=$1::regclass AND tgname=$2`, "public."+table, table+"_revision").Scan(&count); err != nil || count != 1 { t.Fatalf("production revision trigger %s: %d %v", table, count, err) 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..fec62ad96c --- /dev/null +++ b/internal/requests/follows.go @@ -0,0 +1,284 @@ +package requests + +import ( + "context" + "fmt" + "maps" + "slices" + "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 request that was open when it +// was made, since a series can have completed requests still waiting for the +// library beside a newer open request for other seasons; each request's +// notification goes to its own follows, and a profile can follow each of them. +// A follow survives its request failing: the title's next request takes over +// the follows of a failed or replaced one. +// It is cleared once the fulfilled notification has gone out, and when its +// 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 open request. 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 + } + // An open request is what makes a title followable: a series partly in + // the library can have one for its missing seasons. + 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.requestedBy(viewer) { + 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) +} + +// 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{} + others := map[string]int{} + for tmdbID, req := range active { + if req == nil { + continue + } + if req.requestedBy(viewer) { + out[tmdbID] = true + continue + } + others[req.ID] = tmdbID + } + if len(others) == 0 || strings.TrimSpace(viewer.ProfileID) == "" { + return out, nil + } + followed, err := s.store.FollowedRequests(ctx, slices.Collect(maps.Keys(others)), viewer) + if err != nil { + return nil, err + } + for id, ok := range followed { + if ok { + out[others[id]] = 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. +// +// The follow records the open request it read. FOR SHARE holds that request +// until the follow commits. Every transition +// that closes a request (decline, cancel, completion) updates its row, and +// that row lock conflicts with FOR SHARE, so the close cannot commit, and its +// follow cleanup cannot run, between the read and the insert. A follow that +// waited on a close re-checks the updated row, finds it closed, and inserts +// nothing. +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 id FROM media_requests + WHERE media_type = $1 AND provider = 'tmdb' AND tmdb_id = $2 + AND outcome = 'active' AND status <> 'completed' + LIMIT 1 + FOR SHARE + ), inserted AS ( + INSERT INTO media_request_follows (media_type, tmdb_id, user_id, profile_id, request_id) + SELECT $1, $2, $3, $4, id FROM open_request + ON CONFLICT (user_id, profile_id, request_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 the follows of a request that was just declined +// or withdrawn. +func forgetTitleFollows(ctx context.Context, exec requestExecutor, closed *Request) error { + if _, err := exec.Exec(ctx, `DELETE FROM media_request_follows WHERE request_id = $1`, closed.ID); err != nil { + return fmt.Errorf("forget title follows: %w", err) + } + return nil +} + +// adoptTitleFollows gives a new request the follows of the title's failed +// requests, so a follow survives its request failing. The caller creates the +// request in the same transaction, before deleting any failed request it +// replaces. +// +// The follows move in place: an UnfollowTitle that waited on a moved row +// re-checks the moved row, still on the title and the profile, and deletes it, +// where a delete and re-insert would leave it a row it cannot see. +func adoptTitleFollows(ctx context.Context, exec requestExecutor, req *Request) error { + const failed = `SELECT id FROM media_requests + WHERE media_type = $1 AND provider = 'tmdb' AND tmdb_id = $2 AND outcome = 'failed'` + // A profile that followed two failed requests keeps its earliest follow. + if _, err := exec.Exec(ctx, ` + DELETE FROM media_request_follows f + USING media_request_follows keep + WHERE f.request_id IN (`+failed+`) AND keep.request_id IN (`+failed+`) + AND keep.user_id = f.user_id AND keep.profile_id = f.profile_id + AND (keep.created_at, keep.request_id) < (f.created_at, f.request_id) + `, req.MediaType, req.TMDBID); err != nil { + return fmt.Errorf("adopt title follows: %w", err) + } + if _, err := exec.Exec(ctx, ` + UPDATE media_request_follows SET request_id = $3 + WHERE request_id IN (`+failed+`) + `, req.MediaType, req.TMDBID, req.ID); err != nil { + return fmt.Errorf("adopt title follows: %w", err) + } + return nil +} + +// UnfollowTitle removes the profile's follows on every request of the title. +func (r *Repository) UnfollowTitle(ctx context.Context, mediaType MediaType, tmdbID int, viewer Viewer) error { + if _, err := r.pool.Exec(ctx, ` + DELETE FROM media_request_follows + WHERE media_type = $1 AND tmdb_id = $2 AND user_id = $3 AND profile_id = $4 + `, mediaType, tmdbID, viewer.UserID, viewer.ProfileID); err != nil { + return fmt.Errorf("unfollow title: %w", err) + } + return nil +} + +// FollowedRequests reports which of the requests the profile follows. +func (r *Repository) FollowedRequests(ctx context.Context, requestIDs []string, viewer Viewer) (map[string]bool, error) { + out := map[string]bool{} + if len(requestIDs) == 0 { + return out, nil + } + rows, err := r.pool.Query(ctx, ` + SELECT request_id FROM media_request_follows + WHERE request_id = ANY($1) AND user_id = $2 AND profile_id = $3 + `, requestIDs, viewer.UserID, viewer.ProfileID) + if err != nil { + return nil, fmt.Errorf("list followed requests: %w", err) + } + defer rows.Close() + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + out[id] = true + } + return out, rows.Err() +} + +// ListRequestFollowers lists the follows a request's notification goes to. +func (r *Repository) ListRequestFollowers(ctx context.Context, req Request) ([]Follower, error) { + rows, err := r.pool.Query(ctx, ` + SELECT user_id, profile_id FROM media_request_follows + WHERE request_id = $1 + ORDER BY created_at, user_id, profile_id + `, req.ID) + if err != nil { + return nil, fmt.Errorf("list request 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() +} + +// ClearRequestFollowers removes the listed follows once the request's +// notification has gone out. A profile that unfollowed and followed again +// since, for a newer request, keeps its new follow. +func (r *Repository) ClearRequestFollowers(ctx context.Context, req Request, followers []Follower) error { + if len(followers) == 0 { + return nil + } + userIDs := make([]int, 0, len(followers)) + profileIDs := make([]string, 0, len(followers)) + for _, f := range followers { + userIDs = append(userIDs, f.UserID) + profileIDs = append(profileIDs, f.ProfileID) + } + if _, err := r.pool.Exec(ctx, ` + DELETE FROM media_request_follows + WHERE request_id = $1 + AND (user_id, profile_id) IN (SELECT * FROM unnest($2::int[], $3::text[])) + `, req.ID, userIDs, profileIDs); err != nil { + return fmt.Errorf("clear request 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..cd873a2f2a --- /dev/null +++ b/internal/requests/follows_test.go @@ -0,0 +1,729 @@ +package requests + +import ( + "context" + "errors" + "fmt" + "slices" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "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.FollowedRequests(context.Background(), []string{"req-owner"}, testViewer(1)) + if !followed["req-owner"] { + 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.FollowedRequests(context.Background(), []string{"req-owner"}, testViewer(1)) + if followed["req-owner"] { + 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) + } +} + +// Profile ids repeat across accounts (every account from before profiles has +// a "default" one), so a profile on another account with the requester's +// profile id is a follower, not the requester. +func TestFollowSameProfileIDOnAnotherAccount(t *testing.T) { + store := newFakeStore() + req := activeRequestFor(store, 949) + req.RequestedByProfileID = "default" + svc := newTestService(store) + viewer := Viewer{UserID: 1, ProfileID: "default"} + + state, err := svc.Follow(context.Background(), viewer, MediaTypeMovie, 949) + if err != nil { + t.Fatalf("Follow: %v", err) + } + if !state.Following || state.RequestedByViewer { + t.Fatalf("state = %+v, want following and not requested by the viewer", state) + } + followers, _ := store.titleFollowers(MediaTypeMovie, 949) + if len(followers) != 1 || followers[0] != (Follower{UserID: 1, ProfileID: "default"}) { + t.Fatalf("followers = %+v, want the other account's default profile", followers) + } +} + +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) + } +} + +// A title's open request is what makes it followable: a series partly in the +// library can have one for its missing seasons, and a title in the library +// with no open request has nothing to follow. +func TestFollowNeedsAnOpenRequestNotAnEmptyLibrary(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); err != nil { + t.Fatalf("follow an open request for a title partly in the library: %v", err) + } + if _, err := svc.Follow(context.Background(), testViewer(1), MediaTypeMovie, 950); !errors.Is(err, ErrNotRequested) { + t.Fatalf("follow a title with no open request: err = %v, want ErrNotRequested", 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.titleFollowers(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.titleFollowers(MediaTypeMovie, 42); len(followers) != 0 { + t.Fatalf("followers after notifying = %+v, want cleared", followers) + } +} + +// A series can have a completed request waiting for the library beside a newer +// open request for other seasons. Its notification goes to its own follows; +// the open request's follows wait for it. +func TestNotifyFulfilledLeavesFollowsOfNewerRequest(t *testing.T) { + store := newFakeStore() + store.requests["req1"] = completedRequestFixture("req1", 42) + store.unnotified = []string{"req1"} + store.seedFollowFor(MediaTypeMovie, 42, Viewer{UserID: 3, ProfileID: "early"}, "req1") + store.seedFollowFor(MediaTypeMovie, 42, Viewer{UserID: 4, ProfileID: "late"}, "req2") + notifier := &fakeNotifier{} + svc := NewService(store, &fakeTMDBClient{}, presentMovie(42)) + svc.SetFulfillmentNotifier(notifier) + + svc.notifyFulfilledPending(context.Background()) + + if len(notifier.followers) != 1 || !slices.Equal(notifier.followers[0], []Follower{{UserID: 3, ProfileID: "early"}}) { + t.Fatalf("followers handed to the notifier = %+v, want only req1's follower", notifier.followers) + } + left, _ := store.ListRequestFollowers(context.Background(), Request{ID: "req2", MediaType: MediaTypeMovie, TMDBID: 42}) + if !slices.Equal(left, []Follower{{UserID: 4, ProfileID: "late"}}) { + t.Fatalf("req2's followers = %+v, want kept", left) + } +} + +// The server-wide announcement has no per-recipient dedupe, so it goes out +// once, from the pass whose stamp took, and not from a pass whose stamp failed +// and is retried. +func TestNotifyFulfilledAnnouncesOnceAfterTheStamp(t *testing.T) { + store := newFakeStore() + store.requests["req1"] = completedRequestFixture("req1", 42) + store.unnotified = []string{"req1"} + store.markErr = errors.New("stamp failed") + notifier := &fakeNotifier{} + svc := NewService(store, &fakeTMDBClient{}, presentMovie(42)) + svc.SetFulfillmentNotifier(notifier) + + svc.notifyFulfilledPending(context.Background()) + if len(notifier.requestIDs) != 1 || len(notifier.announced) != 0 { + t.Fatalf("after a failed stamp: delivered %v, announced %v; want delivered and not announced", notifier.requestIDs, notifier.announced) + } + + store.markErr = nil + svc.notifyFulfilledPending(context.Background()) + svc.notifyFulfilledPending(context.Background()) + if !slices.Equal(notifier.announced, []string{"req1"}) { + t.Fatalf("announced = %v, want req1 once", notifier.announced) + } +} + +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.titleFollowers(MediaTypeMovie, 42); len(followers) != 1 { + t.Fatalf("followers after a failed dispatch = %+v, want kept for the retry", followers) + } +} + +// A failed clear must leave the request unstamped, or its follows would +// outlive it and fire for a later request of the title. +func TestNotifyFulfilledRetriesWhenClearingFollowersFails(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"}) + store.clearErr = errors.New("clear failed") + svc := NewService(store, &fakeTMDBClient{}, presentMovie(42)) + svc.SetFulfillmentNotifier(&fakeNotifier{}) + + svc.notifyFulfilledPending(context.Background()) + if len(store.unnotified) != 1 { + t.Fatalf("unnotified after a failed clear = %v, want the request kept for a retry", store.unnotified) + } + + store.clearErr = nil + svc.notifyFulfilledPending(context.Background()) + if len(store.unnotified) != 0 { + t.Fatalf("unnotified after the retry = %v, want none", store.unnotified) + } + if followers, _ := store.titleFollowers(MediaTypeMovie, 42); len(followers) != 0 { + t.Fatalf("followers after the retry = %+v, want cleared", 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 := 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.ListRequestFollowers(ctx, Request{ID: "movie-949", MediaType: MediaTypeMovie, TMDBID: 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.FollowedRequests(ctx, []string{"movie-949", "series-949", "other"}, b) + if err != nil { + t.Fatal(err) + } + if !followed["movie-949"] || len(followed) != 1 { + t.Fatalf("followed = %v, want only movie-949", followed) + } + + if err := repo.ClearRequestFollowers(ctx, Request{ID: "movie-949", MediaType: MediaTypeMovie, TMDBID: 949}, []Follower{{UserID: a.UserID, ProfileID: a.ProfileID}}); err != nil { + t.Fatal(err) + } + if err := repo.UnfollowTitle(ctx, MediaTypeMovie, 949, b); err != nil { + t.Fatal(err) + } + if followers, _ := repo.ListRequestFollowers(ctx, Request{ID: "movie-949", MediaType: MediaTypeMovie, TMDBID: 949}); len(followers) != 0 { + t.Fatalf("movie followers after clear and unfollow = %+v, want none", followers) + } + if followers, _ := repo.ListRequestFollowers(ctx, Request{ID: "series-949", MediaType: MediaTypeSeries, TMDBID: 949}); len(followers) != 1 { + t.Fatalf("series followers = %+v, want the one untouched follow", followers) + } + + // Two accounts' profiles can share an id; each keeps its own follow. + mine := Viewer{UserID: 1, ProfileID: "default"} + theirs := Viewer{UserID: 2, ProfileID: "default"} + for _, v := range []Viewer{mine, theirs} { + if err := repo.FollowTitle(ctx, MediaTypeMovie, 949, v); err != nil { + t.Fatalf("follow as account %d: %v", v.UserID, err) + } + } + if followers, _ := repo.ListRequestFollowers(ctx, Request{ID: "movie-949", MediaType: MediaTypeMovie, TMDBID: 949}); len(followers) != 2 { + t.Fatalf("followers sharing a profile id = %+v, want one per account", followers) + } + if followed, _ := repo.FollowedRequests(ctx, []string{"movie-949"}, Viewer{UserID: 3, ProfileID: "default"}); followed["movie-949"] { + t.Fatal("a third account's default profile sees the others' follow") + } + if err := repo.UnfollowTitle(ctx, MediaTypeMovie, 949, mine); err != nil { + t.Fatal(err) + } + followers, err = repo.ListRequestFollowers(ctx, Request{ID: "movie-949", MediaType: MediaTypeMovie, TMDBID: 949}) + if err != nil { + t.Fatal(err) + } + if len(followers) != 1 || followers[0] != (Follower{UserID: theirs.UserID, ProfileID: theirs.ProfileID}) { + t.Fatalf("followers after one account unfollowed = %+v, want only the other account's", followers) + } + if err := repo.ClearRequestFollowers(ctx, Request{ID: "movie-949", MediaType: MediaTypeMovie, TMDBID: 949}, []Follower{{UserID: mine.UserID, ProfileID: mine.ProfileID}}); err != nil { + t.Fatal(err) + } + if followers, _ := repo.ListRequestFollowers(ctx, Request{ID: "movie-949", MediaType: MediaTypeMovie, TMDBID: 949}); len(followers) != 1 { + t.Fatalf("clearing one account's follow removed %+v, want the other account's kept", followers) + } + + if _, err := repo.SetOutcome(ctx, "series-949", guardWithdrawable, OutcomeCancelled, Viewer{}, ""); err != nil { + t.Fatal(err) + } + if followers, _ := repo.ListRequestFollowers(ctx, Request{ID: "series-949", MediaType: MediaTypeSeries, TMDBID: 949}); len(followers) != 0 { + t.Fatalf("series followers after the withdrawal = %+v, want none", followers) + } + if followers, _ := repo.ListRequestFollowers(ctx, Request{ID: "movie-949", MediaType: MediaTypeMovie, TMDBID: 949}); len(followers) != 1 { + t.Fatalf("movie followers after the series withdrawal = %+v, want the one left", followers) + } +} + +// Declining or withdrawing a request clears its title's follows in the same +// transaction. A cleanup after the commit could run once a replacement +// request had gathered followers of its own, and remove theirs. +func TestClosingRequestForgetsFollowsDatabase(t *testing.T) { + repo, pool := lifecycleTestRepository(t) + ctx := t.Context() + follower := Viewer{UserID: 1, ProfileID: "profile-a"} + for _, tc := range []struct { + id string + tmdbID int + outcome Outcome + }{ + {"declined", 971, OutcomeDeclined}, + {"withdrawn", 972, OutcomeCancelled}, + } { + insertLifecycleRequest(t, repo, tc.id, 5, tc.tmdbID, StatusPending) + if err := repo.FollowTitle(ctx, MediaTypeMovie, tc.tmdbID, follower); err != nil { + t.Fatal(err) + } + if _, err := repo.SetOutcome(ctx, tc.id, guardWithdrawable, tc.outcome, Viewer{}, ""); err != nil { + t.Fatal(err) + } + if followers, err := titleFollowers(ctx, pool, MediaTypeMovie, tc.tmdbID); err != nil || len(followers) != 0 { + t.Fatalf("followers once %s committed = %+v, err = %v; want none", tc.id, followers, err) + } + } + + // A failed request can sit beside a newer open request for the same + // title. Closing the failed one leaves the open request's follows alone. + insertLifecycleRequest(t, repo, "failed", 5, 973, StatusApproved) + if _, err := pool.Exec(ctx, `UPDATE media_requests SET outcome = 'failed' WHERE id = 'failed'`); err != nil { + t.Fatal(err) + } + insertLifecycleRequest(t, repo, "open", 6, 973, StatusPending) + if err := repo.FollowTitle(ctx, MediaTypeMovie, 973, follower); err != nil { + t.Fatal(err) + } + if _, err := repo.SetOutcome(ctx, "failed", StateGuard{Outcomes: []Outcome{OutcomeFailed}}, OutcomeCancelled, Viewer{}, ""); err != nil { + t.Fatal(err) + } + if followers, err := titleFollowers(ctx, pool, MediaTypeMovie, 973); err != nil || len(followers) != 1 { + t.Fatalf("followers of the open request after closing the failed one = %+v, err = %v; want one", followers, err) + } +} + +// titleFollowers lists every follow on a title, whichever request it waits for. +func titleFollowers(ctx context.Context, pool *pgxpool.Pool, mediaType MediaType, tmdbID int) ([]Follower, error) { + rows, err := pool.Query(ctx, `SELECT user_id, profile_id FROM media_request_follows + WHERE media_type = $1 AND tmdb_id = $2 ORDER BY user_id, profile_id`, mediaType, tmdbID) + if err != nil { + return nil, err + } + return pgx.CollectRows(rows, func(row pgx.CollectableRow) (Follower, error) { + var f Follower + err := row.Scan(&f.UserID, &f.ProfileID) + return f, err + }) +} + +func completeLifecycleRequest(t *testing.T, pool *pgxpool.Pool, id string) { + t.Helper() + if _, err := pool.Exec(t.Context(), `UPDATE media_requests SET status = 'completed', completed_at = now() WHERE id = $1`, id); err != nil { + t.Fatal(err) + } +} + +// A series can have completed requests still waiting for the library beside a +// newer open request for other seasons. Each request's notification goes to +// the follows made while it was open; clearing them spares a profile that +// followed again since, and declining the open request keeps the others. +func TestRequestFollowersDatabase(t *testing.T) { + repo, pool := lifecycleTestRepository(t) + ctx := t.Context() + a := Viewer{UserID: 1, ProfileID: "profile-a"} + b := Viewer{UserID: 2, ProfileID: "profile-b"} + follow := func(v Viewer) { + t.Helper() + if err := repo.FollowTitle(ctx, MediaTypeMovie, 975, v); err != nil { + t.Fatal(err) + } + } + list := func(id string) []Follower { + t.Helper() + followers, err := repo.ListRequestFollowers(ctx, Request{ID: id, MediaType: MediaTypeMovie, TMDBID: 975}) + if err != nil { + t.Fatal(err) + } + return followers + } + insertLifecycleRequest(t, repo, "older", 5, 975, StatusPending) + follow(a) + completeLifecycleRequest(t, pool, "older") + insertLifecycleRequest(t, repo, "newer", 6, 975, StatusPending) + follow(b) + completeLifecycleRequest(t, pool, "newer") + if got := list("older"); !slices.Equal(got, []Follower{{UserID: 1, ProfileID: "profile-a"}}) { + t.Fatalf("older's followers = %+v, want profile-a", got) + } + if got := list("newer"); !slices.Equal(got, []Follower{{UserID: 2, ProfileID: "profile-b"}}) { + t.Fatalf("newer's followers = %+v, want profile-b", got) + } + + // profile-b unfollows and follows a third request while newer's + // notification is going out; newer's clear leaves the new follow. + if err := repo.UnfollowTitle(ctx, MediaTypeMovie, 975, b); err != nil { + t.Fatal(err) + } + insertLifecycleRequest(t, repo, "third", 7, 975, StatusPending) + follow(b) + if err := repo.ClearRequestFollowers(ctx, Request{ID: "newer", MediaType: MediaTypeMovie, TMDBID: 975}, []Follower{{UserID: 2, ProfileID: "profile-b"}}); err != nil { + t.Fatal(err) + } + if got := list("third"); len(got) != 1 { + t.Fatalf("third's followers after newer's clear = %+v, want profile-b kept", got) + } + // profile-a, still waiting for older, follows third too. + follow(a) + if got := list("third"); len(got) != 2 { + t.Fatalf("third's followers = %+v, want profile-a and profile-b", got) + } + if followed, err := repo.FollowedRequests(ctx, []string{"third"}, a); err != nil || !followed["third"] { + t.Fatalf("profile-a follows third = %v, err = %v; want true", followed, err) + } + + if _, err := repo.SetOutcome(ctx, "third", guardWithdrawable, OutcomeDeclined, Viewer{}, ""); err != nil { + t.Fatal(err) + } + if got, err := titleFollowers(ctx, pool, MediaTypeMovie, 975); err != nil || !slices.Equal(got, []Follower{{UserID: 1, ProfileID: "profile-a"}}) { + t.Fatalf("follows after declining third = %+v, err = %v; want older's kept", got, err) + } +} + +// A follow that commits while a completion is under way belongs to the +// request it read, even though the completion's timestamp, taken when its +// transaction began, is earlier than the follow's. +func TestFollowDuringCompletionDatabase(t *testing.T) { + repo, pool := lifecycleTestRepository(t) + ctx := t.Context() + insertLifecycleRequest(t, repo, "req", 5, 976, StatusPending) + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatal(err) + } + defer func() { _ = tx.Rollback(ctx) }() + if _, err := tx.Exec(ctx, `SELECT now()`); err != nil { + t.Fatal(err) + } + if err := repo.FollowTitle(ctx, MediaTypeMovie, 976, Viewer{UserID: 1, ProfileID: "profile-a"}); err != nil { + t.Fatal(err) + } + if _, err := tx.Exec(ctx, `UPDATE media_requests SET status = 'completed', completed_at = now() WHERE id = 'req'`); err != nil { + t.Fatal(err) + } + if err := tx.Commit(ctx); err != nil { + t.Fatal(err) + } + var inverted bool + if err := pool.QueryRow(ctx, `SELECT f.created_at > r.completed_at FROM media_request_follows f + JOIN media_requests r ON r.id = 'req' WHERE f.tmdb_id = 976`).Scan(&inverted); err != nil || !inverted { + t.Fatalf("follow made after the completion's timestamp = %v, err = %v; the race was not reproduced", inverted, err) + } + followers, err := repo.ListRequestFollowers(ctx, Request{ID: "req", MediaType: MediaTypeMovie, TMDBID: 976}) + if err != nil || len(followers) != 1 { + t.Fatalf("followers = %+v, err = %v; want the follow made during the completion", followers, err) + } +} + +// A follow survives its request failing: the title's next request takes it, +// including when the requester's new request replaces the failed one. +func TestNewRequestAdoptsFollowsOfFailedRequestDatabase(t *testing.T) { + repo, pool := lifecycleTestRepository(t) + ctx := t.Context() + for _, tc := range []struct { + tmdbID int + replace bool + }{{977, false}, {978, true}} { + insertLifecycleRequest(t, repo, fmt.Sprintf("failed-%d", tc.tmdbID), 5, tc.tmdbID, StatusApproved) + if err := repo.FollowTitle(ctx, MediaTypeMovie, tc.tmdbID, Viewer{UserID: 1, ProfileID: "profile-a"}); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `UPDATE media_requests SET outcome = 'failed' WHERE id = $1`, fmt.Sprintf("failed-%d", tc.tmdbID)); err != nil { + t.Fatal(err) + } + retry := fmt.Sprintf("retry-%d", tc.tmdbID) + if _, err := repo.CreateRequest(ctx, CreateRequestRecord{ + ID: retry, + Input: CreateRequestInput{MediaType: MediaTypeMovie, TMDBID: tc.tmdbID, Title: "Retry"}, + Status: StatusPending, + Outcome: OutcomeActive, + Requester: Viewer{UserID: 5, ProfileID: "profile"}, + ReplaceFailed: tc.replace, + }); err != nil { + t.Fatal(err) + } + followers, err := repo.ListRequestFollowers(ctx, Request{ID: retry, MediaType: MediaTypeMovie, TMDBID: tc.tmdbID}) + if err != nil || len(followers) != 1 { + t.Fatalf("replace=%v: the new request's followers = %+v, err = %v; want the failed request's follow", tc.replace, followers, err) + } + } +} + +// An unfollow that runs while a new request takes over a failed request's +// follows waits for it and removes the moved follow, rather than returning +// with the profile still following the new request. +func TestUnfollowDuringAdoptionDatabase(t *testing.T) { + repo, pool := lifecycleTestRepository(t) + ctx := t.Context() + follower := Viewer{UserID: 1, ProfileID: "profile-a"} + insertLifecycleRequest(t, repo, "failed", 5, 979, StatusApproved) + if err := repo.FollowTitle(ctx, MediaTypeMovie, 979, follower); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `UPDATE media_requests SET outcome = 'failed' WHERE id = 'failed'`); err != nil { + t.Fatal(err) + } + + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatal(err) + } + defer func() { _ = tx.Rollback(context.Background()) }() + var adopter int + if err := tx.QueryRow(ctx, `SELECT pg_backend_pid()`).Scan(&adopter); err != nil { + t.Fatal(err) + } + retry, err := repo.insertRequest(ctx, tx, CreateRequestRecord{ + ID: "retry", + Input: CreateRequestInput{MediaType: MediaTypeMovie, TMDBID: 979, Title: "Retry"}, + Requester: Viewer{UserID: 6, ProfileID: "profile"}, + }, StatusPending, OutcomeActive, time.Now(), nil) + if err != nil { + t.Fatal(err) + } + if err := adoptTitleFollows(ctx, tx, retry); err != nil { + t.Fatal(err) + } + + unfollowed := make(chan error, 1) + go func() { unfollowed <- repo.UnfollowTitle(ctx, MediaTypeMovie, 979, follower) }() + for blocked := false; !blocked; { + select { + case err := <-unfollowed: + t.Fatalf("unfollow finished while the adoption was open: err = %v, want it to wait", err) + default: + } + if err := pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE $1 = ANY(pg_blocking_pids(pid)))`, adopter).Scan(&blocked); err != nil { + t.Fatal(err) + } + if !blocked { + time.Sleep(5 * time.Millisecond) + } + } + if err := tx.Commit(ctx); err != nil { + t.Fatal(err) + } + if err := <-unfollowed; err != nil { + t.Fatal(err) + } + if followers, err := titleFollowers(ctx, pool, MediaTypeMovie, 979); err != nil || len(followers) != 0 { + t.Fatalf("follows after the unfollow = %+v, err = %v; want none", followers, err) + } +} + +// A follow racing a withdrawal must not outlive it: the follow waits for the +// withdrawal to commit, sees the request closed, and inserts nothing, so the +// follow cleanup that ran with the withdrawal leaves no stray follower behind. +func TestFollowWaitsForConcurrentWithdrawalDatabase(t *testing.T) { + repo, pool := lifecycleTestRepository(t) + ctx := t.Context() + insertLifecycleRequest(t, repo, "req-race", 5, 959, StatusPending) + + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatal(err) + } + defer func() { _ = tx.Rollback(context.Background()) }() + var withdrawer int + if err := tx.QueryRow(ctx, `SELECT pg_backend_pid()`).Scan(&withdrawer); err != nil { + t.Fatal(err) + } + if _, err := tx.Exec(ctx, `UPDATE media_requests SET outcome = 'cancelled', updated_at = now() WHERE id = 'req-race'`); err != nil { + t.Fatal(err) + } + + followed := make(chan error, 1) + go func() { + followed <- repo.FollowTitle(ctx, MediaTypeMovie, 959, Viewer{UserID: 1, ProfileID: "profile-a"}) + }() + // Wait until the follow is blocked behind the open withdrawal. A follow + // that does not wait finishes first and is caught below. + for blocked := false; !blocked; { + select { + case err := <-followed: + t.Fatalf("follow finished while the withdrawal was open: err = %v, want it to wait", err) + default: + } + if err := pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE $1 = ANY(pg_blocking_pids(pid)))`, withdrawer).Scan(&blocked); err != nil { + t.Fatal(err) + } + if !blocked { + time.Sleep(5 * time.Millisecond) + } + } + if _, err := tx.Exec(ctx, `DELETE FROM media_request_follows WHERE media_type = 'movie' AND tmdb_id = 959`); err != nil { + t.Fatal(err) + } + if err := tx.Commit(ctx); err != nil { + t.Fatal(err) + } + + if err := <-followed; !errors.Is(err, ErrNotRequested) { + t.Fatalf("follow after the withdrawal: err = %v, want ErrNotRequested", err) + } + if followers, err := titleFollowers(ctx, pool, MediaTypeMovie, 959); err != nil || len(followers) != 0 { + t.Fatalf("followers after the withdrawal = %+v, err = %v; want none", followers, err) + } +} diff --git a/internal/requests/group_limits.go b/internal/requests/group_limits.go new file mode 100644 index 0000000000..38590eec08 --- /dev/null +++ b/internal/requests/group_limits.go @@ -0,0 +1,233 @@ +package requests + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/Silo-Server/silo-server/internal/access" +) + +// Who may request, and on what terms, resolves in layers: the account's own +// limits, then its access group's, then the server-wide settings. Blocking is +// not a limit: an account is blocked by the requests switch on the account or +// its access group (access.EffectiveUserPolicy.RequestsAllowed), or by +// requests being off server-wide. The older "blocked" limit and approval +// modes on an account are still honored when written, but no editor offers +// them. + +// GroupLimit is an access group's request approval and quota. +type GroupLimit struct { + Revision int64 + GroupID int64 + LimitMode LimitMode + MaxRequests *int + WindowDays *int + ApprovalMode ApprovalMode + UpdatedAt time.Time +} + +// GroupLimitStore reads and writes access groups' request limits. The +// PostgreSQL repository implements it. +type GroupLimitStore interface { + GetGroupLimit(ctx context.Context, groupID int64) (*GroupLimit, error) + UpsertGroupLimitConditional(ctx context.Context, in GroupLimit, expected int64) (*GroupLimit, error) + GroupExists(ctx context.Context, groupID int64) (bool, error) +} + +func (r *Repository) GroupExists(ctx context.Context, groupID int64) (bool, error) { + var exists bool + err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM access_groups WHERE id=$1)`, groupID).Scan(&exists) + return exists, err +} + +func (r *Repository) GetGroupLimit(ctx context.Context, groupID int64) (*GroupLimit, error) { + row, err := scanGroupLimit(r.pool.QueryRow(ctx, ` + SELECT group_id, limit_mode, max_requests, window_days, approval_mode, updated_at, revision + FROM request_group_limits WHERE group_id = $1`, groupID)) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get request group limit: %w", err) + } + return row, nil +} + +// UpsertGroupLimitConditional saves a group's limits when its revision still +// matches expected (zero for a group with no saved limits, -1 to overwrite). +func (r *Repository) UpsertGroupLimitConditional(ctx context.Context, in GroupLimit, expected int64) (*GroupLimit, error) { + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.ReadCommitted}) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback(ctx) }() + var group int64 + if err = tx.QueryRow(ctx, `SELECT id FROM access_groups WHERE id=$1 FOR KEY SHARE`, in.GroupID).Scan(&group); errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + if err = lockRevision(ctx, tx, `SELECT revision FROM request_group_limits WHERE group_id=$1 FOR UPDATE`, []any{in.GroupID}, expected, true); err != nil { + return nil, err + } + out, err := scanGroupLimit(tx.QueryRow(ctx, ` + INSERT INTO request_group_limits (group_id, limit_mode, max_requests, window_days, approval_mode, updated_at) + VALUES ($1, $2, $3, $4, $5, now()) + ON CONFLICT (group_id) DO UPDATE SET + limit_mode = EXCLUDED.limit_mode, + max_requests = EXCLUDED.max_requests, + window_days = EXCLUDED.window_days, + approval_mode = EXCLUDED.approval_mode, + updated_at = now() + WHERE $6::bigint = -1 OR request_group_limits.revision = $6 + RETURNING group_id, limit_mode, max_requests, window_days, approval_mode, updated_at, revision`, + in.GroupID, in.LimitMode, in.MaxRequests, in.WindowDays, in.ApprovalMode, expected)) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrStaleRevision + } + if err != nil { + return nil, fmt.Errorf("upsert request group limit: %w", err) + } + if err = tx.Commit(ctx); err != nil { + return nil, err + } + return out, nil +} + +func scanGroupLimit(row pgx.Row) (*GroupLimit, error) { + var out GroupLimit + var max, window sql.NullInt64 + if err := row.Scan(&out.GroupID, &out.LimitMode, &max, &window, &out.ApprovalMode, &out.UpdatedAt, &out.Revision); err != nil { + return nil, err + } + if max.Valid { + v := int(max.Int64) + out.MaxRequests = &v + } + if window.Valid { + v := int(window.Int64) + out.WindowDays = &v + } + return &out, nil +} + +func (s *Service) groupLimitStore() (GroupLimitStore, error) { + store, ok := s.store.(GroupLimitStore) + if !ok { + return nil, fmt.Errorf("request store does not support group limits") + } + return store, nil +} + +// GetGroupLimit returns an access group's request limits; a group with none +// saved inherits everything (revision zero). +func (s *Service) GetGroupLimit(ctx context.Context, v Viewer, groupID int64) (*GroupLimit, error) { + if !v.IsAdmin { + return nil, ErrForbidden + } + store, err := s.groupLimitStore() + if err != nil { + return nil, err + } + exists, err := store.GroupExists(ctx, groupID) + if err != nil { + return nil, err + } + if !exists { + return nil, ErrNotFound + } + limit, err := store.GetGroupLimit(ctx, groupID) + if err != nil || limit != nil { + return limit, err + } + return &GroupLimit{GroupID: groupID, LimitMode: LimitModeInherit, ApprovalMode: ApprovalModeInherit}, nil +} + +// UpsertGroupLimitConditional saves an access group's request limits. +func (s *Service) UpsertGroupLimitConditional(ctx context.Context, v Viewer, in GroupLimit, expected int64) (*GroupLimit, error) { + if !v.IsAdmin { + return nil, ErrForbidden + } + in, err := normalizeGroupLimit(in) + if err != nil { + return nil, err + } + store, err := s.groupLimitStore() + if err != nil { + return nil, err + } + return store.UpsertGroupLimitConditional(ctx, in, expected) +} + +func normalizeGroupLimit(in GroupLimit) (GroupLimit, error) { + if in.GroupID <= 0 { + return GroupLimit{}, fmt.Errorf("%w: invalid access group id", ErrInvalidInput) + } + switch in.LimitMode { + case "", LimitModeInherit, LimitModeUnlimited: + if in.LimitMode == "" { + in.LimitMode = LimitModeInherit + } + in.MaxRequests, in.WindowDays = nil, nil + case LimitModeCustom: + if in.MaxRequests == nil || in.WindowDays == nil || *in.MaxRequests < 0 || *in.WindowDays <= 0 { + return GroupLimit{}, &ValidationError{FieldErrors: map[string]string{ + "max_requests": "A custom limit needs a number of requests (0 or more) and a window of at least one day.", + }} + } + default: + return GroupLimit{}, fmt.Errorf("%w: invalid limit mode", ErrInvalidInput) + } + switch in.ApprovalMode { + case "": + in.ApprovalMode = ApprovalModeInherit + case ApprovalModeInherit, ApprovalModeManual, ApprovalModeAuto: + default: + return GroupLimit{}, fmt.Errorf("%w: invalid approval mode", ErrInvalidInput) + } + return in, nil +} + +// requestAccess is what the account and its access group say about +// requesting: whether the account may request at all, and the group's +// limits when the group applies. +type requestAccess struct { + allowed bool + group *GroupLimit +} + +// viewerRequestAccess resolves an account's requests switch and its access +// group's limits. Without a user repository (some tests) it resolves to an +// allowed account with no group; creating a request still enforces the +// switch through ensureViewerRequestsAllowed, which requires one. +func (s *Service) viewerRequestAccess(ctx context.Context, userID int) (requestAccess, error) { + if s.users == nil { + return requestAccess{allowed: true}, nil + } + user, err := s.users.GetByID(ctx, userID) + if err != nil { + return requestAccess{}, err + } + if user == nil { + return requestAccess{}, nil + } + effective, err := access.EffectivePolicyForUser(ctx, user, s.groupProvider) + if err != nil { + return requestAccess{}, err + } + out := requestAccess{allowed: effective.RequestsAllowed} + if access.GroupApplies(user) { + if store, ok := s.store.(GroupLimitStore); ok { + if out.group, err = store.GetGroupLimit(ctx, *user.AccessGroupID); err != nil { + return requestAccess{}, err + } + } + } + return out, nil +} diff --git a/internal/requests/group_limits_test.go b/internal/requests/group_limits_test.go new file mode 100644 index 0000000000..8b5d3ce2c6 --- /dev/null +++ b/internal/requests/group_limits_test.go @@ -0,0 +1,320 @@ +package requests + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/access" + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" + "github.com/Silo-Server/silo-server/internal/models" +) + +func intPtr(v int) *int { return &v } + +// groupedService is a service whose test account belongs to access group 1. +func groupedService(store *fakeStore, user *models.User) *Service { + svc := newTestService(store) + if user != nil { + svc.SetUserRepository(requestUserRepo{user: user}) + } + svc.SetGroupPolicyProvider(requestGroupProvider{group: &access.GroupPolicy{RequestsAllowed: true}}) + return svc +} + +func TestEffectivePolicyLayersAccountGroupServer(t *testing.T) { + store := newFakeStore() + store.settings.GlobalAutoApprovalEnabled = true + store.groupLimits = map[int64]*GroupLimit{1: {GroupID: 1, LimitMode: LimitModeCustom, MaxRequests: intPtr(10), WindowDays: intPtr(30), ApprovalMode: ApprovalModeManual}} + svc := groupedService(store, nil) + + policy, err := svc.EffectivePolicy(context.Background(), 1) + if err != nil { + t.Fatal(err) + } + if policy.MaxRequests != 10 || policy.WindowDays != 30 || policy.AutoApprove || policy.Blocked { + t.Fatalf("group layer: %+v, want 10 per 30 days, manual", policy) + } + + // The account's own settings win where they are set, and only there. + store.limit = &UserLimit{UserID: 1, LimitMode: LimitModeInherit, ApprovalMode: ApprovalModeAuto} + if policy, _ = svc.EffectivePolicy(context.Background(), 1); policy.MaxRequests != 10 || !policy.AutoApprove { + t.Fatalf("account approval: %+v, want the group's limit and automatic approval", policy) + } + store.limit = &UserLimit{UserID: 1, LimitMode: LimitModeUnlimited, ApprovalMode: ApprovalModeInherit} + if policy, _ = svc.EffectivePolicy(context.Background(), 1); !policy.Unlimited || policy.AutoApprove { + t.Fatalf("account limit: %+v, want unlimited with the group's manual approval", policy) + } + + // A group that inherits leaves the server's settings in place. + store.limit = nil + store.groupLimits[1] = &GroupLimit{GroupID: 1, LimitMode: LimitModeInherit, ApprovalMode: ApprovalModeInherit} + if policy, _ = svc.EffectivePolicy(context.Background(), 1); policy.MaxRequests != 5 || policy.WindowDays != 7 || !policy.AutoApprove { + t.Fatalf("inheriting group: %+v, want the server's 5 per 7 days, automatic", policy) + } +} + +func TestAdminsIgnoreTheirGroupLimits(t *testing.T) { + store := newFakeStore() + store.groupLimits = map[int64]*GroupLimit{1: {GroupID: 1, LimitMode: LimitModeCustom, MaxRequests: intPtr(1), WindowDays: intPtr(1), ApprovalMode: ApprovalModeManual}} + groupID := int64(1) + svc := groupedService(store, &models.User{ID: 1, Role: models.RoleAdmin, AccessGroupID: &groupID}) + + policy, err := svc.EffectivePolicy(context.Background(), 1) + if err != nil { + t.Fatal(err) + } + if policy.MaxRequests != 5 { + t.Fatalf("admin policy = %+v, want the server's limit: admins are never capped by a group", policy) + } +} + +// Every way to block shows up the same way: a detail page that says blocked. +func TestAccountSwitchBlocksRequestState(t *testing.T) { + store := newFakeStore() + groupID := int64(1) + off := false + svc := groupedService(store, &models.User{ID: 1, AccessGroupID: &groupID, RequestsAllowed: &off}) + + policy, err := svc.EffectivePolicy(context.Background(), 1) + if err != nil { + t.Fatal(err) + } + if !policy.Blocked { + t.Fatalf("policy = %+v, want blocked by the account's requests switch", policy) + } + if state := requestStateFor(testViewer(1), policy, false, nil); state.Requestable || state.Reason != "blocked" { + t.Fatalf("state = %+v, want not requestable: blocked", state) + } +} + +func TestGroupLimitAdministration(t *testing.T) { + store := newFakeStore() + svc := newTestService(store) + admin := Viewer{UserID: 1, ProfileID: "p", IsAdmin: true} + + if _, err := svc.GetGroupLimit(context.Background(), testViewer(1), 1); !errors.Is(err, ErrForbidden) { + t.Fatalf("member: err = %v, want ErrForbidden", err) + } + if _, err := svc.GetGroupLimit(context.Background(), admin, 9); !errors.Is(err, ErrNotFound) { + t.Fatalf("unknown group: err = %v, want ErrNotFound", err) + } + fresh, err := svc.GetGroupLimit(context.Background(), admin, 1) + if err != nil || fresh.LimitMode != LimitModeInherit || fresh.ApprovalMode != ApprovalModeInherit || fresh.Revision != 0 { + t.Fatalf("unsaved group limit = %+v, %v; want inherit at revision zero", fresh, err) + } + + var verr *ValidationError + if _, err := svc.UpsertGroupLimitConditional(context.Background(), admin, GroupLimit{GroupID: 1, LimitMode: LimitModeCustom}, 0); !errors.As(err, &verr) { + t.Fatalf("custom without numbers: err = %v, want a field error", err) + } + for _, blocked := range []GroupLimit{{GroupID: 1, LimitMode: LimitModeBlocked}, {GroupID: 1, ApprovalMode: ApprovalModeBlocked}} { + if _, err := svc.UpsertGroupLimitConditional(context.Background(), admin, blocked, 0); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("%+v: err = %v, want ErrInvalidInput: groups block with their requests switch", blocked, err) + } + } + saved, err := svc.UpsertGroupLimitConditional(context.Background(), admin, GroupLimit{GroupID: 1, LimitMode: LimitModeUnlimited, MaxRequests: intPtr(3), ApprovalMode: ApprovalModeAuto}, 0) + if err != nil || saved.MaxRequests != nil || saved.ApprovalMode != ApprovalModeAuto { + t.Fatalf("saved = %+v, %v; want unlimited with no count, automatic", saved, err) + } + if _, err := svc.UpsertGroupLimitConditional(context.Background(), admin, GroupLimit{GroupID: 1}, 0); !errors.Is(err, ErrStaleRevision) { + t.Fatalf("stale save: err = %v, want ErrStaleRevision", err) + } +} + +// The migration moves the old "blocked" limit and approval modes onto the +// account's requests switch, and group limits save with revisions. +func TestRequestAccessMigrationDatabase(t *testing.T) { + matches, err := filepath.Glob("../../migrations/sql/*_request_group_limits.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")] + + repo, pool := lifecycleTestRepository(t) + ctx := t.Context() + exec := func(stmt string, args ...any) { + t.Helper() + if _, err := pool.Exec(ctx, stmt, args...); err != nil { + t.Fatal(err) + } + } + for _, table := range []string{"users", "access_groups", "request_user_limits"} { + exec(`CREATE TABLE ` + table + ` (LIKE public.` + table + ` INCLUDING ALL)`) + } + exec(`INSERT INTO users (id, role, username) VALUES (1, 'user', 'a'), (2, 'user', 'b'), (3, 'user', 'c')`) + exec(`INSERT INTO request_user_limits (user_id, limit_mode, approval_mode) VALUES (1, 'blocked', 'auto'), (2, 'custom', 'blocked'), (3, 'unlimited', 'manual')`) + exec(`UPDATE request_user_limits SET max_requests = 4, window_days = 2 WHERE user_id = 2`) + exec(up) + + rows, err := pool.Query(ctx, `SELECT u.id, coalesce(u.requests_allowed, true), l.limit_mode, l.approval_mode + FROM users u JOIN request_user_limits l ON l.user_id = u.id ORDER BY u.id`) + if err != nil { + t.Fatal(err) + } + var got []string + for rows.Next() { + var id int + var allowed bool + var limitMode, approvalMode string + if err := rows.Scan(&id, &allowed, &limitMode, &approvalMode); err != nil { + t.Fatal(err) + } + got = append(got, strings.Join([]string{limitMode, approvalMode, map[bool]string{true: "allowed", false: "blocked"}[allowed]}, "/")) + } + rows.Close() + want := []string{"inherit/auto/blocked", "custom/inherit/blocked", "unlimited/manual/allowed"} + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Fatalf("after migration: %v, want %v", got, want) + } + + var groupID int64 + if err := pool.QueryRow(ctx, `INSERT INTO access_groups (name, configuration_revision) VALUES ('Kids', 1) RETURNING id`).Scan(&groupID); err != nil { + t.Fatal(err) + } + if _, err := repo.UpsertGroupLimitConditional(ctx, GroupLimit{GroupID: groupID + 100, LimitMode: LimitModeInherit, ApprovalMode: ApprovalModeInherit}, 0); !errors.Is(err, ErrNotFound) { + t.Fatalf("unknown group: err = %v, want ErrNotFound", err) + } + saved, err := repo.UpsertGroupLimitConditional(ctx, GroupLimit{GroupID: groupID, LimitMode: LimitModeCustom, MaxRequests: intPtr(2), WindowDays: intPtr(14), ApprovalMode: ApprovalModeManual}, 0) + if err != nil || saved.Revision == 0 || *saved.MaxRequests != 2 { + t.Fatalf("first save = %+v, %v", saved, err) + } + if _, err := repo.UpsertGroupLimitConditional(ctx, GroupLimit{GroupID: groupID, LimitMode: LimitModeInherit, ApprovalMode: ApprovalModeInherit}, 0); !errors.Is(err, ErrStaleRevision) { + t.Fatalf("second first-save: err = %v, want ErrStaleRevision", err) + } + again, err := repo.UpsertGroupLimitConditional(ctx, GroupLimit{GroupID: groupID, LimitMode: LimitModeUnlimited, ApprovalMode: ApprovalModeAuto}, saved.Revision) + if err != nil || again.Revision == saved.Revision || again.LimitMode != LimitModeUnlimited { + t.Fatalf("second save = %+v, %v", again, err) + } + if read, err := repo.GetGroupLimit(ctx, groupID); err != nil || read.Revision != again.Revision { + t.Fatalf("read = %+v, %v", read, err) + } +} + +// A decline or a failure gives the request's quota slot back; a +// cancellation does not, so a request-and-withdraw loop cannot repeat forever. +func TestQuotaRefundsDeclinedAndFailedRequestsDatabase(t *testing.T) { + repo, pool := lifecycleTestRepository(t) + ctx := t.Context() + for i, outcome := range []Outcome{OutcomeActive, OutcomeDeclined, OutcomeCancelled, OutcomeFailed, OutcomeActive} { + id := "q" + string(rune('a'+i)) + insertLifecycleRequest(t, repo, id, 7, 300+i, StatusPending) + if _, err := pool.Exec(ctx, `UPDATE media_requests SET outcome = $2 WHERE id = $1`, id, outcome); err != nil { + t.Fatal(err) + } + } + used, err := repo.CountUserRequestsSince(ctx, 7, time.Now().Add(-time.Hour)) + if err != nil { + t.Fatal(err) + } + if used != 3 { + t.Fatalf("used = %d, want 3: two active and one withdrawn", used) + } + create := func(id string, tmdbID, limit int) error { + _, err := repo.CreateRequest(ctx, CreateRequestRecord{ + ID: id, Input: CreateRequestInput{MediaType: MediaTypeMovie, TMDBID: tmdbID, Title: "New"}, + Status: StatusPending, Outcome: OutcomeActive, Requester: Viewer{UserID: 7, ProfileID: "profile"}, + Quota: &QuotaCheck{UserID: 7, WindowStart: time.Now().Add(-time.Hour), MaxRequests: limit}, + }) + return err + } + if err := create("q-at-limit", 398, 3); !errors.Is(err, ErrQuotaExceeded) { + t.Fatalf("at the limit: err = %v, want ErrQuotaExceeded", err) + } + if err := create("q-new", 399, 4); err != nil { + t.Fatalf("under the limit: %v", err) + } +} + +// Closing a failed request from the admin queue keeps the refund its failure +// gave: cleaning up the failed view must not use up the requester's quota. A +// request the owner withdraws while it backs off after a failed attempt was +// never failed, so it keeps counting. +func TestQuotaKeepsRefundWhenAdminClosesFailedRequestDatabase(t *testing.T) { + repo, _ := lifecycleTestRepository(t) + ctx := t.Context() + admin := Viewer{UserID: 1, ProfileID: "admin", IsAdmin: true} + insertLifecycleRequest(t, repo, "closed", 7, 410, StatusApproved) + if _, err := repo.SetOutcome(ctx, "closed", StateGuard{Statuses: []Status{StatusApproved}, Outcomes: []Outcome{OutcomeActive}}, OutcomeFailed, Viewer{}, "Radarr rejected the movie"); err != nil { + t.Fatal(err) + } + insertLifecycleRequest(t, repo, "withdrawn", 7, 411, StatusPending) + if _, err := repo.SetOutcome(ctx, "withdrawn", guardWithdrawable, OutcomeCancelled, Viewer{UserID: 7, ProfileID: "profile"}, ""); err != nil { + t.Fatal(err) + } + insertLifecycleRequest(t, repo, "deferred", 7, 412, StatusApproved) + claimed, ok, err := repo.ClaimSubmission(ctx, "deferred", time.Minute) + if err != nil || !ok { + t.Fatalf("claim deferred: ok=%v err=%v", ok, err) + } + if _, err := repo.DeferSubmission(ctx, "deferred", *claimed.SubmitLeaseUntil, time.Hour, "router unreachable"); err != nil { + t.Fatal(err) + } + deferredWithdrawn, err := repo.SetOutcome(ctx, "deferred", guardWithdrawable, OutcomeCancelled, Viewer{UserID: 7, ProfileID: "profile"}, "") + if err != nil { + t.Fatal(err) + } + if deferredWithdrawn.LastError != "" { + t.Fatalf("withdrawn deferred request last_error = %q, want it cleared", deferredWithdrawn.LastError) + } + since := time.Now().Add(-time.Hour) + before, err := repo.CountUserRequestsSince(ctx, 7, since) + if err != nil { + t.Fatal(err) + } + closed, err := repo.SetOutcome(ctx, "closed", guardFailed, OutcomeCancelled, admin, "not retrying") + if err != nil { + t.Fatal(err) + } + if closed.Outcome != OutcomeCancelled { + t.Fatalf("closed outcome = %q, want %q", closed.Outcome, OutcomeCancelled) + } + after, err := repo.CountUserRequestsSince(ctx, 7, since) + if err != nil { + t.Fatal(err) + } + if before != 2 || after != 2 { + t.Fatalf("used before/after closing = %d/%d, want 2/2: only the two withdrawals count", before, after) + } +} + +func TestGroupSwitchAndLegacyBlockBothBlock(t *testing.T) { + store := newFakeStore() + svc := newTestService(store) + svc.SetGroupPolicyProvider(requestGroupProvider{group: &access.GroupPolicy{RequestsAllowed: false}}) + if policy, err := svc.EffectivePolicy(context.Background(), 1); err != nil || !policy.Blocked { + t.Fatalf("group switch off: %+v, %v; want blocked", policy, err) + } + + store.groupLimits = map[int64]*GroupLimit{1: {GroupID: 1, LimitMode: LimitModeUnlimited, ApprovalMode: ApprovalModeAuto}} + store.limit = &UserLimit{UserID: 1, LimitMode: LimitModeBlocked, ApprovalMode: ApprovalModeInherit} + svc = groupedService(store, nil) + if policy, err := svc.EffectivePolicy(context.Background(), 1); err != nil || !policy.Blocked { + t.Fatalf("legacy blocked account over an unlimited group: %+v, %v; want blocked", policy, err) + } +} + +// A detail page and its recommendations resolve the viewer's policy once. +func TestDetailResolvesThePolicyOnce(t *testing.T) { + store := newFakeStore() + detail := &tmdb.MediaDetail{MediaType: "movie", ID: 550, Title: "Fight Club", + Recommendations: []tmdb.MediaResult{{ID: 551, MediaType: "movie", Title: "Other"}}} + svc := NewService(store, &fakeTMDBClient{detail: detail}, &fakePresence{}) + svc.SetUserRepository(requestUserRepo{}) + + if _, err := svc.GetDetail(context.Background(), testViewer(1), MediaTypeMovie, 550); err != nil { + t.Fatal(err) + } + if store.userLimitReads != 1 { + t.Fatalf("policy resolutions = %d, want 1 for the detail and its recommendations", store.userLimitReads) + } +} diff --git a/internal/requests/integration_probe.go b/internal/requests/integration_probe.go new file mode 100644 index 0000000000..c3855f9818 --- /dev/null +++ b/internal/requests/integration_probe.go @@ -0,0 +1,226 @@ +package requests + +import ( + "errors" + "fmt" + "net/url" + "regexp" + "strings" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// The editor fields a probe or save error can point at, and the Sonarr and +// Radarr plugin's capability sub-id. +const ( + fieldBaseURL = "base_url" + fieldAPIKey = "api_key_ref" + arrCapabilityID = "arr" +) + +// Host-written messages for a request server the editor cannot use. The +// plugin's own error text comes from the HTTP client talking to the server +// (status lines, dial errors, response bodies) and never reaches the admin; +// these sentences are chosen by classifying it instead. +const ( + integrationAddressMessage = "Enter the server's address, like http://192.168.1.10:8989." + integrationKeyMissing = "Enter the server's API key." + integrationKeyRejected = "The server rejected this API key." + integrationNotArr = "That address answered, but not as Sonarr or Radarr. Check the URL, including a URL base such as /sonarr." + integrationNotService = "That address answered, but not as the expected server. Check the URL, including any URL base." + integrationHTTPNotHTTPS = "That port serves http, not https." + integrationRefused = "Nothing answered at that address. Check the host and port." + integrationNoSuchHost = "That host name couldn't be found." + integrationTimedOut = "The server didn't answer in time." + integrationBadCertificate = "The server's HTTPS certificate wasn't accepted." +) + +// IntegrationUnreachableError is a request server the host could not reach, +// with a host-written sentence saying why. It matches +// ErrIntegrationUnreachable. Detail is safe to show an admin; Error also +// carries the underlying cause, for logs only. +type IntegrationUnreachableError struct { + Detail string + Err error +} + +func (e *IntegrationUnreachableError) Error() string { + parts := []string{ErrIntegrationUnreachable.Error()} + if e.Detail != "" { + parts = append(parts, e.Detail) + } + if e.Err != nil { + parts = append(parts, e.Err.Error()) + } + return strings.Join(parts, ": ") +} + +func (e *IntegrationUnreachableError) Unwrap() []error { + if e.Err == nil { + return []error{ErrIntegrationUnreachable} + } + return []error{ErrIntegrationUnreachable, e.Err} +} + +// normalizeIntegrationBaseURL turns what an admin typed into the address the +// plugin is given: http:// is assumed when no scheme is given, and a trailing +// slash is dropped. It refuses anything that is not a plain http(s) address. +func normalizeIntegrationBaseURL(raw string) (string, error) { + value := strings.TrimSpace(raw) + if value == "" { + return "", fmt.Errorf("%w: base_url is required", ErrInvalidInput) + } + if !strings.Contains(value, "://") { + value = "http://" + value + } + parsed, err := url.Parse(value) + if err != nil || parsed.Host == "" || parsed.Hostname() == "" || + (parsed.Scheme != "http" && parsed.Scheme != "https") || + parsed.User != nil || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" { + return "", fmt.Errorf("%w: base_url must be an http or https address", ErrInvalidInput) + } + return parsed.Scheme + "://" + parsed.Host + strings.TrimRight(parsed.EscapedPath(), "/"), nil +} + +// ProbeValidationError is a failed options probe the host classified into a +// field or form error for the v2 editor. It unwraps to the ValidationError, so +// v2 renders it like any other; the frozen v1 route recognizes it and keeps its +// original answer. A ValidationError the router returned itself is not one. +type ProbeValidationError struct { + *ValidationError +} + +func (e *ProbeValidationError) Unwrap() error { return e.ValidationError } + +func probeValidation(ve *ValidationError) *ProbeValidationError { + return &ProbeValidationError{ValidationError: ve} +} + +// NormalizeIntegrationBaseURL is normalizeIntegrationBaseURL for the v2 +// options probe and save, so the saved address is the one the probe used. A +// refused address is a field error on base_url. The frozen v1 routes do not +// call it. +func NormalizeIntegrationBaseURL(raw string) (string, error) { + baseURL, err := normalizeIntegrationBaseURL(raw) + if err != nil { + return "", &ValidationError{FieldErrors: map[string]string{fieldBaseURL: integrationAddressMessage}} + } + return baseURL, nil +} + +// sameIntegrationBaseURL reports whether two addresses name the same server, +// so a saved row written before normalization still matches its normalized +// form. +func sameIntegrationBaseURL(a, b string) bool { + na, errA := normalizeIntegrationBaseURL(a) + nb, errB := normalizeIntegrationBaseURL(b) + if errA != nil || errB != nil { + return strings.TrimSpace(a) == strings.TrimSpace(b) + } + return na == nb +} + +var integrationHTTPStatus = regexp.MustCompile(`\bHTTP (\d{3})\b`) + +// classifyIntegrationError turns a failed options probe into something the +// admin can act on. Errors the router already classifies (plugin validation +// results and the request-domain sentinels) pass through untouched. A message +// the plugin wrote itself as InvalidArgument or FailedPrecondition is shown as +// a form error. Anything else is matched against the plugin's transport text +// and answered with a fixed host sentence: a field error for a bad address or +// key, or an unreachable error with a detail. +func classifyIntegrationError(err error, capabilityID string) error { + if err == nil { + return nil + } + var validation *ValidationError + if errors.As(err, &validation) { + return err + } + for _, sentinel := range []error{ + ErrInvalidInput, + ErrInvalidMediaType, + ErrRequestsDisabled, + ErrUserBlocked, + ErrQuotaExceeded, + ErrAlreadyAvailable, + ErrAlreadyRequested, + ErrNotFound, + ErrForbidden, + ErrInvalidState, + ErrIntegrationUnreachable, + } { + if errors.Is(err, sentinel) { + return err + } + } + + // The plugin's own status, even when the host wrapped it (status.FromError + // would answer the whole wrapped text as the message). + message := err.Error() + var grpcErr interface{ GRPCStatus() *status.Status } + if errors.As(err, &grpcErr) { + st := grpcErr.GRPCStatus() + switch st.Code() { + case codes.InvalidArgument, codes.FailedPrecondition: + if text := strings.TrimSpace(st.Message()); text != "" { + return probeValidation(&ValidationError{FormError: text}) + } + case codes.DeadlineExceeded: + return &IntegrationUnreachableError{Detail: integrationTimedOut, Err: err} + } + message = st.Message() + } + lower := strings.ToLower(message) + + fieldError := func(field, text string) error { + return probeValidation(&ValidationError{FieldErrors: map[string]string{field: text}}) + } + notTheService := func() error { + if strings.TrimSpace(capabilityID) == arrCapabilityID { + return fieldError(fieldBaseURL, integrationNotArr) + } + return fieldError(fieldBaseURL, integrationNotService) + } + if match := integrationHTTPStatus.FindStringSubmatch(message); match != nil { + switch match[1] { + case "401", "403": + return fieldError(fieldAPIKey, integrationKeyRejected) + case "404": + return notTheService() + } + } + switch { + // A web page where the API should be: Sonarr and Radarr serve their UI + // for any unknown path, so a missing URL base answers HTML, not JSON. A + // truncated or oddly shaped JSON body is not this. + case strings.Contains(lower, "decode response") && strings.Contains(lower, "invalid character '<'"): + return notTheService() + case strings.Contains(lower, "server gave http response to https client"): + return fieldError(fieldBaseURL, integrationHTTPNotHTTPS) + case strings.Contains(lower, "unsupported protocol scheme"), + strings.Contains(lower, "base url is required"): + return fieldError(fieldBaseURL, integrationAddressMessage) + case strings.Contains(lower, "api key is required"): + return fieldError(fieldAPIKey, integrationKeyMissing) + } + + detail := "" + switch { + case strings.Contains(lower, "connection refused"), + strings.Contains(lower, "no route to host"), + strings.Contains(lower, "network is unreachable"): + detail = integrationRefused + case strings.Contains(lower, "no such host"): + detail = integrationNoSuchHost + case strings.Contains(lower, "timeout"), + strings.Contains(lower, "deadline exceeded"): + detail = integrationTimedOut + case strings.Contains(lower, "x509"), + strings.Contains(lower, "certificate"), + strings.Contains(lower, "tls:"): + detail = integrationBadCertificate + } + return &IntegrationUnreachableError{Detail: detail, Err: err} +} diff --git a/internal/requests/integration_probe_test.go b/internal/requests/integration_probe_test.go new file mode 100644 index 0000000000..d78dcb92a7 --- /dev/null +++ b/internal/requests/integration_probe_test.go @@ -0,0 +1,181 @@ +package requests + +import ( + "context" + "errors" + "fmt" + "testing" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// Each way a probe fails gets a host-written answer: a field error for what +// the admin typed wrong, or an unreachable error whose detail says why. The +// plugin's transport text never becomes the answer; only a message the +// plugin wrote itself as InvalidArgument or FailedPrecondition is shown. +func TestLoadIntegrationOptionsClassifiesProbeFailures(t *testing.T) { + grpcUnknown := func(msg string) error { return status.Error(codes.Unknown, msg) } + cases := []struct { + name string + capability string + err error + field string + fieldText string + formError string + detail string + }{ + {name: "unauthorized", err: grpcUnknown("httpclient: HTTP 401: Unauthorized"), field: "api_key_ref", fieldText: integrationKeyRejected}, + {name: "forbidden", err: errors.New("httpclient: HTTP 403"), field: "api_key_ref", fieldText: integrationKeyRejected}, + {name: "not found arr", capability: "arr", err: grpcUnknown("httpclient: HTTP 404: not found"), field: "base_url", fieldText: integrationNotArr}, + {name: "web page instead of the api", capability: "arr", err: grpcUnknown("httpclient: decode response: invalid character '<' looking for beginning of value"), field: "base_url", fieldText: integrationNotArr}, + {name: "truncated json", capability: "arr", err: grpcUnknown("httpclient: decode response: unexpected EOF"), detail: ""}, + {name: "not found other plugin", capability: "seerr", err: grpcUnknown("httpclient: HTTP 404"), field: "base_url", fieldText: integrationNotService}, + {name: "http on https", err: grpcUnknown(`httpclient: request failed: Get "https://10.0.0.5:8989/api/v3/rootfolder": http: server gave HTTP response to HTTPS client`), field: "base_url", fieldText: integrationHTTPNotHTTPS}, + {name: "no scheme", err: grpcUnknown(`httpclient: request failed: Get "10.0.0.5:8989/api/v3/rootfolder": unsupported protocol scheme ""`), field: "base_url", fieldText: integrationAddressMessage}, + {name: "plugin missing key", err: grpcUnknown("httpclient: api key is required"), field: "api_key_ref", fieldText: integrationKeyMissing}, + {name: "refused", err: grpcUnknown(`httpclient: request failed: Get "http://10.0.0.5:8990/api/v3/rootfolder": dial tcp 10.0.0.5:8990: connect: connection refused`), detail: integrationRefused}, + {name: "no such host", err: grpcUnknown("dial tcp: lookup sonarr.invalid: no such host"), detail: integrationNoSuchHost}, + {name: "client timeout", err: grpcUnknown("net/http: request canceled (Client.Timeout exceeded while awaiting headers)"), detail: integrationTimedOut}, + {name: "grpc deadline", err: status.Error(codes.DeadlineExceeded, "context deadline exceeded"), detail: integrationTimedOut}, + {name: "certificate", err: grpcUnknown("tls: failed to verify certificate: x509: certificate signed by unknown authority"), detail: integrationBadCertificate}, + {name: "unknown", err: grpcUnknown("httpclient: HTTP 500: kaboom"), detail: ""}, + {name: "plugin invalid argument", err: status.Error(codes.InvalidArgument, "This is Lidarr, not Sonarr or Radarr."), formError: "This is Lidarr, not Sonarr or Radarr."}, + {name: "wrapped plugin failed precondition", err: fmt.Errorf("router: %w", status.Error(codes.FailedPrecondition, "Set up the server first.")), formError: "Set up the server first."}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + router := &fakeRouterProvider{optionsErr: tc.err} + service := newTestService(newFakeStore()) + service.SetRouterProvider(router) + install := 1 + capability := tc.capability + if capability == "" { + capability = "arr" + } + _, err := service.LoadIntegrationOptions(context.Background(), Viewer{UserID: 1, IsAdmin: true}, Integration{ + ID: "new", CapabilityID: capability, InstallationID: &install, BaseURL: "http://10.0.0.5:8989", APIKeyRef: "key", + }) + if tc.field != "" || tc.formError != "" { + var ve *ValidationError + var probe *ProbeValidationError + if !errors.As(err, &ve) || !errors.As(err, &probe) { + t.Fatalf("err = %v, want a classified *ValidationError", err) + } + if tc.field != "" && ve.FieldErrors[tc.field] != tc.fieldText { + t.Fatalf("field errors = %+v, want %s = %q", ve.FieldErrors, tc.field, tc.fieldText) + } + if ve.FormError != tc.formError { + t.Fatalf("form error = %q, want %q", ve.FormError, tc.formError) + } + return + } + var unreachable *IntegrationUnreachableError + if !errors.As(err, &unreachable) || !errors.Is(err, ErrIntegrationUnreachable) { + t.Fatalf("err = %v, want *IntegrationUnreachableError", err) + } + if unreachable.Detail != tc.detail { + t.Fatalf("detail = %q, want %q", unreachable.Detail, tc.detail) + } + }) + } +} + +// The probe refuses a missing key itself, and passes the address on as given: +// normalizing it is the v2 adapter's job, and the frozen v1 route sends what +// its client submitted. +func TestLoadIntegrationOptionsChecksKeyAndPassesAddressThrough(t *testing.T) { + install := 1 + probe := func(baseURL, key string) (*fakeRouterProvider, error) { + router := &fakeRouterProvider{} + service := newTestService(newFakeStore()) + service.SetRouterProvider(router) + _, err := service.LoadIntegrationOptions(context.Background(), Viewer{UserID: 1, IsAdmin: true}, Integration{ + ID: "new", CapabilityID: "arr", InstallationID: &install, BaseURL: baseURL, APIKeyRef: key, + }) + return router, err + } + + router, err := probe("10.0.0.5:8989", "key") + if err != nil { + t.Fatalf("probe: %v", err) + } + if router.gotOptionsConn.BaseURL != "10.0.0.5:8989" { + t.Fatalf("probe base URL = %q, want it as given", router.gotOptionsConn.BaseURL) + } + + router, err = probe("http://10.0.0.5:8989", "") + var ve *ValidationError + if !errors.As(err, &ve) || ve.FieldErrors["api_key_ref"] != integrationKeyMissing { + t.Fatalf("err = %v, want api_key_ref field error", err) + } + if router.gotOptionsConn.BaseURL != "" { + t.Fatal("plugin was asked without a key") + } +} + +func TestNormalizeIntegrationBaseURL(t *testing.T) { + for _, bad := range []string{"", "ftp://10.0.0.5", "http://", "http://user:pw@10.0.0.5:8989", "http://10.0.0.5:8989/?a=1", "http://10.0.0.5:8989/#x"} { + if got, err := normalizeIntegrationBaseURL(bad); err == nil { + t.Errorf("normalizeIntegrationBaseURL(%q) = %q, want an error", bad, got) + } + } + for in, want := range map[string]string{ + "10.0.0.5:8989": "http://10.0.0.5:8989", + "sonarr.lan": "http://sonarr.lan", + "https://sonarr.lan/sonarr/": "https://sonarr.lan/sonarr", + "HTTP://sonarr.lan:8989": "http://sonarr.lan:8989", + " http://[::1]:8989 ": "http://[::1]:8989", + } { + got, err := normalizeIntegrationBaseURL(in) + if err != nil || got != want { + t.Errorf("normalizeIntegrationBaseURL(%q) = %q, %v; want %q", in, got, err, want) + } + } +} + +// The service saves the address as given: the v2 adapter normalizes it with +// NormalizeIntegrationBaseURL, and the frozen v1 save path stays unchanged. +// A normalized form of the saved address still counts as unchanged for +// keeping the stored key. +func TestSaveIntegrationLeavesBaseURLToTheCaller(t *testing.T) { + store := newFakeStore() + router := &fakeRouterProvider{} + service := newTestService(store) + service.SetRouterProvider(router) + admin := Viewer{UserID: 1, IsAdmin: true} + install := 1 + + created, err := service.CreateIntegration(context.Background(), admin, Integration{ + Name: "Sonarr", CapabilityID: "arr", InstallationID: &install, BaseURL: "10.0.0.5:8989/", APIKeyRef: "key", + }) + if err != nil { + t.Fatalf("CreateIntegration: %v", err) + } + if created.BaseURL != "10.0.0.5:8989/" { + t.Fatalf("created base_url = %q, want it as given", created.BaseURL) + } + + stored := routerInst("router-1") + stored.BaseURL = "http://router-1.local/" + store.integrations = append(store.integrations, stored) + if _, err := service.UpdateIntegration(context.Background(), admin, Integration{ + ID: "router-1", Name: "router-1", CapabilityID: "arr", InstallationID: &install, BaseURL: "http://router-1.local", + }); err != nil { + t.Fatalf("UpdateIntegration with the normalized saved address: %v", err) + } + if router.validateCalls != 2 { + t.Fatalf("plugin Validate calls = %d, want 2", router.validateCalls) + } +} + +func TestNormalizeIntegrationBaseURLRefusesAsFieldError(t *testing.T) { + if got, err := NormalizeIntegrationBaseURL("10.0.0.5:8989/"); err != nil || got != "http://10.0.0.5:8989" { + t.Fatalf("NormalizeIntegrationBaseURL = %q, %v", got, err) + } + _, err := NormalizeIntegrationBaseURL("ftp://10.0.0.5") + var ve *ValidationError + if !errors.As(err, &ve) || ve.FieldErrors["base_url"] != integrationAddressMessage { + t.Fatalf("err = %v, want base_url field error", err) + } +} 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 new file mode 100644 index 0000000000..c2efc3ed9f --- /dev/null +++ b/internal/requests/lifecycle_guard_test.go @@ -0,0 +1,808 @@ +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", "request_routing", "media_request_follows"} { + 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 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, pool := lifecycleTestRepository(t) + ctx := t.Context() + insertLifecycleRequest(t, repo, "req-claim", 1, 202, StatusApproved) + + var lease atomic.Pointer[time.Time] + won := raceLifecycle(t, func() error { + req, claimed, err := repo.ClaimSubmission(ctx, "req-claim", time.Minute) + if err != nil { + return err + } + if !claimed { + return ErrInvalidState + } + lease.Store(req.SubmitLeaseUntil) + return nil + }, ErrInvalidState) + if won != 1 { + t.Fatalf("concurrent claims succeeded %d times, want exactly 1", won) + } + + // A worker whose lease ran out while another server claimed the request + // again must not release or reschedule the newer claim. + stale := *lease.Load() + if _, err := pool.Exec(ctx, `UPDATE media_requests SET submit_lease_until = now() - interval '1 second' WHERE id = 'req-claim'`); err != nil { + t.Fatal(err) + } + reclaimed, claimed, err := repo.ClaimSubmission(ctx, "req-claim", time.Minute) + if err != nil || !claimed { + t.Fatalf("claim after the lease expired: claimed = %v, err = %v; want claimed", claimed, err) + } + if _, err := repo.DeferSubmission(ctx, "req-claim", stale, time.Hour, "stale"); !errors.Is(err, ErrInvalidState) { + t.Fatalf("defer with an expired lease: err = %v, want ErrInvalidState", err) + } + if current, err := repo.GetRequest(ctx, "req-claim"); err != nil || current.SubmitLeaseUntil == nil || current.NextSubmitAt != nil { + t.Fatalf("after a stale defer: request = %+v, err = %v; want the newer claim untouched", current, err) + } + // Nor may it fail the newer attempt after running out of attempts. + if _, err := repo.FailSubmission(ctx, "req-claim", stale, Viewer{}, "stale"); !errors.Is(err, ErrInvalidState) { + t.Fatalf("fail with an expired lease: err = %v, want ErrInvalidState", err) + } + if current, err := repo.GetRequest(ctx, "req-claim"); err != nil || current.Outcome != OutcomeActive || current.SubmitLeaseUntil == nil || current.LastError != "" { + t.Fatalf("after a stale fail: request = %+v, err = %v; want the newer claim untouched", current, err) + } + lease.Store(reclaimed.SubmitLeaseUntil) + + deferred, err := repo.DeferSubmission(ctx, "req-claim", *lease.Load(), time.Hour, "radarr unreachable") + if err != nil { + t.Fatal(err) + } + if deferred.LastError != "radarr unreachable" || deferred.SubmitAttempts != 2 || deferred.NextSubmitAt == nil || deferred.SubmitLeaseUntil != nil { + t.Fatalf("deferred = %+v, want last error, two attempts, 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", *lease.Load(), 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) + } + final, claimed, err := repo.ClaimSubmission(ctx, "req-claim", time.Minute) + if err != nil || !claimed { + t.Fatalf("claim after reopen: claimed = %v, err = %v; want claimed", claimed, err) + } + // The current claim fails its request and releases the lease. + failed, err := repo.FailSubmission(ctx, "req-claim", *final.SubmitLeaseUntil, Viewer{}, "radarr rejected it") + if err != nil { + t.Fatal(err) + } + if failed.Outcome != OutcomeFailed || failed.LastError != "radarr rejected it" || failed.SubmitLeaseUntil != nil { + t.Fatalf("failed = %+v, want failed with the error and the claim released", failed) + } +} + +func TestRecordSubmissionIsFencedOnLeaseDatabase(t *testing.T) { + repo, pool := lifecycleTestRepository(t) + ctx := t.Context() + insertLifecycleRequest(t, repo, "req-record", 1, 212, StatusApproved) + insertLifecycleRequest(t, repo, "req-withdrawn", 1, 213, StatusApproved) + targets := []Target{ + {Quality: Quality1080p, Status: StatusQueued, ExternalID: "42", ExternalStatus: "added"}, + {Quality: Quality2160p, Status: StatusFailed, LastError: "no 4K server"}, + } + targetCount := func(id string) int { + t.Helper() + var n int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM media_request_targets WHERE request_id = $1`, id).Scan(&n); err != nil { + t.Fatal(err) + } + return n + } + expire := func(id string) { + t.Helper() + if _, err := pool.Exec(ctx, `UPDATE media_requests SET submit_lease_until = now() - interval '1 second' WHERE id = $1`, id); err != nil { + t.Fatal(err) + } + } + + // A router call that outlived its lease while the request was withdrawn + // records nothing and leaves the request withdrawn. + withdrawn, claimed, err := repo.ClaimSubmission(ctx, "req-withdrawn", time.Minute) + if err != nil || !claimed { + t.Fatalf("claim: claimed = %v, err = %v", claimed, err) + } + expire("req-withdrawn") + if _, err := repo.SetOutcome(ctx, "req-withdrawn", guardWithdrawable, OutcomeCancelled, Viewer{}, "changed my mind"); err != nil { + t.Fatal(err) + } + if _, err := repo.RecordSubmission(ctx, "req-withdrawn", *withdrawn.SubmitLeaseUntil, targets, Viewer{}); !errors.Is(err, ErrInvalidState) { + t.Fatalf("record on a withdrawn request: err = %v, want ErrInvalidState", err) + } + if current, err := repo.GetRequest(ctx, "req-withdrawn"); err != nil || current.Outcome != OutcomeCancelled || targetCount("req-withdrawn") != 0 { + t.Fatalf("after a stale record: request = %+v, err = %v; want it withdrawn with no targets", current, err) + } + + // Nor may it record over a newer claim. + stale, claimed, err := repo.ClaimSubmission(ctx, "req-record", time.Minute) + if err != nil || !claimed { + t.Fatalf("claim: claimed = %v, err = %v", claimed, err) + } + expire("req-record") + current, claimed, err := repo.ClaimSubmission(ctx, "req-record", time.Minute) + if err != nil || !claimed { + t.Fatalf("claim after the lease expired: claimed = %v, err = %v", claimed, err) + } + if _, err := repo.RecordSubmission(ctx, "req-record", *stale.SubmitLeaseUntil, targets, Viewer{}); !errors.Is(err, ErrInvalidState) { + t.Fatalf("record with an expired lease: err = %v, want ErrInvalidState", err) + } + if targetCount("req-record") != 0 { + t.Fatal("a stale record wrote targets") + } + + // The current claim records its targets and releases the lease. + recorded, err := repo.RecordSubmission(ctx, "req-record", *current.SubmitLeaseUntil, targets, Viewer{}) + if err != nil { + t.Fatal(err) + } + if recorded.Status != StatusQueued || recorded.Outcome != OutcomeActive || recorded.SubmitLeaseUntil != nil { + t.Fatalf("recorded = %+v, want queued/active with the claim released", recorded) + } + stored, err := repo.ListTargets(ctx, "req-record") + if err != nil { + t.Fatal(err) + } + if len(stored) != 2 || stored[0].ExternalID != "42" || stored[0].Status != StatusQueued || + stored[1].Status != StatusFailed || stored[1].LastError != "no 4K server" { + t.Fatalf("targets = %+v, want the queued 1080p and failed 2160p targets", stored) + } +} + +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) + } +} + +// A request submitted after the caller found none of its targets live keeps +// going: the title arriving does not complete it over a queued target. +func TestMarkAvailableRefusesLiveTargetsDatabase(t *testing.T) { + repo, pool := lifecycleTestRepository(t) + ctx := t.Context() + insertLifecycleRequest(t, repo, "req-queued", 1, 708, StatusPending) + if _, err := pool.Exec(ctx, ` + UPDATE media_requests SET status = 'queued' WHERE id = 'req-queued'; + INSERT INTO media_request_targets (request_id, quality, status, updated_at) VALUES ('req-queued', '1080p', 'queued', now());`); err != nil { + t.Fatal(err) + } + if _, err := repo.MarkAvailable(ctx, "req-queued", Viewer{}); !errors.Is(err, ErrInvalidState) { + t.Fatalf("mark available over a queued target: err = %v, want ErrInvalidState", err) + } + if _, err := pool.Exec(ctx, `UPDATE media_request_targets SET status = 'completed' WHERE request_id = 'req-queued'`); err != nil { + t.Fatal(err) + } + if got, err := repo.MarkAvailable(ctx, "req-queued", Viewer{}); err != nil || got.Status != StatusCompleted { + t.Fatalf("mark available once nothing is on its way: %+v, %v", got, err) + } +} + +// A request retried after a later request for the title took its follows and +// failed too gets them back. +func TestReopenFailedTakesBackFollowsDatabase(t *testing.T) { + repo, pool := lifecycleTestRepository(t) + ctx := t.Context() + fail := func(id string) { + t.Helper() + if _, err := pool.Exec(ctx, `UPDATE media_requests SET outcome = 'failed' WHERE id = $1`, id); err != nil { + t.Fatal(err) + } + } + insertLifecycleRequest(t, repo, "first", 1, 709, StatusApproved) + if err := repo.FollowTitle(ctx, MediaTypeMovie, 709, Viewer{UserID: 3, ProfileID: "profile-a"}); err != nil { + t.Fatal(err) + } + fail("first") + insertLifecycleRequest(t, repo, "second", 2, 709, StatusApproved) + fail("second") + + if _, err := repo.ReopenFailed(ctx, "first", Viewer{}); err != nil { + t.Fatal(err) + } + followers, err := repo.ListRequestFollowers(ctx, Request{ID: "first", MediaType: MediaTypeMovie, TMDBID: 709}) + if err != nil || len(followers) != 1 { + t.Fatalf("followers of the retried request = %+v, err = %v; want the follow back", followers, err) + } +} + +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) + } +} + +// reclaimingRouter fails every submission and, while the call is in flight, +// lets the claim lapse and another server claim the request again. +type reclaimingRouter struct { + *fakeRouterProvider + store *fakeStore +} + +func (r reclaimingRouter) Fulfill(ctx context.Context, installationID int, capabilityID string, req Request, qualities []Quality, conns []ResolvedRouterConnection) ([]RouterTarget, string, error) { + r.store.mu.Lock() + newer := time.Now().Add(time.Hour) + r.store.requests[req.ID].SubmitLeaseUntil = &newer + r.store.mu.Unlock() + return r.fakeRouterProvider.Fulfill(ctx, installationID, capabilityID, req, qualities, conns) +} + +func TestSubmitLastAttemptWithExpiredLeaseLeavesNewerClaim(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(reclaimingRouter{&fakeRouterProvider{fulfillErr: errors.New("radarr: connection refused")}, store}) + + req, err := svc.submitApprovedRequest(context.Background(), *store.requests["r1"], Viewer{}, nil) + if err != nil { + t.Fatalf("submit: %v", err) + } + if req.Outcome != OutcomeActive || req.Status != StatusApproved || req.LastError != "" { + t.Fatalf("request = %+v, want the newer claim's request left approved/active", req) + } +} + +// lapsingRouter succeeds, but while the call is in flight its claim lapses and +// meanwhile changes the request as another actor would. +type lapsingRouter struct { + *fakeRouterProvider + store *fakeStore + meanwhile func(req *Request) +} + +func (r lapsingRouter) Fulfill(ctx context.Context, installationID int, capabilityID string, req Request, qualities []Quality, conns []ResolvedRouterConnection) ([]RouterTarget, string, error) { + r.store.mu.Lock() + r.meanwhile(r.store.requests[req.ID]) + r.store.mu.Unlock() + return r.fakeRouterProvider.Fulfill(ctx, installationID, capabilityID, req, qualities, conns) +} + +func TestSubmitSuccessWithExpiredLeaseIsDropped(t *testing.T) { + for _, tc := range []struct { + name string + meanwhile func(req *Request) + want func(req *Request) bool + }{ + { + name: "withdrawn", + meanwhile: func(req *Request) { + req.SubmitLeaseUntil = nil + req.Outcome = OutcomeCancelled + }, + want: func(req *Request) bool { return req.Outcome == OutcomeCancelled }, + }, + { + name: "claimed again", + meanwhile: func(req *Request) { + newer := time.Now().Add(time.Hour) + req.SubmitLeaseUntil = &newer + }, + want: func(req *Request) bool { + return req.Status == StatusApproved && req.Outcome == OutcomeActive && req.SubmitLeaseUntil != nil + }, + }, + } { + t.Run(tc.name, func(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} + svc := newTestService(store) + svc.SetRouterProvider(lapsingRouter{&fakeRouterProvider{}, store, tc.meanwhile}) + + req, err := svc.submitApprovedRequest(context.Background(), *store.requests["r1"], Viewer{}, nil) + if err != nil { + t.Fatalf("submit: %v", err) + } + if !tc.want(req) { + t.Fatalf("request = %+v, want the state the other actor left", req) + } + if targets := store.targets["r1"]; len(targets) != 0 { + t.Fatalf("targets = %+v, want none recorded by the stale attempt", targets) + } + }) + } +} + +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: msgNoTargetForQuality}, + }} + 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) + } +} + +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/notify.go b/internal/requests/notify.go index 3d89980dec..1182a7562d 100644 --- a/internal/requests/notify.go +++ b/internal/requests/notify.go @@ -11,7 +11,13 @@ 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. It may run again for the + // same request, so each recipient's delivery must be idempotent. NotifyFulfilled(ctx context.Context, req Request, contentID string) error + // AnnounceFulfilled posts the server-wide announcement. It runs once per + // request, after the request is stamped as notified, and is best-effort. + AnnounceFulfilled(ctx context.Context, req Request) } // SetFulfillmentNotifier wires the notification system into the reconcile @@ -76,6 +82,15 @@ func (s *Service) notifyApproval(ctx context.Context, req Request, origin Approv // the next reconcile run. const notifyFulfilledLimit = 100 +// markNotifyChecked stamps a completed request the fulfilled pass checked +// without notifying, which rotates it behind the others. +func (s *Service) markNotifyChecked(ctx context.Context, id string) { + if err := s.store.MarkReconciled(ctx, id); err != nil { + slog.WarnContext(ctx, "request fulfill-notify: stamp check failed", "component", "requests", + "request_id", id, "err", err) + } +} + // notifyFulfilledPending notifies completed requests whose media has arrived // in the catalog. Requests completed by an integration before the library // scan imports the files stay pending (fulfilled_notified_at IS NULL) and are @@ -101,20 +116,56 @@ func (s *Service) notifyFulfilledPending(ctx context.Context) { if err != nil { slog.WarnContext(ctx, "request fulfill-notify: presence lookup failed", "component", "requests", "request_id", req.ID, "tmdb_id", req.TMDBID, "err", err) + s.markNotifyChecked(ctx, req.ID) continue } match := matches[req.TMDBID] - if !match.Available { - continue // not in the catalog yet; retry next run + fulfilled, _, err := s.requestFulfilled(ctx, *req, match) + if err != nil { + slog.WarnContext(ctx, "request fulfill-notify: season lookup failed", "component", "requests", + "request_id", req.ID, "err", err) + s.markNotifyChecked(ctx, req.ID) + continue + } + if !fulfilled { + // Not in the catalog yet (or not every requested season); retry + // next run, after the requests not checked as recently. + s.markNotifyChecked(ctx, req.ID) + continue + } + followers, err := s.store.ListRequestFollowers(ctx, *req) + 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) continue } - if err := s.store.MarkFulfilledNotified(ctx, req.ID); err != nil { + // The followers have been told. Only the listed rows are cleared, + // leaving the follows made for other requests of the title. They are + // cleared before the request is stamped, so a failed clear leaves it + // unstamped and the next run retries it (the deliveries dedupe) + // instead of leaving follows behind for a later request of the title. + if err := s.store.ClearRequestFollowers(ctx, *req, followers); err != nil { + slog.WarnContext(ctx, "request fulfill-notify: clear followers failed", "component", "requests", + "request_id", req.ID, "err", err) + continue + } + stamped, err := s.store.MarkFulfilledNotified(ctx, req.ID) + if err != nil { slog.WarnContext(ctx, "request fulfill-notify: mark failed", "component", "requests", "request_id", req.ID, "err", err) + continue + } + // The announcement has no per-recipient dedupe, so it goes out only + // from the pass whose stamp took: a retry after a failed write above + // does not repeat it. + if stamped { + s.notifier.AnnounceFulfilled(ctx, *req) } } } diff --git a/internal/requests/notify_test.go b/internal/requests/notify_test.go index b28cc1e077..42266595f7 100644 --- a/internal/requests/notify_test.go +++ b/internal/requests/notify_test.go @@ -10,6 +10,8 @@ import ( type fakeNotifier struct { requestIDs []string contentIDs []string + followers [][]Follower + announced []string err error } @@ -36,12 +38,17 @@ func (n *recordingLifecycleNotifier) RequestDeclined(_ context.Context, req Requ n.declined = append(n.declined, req.ID) } +func (f *fakeNotifier) AnnounceFulfilled(_ context.Context, req Request) { + f.announced = append(f.announced, req.ID) +} + func (f *fakeNotifier) NotifyFulfilled(_ context.Context, req Request, contentID string) error { if f.err != nil { return f.err } 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/presence.go b/internal/requests/presence.go index 362d34eeca..bb8c299437 100644 --- a/internal/requests/presence.go +++ b/internal/requests/presence.go @@ -35,19 +35,52 @@ type tmdbBackfiller interface { type CatalogPresence struct { items presenceItemLookup + seasons seasonLookup tmdbBackfill tmdbBackfiller } +type seasonLookup interface { + SeriesSeasonAvailability(ctx context.Context, seriesContentIDs []string) (map[string]map[int]catalog.SeasonAvailability, error) +} + func NewCatalogPresence(items *catalog.ItemRepository, providerIDs ...*catalog.ProviderIDRepository) *CatalogPresence { var itemLookup presenceItemLookup + var seasons seasonLookup if items != nil { itemLookup = items + seasons = items } var backfill tmdbBackfiller if len(providerIDs) > 0 && providerIDs[0] != nil { backfill = providerIDs[0] } - return &CatalogPresence{items: itemLookup, tmdbBackfill: backfill} + return &CatalogPresence{items: itemLookup, seasons: seasons, tmdbBackfill: backfill} +} + +// SeasonAvailability implements SeasonPresenceResolver. Once a season has +// aired episodes, only those count as present, so an episode that arrived +// ahead of its air date cannot stand in for a missing aired one. +func (p *CatalogPresence) SeasonAvailability(ctx context.Context, seriesContentIDs []string) (map[string]map[int]SeasonCounts, error) { + if p == nil || p.seasons == nil { + return nil, nil + } + bySeries, err := p.seasons.SeriesSeasonAvailability(ctx, seriesContentIDs) + if err != nil { + return nil, err + } + out := make(map[string]map[int]SeasonCounts, len(bySeries)) + for series, rows := range bySeries { + counts := make(map[int]SeasonCounts, len(rows)) + for season, row := range rows { + have := row.Have + if row.Aired > 0 { + have = row.HaveAired + } + counts[season] = SeasonCounts{Aired: row.Aired, Upcoming: row.Upcoming, Have: have} + } + out[series] = counts + } + return out, nil } func (p *CatalogPresence) Lookup(ctx context.Context, mediaType MediaType, candidates []PresenceCandidate) (map[int]PresenceMatch, error) { diff --git a/internal/requests/provider.go b/internal/requests/provider.go index bb1a42fe39..d8e2b521e8 100644 --- a/internal/requests/provider.go +++ b/internal/requests/provider.go @@ -49,6 +49,9 @@ type RouterTargetStatus struct { Status Status ExternalStatus string Message string + // Progress is set by a plugin that declares reports_download_progress + // while the target has downloads in flight; nil means none. + Progress *DownloadProgress } type RouterOption struct { @@ -73,6 +76,16 @@ type RouterClient interface { type pluginRouterProvider struct{ resolver RouterClientResolver } +// RouterFeatures reads the capability's declared features when the resolver +// can; otherwise the capability declares none. +func (p *pluginRouterProvider) RouterFeatures(ctx context.Context, installationID int, capabilityID string) (RouterFeatures, error) { + reader, ok := p.resolver.(RouterFeatureReader) + if !ok { + return RouterFeatures{}, nil + } + return reader.RouterFeatures(ctx, installationID, capabilityID) +} + func NewPluginRouterProvider(r RouterClientResolver) RequestRouterProvider { return &pluginRouterProvider{resolver: r} } @@ -92,6 +105,14 @@ func routerDescriptor(req Request) *pluginv1.RequestDescriptor { if req.Year != nil { year = *req.Year } + // Seasons name the series seasons requested; none means the whole + // series. A plugin without supports_seasons ignores them. + var seasons []int32 + if req.MediaType == MediaTypeSeries { + for _, season := range req.Seasons { + seasons = append(seasons, int32(season)) + } + } return &pluginv1.RequestDescriptor{ MediaType: string(req.MediaType), Title: req.Title, @@ -102,6 +123,7 @@ func routerDescriptor(req Request) *pluginv1.RequestDescriptor { RequesterProfileId: req.RequestedByProfileID, RequesterEmail: req.RequesterEmail, RequesterUsername: req.RequesterUsername, + Seasons: seasons, } } @@ -179,11 +201,44 @@ func (p *pluginRouterProvider) CheckStatus(ctx context.Context, installationID i out = append(out, RouterTargetStatus{ Quality: Quality(st.GetQuality()), ConnectionID: st.GetConnectionId(), Status: Status(st.GetStatus()), ExternalStatus: st.GetExternalStatus(), Message: st.GetMessage(), + Progress: downloadProgressFromProto(st.GetProgress()), }) } return out, nil } +// downloadProgressFromProto normalizes a plugin's progress report. An unknown +// or empty phase reads as downloading, but a report with neither a phase nor +// a size carries nothing and maps to nil. Byte counts are clamped so that +// 0 <= left <= total; a total of 0 means the size is unknown. +func downloadProgressFromProto(p *pluginv1.DownloadProgress) *DownloadProgress { + if p == nil { + return nil + } + phase := DownloadPhase(p.GetPhase()) + total := max(p.GetBytesTotal(), 0) + if phase == "" && total == 0 { + return nil + } + switch phase { + case DownloadPhaseQueued, DownloadPhaseDownloading, DownloadPhasePaused, + DownloadPhaseStalled, DownloadPhaseImporting, DownloadPhaseImportBlocked: + default: + phase = DownloadPhaseDownloading + } + out := &DownloadProgress{ + Phase: phase, + BytesTotal: total, + BytesLeft: min(max(p.GetBytesLeft(), 0), total), + Downloads: int(max(p.GetDownloads(), 0)), + } + if eta := p.GetEstimatedCompletion(); eta != nil && eta.IsValid() { + at := eta.AsTime().UTC() + out.EstimatedCompletion = &at + } + return out +} + func (p *pluginRouterProvider) ListConfigOptions(ctx context.Context, installationID int, capabilityID string, conn ResolvedRouterConnection) (map[string][]RouterOption, error) { client, err := p.resolver.RequestRouterClient(ctx, installationID, capabilityID) if err != nil { diff --git a/internal/requests/repository.go b/internal/requests/repository.go index a576654dcb..1ffbefb8dd 100644 --- a/internal/requests/repository.go +++ b/internal/requests/repository.go @@ -165,6 +165,17 @@ func (r *Repository) upsertUserLimit(ctx context.Context, exec requestExecutor, return &row, nil } +// quotaOutcomes are the outcomes whose requests count against the quota. A +// decline or a failure gives the slot back; a withdrawal does not, or a +// request-and-withdraw loop could repeat without limit. A failed request an +// admin closes keeps its submission error (SetOutcome clears it on every +// other cancel) and the refund its failure gave it: cleaning up the failed +// view must not use up the requester's quota. A request withdrawn while it +// backs off after a failed attempt was never failed, so it still counts. +const quotaOutcomes = `(outcome = 'active' OR (outcome = 'cancelled' AND last_error = ''))` + +// CountUserRequestsSince counts the requests an account made since a time +// that count against its quota. func (r *Repository) CountUserRequestsSince(ctx context.Context, userID int, since time.Time) (int, error) { var count int if err := r.pool.QueryRow(ctx, ` @@ -172,7 +183,7 @@ func (r *Repository) CountUserRequestsSince(ctx context.Context, userID int, sin FROM media_requests WHERE requested_by_user_id = $1 AND created_at >= $2 - `, userID, since).Scan(&count); err != nil { + AND `+quotaOutcomes, userID, since).Scan(&count); err != nil { return 0, fmt.Errorf("count user requests: %w", err) } return count, nil @@ -208,23 +219,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,13 +236,15 @@ 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.Quota != nil { var count int if err := tx.QueryRow(ctx, ` SELECT COUNT(*) FROM media_requests WHERE requested_by_user_id = $1 AND created_at >= $2 - `, input.Quota.UserID, input.Quota.WindowStart).Scan(&count); err != nil { + AND `+quotaOutcomes, input.Quota.UserID, input.Quota.WindowStart).Scan(&count); err != nil { return nil, fmt.Errorf("count requests for quota: %w", err) } if count >= input.Quota.MaxRequests { @@ -282,6 +278,25 @@ func (r *Repository) CreateRequest(ctx context.Context, input CreateRequestRecor } return nil, err } + if err := adoptTitleFollows(ctx, tx, req); err != nil { + return nil, err + } + // Failed requests do not count against the quota, so the ones this + // replaces go after the insert, once their follows have moved to it. + if input.ReplaceFailed { + // Only the requester's own rows: other accounts' failed requests for + // the title are their history. + 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 err := r.recordEvent(ctx, tx, req.ID, "created", input.Requester, ""); err != nil { return nil, err } @@ -319,22 +334,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, seasons ) 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, $19 ) 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, nonNilSeasons(input.Input.Seasons)) req, err := scanRequest(row) if err != nil { return nil, fmt.Errorf("insert request: %w", err) @@ -362,7 +384,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 { @@ -384,6 +406,83 @@ func (r *Repository) ListReconciliationCandidates(ctx context.Context, limit int return out, nil } +// ListDownloadingRequests returns active requests with a downloading target +// that has download progress, the one asked about longest ago first: a +// request is as due as its most overdue such target. A target counts as asked +// whether or not its server answered, so one that stops answering takes its +// turn and moves to the back instead of heading every batch. A downloading +// target without progress is not listed, however many there are, so targets +// whose plugin never reports any (or has nothing queued) cannot crowd the +// batch; the reconcile pass records a download's first progress. +func (r *Repository) ListDownloadingRequests(ctx context.Context, limit int) ([]*Request, error) { + if limit <= 0 || limit > 500 { + limit = 200 + } + rows, err := r.pool.Query(ctx, requestSelectSQL()+` + JOIN ( + SELECT request_id, min(download_checked_at) AS checked_at + FROM media_request_targets + WHERE status = 'downloading' AND download_phase IS NOT NULL + GROUP BY request_id + ) downloading ON downloading.request_id = media_requests.id + WHERE outcome = 'active' + ORDER BY downloading.checked_at NULLS FIRST, media_requests.id + LIMIT $1 + `, limit) + if err != nil { + return nil, fmt.Errorf("list downloading requests: %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 downloading requests: %w", err) + } + 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 @@ -398,7 +497,9 @@ func (r *Repository) ListFulfilledUnnotified(ctx context.Context, limit int) ([] AND status = 'completed' AND fulfilled_notified_at IS NULL AND completed_at > now() - interval '30 days' - ORDER BY completed_at ASC + -- A request still waiting on the library is stamped each pass, so it + -- moves behind the others and cannot starve newer completions. + ORDER BY last_reconciled_at ASC NULLS FIRST, completed_at ASC LIMIT $1 `, limit) if err != nil { @@ -420,15 +521,16 @@ func (r *Repository) ListFulfilledUnnotified(ctx context.Context, limit int) ([] return out, nil } -// MarkFulfilledNotified stamps the fulfillment-notification marker. Idempotent. -func (r *Repository) MarkFulfilledNotified(ctx context.Context, id string) error { - _, err := r.pool.Exec(ctx, ` +// MarkFulfilledNotified stamps the fulfillment-notification marker. Idempotent; +// stamped reports whether this call set it. +func (r *Repository) MarkFulfilledNotified(ctx context.Context, id string) (bool, error) { + tag, err := r.pool.Exec(ctx, ` UPDATE media_requests SET fulfilled_notified_at = now() WHERE id = $1 AND fulfilled_notified_at IS NULL`, id) if err != nil { - return fmt.Errorf("mark request fulfill-notified: %w", err) + return false, fmt.Errorf("mark request fulfill-notified: %w", err) } - return nil + return tag.RowsAffected() == 1, nil } func (r *Repository) SetExternalIDs(ctx context.Context, id string, tvdbID int, imdbID string) (int, error) { @@ -479,24 +581,64 @@ 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), +// 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 (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, bool) { + 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, g.UnsentOnly +} + +// 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, unsent := guardArgs(from) + // A fresh approval starts a fresh submission budget. req, err := scanRequest(tx.QueryRow(ctx, ` UPDATE media_requests - SET status = $2, + SET status = $5, 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 $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 - RETURNING `+requestColumns(), id, status)) + AND `+guardCondition+` + RETURNING `+requestColumns(), id, statuses, outcomes, unsent, 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) } @@ -509,30 +651,49 @@ 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, unsent := guardArgs(from) req, err := scanRequest(tx.QueryRow(ctx, ` UPDATE media_requests - SET outcome = $2, + SET outcome = $5, last_error = CASE - WHEN $2 = 'failed' THEN $3 - WHEN $2 = 'active' THEN '' + WHEN $5 = 'failed' THEN $6 + WHEN $5 = 'active' THEN '' + -- Only a failed request keeps its error when closed; the quota + -- refunds exactly those (see quotaOutcomes). + WHEN $5 = 'cancelled' AND outcome <> 'failed' 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 - RETURNING `+requestColumns(), id, outcome, strings.TrimSpace(message))) + AND `+guardCondition+` + RETURNING `+requestColumns(), id, statuses, outcomes, unsent, 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) } + // A declined or withdrawn title is no longer on its way, so its follows + // go in the same transaction. Once this commits, another request for the + // title can open and gather followers, and a cleanup run after the commit + // would remove theirs. + if outcome == OutcomeDeclined || outcome == OutcomeCancelled { + if err := forgetTitleFollows(ctx, tx, req); err != nil { + return nil, err + } + } if err := r.recordEvent(ctx, tx, id, "outcome_"+string(outcome), actor, message); err != nil { return nil, err } @@ -542,12 +703,233 @@ 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) + } + // A request for the title created after this one failed may have taken + // its follows and failed too; they come back with the retry. + if err := adoptTitleFollows(ctx, tx, req); err != nil { + return nil, 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, leaseUntil time.Time, 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' + AND submit_lease_until = $4 + RETURNING `+requestColumns(), id, delay.Seconds(), message, leaseUntil)) + 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) FailSubmission(ctx context.Context, id string, leaseUntil time.Time, actor Viewer, message string) (*Request, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin request fail transaction: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + message = strings.TrimSpace(message) + req, err := scanRequest(tx.QueryRow(ctx, ` + UPDATE media_requests + SET outcome = 'failed', + last_error = $2, + submit_lease_until = NULL, + updated_at = now() + WHERE id = $1 + AND status = 'approved' + AND outcome = 'active' + AND submit_lease_until = $3 + RETURNING `+requestColumns(), id, message, leaseUntil)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, guardMiss(ctx, tx, id) + } + return nil, fmt.Errorf("fail request submission: %w", err) + } + if err := r.recordEvent(ctx, tx, id, "outcome_"+string(OutcomeFailed), actor, message); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit request fail 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', + outcome = 'active', + last_error = '', + completed_at = COALESCE(completed_at, now()), + updated_at = now() + WHERE id = $1 + 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')) + ) + -- The title arriving completes only a request with nothing still on + -- its way; a submission that queued a target since the caller looked + -- lets the targets drive completion instead. + AND NOT EXISTS ( + SELECT 1 FROM media_request_targets t + WHERE t.request_id = media_requests.id AND t.status IN ('queued', 'downloading')) + 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` func (r *Repository) ListIntegrations(ctx context.Context) ([]Integration, error) { - rows, err := r.pool.Query(ctx, `SELECT `+integrationColumns+` FROM request_integrations ORDER BY name`) + return r.listIntegrations(ctx, r.pool) +} + +func (r *Repository) listIntegrations(ctx context.Context, exec requestExecutor) ([]Integration, error) { + rows, err := exec.Query(ctx, `SELECT `+integrationColumns+` FROM request_integrations ORDER BY name`) if err != nil { return nil, fmt.Errorf("list request integrations: %w", err) } @@ -682,12 +1064,22 @@ func (r *Repository) SaveIntegrationWithDefaults(ctx context.Context, in Integra } defer tx.Rollback(ctx) + before, standard, err := r.standardBeforeSave(ctx, tx) + if err != nil { + return nil, err + } var out *Integration if isCreate { out, err = r.insertIntegration(ctx, tx, in) - } else { + if err == nil { + err = defaultFirstServer(ctx, tx, out) + } + } else if err = ensureRoutesStillFit(ctx, tx, in, !standard); err == nil { out, err = r.updateIntegration(ctx, tx, in) } + if err == nil && standard { + err = r.advanceIfStandardBroken(ctx, tx, before) + } if err != nil { return nil, err } @@ -697,6 +1089,54 @@ func (r *Repository) SaveIntegrationWithDefaults(ctx context.Context, in Integra return out, nil } +// otherServerServes is true when an enabled request server other than $1 +// takes the media type $2 ('movie' or 'series'): one of the matching kind, or +// a connection of another plugin (Seerr, say) that serves it. +const otherServerServes = `EXISTS ( + SELECT 1 FROM request_integrations i + WHERE i.id <> $1 AND i.enabled + AND CASE WHEN coalesce(i.plugin_config->>'service_kind', '') <> '' + THEN i.plugin_config->>'service_kind' = CASE $2::text WHEN 'movie' THEN 'radarr' ELSE 'sonarr' END + ELSE cardinality(i.supported_media_types) = 0 OR $2::text = ANY(i.supported_media_types) END)` + +// lockServerRouting orders adding and deleting the servers of a kind, so +// deleting the only Radarr while another is added cannot leave movies with no +// Everything else. +func lockServerRouting(ctx context.Context, tx pgx.Tx, kind string) error { + _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext('request-routes-first-server:' || $1))`, kind) + return err +} + +// defaultFirstServer makes the first Radarr (Sonarr) server added the +// destination of Everything else for movies (series), when that media type has +// none and no other server takes it: a single-server setup then needs no +// routing. A server flagged 4K is left alone, since Everything else needs an +// HD server. Later servers change nothing. +func defaultFirstServer(ctx context.Context, tx pgx.Tx, in *Integration) error { + kind, _ := in.PluginConfig[configServiceKind].(string) + mediaType := map[string]MediaType{kindRadarr: MediaTypeMovie, kindSonarr: MediaTypeSeries}[kind] + if mediaType == "" || !in.Enabled { + return nil + } + for _, key := range []string{configIs4K, configIsDefault4K} { + if flagged, _ := in.PluginConfig[key].(bool); flagged { + return nil + } + } + if err := lockServerRouting(ctx, tx, kind); err != nil { + return err + } + if _, err := tx.Exec(ctx, ` + INSERT INTO request_routes (id, media_type, position, name, is_fallback, hd_integration_id) + SELECT $3, $2::text, 1000, $4, true, $1 + WHERE NOT `+otherServerServes+` + ON CONFLICT DO NOTHING + `, in.ID, mediaType, FallbackRouteID(mediaType), fallbackRouteName); err != nil { + return fmt.Errorf("route everything else to the first %s server: %w", kind, err) + } + return nil +} + func (r *Repository) DeleteIntegration(ctx context.Context, id string) error { tx, err := r.pool.Begin(ctx) if err != nil { @@ -704,12 +1144,17 @@ func (r *Repository) DeleteIntegration(ctx context.Context, id string) error { } defer tx.Rollback(ctx) + if err := lockRoutingMode(ctx, tx); err != nil { + return err + } if err := r.deleteIntegration(ctx, tx, id); err != nil { return err } return tx.Commit(ctx) } +// deleteIntegration deletes a server. The caller holds the routing mode lock, +// taken before any row lock as every server write does. func (r *Repository) deleteIntegration(ctx context.Context, tx pgx.Tx, id string) error { var lockedID string if err := tx.QueryRow(ctx, ` @@ -734,6 +1179,79 @@ func (r *Repository) deleteIntegration(ctx context.Context, tx pgx.Tx, id string return ErrInvalidState } + // The last server of its kind can go with the Everything else that + // only it served (made for it when it was added), as long as no rule + // routes that media type; the media type then has no routing. + var kind string + if err := tx.QueryRow(ctx, `SELECT coalesce(plugin_config->>'service_kind', '') FROM request_integrations WHERE id = $1`, id).Scan(&kind); err != nil { + return fmt.Errorf("read request integration kind: %w", err) + } + if kind != "" { + if err := lockServerRouting(ctx, tx, kind); err != nil { + return err + } + } + if _, err := tx.Exec(ctx, ` + DELETE FROM request_routes f + WHERE f.is_fallback + AND $1 IN (f.hd_integration_id, f.uhd_integration_id) + AND coalesce(f.hd_integration_id, $1) = $1 + AND coalesce(f.uhd_integration_id, $1) = $1 + AND NOT EXISTS (SELECT 1 FROM request_routes r WHERE r.media_type = f.media_type AND NOT r.is_fallback) + AND NOT EXISTS ( + SELECT 1 FROM request_integrations i + WHERE i.id <> $1 + AND i.plugin_config->>'service_kind' = CASE f.media_type WHEN 'movie' THEN 'radarr' ELSE 'sonarr' END) + `, id); err != nil { + return fmt.Errorf("clear sole server's route: %w", err) + } + + // Standard does not use Everything else, and hides it: a reference from + // there does not keep the server. Advanced fills it in again from + // Standard's servers. + routing, err := scanRoutingSettings(tx.QueryRow(ctx, `SELECT mode, revision, updated_at FROM request_routing WHERE id`)) + if err != nil { + return err + } + standard := routing.Mode == RoutingStandard + if standard { + if _, err := tx.Exec(ctx, ` + UPDATE request_routes SET + hd_integration_id = NULLIF(hd_integration_id, $1), + uhd_integration_id = NULLIF(uhd_integration_id, $1) + WHERE is_fallback AND $1 IN (hd_integration_id, uhd_integration_id)`, id); err != nil { + return fmt.Errorf("clear everything else under standard routing: %w", err) + } + } + + // 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 && standard { + return &ValidationError{FormError: "Paused routing rules still send requests to this server (" + strings.Join(routes, ", ") + "). Switch to Advanced routing and send them elsewhere first."} + } + if len(routes) > 0 { + return &ValidationError{FormError: "Routing still sends requests to this server (" + strings.Join(routes, ", ") + "); send them elsewhere first."} + } + if _, err := tx.Exec(ctx, `DELETE FROM request_integrations WHERE id = $1`, id); err != nil { return fmt.Errorf("delete request integration: %w", err) } @@ -768,6 +1286,27 @@ func buildRequestListSQL(baseCondition string, baseArgs []any, filter ListFilter args = append(args, filter.Outcome) conditions = append(conditions, "outcome = $"+strconv.Itoa(len(args))) } + if cond := adminViewCondition(filter.View); cond != "" { + conditions = append(conditions, cond) + } + if filter.MediaType != "" { + args = append(args, filter.MediaType) + conditions = append(conditions, "media_type = $"+strconv.Itoa(len(args))) + } + if filter.RequestedByUserID > 0 { + args = append(args, filter.RequestedByUserID) + conditions = append(conditions, "requested_by_user_id = $"+strconv.Itoa(len(args))) + } + if q := strings.TrimSpace(filter.Query); q != "" { + args = append(args, "%"+likeEscaper.Replace(q)+"%") + cond := "title ILIKE $" + strconv.Itoa(len(args)) + // TMDB IDs are 4-byte integers; a longer number is only a title search. + if tmdbID, err := strconv.ParseInt(q, 10, 32); err == nil && tmdbID > 0 { + args = append(args, tmdbID) + cond = "(" + cond + " OR tmdb_id = $" + strconv.Itoa(len(args)) + ")" + } + conditions = append(conditions, cond) + } if filter.Before != nil { args = append(args, filter.Before.CreatedAt, filter.Before.ID) conditions = append(conditions, "(created_at, id) < ($"+strconv.Itoa(len(args)-1)+", $"+strconv.Itoa(len(args))+")") @@ -787,6 +1326,72 @@ func buildRequestListSQL(baseCondition string, baseArgs []any, filter ListFilter LIMIT $` + strconv.Itoa(len(args)-1) + ` OFFSET $` + strconv.Itoa(len(args)), args } +// likeEscaper escapes LIKE wildcards in a search term, so "50%" matches +// itself. +var likeEscaper = strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) + +// adminViewSQL holds each admin view's condition; CountAdminViews counts +// with the same ones. +var adminViewSQL = map[AdminView]string{ + AdminViewNeedsApproval: "(outcome = 'active' AND status = 'pending')", + AdminViewInProgress: "(outcome = 'active' AND status IN ('approved', 'queued', 'downloading'))", + AdminViewFailed: "(outcome = 'failed')", + AdminViewDone: "((outcome = 'active' AND status = 'completed') OR outcome IN ('declined', 'cancelled'))", +} + +func adminViewCondition(view AdminView) string { + return adminViewSQL[view] +} + +// CountAdminViews counts the requests in each admin view. +func (r *Repository) CountAdminViews(ctx context.Context) (AdminViewCounts, error) { + var c AdminViewCounts + err := r.pool.QueryRow(ctx, ` + SELECT count(*) FILTER (WHERE `+adminViewSQL[AdminViewNeedsApproval]+`), + count(*) FILTER (WHERE `+adminViewSQL[AdminViewInProgress]+`), + count(*) FILTER (WHERE `+adminViewSQL[AdminViewFailed]+`), + count(*) FILTER (WHERE `+adminViewSQL[AdminViewDone]+`) + FROM media_requests`).Scan(&c.NeedsApproval, &c.InProgress, &c.Failed, &c.Done) + if err != nil { + return AdminViewCounts{}, fmt.Errorf("count admin request views: %w", err) + } + return c, nil +} + +// ListEvents reads a request's history, newest first. +func (r *Repository) ListEvents(ctx context.Context, requestID string, limit int) ([]RequestEvent, error) { + rows, err := r.pool.Query(ctx, ` + SELECT e.id, e.request_id, e.event_type, e.actor_user_id, e.actor_profile_id, e.message, + e.created_at, COALESCE(u.username, '') + FROM media_request_events e + LEFT JOIN users u ON u.id = e.actor_user_id + WHERE e.request_id = $1 + ORDER BY e.created_at DESC, e.id DESC + LIMIT $2`, requestID, limit) + if err != nil { + return nil, fmt.Errorf("list request events: %w", err) + } + defer rows.Close() + var out []RequestEvent + for rows.Next() { + var e RequestEvent + if err := rows.Scan(&e.ID, &e.RequestID, &e.EventType, &e.ActorUserID, &e.ActorProfileID, &e.Message, + &e.CreatedAt, &e.ActorUsername); err != nil { + return nil, err + } + out = append(out, e) + } + return out, rows.Err() +} + +// nonNilSeasons stores no seasons as an empty array, not NULL. +func nonNilSeasons(seasons []int) []int { + if seasons == nil { + return []int{} + } + return seasons +} + func requestSelectSQL() string { return "SELECT " + requestColumns() + " FROM media_requests " } @@ -795,7 +1400,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, outcome_reason, routing_facts, seasons` } type requestScanner interface { @@ -805,7 +1411,8 @@ 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 + var rawFacts []byte if err := row.Scan( &req.ID, &req.Provider, @@ -828,9 +1435,20 @@ func scanRequest(row requestScanner) (*Request, error) { &req.UpdatedAt, &approvedAt, &completedAt, + &req.SubmitAttempts, + &submitLeaseUntil, + &nextSubmitAt, + &req.OutcomeReason, + &rawFacts, + &req.Seasons, ); 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 @@ -845,6 +1463,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..ff97000f7a 100644 --- a/internal/requests/repository_page_test.go +++ b/internal/requests/repository_page_test.go @@ -27,7 +27,9 @@ 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, + outcome_reason text NOT NULL DEFAULT '', routing_facts jsonb NOT NULL DEFAULT '{}', seasons integer[] NOT NULL DEFAULT '{}'); CREATE INDEX ON media_requests (requested_by_user_id, created_at DESC, id DESC)`) if err != nil { t.Fatal(err) @@ -35,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)`, 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/router_seasons.go b/internal/requests/router_seasons.go new file mode 100644 index 0000000000..6b8d208f04 --- /dev/null +++ b/internal/requests/router_seasons.go @@ -0,0 +1,165 @@ +package requests + +import ( + "context" + "fmt" +) + +// RouterFeatures are the optional request_router.v1 features a router +// capability declares in its plugin manifest. +type RouterFeatures struct { + // SupportsSeasons: Fulfill and CheckStatus honor a series request's + // seasons. Such a plugin acquires only the requested seasons and adds + // them to a series its download server already has, leaving the other + // seasons alone. Any other plugin takes the whole series. + SupportsSeasons bool + // ReportsDownloadProgress: CheckStatus fills each live target's download + // progress. The download refresh pass polls only such plugins. + ReportsDownloadProgress bool +} + +// RouterFeatureReader reads a router capability's declared features from the +// manifest the host stored at install, without launching the plugin. A +// RequestRouterProvider that does not implement it declares no features. +type RouterFeatureReader interface { + RouterFeatures(ctx context.Context, installationID int, capabilityID string) (RouterFeatures, error) +} + +// routerCapabilityKey names one plugin installation's router capability. +type routerCapabilityKey struct { + installationID int + capabilityID string +} + +// routerFeatures returns the features a router capability declares. The +// answer is kept for the fulfill context's lifetime, one reconcile pass or one +// request. +func (s *Service) routerFeatures(ctx context.Context, fc *fulfillContext, installationID int, capabilityID string) (RouterFeatures, error) { + reader, ok := s.router.(RouterFeatureReader) + if !ok { + return RouterFeatures{}, nil + } + key := routerCapabilityKey{installationID, capabilityID} + fc.mu.Lock() + features, cached := fc.features[key] + fc.mu.Unlock() + if cached { + return features, nil + } + features, err := reader.RouterFeatures(ctx, installationID, capabilityID) + if err != nil { + return RouterFeatures{}, fmt.Errorf("read request router features: %w", err) + } + fc.mu.Lock() + if fc.features == nil { + fc.features = map[routerCapabilityKey]RouterFeatures{} + } + fc.features[key] = features + fc.mu.Unlock() + return features, nil +} + +// routerSupportsSeasons reports whether the router capability takes a request +// for particular seasons. +func (s *Service) routerSupportsSeasons(ctx context.Context, fc *fulfillContext, installationID int, capabilityID string) (bool, error) { + features, err := s.routerFeatures(ctx, fc, installationID, capabilityID) + return features.SupportsSeasons, err +} + +// allTakeSeasons reports whether every given connection is bound to a router +// capability that takes seasons. A connection bound to none cannot. +func (s *Service) allTakeSeasons(ctx context.Context, fc *fulfillContext, conns []Integration) (bool, error) { + for _, in := range conns { + if in.InstallationID == nil || in.CapabilityID == "" { + return false, nil + } + ok, err := s.routerSupportsSeasons(ctx, fc, *in.InstallationID, in.CapabilityID) + if err != nil || !ok { + return false, err + } + } + return true, nil +} + +// seriesRouterConnections returns the enabled connections meant to serve +// series, the ones routerConfiguredFor counts, including misconfigured ones. +func seriesRouterConnections(fc *fulfillContext) []Integration { + var out []Integration + for _, in := range fc.integrations { + if in.Enabled && in.CapabilityID != "" && integrationSupportsMediaType(in, MediaTypeSeries) { + out = append(out, in) + } + } + return out +} + +// integrationsByID returns the connections with the given ids that still +// exist. A route to a removed server fails at submission instead. +func integrationsByID(fc *fulfillContext, ids []string) []Integration { + var out []Integration + for _, id := range ids { + for _, in := range fc.integrations { + if in.ID == id { + out = append(out, in) + break + } + } + } + return out +} + +// missingSeasonsDeliverable reports whether a request for seasons of a series +// already in the library can go to a download server: only a router that +// takes seasons fetches just those, and any other would add the whole series +// again. The server a request goes to decides. +// +// Without routing rules the plugin picks among the series connections, so all +// of them must take seasons. With rules, the servers the rules choose for this +// title must. Its routing facts are read first, as submission would; while +// TMDB cannot answer, every server a series rule sends to must, and a later +// pass tries again. submitRouted checks the chosen server again, since facts +// read after the claim can choose another. +func (s *Service) missingSeasonsDeliverable(ctx context.Context, fc *fulfillContext, req Request) (bool, error) { + routes := fc.routesFor(MediaTypeSeries) + if len(routes) == 0 { + return s.allTakeSeasons(ctx, fc, seriesRouterConnections(fc)) + } + var ids []string + if err := s.ensureRoutingFacts(ctx, &req, routes); err == nil { + allowed, _ := s.allowedQualities(ctx, req, fc.settings) + for _, decision := range decideRoutes(routes, req, allowed) { + if !decision.Skip { + ids = append(ids, decision.IntegrationID) + } + } + } else { + for _, route := range routes { + if route.MediaType != MediaTypeSeries || !route.Enabled { + continue + } + for _, id := range []string{route.HD.IntegrationID, route.UHD.IntegrationID} { + if id != "" { + ids = append(ids, id) + } + } + } + } + return s.allTakeSeasons(ctx, fc, integrationsByID(fc, ids)) +} + +// errMissingSeasonsUnsupported is a submission error for a request for the +// missing seasons of a series in the library whose chosen server's plugin +// would add the whole series. +func errMissingSeasonsUnsupported(server string) error { + return fmt.Errorf("%s cannot fetch only the missing seasons of a series already in the library: its plugin would add the whole series", server) +} + +// integrationName is a connection's display name, or its id when it is gone. +func integrationName(fc *fulfillContext, id string) string { + for _, in := range fc.integrations { + if in.ID == id { + return in.Name + } + } + return id +} diff --git a/internal/requests/router_seasons_test.go b/internal/requests/router_seasons_test.go new file mode 100644 index 0000000000..5c42144b6c --- /dev/null +++ b/internal/requests/router_seasons_test.go @@ -0,0 +1,319 @@ +package requests + +import ( + "context" + "errors" + "slices" + "strings" + "testing" +) + +func TestRouterDescriptorCarriesSeasons(t *testing.T) { + got := routerDescriptor(Request{MediaType: MediaTypeSeries, Seasons: []int{0, 2}}).GetSeasons() + if !slices.Equal(got, []int32{0, 2}) { + t.Fatalf("series seasons = %v, want [0 2]", got) + } + if got := routerDescriptor(Request{MediaType: MediaTypeSeries}).GetSeasons(); len(got) != 0 { + t.Fatalf("whole-series seasons = %v, want none", got) + } + if got := routerDescriptor(Request{MediaType: MediaTypeMovie, Seasons: []int{1}}).GetSeasons(); len(got) != 0 { + t.Fatalf("movie seasons = %v, want none", got) + } +} + +type featureResolver struct { + fakeRouterResolver + features RouterFeatures +} + +func (r featureResolver) RouterFeatures(context.Context, int, string) (RouterFeatures, error) { + return r.features, nil +} + +// The plugin provider reads features through a resolver that can, and reports +// none, without launching a plugin, through one that cannot. +func TestPluginRouterProviderRouterFeatures(t *testing.T) { + capable := NewPluginRouterProvider(featureResolver{features: RouterFeatures{SupportsSeasons: true}}).(RouterFeatureReader) + if got, err := capable.RouterFeatures(context.Background(), 1, "arr"); err != nil || !got.SupportsSeasons { + t.Fatalf("features = %+v, %v; want seasons", got, err) + } + plain := NewPluginRouterProvider(fakeRouterResolver{}).(RouterFeatureReader) + if got, err := plain.RouterFeatures(context.Background(), 1, "arr"); err != nil || got.SupportsSeasons { + t.Fatalf("features = %+v, %v; want none", got, err) + } +} + +// seriesRouterInst is a Sonarr connection bound to the given installation. +func seriesRouterInst(id string, installID int) Integration { + in := routerInstOn(id, installID) + in.SupportedMediaTypes = []string{"series"} + in.PluginConfig = map[string]any{"service_kind": "sonarr", "is_default": true} + return in +} + +// With every series server on a plugin that takes seasons, a series in the +// library is requestable for its missing seasons. +func TestMissingSeasonsRequestableWithSeasonRouters(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{seriesRouterInst("sonarr", 1), seriesRouterInst("sonarr-4k", 1)} + movies := routerInstOn("radarr", 2) // an old plugin that only takes movies + movies.SupportedMediaTypes = []string{"movie"} + store.integrations = append(store.integrations, movies) + svc := seasonService(store, severanceInLibrary()) + svc.SetRouterProvider(&fakeRouterProvider{seasonCapable: map[int]bool{1: true}}) + + status, err := svc.GetFeatureStatus(context.Background(), testViewer(1)) + if err != nil { + t.Fatal(err) + } + if !status.MissingSeasonsRequestable { + t.Fatal("status hides missing seasons with season-capable series servers") + } + detail, err := svc.GetDetail(context.Background(), testViewer(1), MediaTypeSeries, 95396) + if err != nil { + t.Fatal(err) + } + if !detail.Request.Requestable { + t.Fatalf("request state = %+v, want requestable", detail.Request) + } + req, err := svc.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{MediaType: MediaTypeSeries, TMDBID: 95396, Title: "Severance"}) + if err != nil { + t.Fatalf("create: %v", err) + } + if !slices.Equal(req.Seasons, []int{2}) { + t.Fatalf("seasons = %v, want the missing season 2", req.Seasons) + } +} + +// One series server on a plugin that cannot take seasons, or not bound to a +// plugin at all, keeps missing seasons off. +func TestMissingSeasonsNotRequestableWithAnOldSeriesRouter(t *testing.T) { + unbound := seriesRouterInst("sonarr-unbound", 1) + unbound.InstallationID = nil + for name, second := range map[string]Integration{ + "old plugin": seriesRouterInst("sonarr-old", 2), + "unbound": unbound, + } { + t.Run(name, func(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{seriesRouterInst("sonarr", 1), second} + svc := seasonService(store, severanceInLibrary()) + svc.SetRouterProvider(&fakeRouterProvider{seasonCapable: map[int]bool{1: true}}) + status, err := svc.GetFeatureStatus(context.Background(), testViewer(1)) + if err != nil { + t.Fatal(err) + } + if status.MissingSeasonsRequestable { + t.Fatal("status offers missing seasons with a series server that would add the whole series") + } + if _, err := svc.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{MediaType: MediaTypeSeries, TMDBID: 95396, Title: "Severance"}); !errors.Is(err, ErrAlreadyAvailable) { + t.Fatalf("err = %v, want ErrAlreadyAvailable", err) + } + }) + } +} + +// A feature read that fails is an error, not a silent "no". +func TestMissingSeasonsRequestableReportsFeatureReadErrors(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{seriesRouterInst("sonarr", 1)} + svc := seasonService(store, severanceInLibrary()) + svc.SetRouterProvider(&fakeRouterProvider{featuresErr: errors.New("db down")}) + if _, err := svc.GetFeatureStatus(context.Background(), testViewer(1)); err == nil || !strings.Contains(err.Error(), "db down") { + t.Fatalf("err = %v, want the feature read error", err) + } +} + +func approvedSeasonRequest(store *fakeStore, id string, tmdbID int, seasons []int) { + req := &Request{ID: id, MediaType: MediaTypeSeries, TMDBID: tmdbID, Title: "Severance", Status: StatusApproved, + Outcome: OutcomeActive, RequestedByUserID: 7, Seasons: seasons} + store.candidates = append(store.candidates, req) + store.requests[id] = req +} + +// A missing-seasons request goes to a season-capable plugin with its seasons, +// and waits for the library with any other. +func TestSubmitMissingSeasonsOnlyToSeasonRouters(t *testing.T) { + for _, tc := range []struct { + name string + capable bool + }{{"season plugin", true}, {"old plugin", false}} { + t.Run(tc.name, func(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{seriesRouterInst("sonarr", 1)} + approvedSeasonRequest(store, "in-library", 95396, []int{2}) + router := &fakeRouterProvider{seasonCapable: map[int]bool{1: tc.capable}} + svc := seasonService(store, severanceInLibrary()) + svc.SetRouterProvider(router) + + if _, err := svc.ReconcileRequests(context.Background(), 10); err != nil { + t.Fatal(err) + } + got := store.requests["in-library"] + if !tc.capable { + if router.fulfillCalls != 0 || got.Status != StatusApproved || got.SubmitAttempts != 0 { + t.Fatalf("calls = %d, request = %+v; want approved and unsent, waiting for the library", router.fulfillCalls, got) + } + return + } + if router.fulfillCalls != 1 || !slices.Equal(router.gotSeasons[0], []int{2}) { + t.Fatalf("calls = %d, seasons = %v; want one call for season 2", router.fulfillCalls, router.gotSeasons) + } + if got.Status == StatusApproved { + t.Fatalf("request = %+v, want submitted", got) + } + }) + } +} + +// A season request for a series not in the library goes to any plugin, as +// before; the descriptor still names its seasons. +func TestSubmitSeasonRequestOutsideTheLibraryToAnyRouter(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{seriesRouterInst("sonarr", 1)} + approvedSeasonRequest(store, "absent", 1399, []int{1}) + router := &fakeRouterProvider{} + svc := seasonService(store, severanceInLibrary()) + svc.SetRouterProvider(router) + if _, err := svc.ReconcileRequests(context.Background(), 10); err != nil { + t.Fatal(err) + } + if router.fulfillCalls != 1 || !slices.Equal(router.gotSeasons[0], []int{1}) { + t.Fatalf("calls = %d, seasons = %v; want one call naming season 1", router.fulfillCalls, router.gotSeasons) + } +} + +// seasonRoutes send anime series to a server on an old plugin and everything +// else to servers on a season plugin. +func seasonRoutes(store *fakeStore) { + store.integrations = []Integration{ + seriesRouterInst("sonarr-hd", 1), seriesRouterInst("sonarr-4k", 1), seriesRouterInst("sonarr-anime", 2), + } + store.routes = []Route{ + {ID: "fallback", MediaType: MediaTypeSeries, Position: 1000, Name: "Everything else", Enabled: true, IsFallback: true, + HD: RouteDestination{IntegrationID: "sonarr-hd"}, UHD: RouteDestination{IntegrationID: "sonarr-4k"}}, + {ID: "anime", MediaType: MediaTypeSeries, Position: 0, Name: "Anime", Enabled: true, + Conditions: RouteConditions{Anime: boolPtr(true)}, HD: RouteDestination{IntegrationID: "sonarr-anime"}, SkipUHD: true}, + } +} + +// With routing rules the servers chosen for the title decide: a mixed setup +// sends a missing-seasons request where the chosen servers take seasons, and +// holds it where one would add the whole series or the choice is unknown. +func TestSubmitRoutedMissingSeasonsToAMixedSetup(t *testing.T) { + store := newFakeStore() + seasonRoutes(store) + approvedSeasonRequest(store, "drama", 95396, []int{2}) + store.requests["drama"].RoutingFacts = capturedFacts(RoutingFacts{}) + router := &fakeRouterProvider{seasonCapable: map[int]bool{1: true}} + svc := seasonService(store, severanceInLibrary()) + svc.SetRouterProvider(router) + svc.SetEntitlementResolver(fixedCeiling{q: "2160p"}) + if _, err := svc.submitApprovedRequest(context.Background(), *store.requests["drama"], Viewer{}, nil); err != nil { + t.Fatalf("submit: %v", err) + } + if len(router.fulfillLog) != 2 { + t.Fatalf("fulfill calls = %+v, want HD and 4K on the season plugin", router.fulfillLog) + } + for i, call := range router.fulfillLog { + if call.installationID != 1 || !slices.Equal(router.gotSeasons[i], []int{2}) { + t.Fatalf("call %d = %+v with seasons %v, want installation 1 and season 2", i, call, router.gotSeasons[i]) + } + } + + for name, tc := range map[string]struct { + facts RoutingFacts + tmdbErr error + }{ + "anime goes to the old plugin": {facts: capturedFacts(RoutingFacts{Anime: true})}, + "facts not captured and TMDB is down": {tmdbErr: errors.New("tmdb down")}, + } { + t.Run(name, func(t *testing.T) { + store := newFakeStore() + seasonRoutes(store) + approvedSeasonRequest(store, "r", 95396, []int{2}) + store.requests["r"].RoutingFacts = tc.facts + router := &fakeRouterProvider{seasonCapable: map[int]bool{1: true}} + svc := seasonService(store, severanceInLibrary()) + svc.tmdb.(*fakeTMDBClient).detailErr = tc.tmdbErr + svc.SetRouterProvider(router) + got, err := svc.submitApprovedRequest(context.Background(), *store.requests["r"], Viewer{}, nil) + if err != nil { + t.Fatalf("submit: %v", err) + } + if router.fulfillCalls != 0 || got.Status != StatusApproved || store.requests["r"].SubmitAttempts != 0 { + t.Fatalf("calls = %d, request = %+v; want held for the library, unclaimed", router.fulfillCalls, got) + } + }) + } +} + +// Should the routing facts read after the claim pick a server whose plugin +// cannot take seasons, the tier is not sent. +func TestSubmitRoutedMissingSeasonsRefusesAnOldPluginAfterTheClaim(t *testing.T) { + store := newFakeStore() + seasonRoutes(store) + approvedSeasonRequest(store, "r", 95396, []int{2}) + store.requests["r"].RoutingFacts = capturedFacts(RoutingFacts{Anime: true}) + router := &fakeRouterProvider{seasonCapable: map[int]bool{1: true}} + svc := seasonService(store, severanceInLibrary()) + svc.SetRouterProvider(router) + fc, err := svc.newFulfillContext(context.Background()) + if err != nil { + t.Fatal(err) + } + _, err = svc.submitClaimed(context.Background(), *store.requests["r"], Viewer{}, fc, true) + if err == nil || !strings.Contains(err.Error(), "cannot fetch only the missing seasons") { + t.Fatalf("err = %v, want the missing-seasons refusal", err) + } + if router.fulfillCalls != 0 { + t.Fatalf("fulfill calls = %d, want none", router.fulfillCalls) + } +} + +// Facts not captured yet are read before the servers are checked, so a +// request the rules send to season-capable servers is not held once TMDB +// answers. +func TestSubmitRoutedMissingSeasonsReadsTheFactsFirst(t *testing.T) { + store := newFakeStore() + seasonRoutes(store) + approvedSeasonRequest(store, "r", 95396, []int{2}) + router := &fakeRouterProvider{seasonCapable: map[int]bool{1: true}} + svc := seasonService(store, severanceInLibrary()) + svc.SetRouterProvider(router) + if _, err := svc.submitApprovedRequest(context.Background(), *store.requests["r"], Viewer{}, nil); err != nil { + t.Fatalf("submit: %v", err) + } + if router.fulfillCalls == 0 || !store.requests["r"].RoutingFacts.Captured() { + t.Fatalf("calls = %d, facts = %+v; want the facts captured and the request sent", router.fulfillCalls, store.requests["r"].RoutingFacts) + } + for i, call := range router.fulfillLog { + if call.installationID != 1 { + t.Fatalf("call %d = %+v, want the season plugin", i, call) + } + } +} + +// A season request approved after its seasons reached the library is not +// sent: the reconcile pass completes it from the library. +func TestSubmitSkipsSeasonsAlreadyInTheLibrary(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{seriesRouterInst("sonarr", 1)} + approvedSeasonRequest(store, "done", 95396, []int{1}) + router := &fakeRouterProvider{seasonCapable: map[int]bool{1: true}} + svc := seasonService(store, severanceInLibrary()) + svc.SetRouterProvider(router) + got, err := svc.submitApprovedRequest(context.Background(), *store.requests["done"], Viewer{}, nil) + if err != nil { + t.Fatalf("submit: %v", err) + } + if router.fulfillCalls != 0 || got.Status != StatusApproved || store.requests["done"].SubmitAttempts != 0 { + t.Fatalf("calls = %d, request = %+v; want unsent, left for the library", router.fulfillCalls, got) + } + if _, err := svc.ReconcileRequests(context.Background(), 10); err != nil { + t.Fatal(err) + } + if router.fulfillCalls != 0 || store.requests["done"].Status != StatusCompleted { + t.Fatalf("calls = %d, request = %+v; want completed from the library", router.fulfillCalls, store.requests["done"]) + } +} diff --git a/internal/requests/routes_admin.go b/internal/requests/routes_admin.go new file mode 100644 index 0000000000..c0db94ca2c --- /dev/null +++ b/internal/requests/routes_admin.go @@ -0,0 +1,903 @@ +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) } + +// The field names of a route's HD and 4K destinations, as the API and its +// field errors name them. +const ( + fieldHD = "hd" + fieldUHD = "uhd" +) + +// 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 + // Rules explains the decision: every route of the media type in + // evaluation order, the conditions it failed, and what it did per tier. + Rules []RouteTrace +} + +// 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 where everything else goes before adding rules."} + } + 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: "Everything else can't be deleted; change where it sends requests 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: s.routingFacts(ctx, detail)} + routes := fc.routesFor(mediaType) + qualities := []Quality{Quality1080p, Quality2160p} + decisions, traces := traceRoutes(routes, req, qualities) + preview := &RoutePreview{Facts: req.RoutingFacts, Rules: traces} + 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 && isStandardRouting(routes, mediaType): + tier.Reason = fmt.Sprintf("No server takes %s %s: the only one is marked 4K.", qualityLabel(q), mediaTypePlural(mediaType)) + case !ok: + tier.Reason = "No rule sends " + qualityLabel(q) + " for this title." + case decision.Skip && isStandardRouting(routes, mediaType): + tier.RouteID, tier.RouteName = decision.RouteID, decision.RouteName + tier.Reason = "No server is marked 4K, so there is no 4K version." + case decision.Skip: + tier.RouteID, tier.RouteName = decision.RouteID, decision.RouteName + tier.Reason = decision.RouteName + " sends no 4K version." + default: + tier.RouteID, tier.RouteName = decision.RouteID, decision.RouteName + tier.IntegrationID, tier.Overrides = decision.IntegrationID, decision.Overrides + in := integrationByID(fc, decision.IntegrationID) + if in != nil { + tier.IntegrationName = in.Name + } + tier.Reason = routedServerProblem(in, mediaType) + } + preview.Tiers = append(preview.Tiers, tier) + } + return preview, nil +} + +// routedServerProblem explains why a tier routed to the server would fail +// when sent, in the cases routedConnection refuses it, and is "" when the +// server can take it. +func routedServerProblem(in *Integration, mediaType MediaType) string { + const fails = ", so this tier would fail." + switch { + case in == nil: + return "The server this rule sends to no longer exists" + fails + case !in.Enabled: + return in.Name + " is disabled" + fails + case in.InstallationID == nil || in.CapabilityID == "": + return in.Name + " is not bound to a plugin installation (re-save it)" + fails + case strings.TrimSpace(in.APIKeyRef) == "": + return in.Name + " has no API key" + fails + case !integrationSupportsMediaType(*in, mediaType): + return in.Name + " does not take " + mediaTypePlural(mediaType) + fails + } + if kind := serverKindMismatch(*in, mediaType); kind != "" { + return fmt.Sprintf("%s is a %s server%s", in.Name, kind, fails) + } + return "" +} + +// 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, or to media +// types, the routes sending to it cannot use: their requests would fail when +// sent. Under Advanced it also refuses a 4K switch change that would leave a +// route sending the wrong version to the server. Standard pauses the stored +// routes, which the admin cannot edit there, and places servers by their 4K +// switch itself, so the switch stays free under Standard; turning Advanced on +// clears the destinations that no longer fit (seedAdvancedFromStandard). +func (s *Service) ensureRoutesKeepServerKind(ctx context.Context, in Integration) error { + routes, err := s.store.ListRoutes(ctx) + if err != nil { + return err + } + checkTier := true + if modes, ok := s.store.(RoutingModeStore); ok { + settings, err := modes.GetRoutingSettings(ctx) + if err != nil { + return err + } + checkTier = settings.Mode != RoutingStandard + } + fields := routeKindConflicts(in, routes) + if checkTier { + current, err := s.store.GetIntegration(ctx, in.ID) + if err != nil && !errors.Is(err, ErrNotFound) { + return err + } + if current == nil || is4KServer(*current) != is4KServer(in) { + if msg := tierConflict(in, routes); msg != "" { + fields["plugin_config."+configIs4K] = msg + } + } + } + if len(fields) == 0 { + return nil + } + return &ValidationError{FieldErrors: fields} +} + +// routeKindConflicts explains, by field, why the routes sending to a server +// keep its type and media types where they are: they send it requests it +// would no longer take. It is empty when none do. The repository checks again +// under the server's row lock (ensureRoutesStillFit), since a route save can +// commit between this check and the server save. +func routeKindConflicts(in Integration, routes []Route) map[string]string { + var wrongKind, unsupported []string + for _, r := range routes { + if r.HD.IntegrationID != in.ID && r.UHD.IntegrationID != in.ID { + continue + } + if serverKindMismatch(in, r.MediaType) != "" { + wrongKind = append(wrongKind, r.Name) + } + if !integrationSupportsMediaType(in, r.MediaType) { + unsupported = append(unsupported, r.Name) + } + } + fields := map[string]string{} + if len(wrongKind) > 0 { + fields["plugin_config."+configServiceKind] = "Routing sends requests of the other media type to this server (" + + strings.Join(wrongKind, ", ") + "); change those routes first." + } + if len(unsupported) > 0 { + fields["supported_media_types"] = "Routing sends a media type this server would no longer take to it (" + + strings.Join(unsupported, ", ") + "); change those routes first." + } + return fields +} + +// tierConflict explains why routes keep a server's 4K switch where it is: they +// send it the version the server would no longer take. It is empty when none +// do. It is checked only when the switch changes, so a route saved before the +// rule existed does not block unrelated edits to its server. The repository +// checks again under the routing-mode lock, since a switch to Advanced can +// commit between this check and the save. +func tierConflict(in Integration, routes []Route) string { + var names []string + for _, r := range routes { + if (r.HD.IntegrationID == in.ID && tierMismatch(in, false) != "") || + (r.UHD.IntegrationID == in.ID && tierMismatch(in, true) != "") { + names = append(names, r.Name) + } + } + if len(names) == 0 { + return "" + } + if is4KServer(in) { + return "Routing sends HD versions to this server (" + strings.Join(names, ", ") + + `), so it can't be marked 4K; change those routes first.` + } + return "Routing sends 4K versions to this server (" + strings.Join(names, ", ") + + `), so "4K server" has to stay on; 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{fieldHD: &route.HD, fieldUHD: &route.UHD} { + validateDestination(field, dest, route.MediaType, integrations, fields) + } + if route.IsFallback { + route.Enabled = true + route.SkipUHD = false + if !conditionsEmpty(route.Conditions) { + fields["conditions"] = "Everything else takes every request; it can't have 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 that gets everything else." + } + } else { + if conditionsEmpty(route.Conditions) { + fields["conditions"] = "Add at least one condition. Requests no rule matches go to Everything else." + } + if route.HD.IntegrationID == "" && route.UHD.IntegrationID == "" && !route.SkipUHD { + fields[fieldHD] = "Choose where the HD and 4K versions go, or don't send a 4K version." + } + if route.SkipUHD && route.UHD.IntegrationID != "" { + fields[fieldUHD] = "A rule can't both send 4K versions somewhere and skip them." + } + } + if len(fields) > 0 { + return &ValidationError{FieldErrors: fields} + } + return nil +} + +// tierMismatch explains why a Radarr or Sonarr can't take the HD or 4K +// version: 4K versions go only to servers marked 4K, and HD versions only to +// the others. It is empty when the server fits. A server of another plugin +// (Seerr) has no 4K switch of ours and handles both versions itself, so it +// fits either. +func tierMismatch(in Integration, uhd bool) string { + if selfRouted(in) { + return "" + } + switch marked := is4KServer(in); { + case uhd && !marked: + return in.Name + ` isn't marked 4K. Turn on "4K server" in its settings to send 4K versions to it.` + case !uhd && marked: + return in.Name + " is marked 4K; it can only take the 4K version." + } + return "" +} + +// destinationMismatch explains why a server can't take a route's HD or 4K +// version: it is the other type of server, doesn't take the media type, or is +// on the other side of the 4K switch. It is empty when the server fits. +func destinationMismatch(in Integration, mediaType MediaType, uhd bool) string { + if kind, _ := in.PluginConfig[configServiceKind].(string); kind != "" { + wantKind := map[MediaType]string{MediaTypeMovie: kindRadarr, MediaTypeSeries: kindSonarr}[mediaType] + if wantKind != "" && kind != wantKind { + return fmt.Sprintf("%s is a %s server; %s need %s.", in.Name, kindLabel(kind), mediaTypePlural(mediaType), kindLabel(wantKind)) + } + } + if mediaType != "" && !integrationSupportsMediaType(in, mediaType) { + return fmt.Sprintf("%s does not take %s.", in.Name, mediaTypePlural(mediaType)) + } + return tierMismatch(in, uhd) +} + +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 msg := destinationMismatch(*in, mediaType, field == fieldUHD); msg != "" { + fields[field+".integration_id"] = msg + } + 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) + c.ExcludeGenreIDs, c.ExcludeKeywordIDs = ids(c.ExcludeGenreIDs), ids(c.ExcludeKeywordIDs) + c.ExcludeNetworkIDs, c.ExcludeCompanyIDs = ids(c.ExcludeNetworkIDs), ids(c.ExcludeCompanyIDs) + c.ExcludeRequesterUserIDs = ids(c.ExcludeRequesterUserIDs) + c.ExcludeOriginalLanguages, c.ExcludeOriginCountries = lower(c.ExcludeOriginalLanguages), upper(c.ExcludeOriginCountries) + c.MaxContentRating = strings.TrimSpace(c.MaxContentRating) + return c +} + +func validateConditions(c RouteConditions, fields map[string]string) { + for field, values := range map[string][]int{ + condGenreIDs: c.GenreIDs, condKeywordIDs: c.KeywordIDs, condNetworkIDs: c.NetworkIDs, + condCompanyIDs: c.CompanyIDs, condRequesterUserIDs: c.RequesterUserIDs, + condExcludeGenreIDs: c.ExcludeGenreIDs, condExcludeKeywordIDs: c.ExcludeKeywordIDs, + condExcludeNetworkIDs: c.ExcludeNetworkIDs, condExcludeCompanyIDs: c.ExcludeCompanyIDs, + condExcludeRequesterUserIDs: c.ExcludeRequesterUserIDs, + } { + for _, v := range values { + if v <= 0 { + fields["conditions."+field] = "IDs must be positive." + break + } + } + } + for field, values := range map[string][]string{condOriginalLanguages: c.OriginalLanguages, condExcludeOriginalLanguages: c.ExcludeOriginalLanguages} { + for _, v := range values { + if !languageCode.MatchString(v) { + fields["conditions."+field] = "Use ISO 639-1 language codes such as ja or en." + break + } + } + } + for field, values := range map[string][]string{condOriginCountries: c.OriginCountries, condExcludeOriginCountries: c.ExcludeOriginCountries} { + for _, v := range values { + if !countryCode.MatchString(v) { + fields["conditions."+field] = "Use ISO 3166-1 country codes such as JP or US." + break + } + } + } + // A value both wanted and excluded makes the rule match nothing. + overlaps := slices.ContainsFunc(c.GenreIDs, func(v int) bool { return slices.Contains(c.ExcludeGenreIDs, v) }) || + slices.ContainsFunc(c.KeywordIDs, func(v int) bool { return slices.Contains(c.ExcludeKeywordIDs, v) }) || + slices.ContainsFunc(c.NetworkIDs, func(v int) bool { return slices.Contains(c.ExcludeNetworkIDs, v) }) || + slices.ContainsFunc(c.CompanyIDs, func(v int) bool { return slices.Contains(c.ExcludeCompanyIDs, v) }) || + slices.ContainsFunc(c.RequesterUserIDs, func(v int) bool { return slices.Contains(c.ExcludeRequesterUserIDs, v) }) || + slices.ContainsFunc(c.OriginalLanguages, func(v string) bool { return slices.Contains(c.ExcludeOriginalLanguages, v) }) || + slices.ContainsFunc(c.OriginCountries, func(v string) bool { return slices.Contains(c.ExcludeOriginCountries, v) }) + if overlaps { + fields["conditions"] = "Remove the values that are in both \u201cis any of\u201d and \u201cis none of\u201d." + } + if c.MaxContentRating != "" { + if _, ok := ratingAge(c.MaxContentRating); !ok { + fields["conditions."+condMaxContentRating] = "Choose a rating such as G, PG, PG-13 or TV-Y7." + } + } + 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 { + lists := len(c.GenreIDs) + len(c.KeywordIDs) + len(c.OriginalLanguages) + len(c.OriginCountries) + + len(c.NetworkIDs) + len(c.CompanyIDs) + len(c.RequesterUserIDs) + + len(c.ExcludeGenreIDs) + len(c.ExcludeKeywordIDs) + len(c.ExcludeOriginalLanguages) + + len(c.ExcludeOriginCountries) + len(c.ExcludeNetworkIDs) + len(c.ExcludeCompanyIDs) + + len(c.ExcludeRequesterUserIDs) + return c.Anime == nil && lists == 0 && c.YearFrom == 0 && c.YearTo == 0 && c.MaxContentRating == "" +} + +// kindLabel names a server kind as admins see it. +func kindLabel(kind string) string { + switch kind { + case kindRadarr: + return "Radarr" + case kindSonarr: + return "Sonarr" + } + return kind +} + +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 + // Servers before the route row, the order a server save that turns + // Advanced on takes them in. + if err := ensureDestinationsFit(ctx, tx, route); err != nil { + return nil, err + } + 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) +} + +// ensureDestinationsFit checks the route's servers again inside the save, +// holding their rows FOR SHARE: a change to a server's type, media types or 4K +// switch committed since validateRoute is seen here, and one still in flight +// waits for this save and then finds the route (ensureRoutesStillFit). +func ensureDestinationsFit(ctx context.Context, tx pgx.Tx, route Route) error { + ids := make([]string, 0, 2) + for _, id := range []string{route.HD.IntegrationID, route.UHD.IntegrationID} { + if id != "" { + ids = append(ids, id) + } + } + if len(ids) == 0 { + return nil + } + rows, err := tx.Query(ctx, `SELECT id, name, supported_media_types, plugin_config FROM request_integrations WHERE id = ANY($1) ORDER BY id FOR SHARE`, ids) + if err != nil { + return fmt.Errorf("lock route servers: %w", err) + } + defer rows.Close() + fields := map[string]string{} + for rows.Next() { + var in Integration + var raw []byte + if err := rows.Scan(&in.ID, &in.Name, &in.SupportedMediaTypes, &raw); err != nil { + return fmt.Errorf("scan route server: %w", err) + } + if len(raw) > 0 { + if err := json.Unmarshal(raw, &in.PluginConfig); err != nil { + return fmt.Errorf("decode route server %s config: %w", in.ID, err) + } + } + for field, uhd := range map[string]bool{fieldHD: false, fieldUHD: true} { + dest := route.HD + if uhd { + dest = route.UHD + } + if dest.IntegrationID == in.ID { + if msg := destinationMismatch(in, route.MediaType, uhd); msg != "" { + fields[field+".integration_id"] = msg + } + } + } + } + if err := rows.Err(); err != nil { + return err + } + if len(fields) > 0 { + return &ValidationError{FieldErrors: fields} + } + return nil +} + +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 +} + +// maxRouteTitleResults bounds the admin title search. +const maxRouteTitleResults = 10 + +// SearchRouteTitles finds titles to try the routing rules on. It is the +// admins' own search: it works while requests are turned off and applies no +// viewer's rating ceiling. +func (s *Service) SearchRouteTitles(ctx context.Context, viewer Viewer, mediaType MediaType, query string) ([]tmdb.MediaResult, error) { + if !viewer.IsAdmin { + return nil, ErrForbidden + } + mediaType, err := normalizeMediaType(mediaType) + if err != nil { + return nil, err + } + query = strings.TrimSpace(query) + if query == "" { + return nil, fmt.Errorf("%w: search text is required", ErrInvalidInput) + } + if s.tmdb == nil { + return nil, ErrIntegrationUnreachable + } + page, err := s.tmdb.SearchMedia(ctx, string(mediaType), query, 1) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrIntegrationUnreachable, err) + } + out := []tmdb.MediaResult{} + if page == nil { + return out, nil + } + for _, result := range page.Results { + if len(out) == maxRouteTitleResults { + break + } + if resultType, err := normalizeMediaType(MediaType(result.MediaType)); err == nil && resultType == mediaType && result.ID > 0 { + out = append(out, result) + } + } + return out, nil +} diff --git a/internal/requests/routes_admin_test.go b/internal/requests/routes_admin_test.go new file mode 100644 index 0000000000..213e95eec2 --- /dev/null +++ b/internal/requests/routes_admin_test.go @@ -0,0 +1,611 @@ +package requests + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "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, "Choose where everything else goes") { + 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) + } +} + +// The first Radarr (Sonarr) added becomes Everything else for movies +// (series); later ones change nothing. Deleting the last server of its kind +// takes that Everything else with it, unless rules still route the media type. +func TestFirstServerBecomesEverythingElseDatabase(t *testing.T) { + repo, pool := lifecycleTestRepository(t) + ctx := t.Context() + for _, stmt := range []string{ + `CREATE TABLE request_routes (LIKE public.request_routes INCLUDING ALL)`, + `CREATE TRIGGER route_revision BEFORE INSERT OR UPDATE ON request_routes FOR EACH ROW EXECUTE FUNCTION public.advance_request_editor_revision()`, + } { + if _, err := pool.Exec(ctx, stmt); err != nil { + t.Fatal(err) + } + } + add := func(id, kind string) { + t.Helper() + config := map[string]any{} + if kind != "" { + config["service_kind"] = kind + } + if _, err := repo.SaveIntegrationWithDefaults(ctx, Integration{ID: id, Name: id, Enabled: true, CapabilityID: "arr", PluginConfig: config}, true); err != nil { + t.Fatal(err) + } + } + fallbacks := func() map[string]string { + t.Helper() + rows, err := pool.Query(ctx, `SELECT media_type, coalesce(hd_integration_id, '') FROM request_routes WHERE is_fallback`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + out := map[string]string{} + for rows.Next() { + var mediaType, id string + if err := rows.Scan(&mediaType, &id); err != nil { + t.Fatal(err) + } + out[mediaType] = id + } + return out + } + + add("other", "") // a plugin that serves no media type named by the fixture's supported types + if _, err := pool.Exec(ctx, `UPDATE request_integrations SET supported_media_types = '{audiobook}' WHERE id = 'other'`); err != nil { + t.Fatal(err) + } + add("radarr-a", kindRadarr) + add("radarr-b", kindRadarr) + add("sonarr", kindSonarr) + if got := fallbacks(); got["movie"] != "radarr-a" || got["series"] != "sonarr" || len(got) != 2 { + t.Fatalf("fallbacks = %v, want movies to radarr-a and series to sonarr", got) + } + + // radarr-a is Everything else and another Radarr exists: refused. + if err := repo.DeleteIntegration(ctx, "radarr-a"); err == nil { + t.Fatal("deleted the Everything else server while another Radarr remains") + } + // The sole Sonarr goes with its Everything else. + if err := repo.DeleteIntegration(ctx, "sonarr"); err != nil { + t.Fatalf("delete the sole Sonarr: %v", err) + } + if got := fallbacks(); got["series"] != "" { + t.Fatalf("fallbacks = %v, want series cleared", got) + } + // A rule keeps Everything else, and so the server, in place. + if _, err := pool.Exec(ctx, `INSERT INTO request_routes (id, media_type, position, name, conditions, hd_integration_id) + VALUES ('anime', 'movie', 0, 'Anime', '{"anime":true}', 'radarr-b')`); err != nil { + t.Fatal(err) + } + if err := repo.DeleteIntegration(ctx, "radarr-b"); err == nil { + t.Fatal("deleted a server a rule sends to") + } + + // A Seerr connection already serves series, and a 4K-flagged server is no + // HD destination: neither first Sonarr becomes Everything else. + if _, err := repo.SaveIntegrationWithDefaults(ctx, Integration{ID: "seerr", Name: "seerr", Enabled: true, CapabilityID: "seerr", + SupportedMediaTypes: []string{"series"}, PluginConfig: map[string]any{}}, true); err != nil { + t.Fatal(err) + } + add("sonarr-2", kindSonarr) + if got := fallbacks(); got["series"] != "" { + t.Fatalf("fallbacks = %v, want series left to the Seerr connection", got) + } + if _, err := pool.Exec(ctx, `DELETE FROM request_integrations WHERE id IN ('seerr', 'sonarr-2')`); err != nil { + t.Fatal(err) + } + if _, err := repo.SaveIntegrationWithDefaults(ctx, Integration{ID: "sonarr-4k", Name: "sonarr-4k", Enabled: true, CapabilityID: "arr", + PluginConfig: map[string]any{"service_kind": kindSonarr, "is_4k": true}}, true); err != nil { + t.Fatal(err) + } + if got := fallbacks(); got["series"] != "" { + t.Fatalf("fallbacks = %v, want a 4K server left alone", got) + } +} + +func TestSingleServerFallbackMigrationDatabase(t *testing.T) { + matches, err := filepath.Glob("../../migrations/sql/*_request_routes_single_server_fallback.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) + + _, 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) + } + for _, seed := range []struct { + id, kind, key string + enabled bool + }{ + {"radarr", kindRadarr, "k", true}, + {"radarr-off", kindRadarr, "k", false}, // not usable: disabled + {"sonarr-a", kindSonarr, "k", true}, + {"sonarr-b", kindSonarr, "k", true}, + } { + if _, err := pool.Exec(ctx, `INSERT INTO request_integrations (id, name, enabled, capability_id, installation_id, api_key_ref, plugin_config) + VALUES ($1, $1, $2, 'arr', 1, $3, jsonb_build_object('service_kind', $4::text))`, seed.id, seed.enabled, seed.key, seed.kind); err != nil { + t.Fatal(err) + } + } + if _, err := pool.Exec(ctx, up); err != nil { + t.Fatal(err) + } + var mediaType, hd string + if err := pool.QueryRow(ctx, `SELECT media_type, hd_integration_id FROM request_routes WHERE is_fallback`).Scan(&mediaType, &hd); err != nil { + t.Fatal(err) + } + var n int + _ = pool.QueryRow(ctx, `SELECT count(*) FROM request_routes`).Scan(&n) + if mediaType != "movie" || hd != "radarr" || n != 1 { + t.Fatalf("seeded %d routes, movie to %q; want only movies to the one usable Radarr (two Sonarrs pick nothing)", n, hd) + } +} + +// The preview flags every server problem a routed submission would fail on, +// so an unusable route never reads as working. +func TestPreviewRouteFlagsUnusableServers(t *testing.T) { + for _, tc := range []struct { + name string + unusable func(*Integration) + want string + }{ + {"not bound", func(in *Integration) { in.InstallationID = nil }, "not bound to a plugin installation"}, + {"no key", func(in *Integration) { in.APIKeyRef = "" }, "has no API key"}, + {"does not take movies", func(in *Integration) { in.SupportedMediaTypes = []string{"series"} }, "does not take movies"}, + {"gone", func(in *Integration) { in.ID = "deleted" }, "no longer exists"}, + } { + store := routingStore(RoutingFacts{}) + tc.unusable(&store.integrations[0]) // radarr-hd, the fallback's HD server + svc := newTestServiceWithTMDB(store, &fakeTMDBClient{detail: &tmdb.MediaDetail{ID: 129}}) + preview, err := svc.PreviewRoute(context.Background(), routeAdmin, MediaTypeMovie, 129, 7) + if err != nil { + t.Fatalf("%s: preview: %v", tc.name, err) + } + if hd := preview.Tiers[0]; !strings.Contains(hd.Reason, tc.want) || !strings.HasSuffix(hd.Reason, "would fail.") { + t.Errorf("%s: HD tier = %+v, want a failure note containing %q", tc.name, hd, tc.want) + } + if uhd := preview.Tiers[1]; uhd.Reason != "" { + t.Errorf("%s: 4K tier = %+v, want no failure note for the working 4K server", tc.name, uhd) + } + } +} + +// A route cannot send a media type to a server that does not take it, and a +// server a route uses cannot stop taking the route's media type. +func TestRoutesRespectSupportedMediaTypes(t *testing.T) { + store := routingStore(RoutingFacts{}) + seriesOnly := routerInst("generic-series") + seriesOnly.SupportedMediaTypes = []string{"series"} + store.integrations = append(store.integrations, seriesOnly) + svc := newTestServiceWithTMDB(store, &fakeTMDBClient{}) + + route := Route{MediaType: MediaTypeMovie, Name: "Anime", Enabled: true, + Conditions: RouteConditions{Anime: boolPtr(true)}, HD: RouteDestination{IntegrationID: "generic-series"}} + if fields := fieldErrors(t, svc.validateRoute(context.Background(), &route)); !strings.Contains(fields["hd.integration_id"], "does not take movies") { + t.Fatalf("field errors = %v, want hd.integration_id refused", fields) + } + + narrowed := store.integrations[2] // radarr-anime, which the movie rule "Anime" sends to + narrowed.SupportedMediaTypes = []string{"series"} + _, err := svc.UpdateIntegration(context.Background(), routeAdmin, narrowed) + var verr *ValidationError + if !errors.As(err, &verr) || !strings.Contains(verr.FieldErrors["supported_media_types"], "Anime") { + t.Fatalf("narrow: err = %v, want a supported_media_types field error naming the route", err) + } +} + +// HD versions go only to servers not marked 4K, and 4K versions only to +// servers marked 4K; a server can't flip its 4K switch while routes rely on +// the other. +func TestRoutesKeepHDAnd4KServersApart(t *testing.T) { + store := routingStore(RoutingFacts{}) + svc := newTestServiceWithTMDB(store, &fakeTMDBClient{}) + ctx := context.Background() + + route := Route{MediaType: MediaTypeMovie, Name: "Anime", Enabled: true, + Conditions: RouteConditions{Anime: boolPtr(true)}, + HD: RouteDestination{IntegrationID: "radarr-4k"}, UHD: RouteDestination{IntegrationID: "radarr-anime"}} + fields := fieldErrors(t, svc.validateRoute(ctx, &route)) + if !strings.Contains(fields["hd.integration_id"], "marked 4K") { + t.Fatalf("hd error = %q, want the 4K server refused for HD", fields["hd.integration_id"]) + } + if !strings.Contains(fields["uhd.integration_id"], "isn't marked 4K") { + t.Fatalf("uhd error = %q, want the HD server refused for 4K", fields["uhd.integration_id"]) + } + + route.HD.IntegrationID, route.UHD.IntegrationID = "radarr-anime", "radarr-4k" + if err := svc.validateRoute(ctx, &route); err != nil { + t.Fatalf("matching tiers: %v", err) + } + + // Everything else sends 4K versions to radarr-4k; turning its switch off + // would leave that 4K version on an HD server. + unmarked := store.integrations[1] + unmarked.PluginConfig = map[string]any{"service_kind": "radarr", "root_folder": "/movies", "is_4k": false} + _, err := svc.UpdateIntegration(ctx, routeAdmin, unmarked) + var verr *ValidationError + if !errors.As(err, &verr) || !strings.Contains(verr.FieldErrors["plugin_config.is_4k"], "Everything else") { + t.Fatalf("unmark: err = %v, want an is_4k field error naming the route", err) + } + + // The Anime rule sends HD versions to radarr-anime, so it can't be marked 4K. + marked := store.integrations[2] + marked.PluginConfig = map[string]any{"service_kind": "radarr", "root_folder": "/movies", "is_4k": true} + _, err = svc.UpdateIntegration(ctx, routeAdmin, marked) + if !errors.As(err, &verr) || !strings.Contains(verr.FieldErrors["plugin_config.is_4k"], "can't be marked 4K") { + t.Fatalf("mark: err = %v, want an is_4k field error", err) + } +} + +// A server of another plugin (Seerr) has no 4K switch of ours and takes both +// versions, so routing can send it either. +func TestRoutesSendEitherVersionToSelfRoutedServers(t *testing.T) { + store := routingStore(RoutingFacts{}) + seerr := routerInst("seerr") + seerr.CapabilityID, seerr.PluginConfig = "seerr", map[string]any{} + seerr.SupportedMediaTypes = []string{"movie"} + store.integrations = append(store.integrations, seerr) + svc := newTestServiceWithTMDB(store, &fakeTMDBClient{}) + + route := Route{MediaType: MediaTypeMovie, Name: "Anime", Enabled: true, + Conditions: RouteConditions{Anime: boolPtr(true)}, + HD: RouteDestination{IntegrationID: "seerr"}, UHD: RouteDestination{IntegrationID: "seerr"}} + if err := svc.validateRoute(context.Background(), &route); err != nil { + t.Fatalf("Seerr for both versions: %v", err) + } +} + +// Standard pauses the stored routes and the editor hides them, so a server's +// 4K switch can change there even when a paused route sends it the other +// version. +func TestStandardRoutingLeavesThe4KSwitchFree(t *testing.T) { + store := &modeStore{fakeStore: routingStore(RoutingFacts{}), mode: RoutingStandard} + svc := modeService(store, &fakeTMDBClient{}) + ctx := context.Background() + + // The paused Anime rule sends HD versions to radarr-anime. + marked := store.integrations[2] + marked.PluginConfig = map[string]any{"service_kind": "radarr", "root_folder": "/movies", "is_4k": true} + if _, err := svc.UpdateIntegration(ctx, routeAdmin, marked); err != nil { + t.Fatalf("mark under Standard: %v", err) + } + + store.mode = RoutingAdvanced + unmarked := store.integrations[1] + unmarked.PluginConfig = map[string]any{"service_kind": "radarr", "root_folder": "/movies", "is_4k": false} + _, err := svc.UpdateIntegration(ctx, routeAdmin, unmarked) + var verr *ValidationError + if !errors.As(err, &verr) || verr.FieldErrors["plugin_config.is_4k"] == "" { + t.Fatalf("unmark under Advanced: err = %v, want an is_4k field error", err) + } +} + +// validateRoute reads the servers before the save; the save checks the tier +// again with the servers locked, so a 4K switch changed in between is caught. +func TestSaveRouteRechecksTierDatabase(t *testing.T) { + ctx := t.Context() + repo, pool := routingModeRepository(t) + for _, in := range []Integration{arrServer("radarr", kindRadarr, nil), arrServer("radarr-4k", kindRadarr, map[string]any{"is_4k": true})} { + in.APIKeyRef = "" + if _, err := repo.SaveIntegrationWithDefaults(ctx, in, true); err != nil { + t.Fatal(err) + } + } + // radarr was marked 4K after the editor validated the route. + if _, err := pool.Exec(ctx, `UPDATE request_integrations SET plugin_config = plugin_config || '{"is_4k": true}' WHERE id = 'radarr'`); err != nil { + t.Fatal(err) + } + route := Route{ID: "anime", MediaType: MediaTypeMovie, Name: "Anime", Enabled: true, + Conditions: RouteConditions{Anime: boolPtr(true)}, + HD: RouteDestination{IntegrationID: "radarr"}, UHD: RouteDestination{IntegrationID: "radarr-4k"}} + _, err := repo.SaveRouteConditional(ctx, route, 0) + fields := fieldErrors(t, err) + if !strings.Contains(fields["hd.integration_id"], "marked 4K") || fields["uhd.integration_id"] != "" { + t.Fatalf("fields = %v, want only the HD server refused", fields) + } + route.HD.IntegrationID = "" + if _, err := repo.SaveRouteConditional(ctx, route, 0); err != nil { + t.Fatalf("4K only: %v", err) + } +} + +// A server's type changed after the editor validated the route is caught by +// the same locked check: movies can't go to what is now a Sonarr. +func TestSaveRouteRechecksServerKindDatabase(t *testing.T) { + ctx := t.Context() + repo, pool := routingModeRepository(t) + in := arrServer("radarr", kindRadarr, nil) + in.APIKeyRef = "" + if _, err := repo.SaveIntegrationWithDefaults(ctx, in, true); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `UPDATE request_integrations + SET plugin_config = plugin_config || '{"service_kind": "sonarr"}', supported_media_types = '{series}' + WHERE id = 'radarr'`); err != nil { + t.Fatal(err) + } + route := Route{ID: "anime", MediaType: MediaTypeMovie, Name: "Anime", Enabled: true, + Conditions: RouteConditions{Anime: boolPtr(true)}, HD: RouteDestination{IntegrationID: "radarr"}, SkipUHD: true} + _, err := repo.SaveRouteConditional(ctx, route, 0) + if msg := fieldErrors(t, err)["hd.integration_id"]; !strings.Contains(msg, "is a Sonarr server") { + t.Fatalf("hd.integration_id = %q, want the server type refused", msg) + } +} diff --git a/internal/requests/routing.go b/internal/requests/routing.go new file mode 100644 index 0000000000..a1660bd6ab --- /dev/null +++ b/internal/requests/routing.go @@ -0,0 +1,383 @@ +package requests + +import ( + "cmp" + "fmt" + "maps" + "slices" + "strings" + + "github.com/Silo-Server/silo-server/internal/access" +) + +// 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" + configSeriesType = "series_type" + seriesTypeAnime = "anime" + kindRadarr = "radarr" + 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 + 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), and an +// exclude list when it has none of them. 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"` + + ExcludeGenreIDs []int `json:"exclude_genre_ids,omitempty"` + ExcludeKeywordIDs []int `json:"exclude_keyword_ids,omitempty"` + ExcludeOriginalLanguages []string `json:"exclude_original_languages,omitempty"` + ExcludeOriginCountries []string `json:"exclude_origin_countries,omitempty"` + ExcludeNetworkIDs []int `json:"exclude_network_ids,omitempty"` + ExcludeCompanyIDs []int `json:"exclude_company_ids,omitempty"` + ExcludeRequesterUserIDs []int `json:"exclude_requester_user_ids,omitempty"` + // MaxContentRating matches titles whose rating is at most this one ("PG" + // takes G and PG), by minimum age: the US rating, or the title's own + // country's when it has no US one. A title with neither does not match, + // as a parental ceiling treats it. + MaxContentRating string `json:"max_content_rating,omitempty"` +} + +// Condition keys, as the JSON fields name them; Unmet reports them. +const ( + condAnime = "anime" + condGenreIDs = "genre_ids" + condKeywordIDs = "keyword_ids" + condOriginalLanguages = "original_languages" + condOriginCountries = "origin_countries" + condYearFrom = "year_from" + condYearTo = "year_to" + condNetworkIDs = "network_ids" + condCompanyIDs = "company_ids" + condRequesterUserIDs = "requester_user_ids" + condExcludeGenreIDs = "exclude_genre_ids" + condExcludeKeywordIDs = "exclude_keyword_ids" + condExcludeOriginalLanguages = "exclude_original_languages" + condExcludeOriginCountries = "exclude_origin_countries" + condExcludeNetworkIDs = "exclude_network_ids" + condExcludeCompanyIDs = "exclude_company_ids" + condExcludeRequesterUserIDs = "exclude_requester_user_ids" + condMaxContentRating = "max_content_rating" +) + +// Matches reports whether a request satisfies every set condition, judged on +// its stored routing facts. +func (c RouteConditions) Matches(req Request) bool { + return len(c.Unmet(req)) == 0 +} + +// Unmet lists the set conditions a request fails, by key, in a fixed order. +// Routing and the admin preview's explanation share it. +func (c RouteConditions) Unmet(req Request) []string { + f := req.RoutingFacts + language := []string{f.OriginalLanguage} + var out []string + fail := func(failed bool, key string) { + if failed { + out = append(out, key) + } + } + fail(c.Anime != nil && *c.Anime != f.Anime, condAnime) + fail(len(c.GenreIDs) > 0 && !anyInt(c.GenreIDs, f.GenreIDs), condGenreIDs) + fail(len(c.KeywordIDs) > 0 && !anyInt(c.KeywordIDs, f.KeywordIDs), condKeywordIDs) + fail(len(c.OriginalLanguages) > 0 && !anyFold(c.OriginalLanguages, language), condOriginalLanguages) + fail(len(c.OriginCountries) > 0 && !anyFold(c.OriginCountries, f.OriginCountries), condOriginCountries) + fail(c.YearFrom > 0 && (f.Year == 0 || f.Year < c.YearFrom), condYearFrom) + fail(c.YearTo > 0 && (f.Year == 0 || f.Year > c.YearTo), condYearTo) + fail(len(c.NetworkIDs) > 0 && !anyInt(c.NetworkIDs, f.NetworkIDs), condNetworkIDs) + fail(len(c.CompanyIDs) > 0 && !anyInt(c.CompanyIDs, f.CompanyIDs), condCompanyIDs) + fail(len(c.RequesterUserIDs) > 0 && !slices.Contains(c.RequesterUserIDs, req.RequestedByUserID), condRequesterUserIDs) + fail(anyInt(c.ExcludeGenreIDs, f.GenreIDs), condExcludeGenreIDs) + fail(anyInt(c.ExcludeKeywordIDs, f.KeywordIDs), condExcludeKeywordIDs) + fail(anyFold(c.ExcludeOriginalLanguages, language), condExcludeOriginalLanguages) + fail(anyFold(c.ExcludeOriginCountries, f.OriginCountries), condExcludeOriginCountries) + fail(anyInt(c.ExcludeNetworkIDs, f.NetworkIDs), condExcludeNetworkIDs) + fail(anyInt(c.ExcludeCompanyIDs, f.CompanyIDs), condExcludeCompanyIDs) + fail(slices.Contains(c.ExcludeRequesterUserIDs, req.RequestedByUserID) && req.RequestedByUserID != 0, condExcludeRequesterUserIDs) + fail(c.MaxContentRating != "" && !ratingWithin(f.ContentRating, c.MaxContentRating), condMaxContentRating) + return out +} + +// routesCheckRating reports whether an enabled route of the media type +// matches on content rating. +func routesCheckRating(routes []Route, mediaType MediaType) bool { + for _, route := range routes { + if route.Enabled && route.MediaType == mediaType && route.Conditions.MaxContentRating != "" { + return true + } + } + return false +} + +// routesUseConditions reports whether an enabled route of the media type has +// conditions, so a request's routing facts matter. +func routesUseConditions(routes []Route, mediaType MediaType) bool { + for _, route := range routes { + if route.Enabled && route.MediaType == mediaType && !conditionsEmpty(route.Conditions) { + return true + } + } + return false +} + +// ratingWithin reports whether a title's rating is at most max, by each +// rating's own minimum age (not the parental-control tiers, which would let +// "TV-Y7 or lower" take TV-PG). An unknown or unrated title is not. +func ratingWithin(rating *string, max string) bool { + if rating == nil || strings.TrimSpace(*rating) == "" { + return false + } + maxAge, ok := ratingAge(max) + if !ok { + return false + } + age, ok := ratingAge(*rating) + return ok && age <= maxAge +} + +func ratingAge(rating string) (int, bool) { + _, age, ok := access.Normalize(rating) + if !ok || age == nil { + return 0, false + } + return *age, 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 { + decisions, _ := traceRoutes(routes, req, qualities) + return decisions +} + +// RouteStep is what one route did for one quality tier. +type RouteStep string + +const ( + // RouteStepSends: the route matched and sent the tier to its server. + RouteStepSends RouteStep = "sends" + // RouteStepSkips: the route matched and made no copy in the tier. + RouteStepSkips RouteStep = "skips" + // RouteStepPasses: the route matched but has no server for the tier, so + // a later route decides. + RouteStepPasses RouteStep = "passes" + // RouteStepNoMatch: the route is off, or the request fails a condition. + RouteStepNoMatch RouteStep = "no_match" + // RouteStepDecided: an earlier route already decided the tier. + RouteStepDecided RouteStep = "already_decided" +) + +// RouteTrace is one route's part in a decision, for the admin preview. +type RouteTrace struct { + Route Route + Unmet []string + Steps map[Quality]RouteStep +} + +// traceRoutes decides like decideRoutes and records, for every route of the +// request's media type in evaluation order, what it did for each tier. +// +// Everything else with no 4K server makes no 4K copy: the tier is skipped, +// even when every request asks for 4K (force-dual), rather than left +// undecided and failed. +func traceRoutes(routes []Route, req Request, qualities []Quality) (map[Quality]RouteDecision, []RouteTrace) { + var ordered []Route + for _, route := range orderRoutes(routes) { + if route.MediaType == req.MediaType { + ordered = append(ordered, route) + } + } + decisions := make(map[Quality]RouteDecision, len(qualities)) + traces := make([]RouteTrace, len(ordered)) + for i, route := range ordered { + traces[i] = RouteTrace{Route: route, Steps: make(map[Quality]RouteStep, len(qualities))} + if route.Enabled { + traces[i].Unmet = route.Conditions.Unmet(req) + } + } + for _, q := range qualities { + for i, route := range ordered { + trace := &traces[i] + if !route.Enabled || len(trace.Unmet) > 0 { + trace.Steps[q] = RouteStepNoMatch + continue + } + if _, done := decisions[q]; done { + trace.Steps[q] = RouteStepDecided + continue + } + dest := route.HD + if q == Quality2160p { + dest = route.UHD + } + if q == Quality2160p && (route.SkipUHD || (route.IsFallback && dest.IntegrationID == "")) { + decisions[q] = RouteDecision{RouteID: route.ID, RouteName: route.Name, Skip: true} + trace.Steps[q] = RouteStepSkips + continue + } + if dest.IntegrationID == "" { + trace.Steps[q] = RouteStepPasses + continue + } + decisions[q] = RouteDecision{ + RouteID: route.ID, + RouteName: route.Name, + IntegrationID: dest.IntegrationID, + Overrides: dest.Overrides, + } + trace.Steps[q] = RouteStepSends + } + } + return decisions, traces +} + +// 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) + case !integrationSupportsMediaType(*in, mediaType): + // The server's media types can change after a route points at it. + return ResolvedRouterConnection{}, 0, "", fmt.Errorf("route %q sends %s to %q, which does not take them", d.RouteName, mediaTypePlural(mediaType), in.Name) + } + // 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 + // The server's own 4K flag must not follow an HD copy there. + config[configIs4K] = q == Quality2160p + 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_facts.go b/internal/requests/routing_facts.go new file mode 100644 index 0000000000..68486d2f5f --- /dev/null +++ b/internal/requests/routing_facts.go @@ -0,0 +1,122 @@ +package requests + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "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"` + // ContentRating is the title's US rating ("PG", "TV-14"), or, when it + // has none, its own country's prefixed with the country ("JP:PG12"); "" + // when TMDB has neither, nil when it was never looked up (requests from + // before it was captured). + ContentRating *string `json:"content_rating,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; listed says whether the +// anime list names the title. A nil detail (TMDB unreachable) yields +// uncaptured facts, so routing retries the lookup later. +func routingFactsFrom(detail *tmdb.MediaDetail, listed bool, now time.Time) RoutingFacts { + if detail == nil { + return RoutingFacts{} + } + rating := routingRating(detail.USCertification, detail.Certifications, detail.OriginCountries) + 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, listed), + ContentRating: &rating, + CapturedAt: &now, + } +} + +// routingRating picks the rating routing matches a title on. Most rules are +// written in US ratings, so the US one wins. A title never rated in the US +// (common for Japanese, Korean or European releases) falls back to its own +// country's, the strictest where that country rated it more than once, +// prefixed with the country so its age reads on that country's scale +// ("JP:PG12" is 12). So does one rated only "NR" in the US. Ratings no known +// scale reads are skipped. +func routingRating(us string, certs map[string][]string, origins []string) string { + us = strings.TrimSpace(us) + if _, ok := ratingAge(us); ok { + return us + } + // No US rating, or only one without an age ("NR" from a festival run): + // the title's own country's, keeping the US marker if it has none. + for _, country := range origins { + country = strings.ToUpper(strings.TrimSpace(country)) + picked, pickedAge := "", -1 + for _, cert := range certs[country] { + prefixed := country + ":" + strings.TrimSpace(cert) + if age, ok := ratingAge(prefixed); ok && age > pickedAge { + picked, pickedAge = prefixed, age + } + } + if picked != "" { + return picked + } + } + return us +} + +// 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/routing_mode.go b/internal/requests/routing_mode.go new file mode 100644 index 0000000000..ba40b5d387 --- /dev/null +++ b/internal/requests/routing_mode.go @@ -0,0 +1,559 @@ +package requests + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +// Routing mode: Standard sends each media type to its one server, and its 4K +// copies to its one 4K server, with each server's own settings; the routing +// rules are kept but paused. Advanced routes with the rules. Standard needs at +// most one enabled server of each kind per media type (a normal one and a 4K +// one), both of one service when either is a plugin that routes itself (Seerr), +// so adding or enabling a server that breaks that turns Advanced on. + +// RoutingMode is how requests find their server. +type RoutingMode string + +const ( + RoutingStandard RoutingMode = "standard" + RoutingAdvanced RoutingMode = "advanced" +) + +// RoutingSettings is the stored routing mode. +type RoutingSettings struct { + Mode RoutingMode + Revision int64 + UpdatedAt time.Time +} + +// StandardDestination is where Standard sends a media type: its one normal +// server and its one 4K server, either of which may be missing. +type StandardDestination struct { + MediaType MediaType + HDIntegrationID string + UHDIntegrationID string +} + +// RoutingOverview is the routing mode with what Standard would do: where it +// sends each media type, or why it cannot be used. +type RoutingOverview struct { + RoutingSettings + Standard []StandardDestination + // StandardBlocker says why Standard cannot be used; empty when it can. + StandardBlocker string +} + +// RoutingModeStore reads and writes the routing mode. The PostgreSQL +// repository implements it. +type RoutingModeStore interface { + GetRoutingSettings(ctx context.Context) (RoutingSettings, error) + // UpdateRoutingModeConditional sets the mode when the revision still + // matches expected (-1 to overwrite). Standard is refused with a + // ValidationError when the servers do not allow it. + UpdateRoutingModeConditional(ctx context.Context, mode RoutingMode, expected int64) (RoutingSettings, error) +} + +var standardMediaTypes = []MediaType{MediaTypeMovie, MediaTypeSeries} + +// serverServes reports whether a server takes a media type: a Radarr or +// Sonarr by its kind, any other by the media types it lists. +func serverServes(in Integration, mediaType MediaType) bool { + if kind, _ := in.PluginConfig[configServiceKind].(string); kind != "" { + return kind == map[MediaType]string{MediaTypeMovie: kindRadarr, MediaTypeSeries: kindSonarr}[mediaType] + } + return integrationSupportsMediaType(in, mediaType) +} + +// is4KServer reports whether a server is marked as the 4K one. +func is4KServer(in Integration) bool { + for _, key := range []string{configIs4K, configIsDefault4K} { + switch flagged := in.PluginConfig[key].(type) { + case bool: + if flagged { + return true + } + case string: + if flagged == "true" { + return true + } + } + } + return false +} + +// selfRouted reports whether a server is a plugin that picks its own server +// (Seerr) rather than a Radarr or Sonarr. +func selfRouted(in Integration) bool { + kind, _ := in.PluginConfig[configServiceKind].(string) + return kind == "" +} + +// splitServices reports whether a media type's normal and 4K servers cannot +// both be used by Standard: when either is a plugin that routes itself, the +// plugin is handed the whole request with no rule, so both tiers must be its +// connections. Two Radarrs or Sonarrs are routed tier by tier and may belong +// to different plugins. +func splitServices(hd, uhd Integration) bool { + if !selfRouted(hd) && !selfRouted(uhd) { + return false + } + return selfRouted(hd) != selfRouted(uhd) || + hd.InstallationID == nil || uhd.InstallationID == nil || + *hd.InstallationID != *uhd.InstallationID || hd.CapabilityID != uhd.CapabilityID +} + +// standardLayout works out where Standard sends each media type from the +// enabled servers, and why Standard cannot be used when a media type has more +// than one normal or more than one 4K server, or its normal and 4K servers are +// different services and one of them picks its own server. +func standardLayout(integrations []Integration) ([]StandardDestination, string) { + var out []StandardDestination + var problems []string + split := false + for _, mediaType := range standardMediaTypes { + var hd, uhd []Integration + for _, in := range integrations { + if !in.Enabled || !serverServes(in, mediaType) { + continue + } + if is4KServer(in) { + uhd = append(uhd, in) + } else { + hd = append(hd, in) + } + } + noun := mediaTypePlural(mediaType) + if len(hd) > 1 { + problems = append(problems, fmt.Sprintf("%s can go to more than one server (%s)", capitalize(noun), serverNames(hd))) + } + if len(uhd) > 1 { + problems = append(problems, fmt.Sprintf("more than one 4K server takes %s (%s)", noun, serverNames(uhd))) + } + if len(hd) > 1 || len(uhd) > 1 || len(hd)+len(uhd) == 0 { + continue + } + if len(hd) == 1 && len(uhd) == 1 && splitServices(hd[0], uhd[0]) { + split = true + problems = append(problems, fmt.Sprintf("%s go to %s and their 4K versions to %s, which are different request services", noun, hd[0].Name, uhd[0].Name)) + continue + } + dest := StandardDestination{MediaType: mediaType} + if len(hd) == 1 { + dest.HDIntegrationID = hd[0].ID + } + if len(uhd) == 1 { + dest.UHDIntegrationID = uhd[0].ID + } + out = append(out, dest) + } + if len(problems) > 0 { + rule := ". Standard sends each request to one server, plus one server marked 4K" + if split { + rule += ", through one request service. Switch to Advanced routing to send them to different services" + } + return nil, capitalize(strings.Join(problems, "; ")) + rule + "." + } + return out, "" +} + +func serverNames(servers []Integration) string { + names := make([]string, 0, len(servers)) + for _, in := range servers { + names = append(names, in.Name) + } + slices.Sort(names) + return strings.Join(names, ", ") +} + +func capitalize(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +// standardRouteID names the route Standard routes a media type with. It is +// not stored; targets record it as the route that sent them. +func standardRouteID(mediaType MediaType) string { return "standard-" + string(mediaType) } + +// standardRouteName is how targets and the preview name Standard's route. +const standardRouteName = "Standard" + +// standardRoutes is Standard's routing for a media type as one fallback +// route: its normal server for HD and its 4K server for 4K, with no +// overrides; for series, an anime route ahead of it sets Sonarr's anime +// series type. A media type whose server is not a Radarr or Sonarr gets none, so +// that plugin keeps routing it itself. +func standardRoutes(integrations []Integration, layout []StandardDestination, mediaType MediaType) []Route { + for _, dest := range layout { + if dest.MediaType != mediaType { + continue + } + for _, id := range []string{dest.HDIntegrationID, dest.UHDIntegrationID} { + for _, in := range integrations { + if in.ID == id && selfRouted(in) { + return nil + } + } + } + routes := []Route{{ + ID: standardRouteID(mediaType), MediaType: mediaType, Position: 1000, Name: standardRouteName, + Enabled: true, IsFallback: true, + HD: RouteDestination{IntegrationID: dest.HDIntegrationID}, + UHD: RouteDestination{IntegrationID: dest.UHDIntegrationID}, + }} + if mediaType == MediaTypeSeries { + // Anime goes to the same servers with Sonarr's anime series type, + // which numbers episodes the way anime releases do; Seerr does + // the same. Other settings stay the server's own. + anime := func(id string) RouteDestination { + if id == "" { + return RouteDestination{} + } + return RouteDestination{IntegrationID: id, Overrides: map[string]any{configSeriesType: seriesTypeAnime}} + } + routes = append([]Route{{ + ID: standardRouteID(mediaType) + "-anime", MediaType: mediaType, Position: 0, Name: standardRouteName, + Enabled: true, Conditions: RouteConditions{Anime: new(true)}, + HD: anime(dest.HDIntegrationID), UHD: anime(dest.UHDIntegrationID), + }}, routes...) + } + return routes + } + return nil +} + +// isStandardRouting reports whether routes are Standard's for the media type. +func isStandardRouting(routes []Route, mediaType MediaType) bool { + return len(routes) > 0 && routes[len(routes)-1].ID == standardRouteID(mediaType) +} + +func (s *Service) routingModeStore() (RoutingModeStore, error) { + store, ok := s.store.(RoutingModeStore) + if !ok { + return nil, fmt.Errorf("request store does not support routing modes") + } + return store, nil +} + +// GetRoutingOverview returns the routing mode and what Standard would do. +func (s *Service) GetRoutingOverview(ctx context.Context, v Viewer) (*RoutingOverview, error) { + if !v.IsAdmin { + return nil, ErrForbidden + } + store, err := s.routingModeStore() + if err != nil { + return nil, err + } + settings, err := store.GetRoutingSettings(ctx) + if err != nil { + return nil, err + } + integrations, err := s.store.ListIntegrations(ctx) + if err != nil { + return nil, err + } + layout, blocker := standardLayout(integrations) + return &RoutingOverview{RoutingSettings: settings, Standard: layout, StandardBlocker: blocker}, nil +} + +// UpdateRoutingModeConditional switches between Standard and Advanced. +func (s *Service) UpdateRoutingModeConditional(ctx context.Context, v Viewer, mode RoutingMode, expected int64) (*RoutingOverview, error) { + if !v.IsAdmin { + return nil, ErrForbidden + } + if mode != RoutingStandard && mode != RoutingAdvanced { + return nil, fmt.Errorf("%w: invalid routing mode", ErrInvalidInput) + } + store, err := s.routingModeStore() + if err != nil { + return nil, err + } + if _, err := store.UpdateRoutingModeConditional(ctx, mode, expected); err != nil { + return nil, err + } + return s.GetRoutingOverview(ctx, v) +} + +// lockRoutingMode orders changes to the routing mode and to the servers, so a +// server added while Standard is turned on cannot leave Standard on with two +// servers of a kind. +func lockRoutingMode(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext('request-routing-mode'))`) + return err +} + +func (r *Repository) GetRoutingSettings(ctx context.Context) (RoutingSettings, error) { + return scanRoutingSettings(r.pool.QueryRow(ctx, `SELECT mode, revision, updated_at FROM request_routing WHERE id`)) +} + +func scanRoutingSettings(row pgx.Row) (RoutingSettings, error) { + var out RoutingSettings + err := row.Scan(&out.Mode, &out.Revision, &out.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + // The migration inserts the row; a database without it routes with + // the rules, as before Standard existed. + return RoutingSettings{Mode: RoutingAdvanced}, nil + } + if err != nil { + return RoutingSettings{}, fmt.Errorf("get request routing mode: %w", err) + } + return out, nil +} + +func (r *Repository) UpdateRoutingModeConditional(ctx context.Context, mode RoutingMode, expected int64) (RoutingSettings, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return RoutingSettings{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + if err := lockRoutingMode(ctx, tx); err != nil { + return RoutingSettings{}, err + } + if err := lockRevision(ctx, tx, `SELECT revision FROM request_routing WHERE id FOR UPDATE`, nil, expected, true); err != nil { + return RoutingSettings{}, err + } + integrations, err := r.listIntegrations(ctx, tx) + if err != nil { + return RoutingSettings{}, err + } + layout, blocker := standardLayout(integrations) + if mode == RoutingStandard && blocker != "" { + return RoutingSettings{}, &ValidationError{FieldErrors: map[string]string{"mode": blocker}} + } + current, err := scanRoutingSettings(tx.QueryRow(ctx, `SELECT mode, revision, updated_at FROM request_routing WHERE id`)) + if err != nil { + return RoutingSettings{}, err + } + if current.Mode == RoutingStandard && mode == RoutingAdvanced { + if err := seedAdvancedFromStandard(ctx, tx, layout, integrations); err != nil { + return RoutingSettings{}, err + } + } + out, err := setRoutingMode(ctx, tx, mode) + if err != nil { + return RoutingSettings{}, err + } + if err := tx.Commit(ctx); err != nil { + return RoutingSettings{}, err + } + return out, nil +} + +func setRoutingMode(ctx context.Context, tx pgx.Tx, mode RoutingMode) (RoutingSettings, error) { + return scanRoutingSettings(tx.QueryRow(ctx, ` + INSERT INTO request_routing (id, mode, updated_at) VALUES (true, $1, now()) + ON CONFLICT (id) DO UPDATE SET mode = EXCLUDED.mode, updated_at = now() + RETURNING mode, revision, updated_at`, mode)) +} + +// seedAdvancedFromStandard gives Everything else the servers Standard was +// using, where it has none, so turning Advanced on sends requests where they +// went before. That includes a 4K server an admin once cleared from +// Everything else: Standard was sending 4K copies there since. Only Radarr and +// Sonarr servers that still take the media type, as saved now, are used; a +// media type another plugin (Seerr) routed itself stays with that plugin (see +// ensureSelfRoutedOwnerKept). +// Each tier is carried on its own, so a server that no longer fits one tier +// does not drop the other tier's server. +// +// A Radarr or Sonarr whose 4K switch changed under Standard can still be a +// paused route's destination for the other version (see +// ensureRoutesKeepServerKind). Those destinations are cleared first, so that +// version falls through to Everything else, which then gets Standard's server. +func seedAdvancedFromStandard(ctx context.Context, tx pgx.Tx, layout []StandardDestination, integrations []Integration) error { + marked, unmarked := []string{}, []string{} + for _, in := range integrations { + switch { + case selfRouted(in): + case is4KServer(in): + marked = append(marked, in.ID) + default: + unmarked = append(unmarked, in.ID) + } + } + if _, err := tx.Exec(ctx, ` + UPDATE request_routes SET + hd_integration_id = CASE WHEN hd_integration_id = ANY($1) THEN NULL ELSE hd_integration_id END, + hd_overrides = CASE WHEN hd_integration_id = ANY($1) THEN '{}'::jsonb ELSE hd_overrides END, + uhd_integration_id = CASE WHEN uhd_integration_id = ANY($2) THEN NULL ELSE uhd_integration_id END, + uhd_overrides = CASE WHEN uhd_integration_id = ANY($2) THEN '{}'::jsonb ELSE uhd_overrides END + WHERE hd_integration_id = ANY($1) OR uhd_integration_id = ANY($2)`, marked, unmarked); err != nil { + return fmt.Errorf("clear routes to servers that changed tier: %w", err) + } + usable := func(id string, mediaType MediaType, fourK bool) *string { + for _, in := range integrations { + if in.ID != id { + continue + } + kind, _ := in.PluginConfig[configServiceKind].(string) + if kind != "" && in.Enabled && serverServes(in, mediaType) && is4KServer(in) == fourK { + return &id + } + return nil + } + return nil + } + for _, dest := range layout { + hd := usable(dest.HDIntegrationID, dest.MediaType, false) + uhd := usable(dest.UHDIntegrationID, dest.MediaType, true) + if hd == nil && uhd == nil { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO request_routes (id, media_type, position, name, is_fallback, hd_integration_id, uhd_integration_id) + VALUES ($1, $2, 1000, $3, true, $4, $5) + ON CONFLICT (id) DO UPDATE SET + hd_integration_id = coalesce(request_routes.hd_integration_id, EXCLUDED.hd_integration_id), + uhd_integration_id = coalesce(request_routes.uhd_integration_id, EXCLUDED.uhd_integration_id) + WHERE request_routes.hd_integration_id IS NULL + OR (request_routes.uhd_integration_id IS NULL AND EXCLUDED.uhd_integration_id IS NOT NULL AND NOT request_routes.skip_uhd)`, + FallbackRouteID(dest.MediaType), dest.MediaType, fallbackRouteName, hd, uhd); err != nil { + return fmt.Errorf("carry standard routing into everything else: %w", err) + } + } + return nil +} + +// standardBeforeSave reads, in a transaction that is about to add or change a +// server, whether Standard is on and where it sends requests. Pass the result +// to advanceIfStandardBroken after the save. +func (r *Repository) standardBeforeSave(ctx context.Context, tx pgx.Tx) (layout []StandardDestination, standard bool, err error) { + if err := lockRoutingMode(ctx, tx); err != nil { + return nil, false, err + } + current, err := scanRoutingSettings(tx.QueryRow(ctx, `SELECT mode, revision, updated_at FROM request_routing WHERE id FOR UPDATE`)) + if err != nil || current.Mode != RoutingStandard { + return nil, false, err + } + integrations, err := r.listIntegrations(ctx, tx) + if err != nil { + return nil, false, err + } + layout, _ = standardLayout(integrations) + return layout, true, nil +} + +// ensureRoutesStillFit refuses, under the routing-mode lock taken by +// standardBeforeSave and the server's row lock, a server update that leaves a +// route sending it requests it would no longer take: the other media type, or +// under Advanced the other version. The service checks the same before the +// save; this catches a route saved, or Advanced turned on, in between. +func ensureRoutesStillFit(ctx context.Context, tx pgx.Tx, in Integration, advanced bool) error { + var raw []byte + // FOR UPDATE before reading the routes: a route save holds its servers + // FOR SHARE (ensureDestinationsFit), so one in flight commits first and + // its route is read below. + err := tx.QueryRow(ctx, `SELECT plugin_config FROM request_integrations WHERE id = $1 FOR UPDATE`, in.ID).Scan(&raw) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil { + return fmt.Errorf("read request integration %s: %w", in.ID, err) + } + stored := Integration{} + if len(raw) > 0 { + if err := json.Unmarshal(raw, &stored.PluginConfig); err != nil { + return fmt.Errorf("decode request integration %s config: %w", in.ID, err) + } + } + routes, err := listRoutes(ctx, tx) + if err != nil { + return err + } + fields := routeKindConflicts(in, routes) + if advanced && is4KServer(stored) != is4KServer(in) { + if msg := tierConflict(in, routes); msg != "" { + fields["plugin_config."+configIs4K] = msg + } + } + if len(fields) > 0 { + return &ValidationError{FieldErrors: fields} + } + return nil +} + +// advanceIfStandardBroken turns Advanced on when a saved server leaves a media +// type with two servers of a kind, and gives Everything else the servers +// Standard was using before, so requests keep going where they went. It +// refuses the save when that cannot be kept for a plugin that routes itself. +func (r *Repository) advanceIfStandardBroken(ctx context.Context, tx pgx.Tx, before []StandardDestination) error { + integrations, err := r.listIntegrations(ctx, tx) + if err != nil { + return err + } + if _, blocker := standardLayout(integrations); blocker == "" { + return nil + } + if err := seedAdvancedFromStandard(ctx, tx, before, integrations); err != nil { + return err + } + if err := ensureSelfRoutedOwnerKept(ctx, tx, before, integrations); err != nil { + return err + } + if _, err := setRoutingMode(ctx, tx, RoutingAdvanced); err != nil { + return fmt.Errorf("turn advanced routing on: %w", err) + } + return nil +} + +// ensureSelfRoutedOwnerKept refuses a save that turns Advanced on when a media +// type Standard sent to a plugin that picks its own server (Seerr) has no +// routing rule and would now also be taken by another request service: with no +// rule, the first of them by name would get its requests. Everything else is +// not seeded with such a plugin, because a rule sends each tier on its own and +// would change how the plugin handles 4K and anime; the admin sets it instead. +func ensureSelfRoutedOwnerKept(ctx context.Context, tx pgx.Tx, before []StandardDestination, integrations []Integration) error { + type service struct { + installation int + capability string + } + for _, dest := range before { + owners := map[service]bool{} + var ownerNames []string + selfRouted := false + for _, in := range integrations { + if in.ID != dest.HDIntegrationID && in.ID != dest.UHDIntegrationID || in.InstallationID == nil { + continue + } + if kind, _ := in.PluginConfig[configServiceKind].(string); kind == "" { + selfRouted = true + } + owners[service{*in.InstallationID, in.CapabilityID}] = true + ownerNames = append(ownerNames, in.Name) + } + if !selfRouted { + continue + } + var routed bool + if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM request_routes WHERE media_type = $1)`, dest.MediaType).Scan(&routed); err != nil { + return fmt.Errorf("check routing rules: %w", err) + } + if routed { + continue + } + var rivals []Integration + for _, in := range integrations { + if eligibleRouterConnection(in, dest.MediaType) && !owners[service{*in.InstallationID, in.CapabilityID}] { + rivals = append(rivals, in) + } + } + if len(rivals) == 0 { + continue + } + slices.Sort(ownerNames) + noun := mediaTypePlural(dest.MediaType) + return &ValidationError{FormError: fmt.Sprintf( + "%s go to %s, which picks their server itself. With %s also taking %s, no routing rule would say which one gets them. Switch to Advanced routing and set Everything else for %s first.", + capitalize(noun), strings.Join(ownerNames, ", "), serverNames(rivals), noun, noun)} + } + return nil +} diff --git a/internal/requests/routing_mode_test.go b/internal/requests/routing_mode_test.go new file mode 100644 index 0000000000..3be91bd76f --- /dev/null +++ b/internal/requests/routing_mode_test.go @@ -0,0 +1,856 @@ +package requests + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" +) + +// modeStore is the fake store with a routing mode. +type modeStore struct { + *fakeStore + mode RoutingMode +} + +func (s *modeStore) GetRoutingSettings(context.Context) (RoutingSettings, error) { + return RoutingSettings{Mode: s.mode, Revision: 1}, nil +} + +func (s *modeStore) UpdateRoutingModeConditional(_ context.Context, mode RoutingMode, _ int64) (RoutingSettings, error) { + s.mode = mode + return RoutingSettings{Mode: mode, Revision: 2}, nil +} + +func modeService(store *modeStore, tmdbClient *fakeTMDBClient) *Service { + service := NewService(store, tmdbClient, &fakePresence{}) + service.Now = func() time.Time { return time.Date(2026, 5, 24, 12, 0, 0, 0, time.UTC) } + service.SetUserRepository(requestUserRepo{}) + return service +} + +func arrServer(id, kind string, config map[string]any) Integration { + in := routerInst(id) + in.Name = id + in.PluginConfig = map[string]any{"service_kind": kind, "root_folder": "/" + id} + for k, v := range config { + in.PluginConfig[k] = v + } + return in +} + +func TestStandardLayout(t *testing.T) { + radarr := arrServer("radarr", kindRadarr, nil) + radarr4K := arrServer("radarr-4k", kindRadarr, map[string]any{"is_4k": true}) + sonarr := arrServer("sonarr", kindSonarr, nil) + + layout, blocker := standardLayout([]Integration{radarr, radarr4K, sonarr}) + if blocker != "" || len(layout) != 2 || + layout[0] != (StandardDestination{MediaType: MediaTypeMovie, HDIntegrationID: "radarr", UHDIntegrationID: "radarr-4k"}) || + layout[1] != (StandardDestination{MediaType: MediaTypeSeries, HDIntegrationID: "sonarr"}) { + t.Fatalf("layout = %+v blocker = %q, want Radarr + Radarr 4K for movies and Sonarr for series", layout, blocker) + } + + second := arrServer("radarr-anime", kindRadarr, nil) + if layout, blocker := standardLayout([]Integration{radarr, second, sonarr}); layout != nil || + !strings.Contains(blocker, "Movies can go to more than one server (radarr, radarr-anime)") { + t.Fatalf("two Radarrs: layout = %+v blocker = %q", layout, blocker) + } + otherUHD := arrServer("radarr-4k-b", kindRadarr, map[string]any{"is_default_4k": true}) + if _, blocker := standardLayout([]Integration{radarr, radarr4K, otherUHD}); !strings.Contains(blocker, "More than one 4K server takes movies") { + t.Fatalf("two 4K Radarrs: blocker = %q", blocker) + } + // A disabled server does not take requests, so it does not count. + second.Enabled = false + if _, blocker := standardLayout([]Integration{radarr, second}); blocker != "" { + t.Fatalf("disabled second Radarr: blocker = %q", blocker) + } + // Another plugin that takes movies is a second movie server too. + seerr := routerInst("seerr") + seerr.Name, seerr.SupportedMediaTypes = "seerr", []string{"movie"} + if _, blocker := standardLayout([]Integration{radarr, seerr, sonarr}); !strings.Contains(blocker, "Movies can go to more than one server") { + t.Fatalf("Radarr + Seerr: blocker = %q", blocker) + } + // Seerr for movies with a Radarr marked 4K: with no rule Seerr would be + // handed every tier, so Standard cannot use both. + if layout, blocker := standardLayout([]Integration{seerr, radarr4K}); layout != nil || + !strings.Contains(blocker, "Movies go to seerr and their 4K versions to radarr-4k, which are different request services") || + !strings.Contains(blocker, "Switch to Advanced routing") { + t.Fatalf("Seerr + 4K Radarr: layout = %+v blocker = %q", layout, blocker) + } + seerr4K := routerInstOn("seerr-4k", 2) + seerr4K.Name, seerr4K.SupportedMediaTypes = "seerr-4k", []string{"movie"} + seerr4K.PluginConfig = map[string]any{"is_default_4k": true} + if _, blocker := standardLayout([]Integration{seerr, seerr4K}); !strings.Contains(blocker, "different request services") { + t.Fatalf("two Seerr installations: blocker = %q", blocker) + } + // The same Seerr's 4K connection is one service, and Radarrs from + // different plugins are routed tier by tier. + sameSeerr4K := seerr4K + sameSeerr4K.InstallationID, sameSeerr4K.CapabilityID = seerr.InstallationID, seerr.CapabilityID + if _, blocker := standardLayout([]Integration{seerr, sameSeerr4K}); blocker != "" { + t.Fatalf("one Seerr with a 4K connection: blocker = %q", blocker) + } + otherRadarr4K := radarr4K + otherInstall := 2 + otherRadarr4K.InstallationID = &otherInstall + if _, blocker := standardLayout([]Integration{radarr, otherRadarr4K}); blocker != "" { + t.Fatalf("Radarrs from two plugins: blocker = %q", blocker) + } + // A media type served by another plugin alone keeps that plugin's routing. + layout, _ = standardLayout([]Integration{seerr}) + if got := standardRoutes([]Integration{seerr}, layout, MediaTypeMovie); got != nil { + t.Fatalf("Seerr-only movies: routes = %+v, want none so the plugin routes", got) + } +} + +// standardStore is routingStore's request with Standard on: one Radarr, one +// Radarr marked 4K, and a paused anime rule. +func standardStore(facts RoutingFacts) *modeStore { + store := routingStore(facts) + store.integrations = []Integration{ + arrServer("radarr-hd", kindRadarr, map[string]any{"is_default": false}), + arrServer("radarr-4k", kindRadarr, map[string]any{"is_4k": true}), + } + store.routes = []Route{ + {ID: "r-anime", MediaType: MediaTypeMovie, Name: "Anime", Enabled: true, Conditions: RouteConditions{Anime: new(true)}, + HD: RouteDestination{IntegrationID: "radarr-4k", Overrides: map[string]any{"root_folder": "/anime"}}}, + {ID: "fallback-movie", MediaType: MediaTypeMovie, Position: 1000, Name: "Everything else", Enabled: true, IsFallback: true, + HD: RouteDestination{IntegrationID: "radarr-hd", Overrides: map[string]any{"root_folder": "/elsewhere"}}}, + } + return &modeStore{fakeStore: store, mode: RoutingStandard} +} + +func TestStandardSendsEachTierToItsServerAsIs(t *testing.T) { + // Facts were never captured and there is no TMDB client: Standard does + // not need them. + store := standardStore(RoutingFacts{}) + router := &fakeRouterProvider{} + svc := modeService(store, &fakeTMDBClient{}) + 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) + } + hd, uhd := router.fulfillLog[0].conns[0], router.fulfillLog[1].conns[0] + if hd.ID != "radarr-hd" || hd.Config["root_folder"] != "/radarr-hd" || hd.Config["is_default"] != true { + t.Fatalf("HD = %+v, want the Radarr with its own root folder, not the paused rules' or Everything else's", hd) + } + if uhd.ID != "radarr-4k" || uhd.Config["root_folder"] != "/radarr-4k" || uhd.Config["is_default_4k"] != true { + t.Fatalf("4K = %+v, want the Radarr marked 4K", uhd) + } + targets, _ := store.ListTargets(context.Background(), "r1") + for _, target := range targets { + if target.RouteName != standardRouteName { + t.Fatalf("target = %+v, want it stamped Standard", target) + } + } + if _, fetched := store.factsSet["r1"]; fetched { + t.Fatal("Standard fetched routing facts it does not use") + } +} + +func TestStandardWithoutA4KServerMakesNoCopy(t *testing.T) { + store := standardStore(capturedFacts(RoutingFacts{})) + store.integrations = store.integrations[:1] + store.settings.ForceDualQuality = true + router := &fakeRouterProvider{} + svc := modeService(store, &fakeTMDBClient{}) + 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 { + t.Fatalf("targets = %+v, want HD only", targets) + } +} + +// Standard saved while a media type has two servers of a kind (it is turned +// off around such a change, so this is a race) routes with the rules. +func TestStandardWithTwoServersRoutesWithTheRules(t *testing.T) { + store := standardStore(capturedFacts(RoutingFacts{Anime: true})) + store.integrations[1].PluginConfig["is_4k"] = false + router := &fakeRouterProvider{} + svc := modeService(store, &fakeTMDBClient{}) + svc.SetRouterProvider(router) + + if _, err := svc.submitApprovedRequest(context.Background(), *store.requests["r1"], Viewer{}, nil); err != nil { + t.Fatalf("submit: %v", err) + } + if hd := router.fulfillLog[0].conns[0]; hd.ID != "radarr-4k" || hd.Config["root_folder"] != "/anime" { + t.Fatalf("HD = %+v, want the anime rule's server and folder", hd) + } +} + +// switchingStore turns Advanced on, with Everything else, right after the +// rules are read, as a switch committed between two reads would. +type switchingStore struct { + *modeStore +} + +func (s *switchingStore) ListRoutes(ctx context.Context) ([]Route, error) { + routes, err := s.modeStore.ListRoutes(ctx) + s.mode = RoutingAdvanced + s.routes = append(s.routes, Route{ID: FallbackRouteID(MediaTypeMovie), MediaType: MediaTypeMovie, Position: 1000, + Name: fallbackRouteName, Enabled: true, IsFallback: true, HD: RouteDestination{IntegrationID: "radarr-hd"}}) + return routes, err +} + +func TestFulfillContextReadsTheModeBeforeTheRules(t *testing.T) { + store := standardStore(RoutingFacts{}) + store.routes = nil + svc := modeService(store, &fakeTMDBClient{}) + svc.store = &switchingStore{modeStore: store} + + fc, err := svc.newFulfillContext(context.Background()) + if err != nil { + t.Fatal(err) + } + // Standard read before the switch routes with Standard; Advanced read + // after it would have come with no rules at all. + if !fc.standardOn && len(fc.routesFor(MediaTypeMovie)) == 0 { + t.Fatalf("mode read after the rules: Advanced with the rules from before the switch (%+v)", fc.routes) + } +} + +func TestPreviewUnderStandard(t *testing.T) { + store := standardStore(RoutingFacts{}) + store.integrations = store.integrations[:1] + svc := modeService(store, &fakeTMDBClient{detail: &tmdb.MediaDetail{ID: 129, Year: 2001}}) + + preview, err := svc.PreviewRoute(context.Background(), Viewer{IsAdmin: true}, MediaTypeMovie, 129, 0) + if err != nil { + t.Fatalf("preview: %v", err) + } + hd, uhd := preview.Tiers[0], preview.Tiers[1] + if hd.IntegrationID != "radarr-hd" || hd.RouteName != standardRouteName || len(hd.Overrides) != 0 { + t.Fatalf("HD tier = %+v, want Standard's Radarr", hd) + } + if uhd.Reason != "No server is marked 4K, so there is no 4K version." { + t.Fatalf("4K tier = %+v", uhd) + } + if len(preview.Rules) != 1 || preview.Rules[0].Route.ID != standardRouteID(MediaTypeMovie) { + t.Fatalf("rules = %+v, want only Standard's route", preview.Rules) + } +} + +// routingModeRepository is the lifecycle test schema with the route and mode +// tables, the mode set to Standard. +func routingModeRepository(t *testing.T) (*Repository, *pgxpool.Pool) { + t.Helper() + repo, pool := lifecycleTestRepository(t) + for _, stmt := range []string{ + `CREATE TABLE request_routes (LIKE public.request_routes INCLUDING ALL)`, + `CREATE TRIGGER route_revision BEFORE INSERT OR UPDATE ON request_routes FOR EACH ROW EXECUTE FUNCTION public.advance_request_editor_revision()`, + `CREATE TRIGGER routing_revision BEFORE INSERT OR UPDATE ON request_routing FOR EACH ROW EXECUTE FUNCTION public.advance_request_editor_revision()`, + `INSERT INTO request_routing (id, mode) VALUES (true, 'standard')`, + } { + if _, err := pool.Exec(t.Context(), stmt); err != nil { + t.Fatal(err) + } + } + return repo, pool +} + +func TestRoutingModeDatabase(t *testing.T) { + repo, pool := routingModeRepository(t) + ctx := t.Context() + svc := NewService(repo, &fakeTMDBClient{}, &fakePresence{}) + add := func(id, kind string, config map[string]any) { + t.Helper() + in := arrServer(id, kind, config) + in.APIKeyRef = "" + if _, err := repo.SaveIntegrationWithDefaults(ctx, in, true); err != nil { + t.Fatalf("add %s: %v", id, err) + } + } + mode := func() RoutingMode { + t.Helper() + o, err := svc.GetRoutingOverview(ctx, routeAdmin) + if err != nil { + t.Fatal(err) + } + return o.Mode + } + + add("radarr", kindRadarr, nil) + add("sonarr", kindSonarr, nil) + add("radarr-4k", kindRadarr, map[string]any{"is_4k": true}) + o, err := svc.GetRoutingOverview(ctx, routeAdmin) + if err != nil || o.Mode != RoutingStandard || o.StandardBlocker != "" || len(o.Standard) != 2 || + o.Standard[0].UHDIntegrationID != "radarr-4k" { + t.Fatalf("overview = %+v %v, want Standard with Radarr 4K taking movies' 4K copies", o, err) + } + + // A second normal Radarr turns Advanced on, and Everything else keeps + // sending where Standard did: HD to radarr, 4K to radarr-4k. + add("radarr-anime", kindRadarr, nil) + if got := mode(); got != RoutingAdvanced { + t.Fatalf("mode = %q after a second Radarr, want advanced", got) + } + var hd, uhd string + if err := pool.QueryRow(ctx, `SELECT hd_integration_id, uhd_integration_id FROM request_routes WHERE id = 'fallback-movie'`).Scan(&hd, &uhd); err != nil { + t.Fatal(err) + } + if hd != "radarr" || uhd != "radarr-4k" { + t.Fatalf("Everything else = %q / %q, want radarr / radarr-4k", hd, uhd) + } + + _, err = svc.UpdateRoutingModeConditional(ctx, routeAdmin, RoutingStandard, -1) + if msg := fieldErrors(t, err)["mode"]; !strings.Contains(msg, "radarr, radarr-anime") { + t.Fatalf("switch to Standard with two Radarrs: %q", msg) + } + + // Turning the second Radarr off allows Standard again; it stays Advanced + // until an admin switches. + second, err := repo.GetIntegration(ctx, "radarr-anime") + if err != nil { + t.Fatal(err) + } + second.Enabled = false + if _, err := repo.UpdateIntegrationConditional(ctx, *second, second.Revision); err != nil { + t.Fatal(err) + } + if got := mode(); got != RoutingAdvanced { + t.Fatalf("mode = %q, want Advanced kept", got) + } + if _, err := svc.UpdateRoutingModeConditional(ctx, routeAdmin, RoutingStandard, -1); err != nil { + t.Fatalf("switch to Standard: %v", err) + } + // Turning it back on is adding a second Radarr again. + second, _ = repo.GetIntegration(ctx, "radarr-anime") + second.Enabled = true + if _, err := repo.UpdateIntegrationConditional(ctx, *second, second.Revision); err != nil { + t.Fatal(err) + } + if got := mode(); got != RoutingAdvanced { + t.Fatalf("mode = %q after re-enabling the second Radarr, want advanced", got) + } + // A stale revision is refused. + if _, err := svc.UpdateRoutingModeConditional(ctx, routeAdmin, RoutingAdvanced, 1); !errors.Is(err, ErrStaleRevision) { + t.Fatalf("stale switch: %v", err) + } +} + +func TestRoutingModeMigrationDatabase(t *testing.T) { + matches, err := filepath.Glob("../../migrations/sql/*_request_routing_mode.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) + + for _, tc := range []struct { + name string + setup []string + want RoutingMode + }{ + {"one server of each kind", []string{`INSERT INTO request_routes (id, media_type, position, name, is_fallback, hd_integration_id) VALUES ('fallback-movie', 'movie', 1000, 'Everything else', true, 'radarr')`}, RoutingStandard}, + {"a 4K server", []string{`INSERT INTO request_integrations (id, name, enabled, capability_id, plugin_config) VALUES ('radarr-4k', 'radarr-4k', true, 'arr', '{"service_kind":"radarr","is_4k":true}')`}, RoutingStandard}, + {"a paused rule", []string{`INSERT INTO request_routes (id, media_type, position, name, enabled, conditions, hd_integration_id) VALUES ('r', 'movie', 0, 'Anime', false, '{"anime":true}', 'radarr')`}, RoutingStandard}, + {"a rule", []string{`INSERT INTO request_routes (id, media_type, position, name, conditions, hd_integration_id) VALUES ('r', 'movie', 0, 'Anime', '{"anime":true}', 'radarr')`}, RoutingAdvanced}, + {"a folder on Everything else", []string{`INSERT INTO request_routes (id, media_type, position, name, is_fallback, hd_integration_id, hd_overrides) VALUES ('fallback-movie', 'movie', 1000, 'Everything else', true, 'radarr', '{"root_folder":"/x"}')`}, RoutingAdvanced}, + {"4K copies from Everything else", []string{`INSERT INTO request_routes (id, media_type, position, name, is_fallback, hd_integration_id, uhd_integration_id) VALUES ('fallback-series', 'series', 1000, 'Everything else', true, 'sonarr', 'sonarr')`}, RoutingAdvanced}, + {"two Sonarrs", []string{`INSERT INTO request_integrations (id, name, enabled, capability_id, plugin_config) VALUES ('sonarr-2', 'sonarr-2', true, 'arr', '{"service_kind":"sonarr"}')`}, RoutingAdvanced}, + {"a Seerr taking movies", []string{`INSERT INTO request_integrations (id, name, enabled, capability_id, supported_media_types, plugin_config) VALUES ('seerr', 'seerr', true, 'seerr', '{movie}', '{}')`}, RoutingAdvanced}, + {"no 4K copies from Everything else while a server marked 4K exists", []string{ + `INSERT INTO request_routes (id, media_type, position, name, is_fallback, hd_integration_id) VALUES ('fallback-movie', 'movie', 1000, 'Everything else', true, 'radarr')`, + `INSERT INTO request_integrations (id, name, enabled, capability_id, plugin_config) VALUES ('radarr-4k', 'radarr-4k', true, 'arr', '{"service_kind":"radarr","is_4k":true}')`, + }, RoutingAdvanced}, + {"Everything else sending to Seerr", []string{ + `UPDATE request_integrations SET enabled = false WHERE id = 'radarr'`, + `INSERT INTO request_integrations (id, name, enabled, capability_id, supported_media_types, plugin_config) VALUES ('seerr', 'seerr', true, 'seerr', '{movie}', '{}')`, + `INSERT INTO request_routes (id, media_type, position, name, is_fallback, hd_integration_id) VALUES ('fallback-movie', 'movie', 1000, 'Everything else', true, 'seerr')`, + }, RoutingAdvanced}, + {"a disabled second Sonarr", []string{`INSERT INTO request_integrations (id, name, enabled, capability_id, plugin_config) VALUES ('sonarr-2', 'sonarr-2', false, 'arr', '{"service_kind":"sonarr"}')`}, RoutingStandard}, + } { + t.Run(tc.name, func(t *testing.T) { + _, pool := lifecycleTestRepository(t) + ctx := t.Context() + stmts := append([]string{ + `DROP TABLE request_routing`, + `CREATE TABLE request_routes (LIKE public.request_routes INCLUDING ALL)`, + `INSERT INTO request_integrations (id, name, enabled, capability_id, plugin_config) VALUES ('radarr', 'radarr', true, 'arr', '{"service_kind":"radarr"}'), ('sonarr', 'sonarr', true, 'arr', '{"service_kind":"sonarr"}')`, + }, tc.setup...) + for _, stmt := range append(stmts, up) { + if _, err := pool.Exec(ctx, stmt); err != nil { + t.Fatalf("%s: %v", stmt, err) + } + } + var got RoutingMode + if err := pool.QueryRow(ctx, `SELECT mode FROM request_routing`).Scan(&got); err != nil { + t.Fatal(err) + } + if got != tc.want { + t.Fatalf("mode = %q, want %q", got, tc.want) + } + }) + } +} + +func TestAdvancedSeedUsesOnlyServersThatStillFitDatabase(t *testing.T) { + ctx := t.Context() + fallbacks := func(t *testing.T, pool *pgxpool.Pool) map[string][2]string { + t.Helper() + rows, err := pool.Query(ctx, `SELECT media_type, coalesce(hd_integration_id, ''), coalesce(uhd_integration_id, '') FROM request_routes WHERE is_fallback`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + out := map[string][2]string{} + for rows.Next() { + var mediaType, hd, uhd string + if err := rows.Scan(&mediaType, &hd, &uhd); err != nil { + t.Fatal(err) + } + out[mediaType] = [2]string{hd, uhd} + } + return out + } + save := func(t *testing.T, repo *Repository, in Integration, create bool) { + t.Helper() + in.APIKeyRef = "" + if _, err := repo.SaveIntegrationWithDefaults(ctx, in, create); err != nil { + t.Fatal(err) + } + } + + t.Run("Standard with Seerr for movies, add Radarr", func(t *testing.T) { + repo, pool := routingModeRepository(t) + seerr := routerInst("seerr") + seerr.Name, seerr.CapabilityID, seerr.PluginConfig = "Seerr", "seerr", map[string]any{} + seerr.SupportedMediaTypes = []string{"movie"} + save(t, repo, seerr, true) + radarr := arrServer("radarr", kindRadarr, nil) + radarr.Name, radarr.SupportedMediaTypes = "Radarr", []string{"movie"} + + // With no rule for movies, the first server by name (Radarr) would + // take the requests Seerr was getting: the save is refused. + radarr.APIKeyRef = "" + _, err := repo.SaveIntegrationWithDefaults(ctx, radarr, true) + var verr *ValidationError + if !errors.As(err, &verr) || !strings.Contains(verr.FormError, "Switch to Advanced routing and set Everything else for movies first") { + t.Fatalf("add Radarr beside Seerr under Standard: %v, want a validation error", err) + } + if got, _ := repo.GetRoutingSettings(ctx); got.Mode != RoutingStandard { + t.Fatalf("mode = %q, want Standard kept", got.Mode) + } + if _, err := repo.GetIntegration(ctx, "radarr"); err == nil { + t.Fatal("Radarr was saved") + } + if got := fallbacks(t, pool); len(got) != 0 { + t.Fatalf("Everything else = %v, want none", got) + } + + // Following the message: Advanced, then Everything else to Seerr. + // Radarr can be added, and movies keep going to Seerr. + if _, err := repo.UpdateRoutingModeConditional(ctx, RoutingAdvanced, -1); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO request_routes (id, media_type, position, name, is_fallback, hd_integration_id) + VALUES ($1, 'movie', 1000, 'Everything else', true, 'seerr')`, FallbackRouteID(MediaTypeMovie)); err != nil { + t.Fatal(err) + } + save(t, repo, radarr, true) + if got := fallbacks(t, pool)["movie"]; got != [2]string{"seerr", ""} { + t.Fatalf("Everything else for movies = %v, want Seerr", got) + } + }) + + t.Run("Seerr keeps its media type when a save turns Advanced on for the other", func(t *testing.T) { + repo, pool := routingModeRepository(t) + seerr := routerInst("seerr") + seerr.Name, seerr.CapabilityID, seerr.PluginConfig = "Seerr", "seerr", map[string]any{} + seerr.SupportedMediaTypes = []string{"movie"} + save(t, repo, seerr, true) + for _, id := range []string{"sonarr-a", "sonarr-b"} { + sonarr := arrServer(id, kindSonarr, nil) + sonarr.SupportedMediaTypes = []string{"series"} + save(t, repo, sonarr, true) + } + if got, _ := repo.GetRoutingSettings(ctx); got.Mode != RoutingAdvanced { + t.Fatalf("mode = %q, want advanced with two Sonarrs", got.Mode) + } + got := fallbacks(t, pool) + if _, ok := got["movie"]; ok { + t.Fatalf("Everything else = %v, want movies left to Seerr", got) + } + if got["series"] != [2]string{"sonarr-a", ""} { + t.Fatalf("Everything else for series = %v, want the Sonarr Standard used", got["series"]) + } + }) + + t.Run("a server switched to the other kind is not seeded for its old one", func(t *testing.T) { + repo, pool := routingModeRepository(t) + save(t, repo, arrServer("radarr", kindRadarr, nil), true) + save(t, repo, arrServer("sonarr", kindSonarr, nil), true) + // Standard from the migration: no Everything else yet. + if _, err := pool.Exec(ctx, `DELETE FROM request_routes`); err != nil { + t.Fatal(err) + } + switched, err := repo.GetIntegration(ctx, "radarr") + if err != nil { + t.Fatal(err) + } + switched.PluginConfig[configServiceKind] = kindSonarr + save(t, repo, *switched, false) + if got, _ := repo.GetRoutingSettings(ctx); got.Mode != RoutingAdvanced { + t.Fatalf("mode = %q, want advanced with two Sonarrs", got.Mode) + } + got := fallbacks(t, pool) + if _, ok := got["movie"]; ok { + t.Fatalf("Everything else = %v, want no movie route to a server that is a Sonarr now", got) + } + if got["series"] != [2]string{"sonarr", ""} { + t.Fatalf("Everything else for series = %v, want the Sonarr Standard used", got["series"]) + } + }) + + t.Run("a server marked 4K keeps the 4K server Standard used", func(t *testing.T) { + for _, withFallback := range []bool{true, false} { + repo, pool := routingModeRepository(t) + save(t, repo, arrServer("radarr-a", kindRadarr, nil), true) + save(t, repo, arrServer("radarr-4k", kindRadarr, map[string]any{"is_4k": true}), true) + if !withFallback { + // Standard from the migration: no Everything else yet. + if _, err := pool.Exec(ctx, `DELETE FROM request_routes`); err != nil { + t.Fatal(err) + } + } + // radarr-a is marked 4K now, so Everything else no longer sends + // it HD versions, whether it did before or not: no HD server is + // left. + wantHD := "" + marked, err := repo.GetIntegration(ctx, "radarr-a") + if err != nil { + t.Fatal(err) + } + marked.PluginConfig["is_4k"] = true + save(t, repo, *marked, false) + if got, _ := repo.GetRoutingSettings(ctx); got.Mode != RoutingAdvanced { + t.Fatalf("fallback=%v: mode = %q, want advanced with two 4K Radarrs", withFallback, got.Mode) + } + if got := fallbacks(t, pool)["movie"]; got != [2]string{wantHD, "radarr-4k"} { + t.Fatalf("fallback=%v: Everything else = %v, want 4K still going to radarr-4k", withFallback, got) + } + } + }) + + t.Run("Standard with Seerr for movies, add a Radarr marked 4K", func(t *testing.T) { + repo, pool := routingModeRepository(t) + seerr := routerInst("seerr") + seerr.Name, seerr.CapabilityID, seerr.PluginConfig = "Seerr", "seerr", map[string]any{} + seerr.SupportedMediaTypes = []string{"movie"} + save(t, repo, seerr, true) + radarr4K := arrServer("radarr-4k", kindRadarr, map[string]any{"is_4k": true}) + radarr4K.Name, radarr4K.SupportedMediaTypes = "Radarr 4K", []string{"movie"} + + // Standard cannot send HD to Seerr and 4K to Radarr, so the save + // turns Advanced on, and with no rule for movies Seerr's requests + // would go to the first server by name: the save is refused. + radarr4K.APIKeyRef = "" + _, err := repo.SaveIntegrationWithDefaults(ctx, radarr4K, true) + var verr *ValidationError + if !errors.As(err, &verr) || !strings.Contains(verr.FormError, "Switch to Advanced routing and set Everything else for movies first") { + t.Fatalf("add a 4K Radarr beside Seerr under Standard: %v, want a validation error", err) + } + if got, _ := repo.GetRoutingSettings(ctx); got.Mode != RoutingStandard { + t.Fatalf("mode = %q, want Standard kept", got.Mode) + } + if _, err := repo.GetIntegration(ctx, "radarr-4k"); err == nil { + t.Fatal("Radarr 4K was saved") + } + if got := fallbacks(t, pool); len(got) != 0 { + t.Fatalf("Everything else = %v, want none", got) + } + }) + + t.Run("Standard with Radarr for movies, add a Seerr marked 4K", func(t *testing.T) { + repo, pool := routingModeRepository(t) + svc := NewService(repo, &fakeTMDBClient{}, &fakePresence{}) + radarr := arrServer("radarr", kindRadarr, nil) + radarr.Name, radarr.SupportedMediaTypes = "Radarr HD", []string{"movie"} + save(t, repo, radarr, true) + seerr := routerInstOn("seerr", 2) + seerr.Name, seerr.CapabilityID, seerr.PluginConfig = "Seerr", "seerr", map[string]any{"is_default_4k": true} + seerr.SupportedMediaTypes = []string{"movie"} + save(t, repo, seerr, true) + + // Advanced is on, and HD keeps going to Radarr. + o, err := svc.GetRoutingOverview(ctx, routeAdmin) + if err != nil || o.Mode != RoutingAdvanced || !strings.Contains(o.StandardBlocker, "different request services") { + t.Fatalf("overview = %+v %v, want Advanced with Standard unavailable", o, err) + } + if got := fallbacks(t, pool)["movie"]; got != [2]string{"radarr", ""} { + t.Fatalf("Everything else for movies = %v, want Radarr", got) + } + _, err = svc.UpdateRoutingModeConditional(ctx, routeAdmin, RoutingStandard, -1) + if msg := fieldErrors(t, err)["mode"]; !strings.Contains(msg, "Movies go to Radarr HD and their 4K versions to Seerr") { + t.Fatalf("switch to Standard with Radarr and a 4K Seerr: %q", msg) + } + }) + + t.Run("deleting a server under Standard ignores Everything else", func(t *testing.T) { + repo, pool := routingModeRepository(t) + save(t, repo, arrServer("radarr-a", kindRadarr, nil), true) + save(t, repo, arrServer("radarr-4k", kindRadarr, map[string]any{"is_4k": true}), true) + if got := fallbacks(t, pool)["movie"]; got[0] != "radarr-a" { + t.Fatalf("Everything else = %v, want the first Radarr", got) + } + if err := repo.DeleteIntegration(ctx, "radarr-a"); err != nil { + t.Fatalf("delete Standard's Radarr: %v", err) + } + // A paused rule still holds its server, and says how to change it. + if _, err := pool.Exec(ctx, `INSERT INTO request_routes (id, media_type, position, name, conditions, uhd_integration_id) + VALUES ('r', 'movie', 0, 'Anime', '{"anime":true}', 'radarr-4k')`); err != nil { + t.Fatal(err) + } + err := repo.DeleteIntegration(ctx, "radarr-4k") + var verr *ValidationError + if !errors.As(err, &verr) || !strings.Contains(verr.FormError, "Switch to Advanced") { + t.Fatalf("delete a server a paused rule uses: %v", err) + } + }) +} + +func TestStandardSendsAnimeSeriesWithTheAnimeSeriesType(t *testing.T) { + for _, anime := range []bool{true, false} { + store := standardStore(capturedFacts(RoutingFacts{Anime: anime})) + store.integrations = []Integration{ + arrServer("sonarr", kindSonarr, map[string]any{"series_type": "standard"}), + arrServer("sonarr-4k", kindSonarr, map[string]any{"is_4k": true, "series_type": "standard"}), + } + req := store.requests["r1"] + req.MediaType, req.IsAnime = MediaTypeSeries, anime + router := &fakeRouterProvider{} + svc := modeService(store, &fakeTMDBClient{}) + svc.SetRouterProvider(router) + svc.SetEntitlementResolver(fixedCeiling{q: "2160p"}) + + if _, err := svc.submitApprovedRequest(context.Background(), *req, Viewer{}, nil); err != nil { + t.Fatalf("anime=%v: submit: %v", anime, err) + } + want := "standard" + if anime { + want = seriesTypeAnime + } + if len(router.fulfillLog) != 2 { + t.Fatalf("anime=%v: fulfill calls = %+v, want HD and 4K", anime, router.fulfillLog) + } + for _, call := range router.fulfillLog { + if conn := call.conns[0]; conn.Config[configSeriesType] != want { + t.Errorf("anime=%v: %s to %s with series type %v, want %s", anime, call.qualities[0], conn.ID, conn.Config[configSeriesType], want) + } + } + targets, _ := store.ListTargets(context.Background(), "r1") + for _, target := range targets { + if target.RouteName != standardRouteName { + t.Errorf("anime=%v: target route %q, want Standard", anime, target.RouteName) + } + } + } +} + +func TestStandardRoutesLegacySeriesByItsAnimeFlagWhenTMDBIsDown(t *testing.T) { + for _, anime := range []bool{true, false} { + // A request from before routing facts were captured, with TMDB down: + // Standard still routes it, by the anime flag stored with it. + store := standardStore(RoutingFacts{}) + store.integrations = []Integration{arrServer("sonarr", kindSonarr, map[string]any{"series_type": "standard"})} + req := store.requests["r1"] + req.MediaType, req.IsAnime = MediaTypeSeries, anime + router := &fakeRouterProvider{} + svc := modeService(store, &fakeTMDBClient{detailErr: errors.New("tmdb unavailable")}) + svc.SetRouterProvider(router) + + if _, err := svc.submitApprovedRequest(context.Background(), *req, Viewer{}, nil); err != nil { + t.Fatalf("anime=%v: submit: %v", anime, err) + } + want := "standard" + if anime { + want = seriesTypeAnime + } + if len(router.fulfillLog) != 1 { + t.Fatalf("anime=%v: fulfill calls = %+v, want one", anime, router.fulfillLog) + } + if got := router.fulfillLog[0].conns[0].Config[configSeriesType]; got != want { + t.Errorf("anime=%v: series type %v, want %s", anime, got, want) + } + if store.requests["r1"].RoutingFacts.Captured() { + t.Errorf("anime=%v: facts stored as captured without TMDB", anime) + } + } +} + +// A 4K switch changed under Standard can leave a paused route sending the +// other version to the server. Turning Advanced on, by hand or because the +// change broke Standard, clears those destinations: that version falls through +// to Everything else, which gets Standard's server. +func TestAdvancedClearsDestinationsThatChangedTierDatabase(t *testing.T) { + ctx := t.Context() + destinations := func(t *testing.T, pool *pgxpool.Pool, id string) [2]string { + t.Helper() + var hd, uhd string + if err := pool.QueryRow(ctx, `SELECT coalesce(hd_integration_id, ''), coalesce(uhd_integration_id, '') FROM request_routes WHERE id = $1`, id).Scan(&hd, &uhd); err != nil { + t.Fatal(err) + } + return [2]string{hd, uhd} + } + setup := func(t *testing.T, servers ...Integration) (*Service, *Repository, *pgxpool.Pool) { + t.Helper() + repo, pool := routingModeRepository(t) + for _, in := range servers { + in.APIKeyRef = "" + if _, err := repo.SaveIntegrationWithDefaults(ctx, in, true); err != nil { + t.Fatal(err) + } + } + return NewService(repo, &fakeTMDBClient{}, &fakePresence{}), repo, pool + } + flip := func(t *testing.T, svc *Service, repo *Repository, id string, fourK bool) { + t.Helper() + in, err := repo.GetIntegration(ctx, id) + if err != nil { + t.Fatal(err) + } + in.PluginConfig["is_4k"] = fourK + if _, err := svc.UpdateIntegration(ctx, routeAdmin, *in); err != nil { + t.Fatalf("flip %s under Standard: %v", id, err) + } + } + + t.Run("switched by hand", func(t *testing.T) { + svc, repo, pool := setup(t, arrServer("radarr", kindRadarr, nil)) + if _, err := pool.Exec(ctx, `INSERT INTO request_routes (id, media_type, position, name, is_fallback, hd_integration_id, hd_overrides) + VALUES ('fallback-movie', 'movie', 1000, 'Everything else', true, 'radarr', '{"root_folder":"/hd"}') + ON CONFLICT (id) DO UPDATE SET hd_integration_id = 'radarr', hd_overrides = '{"root_folder":"/hd"}'`); err != nil { + t.Fatal(err) + } + flip(t, svc, repo, "radarr", true) + if _, err := svc.UpdateRoutingModeConditional(ctx, routeAdmin, RoutingAdvanced, -1); err != nil { + t.Fatal(err) + } + if got := destinations(t, pool, "fallback-movie"); got != [2]string{"", "radarr"} { + t.Fatalf("Everything else = %v, want no HD server and 4K to radarr", got) + } + var overrides string + if err := pool.QueryRow(ctx, `SELECT hd_overrides::text FROM request_routes WHERE id = 'fallback-movie'`).Scan(&overrides); err != nil || overrides != "{}" { + t.Fatalf("HD overrides = %q %v, want cleared with the server", overrides, err) + } + }) + + t.Run("Advanced turned on by the change", func(t *testing.T) { + svc, repo, pool := setup(t, arrServer("radarr", kindRadarr, nil), arrServer("radarr-4k", kindRadarr, map[string]any{"is_4k": true})) + if _, err := pool.Exec(ctx, `INSERT INTO request_routes (id, media_type, position, name, enabled, conditions, hd_integration_id, uhd_integration_id) + VALUES ('anime', 'movie', 0, 'Anime', true, '{"anime":true}', 'radarr', 'radarr-4k')`); err != nil { + t.Fatal(err) + } + // Two normal Radarrs break Standard, which turns Advanced on. + flip(t, svc, repo, "radarr-4k", false) + if got, _ := repo.GetRoutingSettings(ctx); got.Mode != RoutingAdvanced { + t.Fatalf("mode = %q, want Advanced", got.Mode) + } + if got := destinations(t, pool, "anime"); got != [2]string{"radarr", ""} { + t.Fatalf("Anime = %v, want HD kept and 4K cleared", got) + } + if got := destinations(t, pool, FallbackRouteID(MediaTypeMovie)); got != [2]string{"radarr", ""} { + t.Fatalf("Everything else = %v, want HD to radarr and no 4K server", got) + } + }) +} + +// The service checks a 4K switch change against the routes before the save, +// reading the mode then; the save checks again under the routing-mode lock, so +// Advanced turned on in between cannot leave a route sending a server the other +// version. A route saved before the rule does not block other edits. +func TestSaveRechecksTierUnderAdvancedDatabase(t *testing.T) { + ctx := t.Context() + repo, pool := routingModeRepository(t) + for _, in := range []Integration{arrServer("radarr", kindRadarr, nil), arrServer("radarr-4k", kindRadarr, map[string]any{"is_4k": true})} { + in.APIKeyRef = "" + if _, err := repo.SaveIntegrationWithDefaults(ctx, in, true); err != nil { + t.Fatal(err) + } + } + if _, err := repo.UpdateRoutingModeConditional(ctx, RoutingAdvanced, -1); err != nil { + t.Fatal(err) + } + // An older rule sending HD versions to the 4K server. + if _, err := pool.Exec(ctx, `INSERT INTO request_routes (id, media_type, position, name, enabled, conditions, hd_integration_id) + VALUES ('anime', 'movie', 0, 'Anime', true, '{"anime":true}', 'radarr-4k')`); err != nil { + t.Fatal(err) + } + get := func(id string) Integration { + t.Helper() + in, err := repo.GetIntegration(ctx, id) + if err != nil { + t.Fatal(err) + } + return *in + } + + flipped := get("radarr") + flipped.PluginConfig["is_4k"] = true + _, err := repo.UpdateIntegrationConditional(ctx, flipped, flipped.Revision) + if msg := fieldErrors(t, err)["plugin_config.is_4k"]; !strings.Contains(msg, "Everything else") { + t.Fatalf("conditional save marking radarr 4K: %q, want the is_4k error naming Everything else", msg) + } + if _, err := repo.SaveIntegrationWithDefaults(ctx, flipped, false); !strings.Contains(fieldErrors(t, err)["plugin_config.is_4k"], "can't be marked 4K") { + t.Fatalf("save marking radarr 4K: %v, want an is_4k field error", err) + } + + renamed := get("radarr-4k") + renamed.Name = "Radarr UHD" + if _, err := repo.UpdateIntegrationConditional(ctx, renamed, renamed.Revision); err != nil { + t.Fatalf("rename the 4K server an older rule sends HD to: %v", err) + } +} + +// A route saved after the service checked the server's routes is found by the +// save's locked check, under Standard too: the server can't become a Sonarr +// while a rule sends it movies. +func TestSaveRechecksServerKindUnderLockDatabase(t *testing.T) { + ctx := t.Context() + repo, pool := routingModeRepository(t) + in := arrServer("radarr", kindRadarr, nil) + in.APIKeyRef = "" + if _, err := repo.SaveIntegrationWithDefaults(ctx, in, true); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO request_routes (id, media_type, position, name, enabled, conditions, hd_integration_id) + VALUES ('anime', 'movie', 0, 'Anime', true, '{"anime":true}', 'radarr')`); err != nil { + t.Fatal(err) + } + stored, err := repo.GetIntegration(ctx, "radarr") + if err != nil { + t.Fatal(err) + } + sonarr := *stored + sonarr.PluginConfig["service_kind"] = kindSonarr + sonarr.SupportedMediaTypes = []string{string(MediaTypeSeries)} + _, err = repo.UpdateIntegrationConditional(ctx, sonarr, sonarr.Revision) + if fields := fieldErrors(t, err); !strings.Contains(fields["plugin_config.service_kind"], "Anime") || !strings.Contains(fields["supported_media_types"], "Anime") { + t.Fatalf("conditional save making radarr a Sonarr: %v, want the type and media type refused naming Anime", fields) + } + if _, err := repo.SaveIntegrationWithDefaults(ctx, sonarr, false); !strings.Contains(fieldErrors(t, err)["plugin_config.service_kind"], "Anime") { + t.Fatalf("save making radarr a Sonarr: %v, want a service_kind field error", err) + } +} diff --git a/internal/requests/routing_repository.go b/internal/requests/routing_repository.go new file mode 100644 index 0000000000..4321726ad2 --- /dev/null +++ b/internal/requests/routing_repository.go @@ -0,0 +1,67 @@ +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) { + return listRoutes(ctx, r.pool) +} + +func listRoutes(ctx context.Context, exec requestExecutor) ([]Route, error) { + rows, err := exec.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..1356f40936 --- /dev/null +++ b/internal/requests/routing_test.go @@ -0,0 +1,694 @@ +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} + if id == "radarr-4k" { + in.PluginConfig["is_4k"] = 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) + } +} + +// Everything else with no 4K server makes no 4K copy, even when every +// request asks for one: the 4K tier is skipped rather than failed. +func TestEverythingElseWithoutA4KServerMakesNoCopy(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") + if len(targets) != 1 || targets[0].Quality != Quality1080p { + t.Fatalf("targets = %+v, want HD only", targets) + } +} + +// 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) + } +} + +// A plugin may return a target without its connection. Such a target is +// still checked: through the plugin that routes the media type without rules, +// with all its connections, as before routing. +func TestReconcileChecksATargetWithoutAConnection(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInstOn("seerr-a", 3), routerInstOn("seerr-b", 3), routerInstOn("other", 4)} + store.candidates = []*Request{{ID: "r1", MediaType: MediaTypeMovie, TMDBID: 1, Status: StatusQueued, Outcome: OutcomeActive}} + store.targets = map[string][]Target{"r1": { + {ID: 1, RequestID: "r1", Quality: Quality1080p, Status: StatusQueued, ExternalID: "a"}, + }} + router := &fakeRouterProvider{statuses: []RouterTargetStatus{{Quality: Quality1080p, Status: StatusDownloading}}} + svc := newTestService(store) + svc.SetRouterProvider(router) + + if _, err := svc.ReconcileRequests(context.Background(), 10); err != nil { + t.Fatalf("ReconcileRequests: %v", err) + } + if len(router.statusLog) != 1 { + t.Fatalf("status calls = %+v, want the unattributed target checked", router.statusLog) + } + call := router.statusLog[0] + if call.installationID != 3 || len(call.conns) != 2 || len(call.refs) != 1 || call.refs[0].ExternalID != "a" { + t.Fatalf("status call = %+v, want the first plugin with all its connections", call) + } + if got := store.targets["r1"][0].Status; got != StatusDownloading { + t.Fatalf("target = %s, want the plugin's status applied", got) + } +} + +// A routed tier goes to one server, so a target returned without a connection +// is recorded on that server and checked through its plugin. +func TestSubmitRoutedRecordsTheServerOfATargetWithoutAConnection(t *testing.T) { + store := routingStore(capturedFacts(RoutingFacts{Anime: true})) + router := &fakeRouterProvider{targetsOverride: []RouterTarget{{Quality: Quality1080p, ExternalID: "x", Status: StatusQueued}}} + 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 hd *Target + for i := range targets { + if targets[i].Quality == Quality1080p { + hd = &targets[i] + } + } + if hd == nil || hd.IntegrationID != "radarr-anime" || hd.IntegrationKind != "radarr" { + t.Fatalf("targets = %+v, want the HD target recorded on the Anime route's server", targets) + } +} + +// Each routed call asks for one tier. A target the plugin labels with the +// other tier is dropped, so it cannot take that tier's slot from the target +// its own call returned, and a tier whose call returned only the other tier's +// target is recorded as failed. +func TestSubmitRoutedDropsTargetsForTheOtherTier(t *testing.T) { + cases := []struct { + name string + returned []RouterTarget + want4K func(*Target) bool + }{ + { + name: "each call returns both tiers", + returned: []RouterTarget{ + {Quality: Quality1080p, ExternalID: "hd-target", Status: StatusQueued}, + {Quality: Quality2160p, ExternalID: "4k-target", Status: StatusQueued}, + }, + want4K: func(t *Target) bool { return t.Status == StatusQueued && t.IntegrationID == "radarr-4k" }, + }, + { + name: "the 4K call returns only an HD target", + returned: []RouterTarget{{Quality: Quality1080p, ExternalID: "hd-target", Status: StatusQueued}}, + want4K: func(t *Target) bool { + return t.Status == StatusFailed && t.IntegrationID == "radarr-4k" && t.LastError == msgNoTargetForQuality + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + store := routingStore(capturedFacts(RoutingFacts{Anime: true})) + router := &fakeRouterProvider{targetsOverride: tc.returned} + 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) + } + targets, _ := store.ListTargets(context.Background(), "r1") + byQuality := map[Quality]*Target{} + for i := range targets { + byQuality[targets[i].Quality] = &targets[i] + } + if hd := byQuality[Quality1080p]; hd == nil || hd.IntegrationID != "radarr-anime" || hd.Status != StatusQueued { + t.Fatalf("targets = %+v, want the HD target on the Anime route's server", targets) + } + if uhd := byQuality[Quality2160p]; uhd == nil || !tc.want4K(uhd) { + t.Fatalf("4K target = %+v, want it on the 4K route's server as returned by the 4K call", uhd) + } + }) + } +} + +// A plugin that omits the connection from a routed target omits it from the +// target's status too; the target was recorded on its route's server, so the +// status still applies to it. +func TestReconcileAppliesAStatusWithoutAConnectionToTheRoutedServer(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInstOn("radarr-anime", 1)} + store.candidates = []*Request{{ID: "r1", MediaType: MediaTypeMovie, TMDBID: 1, Status: StatusQueued, Outcome: OutcomeActive}} + store.targets = map[string][]Target{"r1": { + {ID: 1, RequestID: "r1", IntegrationID: "radarr-anime", Quality: Quality1080p, Status: StatusQueued, ExternalID: "x"}, + }} + router := &fakeRouterProvider{statuses: []RouterTargetStatus{{Quality: Quality1080p, Status: StatusDownloading}}} + svc := newTestService(store) + svc.SetRouterProvider(router) + + if _, err := svc.ReconcileRequests(context.Background(), 10); err != nil { + t.Fatalf("ReconcileRequests: %v", err) + } + if got := store.targets["r1"][0].Status; got != StatusDownloading { + t.Fatalf("target = %s, want the connectionless status applied to the routed server's target", got) + } +} + +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) { + up := migrationUp(t, "request_routes") + 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) + } + // The owner repair keeps these routes: every seeded server belongs to the + // plugin that owns the first usable connection of its media type. + if _, err := pool.Exec(ctx, migrationUp(t, "request_routes_keep_legacy_owner")); err != nil { + t.Fatalf("run owner repair: %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) + } +} + +// migrationUp returns the Up section of the migration named name, runnable as +// one statement batch. +func migrationUp(t *testing.T, name string) string { + t.Helper() + matches, err := filepath.Glob("../../migrations/sql/*_" + name + ".sql") + if err != nil || len(matches) != 1 { + t.Fatalf("find migration %s: %v %v", name, 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")] + return strings.NewReplacer("-- +goose StatementBegin", "", "-- +goose StatementEnd", "").Replace(up) +} + +// TestRouteSeedingKeepsTheLegacyOwnerDatabase covers an install whose first +// usable movie connection by name is Seerr: before routing, Seerr took every +// movie request, so the seeded movie routes (which send to Radarr) are removed +// and movies stay with Seerr. The series fallback sends 4K to another plugin's +// server while the series Anime route stays on the owner's: the media type's +// seeded routes go together, unless an admin has saved one of its routes. +func TestRouteSeedingKeepsTheLegacyOwnerDatabase(t *testing.T) { + repo, pool := lifecycleTestRepository(t) + ctx := t.Context() + 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, installation int, capability, mediaTypes, config string) { + t.Helper() + if _, err := pool.Exec(ctx, `INSERT INTO request_integrations (id, name, enabled, capability_id, installation_id, api_key_ref, supported_media_types, plugin_config) + VALUES ($1, $2, true, $3, $4, 'k', $5::text[], $6::jsonb)`, id, name, capability, installation, mediaTypes, config); err != nil { + t.Fatal(err) + } + } + seed("seerr", "Jellyseerr", 2, "seerr", "{movie}", `{}`) + seed("radarr", "Radarr", 1, "arr", "{movie}", `{"service_kind":"radarr","is_default":true,"anime_enabled":true,"anime_root_folder":"/anime"}`) + seed("sonarr", "Sonarr", 1, "arr", "{series}", `{"service_kind":"sonarr","is_default":true,"anime_enabled":true}`) + seed("sonarr-4k", "Sonarr 4K", 3, "arr", "{series}", `{"service_kind":"sonarr","is_default_4k":true,"is_4k":true}`) + if _, err := pool.Exec(ctx, migrationUp(t, "request_routes")); err != nil { + t.Fatalf("run migration: %v", err) + } + var seeded int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM request_routes`).Scan(&seeded); err != nil || seeded != 4 { + t.Fatalf("seeded %d routes (%v), want a fallback and an Anime route per media type", seeded, err) + } + routeIDs := func() []string { + t.Helper() + routes, err := repo.ListRoutes(ctx) + if err != nil { + t.Fatal(err) + } + ids := []string{} + for _, route := range routes { + ids = append(ids, route.ID) + } + slices.Sort(ids) + return ids + } + + // An admin saved the series Anime route after the seeding: series routing + // is theirs, and the seeded fallback stays so every series has a server. + if _, err := pool.Exec(ctx, `UPDATE request_routes SET updated_at = updated_at + interval '1 minute' WHERE id = 'anime-series'`); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, migrationUp(t, "request_routes_keep_legacy_owner")); err != nil { + t.Fatalf("run owner repair: %v", err) + } + if ids, want := routeIDs(), []string{"anime-series", "fallback-series"}; !slices.Equal(ids, want) { + t.Fatalf("routes = %v, want %v: movies go back to Seerr, series keep their routes", ids, want) + } + + // Untouched, the series routes go together: the fallback's 4K server is + // another plugin's, and an Anime route left alone would route series with + // no fallback. + if _, err := pool.Exec(ctx, `UPDATE request_routes SET updated_at = created_at WHERE id = 'anime-series'`); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, migrationUp(t, "request_routes_keep_legacy_owner")); err != nil { + t.Fatalf("rerun owner repair: %v", err) + } + if ids := routeIDs(); len(ids) != 0 { + t.Fatalf("routes = %v, want none: series go back to the Sonarr plugin", ids) + } +} + +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'); + UPDATE request_integrations SET plugin_config = '{"service_kind":"radarr"}'; + 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) + } +} + +func TestSubmitRoutedRefusesAServerThatDoesNotTakeTheMediaType(t *testing.T) { + store := routingStore(capturedFacts(RoutingFacts{})) + store.integrations[0].SupportedMediaTypes = []string{"series"} // radarr-hd, narrowed 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, "does not take them") { + t.Fatalf("request = %+v (fulfill calls %d), want nothing sent and a retry with the mismatch named", req, router.fulfillCalls) + } +} diff --git a/internal/requests/routing_trace_test.go b/internal/requests/routing_trace_test.go new file mode 100644 index 0000000000..748c12d13f --- /dev/null +++ b/internal/requests/routing_trace_test.go @@ -0,0 +1,235 @@ +package requests + +import ( + "context" + "errors" + "slices" + "testing" + "time" +) + +func ratingPtr(r string) *string { return &r } + +func TestExcludeAndRatingConditions(t *testing.T) { + facts := capturedFacts(RoutingFacts{OriginalLanguage: "ja", GenreIDs: []int{16}, ContentRating: ratingPtr("TV-14")}) + req := Request{MediaType: MediaTypeSeries, RequestedByUserID: 7, RoutingFacts: facts} + for _, tc := range []struct { + name string + c RouteConditions + want []string + }{ + {"not English matches a Japanese title", RouteConditions{ExcludeOriginalLanguages: []string{"en"}}, nil}, + {"not Japanese fails", RouteConditions{ExcludeOriginalLanguages: []string{"ja"}}, []string{condExcludeOriginalLanguages}}, + {"none of Animation fails", RouteConditions{ExcludeGenreIDs: []int{16, 99}}, []string{condExcludeGenreIDs}}, + {"not this requester fails", RouteConditions{ExcludeRequesterUserIDs: []int{7}}, []string{condExcludeRequesterUserIDs}}, + {"TV-14 is above PG", RouteConditions{MaxContentRating: "PG"}, []string{condMaxContentRating}}, + {"TV-14 is within R", RouteConditions{MaxContentRating: "R"}, nil}, + {"TV-14 is above TV-PG", RouteConditions{MaxContentRating: "TV-PG"}, []string{condMaxContentRating}}, + {"every failure is listed", RouteConditions{GenreIDs: []int{10751}, MaxContentRating: "PG"}, []string{condGenreIDs, condMaxContentRating}}, + } { + if got := tc.c.Unmet(req); !slices.Equal(got, tc.want) { + t.Errorf("%s: unmet = %v, want %v", tc.name, got, tc.want) + } + } + // A title with no US rating, or one never looked up, is not within any ceiling. + for _, rating := range []*string{nil, ratingPtr("")} { + unrated := Request{RoutingFacts: capturedFacts(RoutingFacts{ContentRating: rating})} + if (RouteConditions{MaxContentRating: "R"}).Matches(unrated) { + t.Errorf("rating %v matched a ceiling", rating) + } + } +} + +func TestTraceExplainsEachRoute(t *testing.T) { + routes := testRoutes() + anime := Request{MediaType: MediaTypeMovie, RoutingFacts: capturedFacts(RoutingFacts{Anime: true, Year: 1988})} + decisions, traces := traceRoutes(routes, anime, []Quality{Quality1080p, Quality2160p}) + if !maps2Equal(decisions, decideRoutes(routes, anime, []Quality{Quality1080p, Quality2160p})) { + t.Fatal("trace and decideRoutes disagree") + } + got := map[string][2]RouteStep{} + for _, tr := range traces { + got[tr.Route.Name] = [2]RouteStep{tr.Steps[Quality1080p], tr.Steps[Quality2160p]} + } + want := map[string][2]RouteStep{ + "Anime": {RouteStepSends, RouteStepPasses}, + "80s": {RouteStepNoMatch, RouteStepNoMatch}, + "Everything else": {RouteStepDecided, RouteStepSends}, + } + for name, steps := range want { + if got[name] != steps { + t.Errorf("%s: steps = %v, want %v", name, got[name], steps) + } + } + if traces[len(traces)-1].Route.Name != "Everything else" { + t.Fatal("Everything else is not last") + } +} + +func maps2Equal(a, b map[Quality]RouteDecision) bool { + if len(a) != len(b) { + return false + } + for q, d := range a { + if b[q].RouteID != d.RouteID || b[q].IntegrationID != d.IntegrationID || b[q].Skip != d.Skip { + return false + } + } + return true +} + +func TestRuleValidationForNewConditions(t *testing.T) { + store := routingStore(RoutingFacts{}) + svc := newTestService(store) + base := Route{MediaType: MediaTypeMovie, Name: "Kids", Enabled: true, HD: RouteDestination{IntegrationID: "radarr-hd"}} + + bad := base + bad.Conditions = RouteConditions{MaxContentRating: "nonsense"} + var verr *ValidationError + if err := svc.validateRoute(context.Background(), &bad); !errors.As(err, &verr) || verr.FieldErrors["conditions.max_content_rating"] == "" { + t.Fatalf("bad rating: err = %v", err) + } + both := base + both.Conditions = RouteConditions{OriginalLanguages: []string{"ja"}, ExcludeOriginalLanguages: []string{"JA"}} + if err := svc.validateRoute(context.Background(), &both); !errors.As(err, &verr) || verr.FieldErrors["conditions"] == "" { + t.Fatalf("overlap: err = %v", err) + } + onlyExclude := base + onlyExclude.Conditions = RouteConditions{ExcludeOriginalLanguages: []string{"en"}} + if err := svc.validateRoute(context.Background(), &onlyExclude); err != nil { + t.Fatalf("an exclude-only rule is a rule: %v", err) + } +} + +// A request captured before its US rating was is given one when a route +// checks ratings; a TMDB failure is a submission error, retried later. +func TestRatingIsCapturedLazily(t *testing.T) { + store := routingStore(capturedFacts(RoutingFacts{})) + store.routes = append(store.routes, Route{ID: "kids", MediaType: MediaTypeMovie, Name: "Kids", Enabled: true, + Conditions: RouteConditions{MaxContentRating: "PG"}, HD: RouteDestination{IntegrationID: "radarr-anime"}}) + certs := &certTMDB{fakeTMDBClient: fakeTMDBClient{}, rating: "G"} + svc := NewService(store, certs, &fakePresence{}) + svc.SetUserRepository(requestUserRepo{}) + req := *store.requests["r1"] + if err := svc.ensureRoutingFacts(context.Background(), &req, store.routes); err != nil { + t.Fatal(err) + } + if req.RoutingFacts.ContentRating == nil || *req.RoutingFacts.ContentRating != "G" || certs.calls != 1 { + t.Fatalf("facts = %+v after %d calls, want rating G", req.RoutingFacts, certs.calls) + } + if err := svc.ensureRoutingFacts(context.Background(), &req, store.routes); err != nil || certs.calls != 1 { + t.Fatalf("second pass looked the rating up again (%d calls, err %v)", certs.calls, err) + } + + certs.err = errors.New("tmdb down") + fresh := *store.requests["r1"] + fresh.RoutingFacts.ContentRating = nil + if err := svc.ensureRoutingFacts(context.Background(), &fresh, store.routes); err == nil { + t.Fatal("a TMDB failure must fail the submission so it retries") + } +} + +type certTMDB struct { + fakeTMDBClient + rating string + err error + calls int +} + +func (c *certTMDB) GetCertification(context.Context, string, int) (string, error) { + c.calls++ + return c.rating, c.err +} + +var _ TMDBCertificationClient = (*certTMDB)(nil) + +// A ceiling compares ratings by their own ages, not parental-control tiers: +// "TV-Y7 or lower" does not take TV-PG. +func TestRatingCeilingUsesEachRatingsOwnAge(t *testing.T) { + for _, tc := range []struct { + rating, max string + want bool + }{ + {"TV-PG", "TV-Y7", false}, + {"TV-Y", "TV-Y7", true}, + {"PG-13", "PG", false}, + {"G", "PG", true}, + {"NC-17", "R", false}, + } { + if got := ratingWithin(ratingPtr(tc.rating), tc.max); got != tc.want { + t.Errorf("%s within %s = %v, want %v", tc.rating, tc.max, got, tc.want) + } + } +} + +// A title TMDB had not rated is asked about again after a day. +func TestUnratedTitleIsRecheckedLater(t *testing.T) { + store := routingStore(capturedFacts(RoutingFacts{ContentRating: ratingPtr("")})) + store.routes = append(store.routes, Route{ID: "kids", MediaType: MediaTypeMovie, Name: "Kids", Enabled: true, + Conditions: RouteConditions{MaxContentRating: "PG"}, HD: RouteDestination{IntegrationID: "radarr-anime"}}) + certs := &certTMDB{rating: "PG"} + svc := NewService(store, certs, &fakePresence{}) + req := *store.requests["r1"] + captured := time.Date(2026, 5, 24, 12, 0, 0, 0, time.UTC) + req.RoutingFacts.CapturedAt = &captured + + svc.Now = func() time.Time { return captured.Add(time.Hour) } + if err := svc.ensureRoutingFacts(context.Background(), &req, store.routes); err != nil || certs.calls != 0 { + t.Fatalf("asked again within a day (%d calls, err %v)", certs.calls, err) + } + svc.Now = func() time.Time { return captured.Add(25 * time.Hour) } + if err := svc.ensureRoutingFacts(context.Background(), &req, store.routes); err != nil || certs.calls != 1 || + req.RoutingFacts.ContentRating == nil || *req.RoutingFacts.ContentRating != "PG" { + t.Fatalf("after a day: %d calls, facts %+v, err %v; want the new rating", certs.calls, req.RoutingFacts, err) + } +} + +func TestHDCopyDropsTheServers4KFlag(t *testing.T) { + install := 1 + fc := &fulfillContext{integrations: []Integration{{ID: "radarr-4k", Name: "Radarr 4K", Enabled: true, CapabilityID: "arr", + InstallationID: &install, APIKeyRef: "k", PluginConfig: map[string]any{"service_kind": "radarr", "is_4k": true}}}} + for q, want := range map[Quality]bool{Quality1080p: false, Quality2160p: true} { + conn, _, _, err := routedConnection(fc, RouteDecision{RouteName: "Everything else", IntegrationID: "radarr-4k"}, MediaTypeMovie, q) + if err != nil { + t.Fatal(err) + } + if conn.Config[configIs4K] != want { + t.Errorf("%s: is_4k = %v, want %v", q, conn.Config[configIs4K], want) + } + } +} + +type certsTMDB struct { + certTMDB + all map[string][]string +} + +func (c *certsTMDB) GetCertifications(context.Context, string, int) (map[string][]string, error) { + return c.all, c.err +} + +// A title never rated in the US is routed on its own country's rating. +func TestLazyRatingFallsBackToTheTitlesOwnCountry(t *testing.T) { + store := routingStore(capturedFacts(RoutingFacts{OriginCountries: []string{"JP"}})) + store.routes = append(store.routes, Route{ID: "kids", MediaType: MediaTypeMovie, Name: "Kids", Enabled: true, + Conditions: RouteConditions{MaxContentRating: "PG-13"}, HD: RouteDestination{IntegrationID: "radarr-anime"}}) + certs := &certsTMDB{all: map[string][]string{"JP": {"G", "PG12"}, "FR": {"12"}}} + svc := NewService(store, certs, &fakePresence{}) + req := *store.requests["r1"] + if err := svc.ensureRoutingFacts(context.Background(), &req, store.routes); err != nil { + t.Fatal(err) + } + if got := req.RoutingFacts.ContentRating; got == nil || *got != "JP:PG12" { + t.Fatalf("rating = %v, want JP:PG12", got) + } + if !store.routes[len(store.routes)-1].Conditions.Matches(req) { + t.Fatal("a Japanese PG12 title should match an at-most-PG-13 rule") + } + // With a US rating, the strictest US one is used, as GetCertification + // picks it. + certs.all["US"] = []string{"NR", "PG", "R"} + req.RoutingFacts.ContentRating = nil + if err := svc.ensureRoutingFacts(context.Background(), &req, store.routes); err != nil || *req.RoutingFacts.ContentRating != "R" { + t.Fatalf("rating = %v, err %v; want the US R", req.RoutingFacts.ContentRating, err) + } +} diff --git a/internal/requests/seasons.go b/internal/requests/seasons.go new file mode 100644 index 0000000000..138d9f5f20 --- /dev/null +++ b/internal/requests/seasons.go @@ -0,0 +1,232 @@ +package requests + +import ( + "context" + "fmt" + "slices" + "time" + + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" +) + +// Season requests: a series request names the seasons it wants. A request +// made before season requests (no seasons) wants the whole series and keeps +// the old rule that any episode in the library fulfills it. A season is +// complete when every aired episode of it has a file in an enabled library +// (the library's own provider metadata dates the episodes, so no external +// service is involved); a season whose episodes have no air dates yet counts +// as complete once any of its episodes is present. A season dated to air +// later is not complete before its first episode airs, even if one arrived +// early. +// +// Once the download server reports a request done, the rule relaxes to the +// old one per season (see seasonDelivered): an episode the server could not +// find would otherwise hold the request, and its notification, open for good. + +// SeasonCounts is one season's episodes: aired, dated to air later, and +// present in a library. +type SeasonCounts struct { + Aired int + Upcoming int + Have int +} + +// Complete reports whether the season is fully in the library. +func (c SeasonCounts) Complete() bool { + if c.Aired > 0 { + return c.Have >= c.Aired + } + return c.Upcoming == 0 && c.Have > 0 +} + +// SeasonPresenceResolver reports per-season episode counts for series in the +// library, by series content ID. CatalogPresence implements it. +type SeasonPresenceResolver interface { + SeasonAvailability(ctx context.Context, seriesContentIDs []string) (map[string]map[int]SeasonCounts, error) +} + +// SeasonProgress is one requested season's episode counts. +type SeasonProgress struct { + Season int + SeasonCounts +} + +// RequestSeason is one season of a series as the detail page shows it. +type RequestSeason struct { + Number int `json:"-"` + Name string `json:"-"` + EpisodeCount int `json:"-"` + AirDate string `json:"-"` + PosterPath string `json:"-"` + Availability Availability `json:"-"` + // Requested reports that the title's active request covers the season. + Requested bool `json:"-"` +} + +// AvailabilityPartial marks a season some, but not all, of whose aired +// episodes are in the library. +const AvailabilityPartial Availability = "partial" + +// seasonCounts reads a series' per-season counts, or nil when the presence +// resolver cannot report seasons or the series is not in the library. +func (s *Service) seasonCounts(ctx context.Context, match PresenceMatch) (map[int]SeasonCounts, error) { + resolver, ok := s.presence.(SeasonPresenceResolver) + if !ok || !match.Available || match.ContentID == "" { + return nil, nil + } + bySeries, err := resolver.SeasonAvailability(ctx, []string{match.ContentID}) + if err != nil { + return nil, err + } + // A series in the library with no episode rows yet still has counts: none. + if counts := bySeries[match.ContentID]; counts != nil { + return counts, nil + } + return map[int]SeasonCounts{}, nil +} + +// seasonProgress reports how far each requested season is, from the series' +// counts. +func seasonProgress(seasons []int, counts map[int]SeasonCounts) []SeasonProgress { + out := make([]SeasonProgress, 0, len(seasons)) + for _, season := range seasons { + out = append(out, SeasonProgress{Season: season, SeasonCounts: counts[season]}) + } + return out +} + +// seasonDelivered reports whether a requested season counts as in the +// library. A complete season always does; once the download server reports +// the request done, so does a season with any episode present. A season with +// no episode in the library never does: it may not have aired yet, and the +// server's word alone does not make it watchable. +func seasonDelivered(c SeasonCounts, serverDone bool) bool { + return c.Complete() || (serverDone && c.Have > 0) +} + +// seasonsDelivered reports whether every season in progress is delivered. +func seasonsDelivered(progress []SeasonProgress, serverDone bool) bool { + for _, p := range progress { + if !seasonDelivered(p.SeasonCounts, serverDone) { + return false + } + } + return len(progress) > 0 +} + +// requestFulfilled reports whether a request's media is in the library: a +// movie or a whole-series request when the title is, a season request when +// every requested season is complete. progress is set for season requests +// whose series is in the library. +func (s *Service) requestFulfilled(ctx context.Context, req Request, match PresenceMatch) (bool, []SeasonProgress, error) { + if req.MediaType != MediaTypeSeries || len(req.Seasons) == 0 { + return match.Available, nil, nil + } + counts, err := s.seasonCounts(ctx, match) + if err != nil || counts == nil { + return false, nil, err + } + progress := seasonProgress(req.Seasons, counts) + return seasonsDelivered(progress, req.Status == StatusCompleted), progress, nil +} + +// airedSeasons returns the numbers of a series' regular seasons that have +// started airing, by TMDB's dates. +func airedSeasons(detail *tmdb.MediaDetail, today time.Time) []int { + var out []int + for _, season := range detail.Seasons { + if season.Number <= 0 || season.AirDate == "" || season.EpisodeCount == 0 { + continue + } + aired, err := time.Parse(time.DateOnly, season.AirDate) + if err == nil && !aired.After(today) { + out = append(out, season.Number) + } + } + return out +} + +// resolveRequestedSeasons decides which seasons a new series request asks +// for: the ones named, or every aired season, minus the seasons already +// complete in the library. It answers ErrAlreadyAvailable when nothing is left +// and ErrInvalidInput for a season TMDB does not know. With no TMDB detail it +// returns the named seasons as they are (none means the whole series). +func (s *Service) resolveRequestedSeasons(ctx context.Context, named []int, detail *tmdb.MediaDetail, match PresenceMatch) ([]int, error) { + named = normalizeSeasons(named) + if detail == nil { + if match.Available && len(named) == 0 { + return nil, ErrAlreadyAvailable + } + return named, nil + } + known := map[int]bool{} + for _, season := range detail.Seasons { + known[season.Number] = true + } + for _, season := range named { + if !known[season] { + return nil, fmt.Errorf("%w: season %d is not a season of this series", ErrInvalidInput, season) + } + } + candidates := named + if len(candidates) == 0 { + candidates = airedSeasons(detail, s.now()) + } + counts, err := s.seasonCounts(ctx, match) + if err != nil { + return nil, err + } + wanted := slices.DeleteFunc(slices.Clone(candidates), func(season int) bool { + return counts[season].Complete() + }) + if len(wanted) == 0 { + if len(candidates) == 0 && !match.Available { + // TMDB lists no aired season yet: request the whole series. + return nil, nil + } + return nil, ErrAlreadyAvailable + } + return wanted, nil +} + +func normalizeSeasons(seasons []int) []int { + out := slices.DeleteFunc(slices.Clone(seasons), func(season int) bool { return season <= 0 }) + slices.Sort(out) + return slices.Compact(out) +} + +// requestSeasons builds the detail page's season list: TMDB's regular +// seasons with their library availability and whether the active request +// covers them. +func requestSeasons(detail *tmdb.MediaDetail, counts map[int]SeasonCounts, active *Request) []RequestSeason { + var out []RequestSeason + for _, season := range detail.Seasons { + if season.Number <= 0 { + continue + } + availability := AvailabilityMissing + if c := counts[season.Number]; c.Complete() { + availability = AvailabilityAvailable + } else if c.Have > 0 { + availability = AvailabilityPartial + } + requested := active != nil && (len(active.Seasons) == 0 || slices.Contains(active.Seasons, season.Number)) + out = append(out, RequestSeason{ + Number: season.Number, Name: season.Name, EpisodeCount: season.EpisodeCount, AirDate: season.AirDate, + PosterPath: season.PosterPath, Availability: availability, Requested: requested, + }) + } + return out +} + +// seriesHasOpenSeason reports whether a series in the library has a regular +// season it lacks: aired and incomplete, or not aired yet. The series detail +// then stays requestable, for the missing seasons or the upcoming ones. +func seriesHasOpenSeason(detail *tmdb.MediaDetail, counts map[int]SeasonCounts) bool { + for _, season := range detail.Seasons { + if season.Number > 0 && !counts[season.Number].Complete() { + return true + } + } + return false +} diff --git a/internal/requests/seasons_test.go b/internal/requests/seasons_test.go new file mode 100644 index 0000000000..32bb1d6b34 --- /dev/null +++ b/internal/requests/seasons_test.go @@ -0,0 +1,465 @@ +package requests + +import ( + "context" + "errors" + "slices" + "testing" + + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" +) + +// severanceDetail is a series with two aired seasons, one announced, and +// specials. The test clock (newTestService) is 2026-05-24. +func severanceDetail() *tmdb.MediaDetail { + return &tmdb.MediaDetail{MediaType: "series", ID: 95396, Title: "Severance", Year: 2022, Seasons: []tmdb.SeasonSummary{ + {Number: 0, Name: "Specials", EpisodeCount: 3, AirDate: "2022-01-01"}, + {Number: 1, Name: "Season 1", EpisodeCount: 9, AirDate: "2022-02-18"}, + {Number: 2, Name: "Season 2", EpisodeCount: 10, AirDate: "2025-01-17"}, + {Number: 3, Name: "Season 3", EpisodeCount: 0, AirDate: ""}, + }} +} + +// severanceInLibrary puts the series in the library with season 1 complete +// and season 2 partly present. +func severanceInLibrary() *fakePresence { + return &fakePresence{ + available: map[MediaType]map[int]bool{MediaTypeSeries: {95396: true}}, + seasons: map[string]map[int]SeasonCounts{ + fakePresenceContentID(MediaTypeSeries, 95396): {1: {Aired: 9, Have: 9}, 2: {Aired: 10, Have: 4}}, + }, + } +} + +func seasonService(store *fakeStore, presence *fakePresence) *Service { + svc := NewService(store, &fakeTMDBClient{detail: severanceDetail()}, presence) + svc.Now = newTestService(store).Now + svc.SetUserRepository(requestUserRepo{}) + return svc +} + +func TestSeasonCountsComplete(t *testing.T) { + for _, tc := range []struct { + c SeasonCounts + want bool + }{ + {SeasonCounts{Aired: 9, Have: 9}, true}, + {SeasonCounts{Aired: 9, Have: 8}, false}, + {SeasonCounts{Aired: 0, Have: 2}, true}, // no air dates yet: any episode counts + {SeasonCounts{Upcoming: 8, Have: 1}, false}, // dated, not aired: an early episode is not the season + {SeasonCounts{Aired: 2, Upcoming: 6, Have: 2}, true}, // airing: every aired episode is in + {SeasonCounts{}, false}, + } { + if got := tc.c.Complete(); got != tc.want { + t.Errorf("%+v.Complete() = %v, want %v", tc.c, got, tc.want) + } + } +} + +func TestCreateSeriesRequestAsksForTheMissingSeasons(t *testing.T) { + store := newFakeStore() + svc := seasonService(store, severanceInLibrary()) + + req, err := svc.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{MediaType: MediaTypeSeries, TMDBID: 95396, Title: "Severance"}) + if err != nil { + t.Fatalf("CreateRequest: %v", err) + } + // Season 1 is complete; season 2 is aired but partial; season 3 has not + // aired; specials are never requested by default. + if !slices.Equal(req.Seasons, []int{2}) { + t.Fatalf("seasons = %v, want [2]", req.Seasons) + } +} + +func TestCreateSeriesRequestForNamedSeasons(t *testing.T) { + store := newFakeStore() + svc := seasonService(store, &fakePresence{}) + + req, err := svc.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{MediaType: MediaTypeSeries, TMDBID: 95396, Title: "Severance", Seasons: []int{3, 1, 1}}) + if err != nil { + t.Fatalf("CreateRequest: %v", err) + } + if !slices.Equal(req.Seasons, []int{1, 3}) { + t.Fatalf("seasons = %v, want [1 3]", req.Seasons) + } + + if _, err := svc.CreateRequest(context.Background(), testViewer(2), CreateRequestInput{MediaType: MediaTypeSeries, TMDBID: 95396, Title: "Severance", Seasons: []int{9}}); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("unknown season: err = %v, want ErrInvalidInput", err) + } + if _, err := svc.CreateRequest(context.Background(), testViewer(2), CreateRequestInput{MediaType: MediaTypeMovie, TMDBID: 550, Title: "Fight Club", Seasons: []int{1}}); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("movie with seasons: err = %v, want ErrInvalidInput", err) + } + // A season below 1 is refused on the seasons field, never dropped into a + // request for every missing season. + for _, seasons := range [][]int{{0}, {-1}, {2, 0}} { + _, err := svc.CreateRequest(context.Background(), testViewer(2), CreateRequestInput{MediaType: MediaTypeSeries, TMDBID: 95396, Title: "Severance", Seasons: seasons}) + verr, ok := errors.AsType[*ValidationError](err) + if !ok || verr.FieldErrors["seasons"] == "" { + t.Fatalf("seasons %v: err = %v, want a validation error on seasons", seasons, err) + } + } +} + +func TestCreateSeriesRequestRefusesACompleteSeries(t *testing.T) { + store := newFakeStore() + presence := severanceInLibrary() + presence.seasons[fakePresenceContentID(MediaTypeSeries, 95396)][2] = SeasonCounts{Aired: 10, Have: 10} + svc := seasonService(store, presence) + + if _, err := svc.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{MediaType: MediaTypeSeries, TMDBID: 95396, Title: "Severance"}); !errors.Is(err, ErrAlreadyAvailable) { + t.Fatalf("complete series: err = %v, want ErrAlreadyAvailable", err) + } + if _, err := svc.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{MediaType: MediaTypeSeries, TMDBID: 95396, Title: "Severance", Seasons: []int{1}}); !errors.Is(err, ErrAlreadyAvailable) { + t.Fatalf("complete named season: err = %v, want ErrAlreadyAvailable", err) + } +} + +// A season request completes when its seasons are complete, not when the +// first episode of the series is scanned in. +func TestReconcileCompletesASeasonRequestOnlyWhenItsSeasonsAre(t *testing.T) { + store := newFakeStore() + presence := severanceInLibrary() + store.waiting = []*Request{{ID: "r1", MediaType: MediaTypeSeries, TMDBID: 95396, Status: StatusPending, Outcome: OutcomeActive, Seasons: []int{2}}} + svc := seasonService(store, presence) + + if _, err := svc.ReconcileRequests(context.Background(), 10); err != nil { + t.Fatal(err) + } + if got := store.requests["r1"]; got != nil && got.Status == StatusCompleted { + t.Fatalf("request = %+v, want still pending: season 2 is partial", got) + } + + presence.seasons[fakePresenceContentID(MediaTypeSeries, 95396)][2] = SeasonCounts{Aired: 10, Have: 10} + if _, err := svc.ReconcileRequests(context.Background(), 10); err != nil { + t.Fatal(err) + } + if got := store.requests["r1"]; got == nil || got.Status != StatusCompleted { + t.Fatalf("request = %+v, want completed once season 2 is", got) + } +} + +func TestSeasonRequestState(t *testing.T) { + partial := Request{MediaType: MediaTypeSeries, Status: StatusQueued, Outcome: OutcomeActive, Seasons: []int{1, 2}, + SeasonProgress: []SeasonProgress{{Season: 1, SeasonCounts: SeasonCounts{Aired: 9, Have: 9}}, {Season: 2, SeasonCounts: SeasonCounts{Aired: 10}}}} + if got := partial.State(); got != StatePartiallyAvailable { + t.Fatalf("partial = %s, want partially_available", got) + } + partial.SeasonProgress[1].Have = 10 + if got := partial.State(); got != StateAvailable { + t.Fatalf("complete = %s, want available", got) + } + waiting := Request{MediaType: MediaTypeSeries, Status: StatusCompleted, Outcome: OutcomeActive, Seasons: []int{2}, LibraryContentID: "series-1", + SeasonProgress: []SeasonProgress{{Season: 2, SeasonCounts: SeasonCounts{Aired: 10}}}} + if got := waiting.State(); got != StateProcessing { + t.Fatalf("downloaded, not scanned = %s, want processing", got) + } +} + +func TestSeriesDetailListsSeasonsAndStaysRequestable(t *testing.T) { + store := newFakeStore() + svc := seasonService(store, severanceInLibrary()) + + detail, err := svc.GetDetail(context.Background(), testViewer(1), MediaTypeSeries, 95396) + if err != nil { + t.Fatalf("GetDetail: %v", err) + } + if !detail.Request.Requestable { + t.Fatalf("request state = %+v, want requestable: season 2 is incomplete", detail.Request) + } + byNumber := map[int]RequestSeason{} + for _, season := range detail.Seasons { + byNumber[season.Number] = season + } + if len(detail.Seasons) != 3 || byNumber[1].Availability != AvailabilityAvailable || + byNumber[2].Availability != AvailabilityPartial || byNumber[3].Availability != AvailabilityMissing { + t.Fatalf("seasons = %+v", detail.Seasons) + } +} + +func TestSeasonDeliveredRelaxesOnceTheServerIsDone(t *testing.T) { + for _, tc := range []struct { + name string + c SeasonCounts + serverDone bool + want bool + }{ + {"complete", SeasonCounts{Aired: 9, Have: 9}, false, true}, + {"partial while downloading", SeasonCounts{Aired: 9, Have: 4}, false, false}, + {"partial once done: an episode the server could not find", SeasonCounts{Aired: 9, Have: 4}, true, true}, + {"missing once done", SeasonCounts{Aired: 9}, true, false}, + {"nothing in the library once done", SeasonCounts{}, true, false}, + } { + if got := seasonDelivered(tc.c, tc.serverDone); got != tc.want { + t.Errorf("%s: seasonDelivered(%+v, %v) = %v, want %v", tc.name, tc.c, tc.serverDone, got, tc.want) + } + } +} + +// A season request the download server finished is notified once each of its +// seasons has an episode in the library, even with one the server could not +// find; one still waiting on the library is stamped so it rotates behind +// newer completions. +func TestNotifyFulfilledSeasonRequests(t *testing.T) { + store := newFakeStore() + presence := severanceInLibrary() + store.requests["partial"] = &Request{ID: "partial", MediaType: MediaTypeSeries, TMDBID: 95396, + Status: StatusCompleted, Outcome: OutcomeActive, Seasons: []int{2}} + store.requests["waiting"] = &Request{ID: "waiting", MediaType: MediaTypeSeries, TMDBID: 95396, + Status: StatusCompleted, Outcome: OutcomeActive, Seasons: []int{2, 3}} + store.unnotified = []string{"partial", "waiting"} + notifier := &fakeNotifier{} + svc := seasonService(store, presence) + svc.SetFulfillmentNotifier(notifier) + + svc.notifyFulfilledPending(context.Background()) + + if !slices.Equal(notifier.requestIDs, []string{"partial"}) { + t.Fatalf("notified = %v, want only the partial request", notifier.requestIDs) + } + // Season 3 has not reached the library at all: the server's word alone + // does not make it available. + if !slices.Equal(store.reconciled, []string{"waiting"}) { + t.Fatalf("stamped = %v, want the waiting request stamped", store.reconciled) + } +} + +func TestRequestListsReadSeasonCountsOnce(t *testing.T) { + store := newFakeStore() + presence := severanceInLibrary() + presence.available[MediaTypeSeries][1399] = true + presence.seasons[fakePresenceContentID(MediaTypeSeries, 1399)] = map[int]SeasonCounts{1: {Aired: 10, Have: 3}} + svc := seasonService(store, presence) + reqs := []*Request{ + {ID: "a", MediaType: MediaTypeSeries, TMDBID: 95396, Seasons: []int{2}}, + {ID: "b", MediaType: MediaTypeSeries, TMDBID: 1399, Seasons: []int{1}}, + {ID: "c", MediaType: MediaTypeSeries, TMDBID: 95396, Seasons: []int{1, 2}}, + } + + if err := svc.attachLibraryContent(context.Background(), reqs...); err != nil { + t.Fatal(err) + } + if presence.seasonLookups != 1 { + t.Fatalf("season lookups = %d, want one for the whole page", presence.seasonLookups) + } + if got := reqs[1].SeasonProgress; len(got) != 1 || got[0].Have != 3 { + t.Fatalf("progress = %+v", got) + } +} + +// A router plugin without supports_seasons takes a whole series, so with a +// download server for series on one, a series already in the library is not +// requestable for its missing seasons. +func TestMissingSeasonsNeedALibraryOnlySetup(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInst("sonarr")} + svc := seasonService(store, severanceInLibrary()) + svc.SetRouterProvider(&fakeRouterProvider{}) + + if _, err := svc.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{MediaType: MediaTypeSeries, TMDBID: 95396, Title: "Severance"}); !errors.Is(err, ErrAlreadyAvailable) { + t.Fatalf("err = %v, want ErrAlreadyAvailable", err) + } + detail, err := svc.GetDetail(context.Background(), testViewer(1), MediaTypeSeries, 95396) + if err != nil { + t.Fatal(err) + } + if detail.Request.Requestable || detail.Request.Reason != "already_available" { + t.Fatalf("request state = %+v, want already_available", detail.Request) + } + if len(detail.Seasons) != 3 { + t.Fatalf("seasons = %+v, want them listed regardless", detail.Seasons) + } + status, err := svc.GetFeatureStatus(context.Background(), testViewer(1)) + if err != nil { + t.Fatal(err) + } + if status.MissingSeasonsRequestable { + t.Fatal("status advertises missing seasons as requestable with a download server for series") + } + + libraryOnly, err := seasonService(newFakeStore(), severanceInLibrary()).GetFeatureStatus(context.Background(), testViewer(1)) + if err != nil { + t.Fatal(err) + } + if !libraryOnly.MissingSeasonsRequestable { + t.Fatal("status hides missing seasons without a download server") + } +} + +// v1 keeps the whole-series rule: refused once the series is in the library, +// and a request for the whole series otherwise. +func TestWholeSeriesRequests(t *testing.T) { + store := newFakeStore() + svc := seasonService(store, severanceInLibrary()) + if _, err := svc.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{MediaType: MediaTypeSeries, TMDBID: 95396, Title: "Severance", WholeSeries: true}); !errors.Is(err, ErrAlreadyAvailable) { + t.Fatalf("in the library: err = %v, want ErrAlreadyAvailable", err) + } + + svc = seasonService(newFakeStore(), &fakePresence{}) + req, err := svc.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{MediaType: MediaTypeSeries, TMDBID: 95396, Title: "Severance", WholeSeries: true}) + if err != nil { + t.Fatal(err) + } + if len(req.Seasons) != 0 { + t.Fatalf("seasons = %v, want none: the whole series", req.Seasons) + } +} + +// A series whose aired seasons are all in the library stays requestable for +// a season that has not aired; one with nothing left to air is available. +func TestLibrarySeriesCanRequestAnUpcomingSeason(t *testing.T) { + store := newFakeStore() + presence := severanceInLibrary() + presence.seasons[fakePresenceContentID(MediaTypeSeries, 95396)][2] = SeasonCounts{Aired: 10, Have: 10} + svc := seasonService(store, presence) + + detail, err := svc.GetDetail(context.Background(), testViewer(1), MediaTypeSeries, 95396) + if err != nil { + t.Fatal(err) + } + if !detail.Request.Requestable { + t.Fatalf("request state = %+v, want requestable for the upcoming season 3", detail.Request) + } + req, err := svc.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{MediaType: MediaTypeSeries, TMDBID: 95396, Title: "Severance", Seasons: []int{3}}) + if err != nil || !slices.Equal(req.Seasons, []int{3}) { + t.Fatalf("request for season 3 = %+v, %v", req, err) + } + + ended := severanceDetail() + ended.Seasons = ended.Seasons[:3] // no season 3 announced + svc = NewService(newFakeStore(), &fakeTMDBClient{detail: ended}, presence) + svc.Now = newTestService(store).Now + svc.SetUserRepository(requestUserRepo{}) + if detail, err = svc.GetDetail(context.Background(), testViewer(1), MediaTypeSeries, 95396); err != nil { + t.Fatal(err) + } + if detail.Request.Requestable || detail.Request.Reason != "already_available" { + t.Fatalf("request state = %+v, want already_available: every season is in the library", detail.Request) + } +} + +// An episode of an upcoming season that reached the library ahead of its air +// date does not make the season complete: it can still be requested. +func TestAnEarlyEpisodeDoesNotCompleteAnUpcomingSeason(t *testing.T) { + presence := severanceInLibrary() + presence.seasons[fakePresenceContentID(MediaTypeSeries, 95396)][3] = SeasonCounts{Upcoming: 8, Have: 1} + svc := seasonService(newFakeStore(), presence) + + req, err := svc.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{MediaType: MediaTypeSeries, TMDBID: 95396, Title: "Severance", Seasons: []int{3}}) + if err != nil || !slices.Equal(req.Seasons, []int{3}) { + t.Fatalf("request for season 3 = %+v, %v", req, err) + } +} + +// The detail page reads an active season request's state from the series' +// season counts, as the request lists do. +func TestSeriesDetailShowsSeasonRequestProgress(t *testing.T) { + store := newFakeStore() + store.active[MediaTypeSeries] = map[int]*Request{95396: {ID: "r1", MediaType: MediaTypeSeries, TMDBID: 95396, + Status: StatusQueued, Outcome: OutcomeActive, RequestedByUserID: 1, Seasons: []int{2}}} + svc := seasonService(store, severanceInLibrary()) + + detail, err := svc.GetDetail(context.Background(), testViewer(1), MediaTypeSeries, 95396) + if err != nil { + t.Fatal(err) + } + if detail.Request.State != StatePartiallyAvailable { + t.Fatalf("state = %q, want partially_available: 4 of season 2's 10 episodes are in", detail.Request.State) + } +} + +// Reconciliation reads the season counts of a whole batch in one lookup. +func TestPresentRequestsReadSeasonCountsOnce(t *testing.T) { + presence := severanceInLibrary() + presence.available[MediaTypeSeries][1399] = true + presence.seasons[fakePresenceContentID(MediaTypeSeries, 1399)] = map[int]SeasonCounts{1: {Aired: 10, Have: 10}} + svc := seasonService(newFakeStore(), presence) + reqs := []*Request{ + {ID: "partial", MediaType: MediaTypeSeries, TMDBID: 95396, Status: StatusApproved, Seasons: []int{2}}, + {ID: "complete", MediaType: MediaTypeSeries, TMDBID: 1399, Status: StatusApproved, Seasons: []int{1}}, + {ID: "done-partial", MediaType: MediaTypeSeries, TMDBID: 95396, Status: StatusCompleted, Seasons: []int{1, 2}}, + {ID: "whole", MediaType: MediaTypeSeries, TMDBID: 95396, Status: StatusApproved}, + {ID: "absent", MediaType: MediaTypeSeries, TMDBID: 7, Status: StatusApproved, Seasons: []int{1}}, + } + + present, err := svc.presentRequests(context.Background(), reqs) + if err != nil { + t.Fatal(err) + } + if presence.seasonLookups != 1 { + t.Fatalf("season lookups = %d, want one for the whole batch", presence.seasonLookups) + } + want := map[string]bool{"partial": false, "complete": true, "done-partial": true, "whole": true, "absent": false} + for id, w := range want { + if present[id] != w { + t.Errorf("present[%s] = %v, want %v", id, present[id], w) + } + } +} + +// A season request made for a series in the library while no download server +// took series is not sent to one set up later on a plugin without +// supports_seasons: it would add the whole series. A season request for a +// series outside the library still goes. +func TestSeasonRequestForALibrarySeriesSkipsALaterRouter(t *testing.T) { + store := newFakeStore() + store.integrations = []Integration{routerInst("sonarr")} + for _, req := range []*Request{ + {ID: "in-library", MediaType: MediaTypeSeries, TMDBID: 95396, Status: StatusApproved, Outcome: OutcomeActive, Seasons: []int{2}}, + {ID: "absent", MediaType: MediaTypeSeries, TMDBID: 1399, Status: StatusApproved, Outcome: OutcomeActive, Seasons: []int{1}}, + } { + store.candidates = append(store.candidates, req) + store.requests[req.ID] = req + } + router := &fakeRouterProvider{} + svc := seasonService(store, severanceInLibrary()) + svc.SetRouterProvider(router) + + if _, err := svc.ReconcileRequests(context.Background(), 10); err != nil { + t.Fatal(err) + } + if router.fulfillCalls != 1 { + t.Fatalf("router calls = %d, want one, for the series outside the library", router.fulfillCalls) + } + if got := store.requests["in-library"]; got.Status != StatusApproved || got.SubmitAttempts != 0 { + t.Fatalf("in-library request = %+v, want approved and unsent, waiting for the library", got) + } + if got := store.requests["absent"]; got.Status == StatusApproved { + t.Fatalf("absent request = %+v, want submitted", got) + } +} + +// A mutation returns the request with its season progress, so its state +// matches what a detail or list read reports. +func TestSeasonRequestMutationsReturnSeasonProgress(t *testing.T) { + contentID := fakePresenceContentID(MediaTypeSeries, 95396) + input := CreateRequestInput{MediaType: MediaTypeSeries, TMDBID: 95396, Title: "Severance", Seasons: []int{2}} + + store := newFakeStore() + store.settings.GlobalAutoApprovalEnabled = true + svc := seasonService(store, severanceInLibrary()) + created, err := svc.CreateRequest(context.Background(), testViewer(1), input) + if err != nil { + t.Fatal(err) + } + if created.Status != StatusApproved || created.LibraryContentID != contentID || created.State() != StatePartiallyAvailable { + t.Fatalf("auto-approved create = status %s, content %q, state %s; want approved, %q, partially_available", + created.Status, created.LibraryContentID, created.State(), contentID) + } + + store = newFakeStore() + svc = seasonService(store, severanceInLibrary()) + pending, err := svc.CreateRequest(context.Background(), testViewer(1), input) + if err != nil { + t.Fatal(err) + } + if pending.LibraryContentID != contentID { + t.Fatalf("pending create content = %q, want %q", pending.LibraryContentID, contentID) + } + approved, err := svc.Approve(context.Background(), Viewer{UserID: 99, ProfileID: "admin", IsAdmin: true}, pending.ID) + if err != nil { + t.Fatal(err) + } + if approved.LibraryContentID != contentID || approved.State() != StatePartiallyAvailable { + t.Fatalf("approve = content %q, state %s; want %q, partially_available", approved.LibraryContentID, approved.State(), contentID) + } +} diff --git a/internal/requests/service.go b/internal/requests/service.go index e8d099033d..36a68caad5 100644 --- a/internal/requests/service.go +++ b/internal/requests/service.go @@ -5,9 +5,13 @@ import ( "errors" "fmt" "log/slog" + "maps" "regexp" + "slices" "strings" + "sync" "time" + "unicode/utf8" "github.com/Silo-Server/silo-server/internal/access" "github.com/Silo-Server/silo-server/internal/idgen" @@ -74,6 +78,7 @@ type TVDBIDResolver interface { type Service struct { store Store tmdb TMDBClient + animeIndex AnimeIndex presence PresenceResolver router RequestRouterProvider entitlements EntitlementResolver @@ -142,15 +147,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 @@ -368,16 +375,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 @@ -387,9 +397,50 @@ func (s *Service) allowedQualities(ctx context.Context, req Request, settings Se type fulfillContext struct { integrations []Integration settings Settings + routes []Route + // standard holds where Standard routing sends each media type, when + // Standard is on and the servers allow it. + standard []StandardDestination + // standardOn is set when Standard routing is in effect. + standardOn bool + + // mu guards features, which caches the features each router capability + // declares (see routerFeatures). + mu sync.Mutex + features map[routerCapabilityKey]RouterFeatures +} + +// routesFor returns the media type's routing rules; none means the router +// plugin routes the media type itself. Under Standard the rules are paused +// and the media type's one server decides. +func (fc *fulfillContext) routesFor(mediaType MediaType) []Route { + if fc.standardOn { + return standardRoutes(fc.integrations, fc.standard, mediaType) + } + 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) { + // The mode is read before the servers and the rules, in separate queries. + // A switch to Advanced writes Everything else and the mode in one commit, + // so reading the mode first sees either Standard, which ignores the rules + // (and routes with the rules read after it when the servers read after it + // no longer allow Standard), or Advanced with the rules it was committed + // with. Reading the rules first could pair the old rules with Advanced. + mode := RoutingAdvanced + if store, ok := s.store.(RoutingModeStore); ok { + routing, err := store.GetRoutingSettings(ctx) + if err != nil { + return nil, err + } + mode = routing.Mode + } integrations, err := s.store.ListIntegrations(ctx) if err != nil { return nil, err @@ -398,7 +449,18 @@ 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 + } + fc := &fulfillContext{integrations: integrations, settings: settings, routes: routes} + if mode == RoutingStandard { + // Standard with two servers of a kind (saved around a server + // change) routes with the rules until an admin sorts it out. + layout, blocker := standardLayout(integrations) + fc.standard, fc.standardOn = layout, blocker == "" + } + return fc, nil } // resolveRouterConnections turns enabled request_router integrations that serve @@ -406,8 +468,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 @@ -444,11 +506,81 @@ 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" +} + +// moreSeasonsRequestable reports whether a series already in the library can +// be requested for the seasons it is missing. The library fulfills such a +// request when no download server takes series. A download server fetches +// only the missing seasons when its router plugin declares supports_seasons; +// any other plugin would add the whole series again, refused by a download +// server that has it and every season downloaded by one that does not. So it +// is offered when every download server that takes series is bound to a +// plugin that takes seasons. A request made before a server that cannot was +// set up waits for the library (see submitApprovedRequest). +func (s *Service) moreSeasonsRequestable(ctx context.Context) (bool, error) { + if s.router == nil { + return true, nil + } + fc, err := s.newFulfillContext(ctx) + if err != nil { + return false, err + } + return s.allTakeSeasons(ctx, fc, seriesRouterConnections(fc)) +} + +// 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. +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 -// 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) @@ -531,6 +663,7 @@ func (s *Service) DiscoverAll(ctx context.Context, viewer Viewer) ([]DiscoverySe if s == nil || s.store == nil || s.tmdb == nil { return nil, fmt.Errorf("request service is not configured") } + ctx = withPolicyCache(ctx) if err := s.ensureRequestsEnabled(ctx); err != nil { return nil, err } @@ -565,6 +698,7 @@ func (s *Service) GetDetail(ctx context.Context, viewer Viewer, mediaType MediaT if s == nil || s.store == nil || s.tmdb == nil { return nil, fmt.Errorf("request service is not configured") } + ctx = withPolicyCache(ctx) if err := s.ensureRequestsEnabled(ctx); err != nil { return nil, err } @@ -622,6 +756,41 @@ 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 + } + // A series counts as available only when every aired season is complete; + // until then its missing seasons can be requested. + available := primaryMatch.Available + var seasons []RequestSeason + if mediaType == MediaTypeSeries { + counts, err := s.seasonCounts(ctx, primaryMatch) + if err != nil { + return nil, err + } + seasons = requestSeasons(raw, counts, primaryRequests[raw.ID]) + if active := primaryRequests[raw.ID]; active != nil && len(active.Seasons) > 0 && counts != nil { + // Attach the season progress the request lists attach, so the + // state can read partially available or available. + withProgress := *active + withProgress.LibraryContentID = primaryMatch.ContentID + withProgress.SeasonProgress = seasonProgress(active.Seasons, counts) + primaryRequests[raw.ID] = &withProgress + } + if available && seriesHasOpenSeason(raw, counts) { + more, err := s.moreSeasonsRequestable(ctx) + if err != nil { + return nil, err + } + available = !more + } + } + primaryState := requestStateFor(viewer, policy, available, primaryRequests[raw.ID]) + primaryState.Following = primaryRequests[raw.ID] != nil && primaryFollowing[raw.ID] + if primaryState.Download, err = s.activeRequestDownload(ctx, primaryRequests[raw.ID]); err != nil { + return nil, err + } detail := &MediaDetail{ MediaType: mediaType, @@ -652,7 +821,8 @@ 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, + Seasons: seasons, } if raw.TVDBID > 0 { tvdb := raw.TVDBID @@ -703,14 +873,23 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create return nil, err } tvdbLookupFailed := 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 { return nil, err } - if matches[normalized.TMDBID].Available { - return nil, ErrAlreadyAvailable + match := matches[normalized.TMDBID] + if match.Available { + if normalized.MediaType == MediaTypeMovie || normalized.WholeSeries { + return nil, ErrAlreadyAvailable + } + more, err := s.moreSeasonsRequestable(ctx) + if err != nil { + return nil, err + } + if !more { + return nil, ErrAlreadyAvailable + } } active, err := s.store.ListActiveByTMDB(ctx, normalized.MediaType, []int{normalized.TMDBID}) @@ -721,17 +900,35 @@ 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 + // 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 := s.routingFacts(ctx, detail) + if normalized.MediaType == MediaTypeSeries && !normalized.WholeSeries { + // A series partly in the library can still be requested for the + // seasons it is missing. + seasons, err := s.resolveRequestedSeasons(ctx, normalized.Seasons, detail, match) + if err != nil { + return nil, err + } + normalized.Seasons = seasons } 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 } @@ -739,21 +936,24 @@ 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, 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 + // integration error) replaces their failed row. + ReplaceFailed: true, } if !policy.Unlimited { record.Quota = &QuotaCheck{ @@ -783,9 +983,9 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create s.notifyApproval(ctx, *req, ApprovalOriginPolicy) req.externalIDsResolved = true req.tvdbLookupFailed = tvdbLookupFailed - return s.submitApprovedRequest(ctx, *req, viewer, nil) + return s.withLibraryContent(ctx, s.submitAfterCommit(ctx, *req, viewer)), nil } - return req, nil + return s.withLibraryContent(ctx, req), nil } func (s *Service) ListMine(ctx context.Context, viewer Viewer, filter ListFilter) ([]*Request, error) { @@ -812,6 +1012,19 @@ func (s *Service) ListAdmin(ctx context.Context, viewer Viewer, filter ListFilte if !viewer.IsAdmin { return nil, ErrForbidden } + if filter.View != "" && !filter.View.Valid() { + return nil, fmt.Errorf("%w: unknown view %q", ErrInvalidInput, filter.View) + } + if filter.MediaType != "" { + mediaType, err := normalizeMediaType(filter.MediaType) + if err != nil { + return nil, err + } + filter.MediaType = mediaType + } + if utf8.RuneCountInString(filter.Query) > maxAdminQueryLength { + return nil, fmt.Errorf("%w: search is longer than %d characters", ErrInvalidInput, maxAdminQueryLength) + } reqs, err := s.store.ListAdmin(ctx, normalizeListFilter(filter)) if err != nil { return nil, err @@ -825,18 +1038,53 @@ func (s *Service) ListAdmin(ctx context.Context, viewer Viewer, filter ListFilte return reqs, nil } +// maxAdminQueryLength bounds the admin queue's title search. +const maxAdminQueryLength = 200 + +// maxRequestEvents bounds a request's history as the admin queue reads it. +const maxRequestEvents = 200 + +// CountAdminViews counts the requests in each admin queue view. +func (s *Service) CountAdminViews(ctx context.Context, viewer Viewer) (AdminViewCounts, error) { + if !viewer.IsAdmin { + return AdminViewCounts{}, ErrForbidden + } + return s.store.CountAdminViews(ctx) +} + +// ListRequestEvents returns a request's history, newest first, for admins. +func (s *Service) ListRequestEvents(ctx context.Context, viewer Viewer, id string) ([]RequestEvent, error) { + if !viewer.IsAdmin { + return nil, ErrForbidden + } + id = strings.TrimSpace(id) + if _, err := s.store.GetRequest(ctx, id); err != nil { + return nil, err + } + return s.store.ListEvents(ctx, id, maxRequestEvents) +} + // attachTargets loads and attaches the per-instance fulfillment targets for each // request so callers (admin queue, detail view) can surface multi-target status. +// One query serves the whole page. func (s *Service) attachTargets(ctx context.Context, reqs ...*Request) error { + ids := make([]string, 0, len(reqs)) for _, r := range reqs { - if r == nil { - continue + if r != nil { + ids = append(ids, r.ID) } - targets, err := s.store.ListTargets(ctx, r.ID) - if err != nil { - return err + } + if len(ids) == 0 { + return nil + } + byRequest, err := s.store.ListTargetsForRequests(ctx, ids) + if err != nil { + return err + } + for _, r := range reqs { + if r != nil { + r.Targets = byRequest[r.ID] } - r.Targets = targets } return nil } @@ -867,6 +1115,9 @@ func (s *Service) attachLibraryContent(ctx context.Context, reqs ...*Request) er candidatesByType[req.MediaType] = append(candidatesByType[req.MediaType], requestPresenceCandidate(*req)) } + // Season requests whose series is in the library, by series content ID, + // so one query reads every series' season counts. + seasonRequests := map[string][]*Request{} for mediaType, candidates := range candidatesByType { matches, err := s.lookupPresence(ctx, mediaType, candidates) if err != nil { @@ -878,12 +1129,40 @@ func (s *Service) attachLibraryContent(ctx context.Context, reqs ...*Request) er } for _, req := range requestsByKey[requestKey{mediaType: mediaType, tmdbID: tmdbID}] { req.LibraryContentID = match.ContentID + if req.MediaType == MediaTypeSeries && len(req.Seasons) > 0 { + seasonRequests[match.ContentID] = append(seasonRequests[match.ContentID], req) + } } } } + resolver, ok := s.presence.(SeasonPresenceResolver) + if !ok || len(seasonRequests) == 0 { + return nil + } + bySeries, err := resolver.SeasonAvailability(ctx, slices.Collect(maps.Keys(seasonRequests))) + if err != nil { + return err + } + for series, reqs := range seasonRequests { + for _, req := range reqs { + req.SeasonProgress = seasonProgress(req.Seasons, bySeries[series]) + } + } return nil } +// withLibraryContent attaches the library match and season progress to a +// request a mutation returns, so its state reads as a detail or list read +// would. The mutation has committed, so a lookup failure is logged and the +// request returned without them rather than reported as a failed mutation. +func (s *Service) withLibraryContent(ctx context.Context, req *Request) *Request { + if err := s.attachLibraryContent(ctx, req); err != nil { + slog.WarnContext(ctx, "requests: attach library content to mutation response failed", "component", "requests", + "request_id", req.ID, "err", err) + } + return req +} + func (s *Service) GetRequest(ctx context.Context, viewer Viewer, id string) (*Request, error) { if err := s.ensureRequestsEnabled(ctx); err != nil { return nil, err @@ -908,55 +1187,50 @@ 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.withLibraryContent(ctx, s.submitAfterCommit(ctx, *approved, viewer)), nil } +// 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 } - 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), guardWithdrawable, OutcomeDeclined, viewer, reason) if err != nil { return nil, err } - declined.DeclineReason = strings.TrimSpace(reason) s.notifyLifecycle(ctx, *declined, LifecycleNotifier.RequestDeclined) return declined, nil } -// 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. +// 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) { + return s.cancel(ctx, viewer, id, reason, false) +} + +// AdminCancel is Cancel for the admin queue, which may also close a failed +// request instead of retrying it. The v1 cancel keeps refusing failed +// requests. +func (s *Service) AdminCancel(ctx context.Context, viewer Viewer, id, reason string) (*Request, error) { + if !viewer.IsAdmin { + return nil, ErrForbidden + } + return s.cancel(ctx, viewer, id, reason, true) +} + +func (s *Service) cancel(ctx context.Context, viewer Viewer, id, reason string, closeFailed bool) (*Request, error) { if viewer.UserID == 0 { return nil, ErrForbidden } @@ -972,39 +1246,28 @@ 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 + guard := guardWithdrawable + if closeFailed && req.Outcome == OutcomeFailed { + // Closing a failed request moves it out of the admin's failed view; + // nothing more is sent for it. + guard = guardFailed + } + withdrawn, err := s.store.SetOutcome(ctx, req.ID, guard, OutcomeCancelled, viewer, reason) + if err != nil { + return nil, err } - return s.store.SetOutcome(ctx, req.ID, OutcomeCancelled, viewer, reason) + return withdrawn, nil } 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)) - 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) + reopened, err := s.store.ReopenFailed(ctx, strings.TrimSpace(id), viewer) if err != nil { return nil, err } - return s.submitApprovedRequest(ctx, *active, viewer, nil) + return s.withLibraryContent(ctx, s.submitAfterCommit(ctx, *reopened, viewer)), nil } func (s *Service) ReconcileRequests(ctx context.Context, limit int) (ReconcileResult, error) { @@ -1022,12 +1285,21 @@ 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 { + 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, @@ -1049,10 +1321,15 @@ func (s *Service) ReconcileRequests(ctx context.Context, limit int) (ReconcileRe result.Completed++ case reconcileFailed: result.Failed++ + case reconcileDeferred: + result.Deferred++ case reconcileSkipped: 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. @@ -1079,10 +1356,16 @@ func (s *Service) GetFeatureStatus(ctx context.Context, _ Viewer) (FeatureStatus // server behaves like an older version, and clients should know that. _, hasRatings := s.entitlements.(ContentRatingResolver) _, hasCerts := s.tmdb.(TMDBCertificationClient) - return FeatureStatus{ + status := FeatureStatus{ RequestsEnabled: settings.RequestsEnabled, RatingRestrictionsEnforced: hasRatings && hasCerts, - }, nil + } + if settings.RequestsEnabled { + if status.MissingSeasonsRequestable, err = s.moreSeasonsRequestable(ctx); err != nil { + return FeatureStatus{}, err + } + } + return status, nil } func (s *Service) ensureRequestsEnabled(ctx context.Context) error { @@ -1207,6 +1490,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 } @@ -1235,7 +1521,7 @@ func (s *Service) validateViaPlugin(ctx context.Context, in Integration) error { // Don't pair a stored API key with a caller-changed base URL: require the // key to be re-entered when the server URL changes (defense against // exfiltrating a stored, API-unreadable key to an attacker-supplied URL). - if strings.TrimSpace(in.BaseURL) != "" && strings.TrimSpace(in.BaseURL) != strings.TrimSpace(stored.BaseURL) { + if strings.TrimSpace(in.BaseURL) != "" && !sameIntegrationBaseURL(in.BaseURL, stored.BaseURL) { return &ValidationError{FieldErrors: map[string]string{"api_key_ref": "re-enter the API key when changing the base URL"}} } in.APIKeyRef = stored.APIKeyRef @@ -1332,11 +1618,10 @@ func (s *Service) LoadIntegrationOptions(ctx context.Context, viewer Viewer, int } if stored != nil { submittedBaseURL := strings.TrimSpace(integration.BaseURL) - storedBaseURL := strings.TrimSpace(stored.BaseURL) - if strings.TrimSpace(integration.BaseURL) == "" { + if submittedBaseURL == "" { integration.BaseURL = stored.BaseURL } - if strings.TrimSpace(integration.APIKeyRef) == "" && (submittedBaseURL == "" || submittedBaseURL == storedBaseURL) { + if strings.TrimSpace(integration.APIKeyRef) == "" && (submittedBaseURL == "" || sameIntegrationBaseURL(submittedBaseURL, stored.BaseURL)) { integration.APIKeyRef = stored.APIKeyRef } if strings.TrimSpace(integration.CapabilityID) == "" { @@ -1351,50 +1636,60 @@ func (s *Service) LoadIntegrationOptions(ctx context.Context, viewer Viewer, int } } + // Without a key the plugin could only fail; say so on the key field. The + // address is passed as given: the v2 adapter normalizes it first, and the + // frozen v1 route keeps sending what the client submitted. apiKey := strings.TrimSpace(integration.APIKeyRef) + if apiKey == "" { + return nil, probeValidation(&ValidationError{FieldErrors: map[string]string{fieldAPIKey: integrationKeyMissing}}) + } if s.router == nil || integration.InstallationID == nil { return nil, fmt.Errorf("no fulfillment backend configured") } conn := ResolvedRouterConnection{ID: integration.ID, BaseURL: integration.BaseURL, APIKey: apiKey, Config: integration.PluginConfig} options, err := s.router.ListConfigOptions(ctx, *integration.InstallationID, integration.CapabilityID, conn) if err != nil { - return nil, classifyIntegrationTransportError(err) + return nil, classifyIntegrationError(err, integration.CapabilityID) } return options, nil } -// classifyIntegrationTransportError marks a failure to reach the configured -// integration as a dependency failure. Errors the router already classifies -// (plugin validation results and the request-domain sentinels) pass through -// untouched so the API keeps rendering them as client problems. -func classifyIntegrationTransportError(err error) error { +func (s *Service) EffectivePolicy(ctx context.Context, userID int) (EffectivePolicy, error) { + cache, _ := ctx.Value(policyCacheKey{}).(*policyCache) + if cache == nil { + return s.resolvePolicy(ctx, userID) + } + // Held while resolving, so sections enriched concurrently wait for the + // first rather than each reading the account, group and quota again. + cache.mu.Lock() + defer cache.mu.Unlock() + if policy, ok := cache.byUser[userID]; ok { + return policy, nil + } + policy, err := s.resolvePolicy(ctx, userID) if err == nil { - return nil + cache.byUser[userID] = policy } - var validation *ValidationError - if errors.As(err, &validation) { - return err - } - for _, sentinel := range []error{ - ErrInvalidInput, - ErrInvalidMediaType, - ErrRequestsDisabled, - ErrUserBlocked, - ErrQuotaExceeded, - ErrAlreadyAvailable, - ErrAlreadyRequested, - ErrNotFound, - ErrForbidden, - ErrInvalidState, - } { - if errors.Is(err, sentinel) { - return err - } + return policy, err +} + +// policyCache shares a viewer's resolved policy across the page enrichments +// of one call (DiscoverAll's sections, a detail and its recommendations). +type policyCache struct { + mu sync.Mutex + byUser map[int]EffectivePolicy +} + +type policyCacheKey struct{} + +func withPolicyCache(ctx context.Context) context.Context { + if _, ok := ctx.Value(policyCacheKey{}).(*policyCache); ok { + return ctx } - return fmt.Errorf("%w: %w", ErrIntegrationUnreachable, err) + return context.WithValue(ctx, policyCacheKey{}, &policyCache{byUser: map[int]EffectivePolicy{}}) } -func (s *Service) EffectivePolicy(ctx context.Context, userID int) (EffectivePolicy, error) { +func (s *Service) resolvePolicy(ctx context.Context, userID int) (EffectivePolicy, error) { settings, err := s.store.GetSettings(ctx) if err != nil { return EffectivePolicy{}, err @@ -1403,39 +1698,61 @@ func (s *Service) EffectivePolicy(ctx context.Context, userID int) (EffectivePol if err != nil { return EffectivePolicy{}, err } + viewerAccess, err := s.viewerRequestAccess(ctx, userID) + if err != nil { + return EffectivePolicy{}, err + } policy := EffectivePolicy{ RequestsEnabled: settings.RequestsEnabled, MaxRequests: settings.GlobalMaxRequests, WindowDays: settings.GlobalWindowDays, AutoApprove: settings.GlobalAutoApprovalEnabled, + Blocked: !viewerAccess.allowed, } if policy.WindowDays <= 0 { policy.WindowDays = 7 } + // The account's own limits win, then its access group's, then the + // server's; a layer set to inherit defers to the next. + limitMode, maxRequests, windowDays := LimitModeInherit, (*int)(nil), (*int)(nil) + approval := ApprovalModeInherit + layers := []*UserLimit{} + if g := viewerAccess.group; g != nil { + layers = append(layers, &UserLimit{LimitMode: g.LimitMode, MaxRequests: g.MaxRequests, WindowDays: g.WindowDays, ApprovalMode: g.ApprovalMode}) + } if limit != nil { - switch limit.LimitMode { - case LimitModeBlocked: - policy.Blocked = true - case LimitModeUnlimited: - policy.Unlimited = true - case LimitModeCustom: - if limit.MaxRequests != nil { - policy.MaxRequests = *limit.MaxRequests - } - if limit.WindowDays != nil && *limit.WindowDays > 0 { - policy.WindowDays = *limit.WindowDays - } + layers = append(layers, limit) + } + for _, layer := range layers { + if layer.LimitMode != "" && layer.LimitMode != LimitModeInherit { + limitMode, maxRequests, windowDays = layer.LimitMode, layer.MaxRequests, layer.WindowDays + } + if layer.ApprovalMode != "" && layer.ApprovalMode != ApprovalModeInherit { + approval = layer.ApprovalMode + } + } + switch limitMode { + case LimitModeBlocked: + policy.Blocked = true + case LimitModeUnlimited: + policy.Unlimited = true + case LimitModeCustom: + if maxRequests != nil { + policy.MaxRequests = *maxRequests } - switch limit.ApprovalMode { - case ApprovalModeBlocked: - policy.Blocked = true - case ApprovalModeManual: - policy.AutoApprove = false - case ApprovalModeAuto: - policy.AutoApprove = true + if windowDays != nil && *windowDays > 0 { + policy.WindowDays = *windowDays } } + switch approval { + case ApprovalModeBlocked: + policy.Blocked = true + case ApprovalModeManual: + policy.AutoApprove = false + case ApprovalModeAuto: + policy.AutoApprove = true + } policy.WindowStart = s.now().AddDate(0, 0, -policy.WindowDays) if !policy.Unlimited { @@ -1494,6 +1811,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 { @@ -1505,6 +1823,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{ @@ -1520,6 +1841,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, @@ -1533,7 +1856,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 @@ -1764,34 +2087,6 @@ func explainSubmissionFailure(req Request, msg string) string { return missingTVDBIDMessage } -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) -} - -// 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 { @@ -1806,13 +2101,50 @@ 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 } - 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 { @@ -1820,64 +2152,124 @@ func (s *Service) submitApprovedRequest(ctx context.Context, req Request, actor } fc = built } - conns, installationID, capabilityID, err := s.resolveRouterConnections(ctx, fc, req.MediaType) - if err != nil { - return nil, err + 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 } - 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 + missingSeasons := false + if req.MediaType == MediaTypeSeries && len(req.Seasons) > 0 { + // A season request for a series already in the library goes only to + // a server whose plugin takes seasons; any other would add the whole + // series (see moreSeasonsRequestable). Otherwise it waits for the + // library, even when the server was set up after it was made. + matches, err := s.lookupPresence(ctx, req.MediaType, []PresenceCandidate{requestPresenceCandidate(req)}) + if err != nil { + return nil, err + } + if match := matches[req.TMDBID]; match.Available { + // Seasons that reached the library since the request was made + // need no download: the reconcile pass completes the request. + fulfilled, _, err := s.requestFulfilled(ctx, req, match) + if err != nil { + return nil, err + } + if fulfilled { + return &req, nil + } + deliverable, err := s.missingSeasonsDeliverable(ctx, fc, req) + if err != nil { + return nil, err } + if !deliverable { + return &req, nil + } + missingSeasons = true } - return s.markSubmissionFailed(ctx, req.ID, actor, errors.New(msg)) } - existing, err := s.store.ListTargets(ctx, req.ID) + claimed, ok, err := s.store.ClaimSubmission(ctx, req.ID, submitLease) if err != nil { return nil, err } - healthy := map[Quality]bool{} - for _, t := range existing { - if t.Status != StatusFailed { - healthy[t.Quality] = true - } - } - allowed := s.allowedQualities(ctx, req, fc.settings) - if !fc.settings.ForceDualQuality { - allowed = filterUnconfiguredOptionalQualities(allowed, conns) - } - var want []Quality - for _, q := range allowed { - if !healthy[q] { - want = append(want, q) - } - } - if len(want) == 0 { + 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 } - // Resolve before dropping the failed targets below, so a failed save + // The claim reloads the row; keep this call's external-ID lookup result. + claimed.externalIDsResolved = req.externalIDsResolved + claimed.tvdbLookupFailed = req.tvdbLookupFailed + submitted, submitErr := s.submitClaimed(ctx, *claimed, actor, fc, missingSeasons) + if submitErr == nil { + return submitted, nil + } + if claimed.SubmitAttempts >= maxSubmitAttempts { + return s.markSubmissionFailed(ctx, *claimed, actor, submitErr) + } + deferred, err := s.store.DeferSubmission(ctx, claimed.ID, claimLease(*claimed), 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 and the per-target state now owns it; or + // this attempt outlived its lease and another claim holds the + // request now. + 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. missingSeasons marks a request for seasons of a series already in the +// library, which only a router that takes seasons may receive. +func (s *Service) submitClaimed(ctx context.Context, req Request, actor Viewer, fc *fulfillContext, missingSeasons bool) (*Request, error) { + // Resolve before planSubmission drops failed targets, so a failed save // leaves their error records in place. if err := s.ensureSeriesTVDBID(ctx, &req); err != nil { return nil, 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 routes := fc.routesFor(req.MediaType); len(routes) > 0 { + return s.submitRouted(ctx, req, actor, fc, routes, missingSeasons) + } + conns, installationID, capabilityID, err := s.resolveRouterConnections(ctx, fc, req.MediaType) + if err != nil { + return nil, err + } + if len(conns) == 0 { + // 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)) + } + if missingSeasons { + // missingSeasonsDeliverable checked every series connection; this + // guards the plugin actually chosen. + ok, err := s.routerSupportsSeasons(ctx, fc, installationID, capabilityID) + if err != nil { + return nil, err + } + if !ok { + return nil, errMissingSeasonsUnsupported("The request backend") } } + 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, want, conns) + targets, msg, err := s.router.Fulfill(ctx, installationID, capabilityID, req, plan.want, conns) if err != nil { return nil, err } @@ -1885,24 +2277,295 @@ func (s *Service) submitApprovedRequest(ctx context.Context, req Request, actor if msg == "" { msg = "fulfillment backend created no targets" } - return s.markSubmissionFailed(ctx, req.ID, actor, errors.New(explainSubmissionFailure(req, msg))) + return s.markSubmissionFailed(ctx, req, actor, errors.New(explainSubmissionFailure(req, 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, missingSeasons bool) (*Request, error) { + if err := s.ensureRoutingFacts(ctx, &req, routes); err != nil { + return nil, err } - connKind := connectionKindByID(conns) - 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. + allowed, resolved := s.allowedQualities(ctx, req, fc.settings) + 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] = unroutedMessage(routes, req.MediaType, q) + 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 + } + if missingSeasons { + // Routing facts read after the claim can choose a server + // missingSeasonsDeliverable did not check. + ok, err := s.routerSupportsSeasons(ctx, fc, installationID, capabilityID) + if err == nil && !ok { + err = errMissingSeasonsUnsupported(fmt.Sprintf("%q (route %q)", integrationName(fc, conn.ID), decision.RouteName)) + } + if err != nil { + if len(targets) == 0 { + 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 + } + // The call asked for this tier only. A target labeled with the other + // tier would sit on this tier's server and could win the other tier's + // slot in recordTargets over that tier's real target, so drop it. + var tier []RouterTarget + for _, t := range got { + if t.Quality != q { + slog.WarnContext(ctx, "requests: plugin returned a target for another quality; skipping", "component", "requests", + "request_id", req.ID, "requested_quality", string(q), "quality", string(t.Quality)) + continue + } + // The plugin was handed only this server, so a target it returns + // without a connection is on it; recording that keeps the target + // checked through the plugin that owns the server. + if t.ConnectionID == "" { + t.ConnectionID = conn.ID + } + tier = append(tier, t) + } + if len(tier) == 0 && msg != "" { + failures[q] = msg + } + targets = append(targets, tier...) + } + return s.recordTargets(ctx, req, actor, plan, targets, connKind, decisions, failures) +} + +// unroutedMessage says why a tier went nowhere. Under Standard it can only be +// HD, when the media type's one server is marked 4K. +func unroutedMessage(routes []Route, mediaType MediaType, q Quality) string { + if isStandardRouting(routes, mediaType) { + return fmt.Sprintf("no server takes %s %s: the only one is marked 4K", qualityLabel(q), mediaTypePlural(mediaType)) + } + return "no routing rule sends " + qualityLabel(q) + " for this title" +} + +// qualityLabel names a tier in messages. +func qualityLabel(q Quality) string { + if q == Quality2160p { + return "4K" + } + return "HD" +} + +// unratedRecheck is how long a title with no US rating goes before routing +// asks TMDB again. +const unratedRecheck = 24 * time.Hour + +// TMDBCertificationsClient reads every country's certifications, for the +// routing rating's fallback to a title's own country. +type TMDBCertificationsClient interface { + GetCertifications(ctx context.Context, mediaType string, id int) (map[string][]string, error) +} + +// routingRatingOf reads a captured request's rating again: its US rating, or +// its own country's when it has none. A client without the per-country read +// answers with the US rating alone. +func (s *Service) routingRatingOf(ctx context.Context, req Request) (string, error) { + if certs, ok := s.tmdb.(TMDBCertificationsClient); ok { + all, err := certs.GetCertifications(ctx, string(req.MediaType), req.TMDBID) + if err != nil { + return "", err + } + us := tmdb.USCertificationFrom(string(req.MediaType), all) + return routingRating(us, all, req.RoutingFacts.OriginCountries), nil + } + one, ok := s.tmdb.(TMDBCertificationClient) + if !ok { + return "", errors.New("no certification client") + } + return one.GetCertification(ctx, string(req.MediaType), req.TMDBID) +} + +// 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. A request captured before its +// rating was is given one, only when an enabled route checks ratings. +func (s *Service) ensureRoutingFacts(ctx context.Context, req *Request, routes []Route) error { + if req.RoutingFacts.Captured() { + // A title TMDB had not rated yet (unreleased) is asked again a day + // later, so a rating route can still match it once it is rated. + stored := req.RoutingFacts.ContentRating + known := stored != nil && (*stored != "" || s.now().Sub(*req.RoutingFacts.CapturedAt) < unratedRecheck) + if known || !routesCheckRating(routes, req.MediaType) { + return nil + } + rating, err := s.routingRatingOf(ctx, *req) + if err != nil { + return fmt.Errorf("could not read the title's rating from TMDB to route it: %w", err) + } + facts := req.RoutingFacts + now := s.now() + facts.ContentRating, facts.CapturedAt = &rating, &now + updated, err := s.store.SetRoutingFacts(ctx, req.ID, facts) + if err != nil { + return err + } + req.RoutingFacts = updated.RoutingFacts + return nil + } + detail := s.requestDetail(ctx, req.MediaType, req.TMDBID) + if detail == nil { + if isStandardRouting(routes, req.MediaType) { + // Standard's only condition is anime, which the request's stored + // anime flag already answers, so it is sent without the facts. + req.RoutingFacts.Anime = req.IsAnime + return nil + } + if !routesUseConditions(routes, req.MediaType) { + // Only Everything else decides: the facts would + // not change where the request goes, so it is sent without them. + return nil + } + return errors.New("could not read the title's details from TMDB to route it") + } + updated, err := s.store.SetRoutingFacts(ctx, req.ID, s.routingFacts(ctx, detail)) + 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 + } + 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 && !slices.Contains(allowed, t.Quality) { + if err := s.store.DeleteTarget(ctx, t.ID); err != nil && !errors.Is(err, ErrNotFound) { + return submissionPlan{}, nil, err + } + } + } + } + for _, q := range allowed { + if !plan.healthy[q] { + plan.want = append(plan.want, q) + } + } + 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) { + updated, err = s.store.GetRequest(ctx, req.ID) + } + if err != nil { + return submissionPlan{}, nil, err + } + return submissionPlan{}, updated, nil + } + 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 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) { validQuality := map[Quality]bool{Quality1080p: true, Quality2160p: true} validStatus := map[Status]bool{StatusQueued: true, StatusDownloading: true, StatusCompleted: true, StatusFailed: true} returned := map[Quality]bool{} + var record []Target for _, rt := range targets { if !validQuality[rt.Quality] { 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 != "" { @@ -1912,13 +2575,7 @@ func (s *Service) submitApprovedRequest(ctx context.Context, req Request, actor } } returned[rt.Quality] = true - 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, - }) - if err != nil { - return nil, err - } + decision := decisions[rt.Quality] status := rt.Status if status == "" || !validStatus[status] { status = StatusQueued // coerce unknown/empty status to the DB-valid default @@ -1927,37 +2584,38 @@ func (s *Service) submitApprovedRequest(ctx context.Context, req Request, actor if status == StatusFailed { message = explainSubmissionFailure(req, message) } - updated, err := s.store.UpdateTargetStatus(ctx, created.ID, status, rt.ExternalID, rt.ExternalStatus, message, actor) - if err != nil { - return nil, err - } - if updated != nil { - latest = updated - } + record = append(record, Target{ + IntegrationID: rt.ConnectionID, IntegrationKind: connKind[rt.ConnectionID], + Quality: rt.Quality, IsAnime: req.IsAnime, Status: status, + ExternalID: rt.ExternalID, ExternalStatus: rt.ExternalStatus, LastError: message, + RouteID: decision.RouteID, RouteName: decision.RouteName, + }) } - // 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 } - created, err := s.store.CreateTarget(ctx, Target{ - RequestID: req.ID, Quality: q, IsAnime: req.IsAnime, Status: StatusFailed, LastError: noTargetMsg, - }) - if err != nil { - return nil, err - } - updated, err := s.store.UpdateTargetStatus(ctx, created.ID, StatusFailed, "", "", noTargetMsg, actor) - if err != nil { - return nil, err - } - if updated != nil { - latest = updated + msg := explainSubmissionFailure(req, failures[q]) + if msg == "" { + msg = msgNoTargetForQuality } + decision := decisions[q] + record = append(record, Target{ + IntegrationID: decision.IntegrationID, Quality: q, IsAnime: req.IsAnime, + Status: StatusFailed, LastError: msg, RouteID: decision.RouteID, RouteName: decision.RouteName, + }) } - return latest, nil + recorded, err := s.store.RecordSubmission(ctx, req.ID, claimLease(req), record, actor) + if errors.Is(err, ErrInvalidState) { + // The router call outlived this claim's lease, and meanwhile the + // request was withdrawn, completed from the library, or claimed + // again. That state stands; the downstream service may still hold + // what this call added. + slog.WarnContext(ctx, "requests: submission outlived its claim; result dropped", "component", "requests", + "request_id", req.ID, "targets", len(record)) + return s.store.GetRequest(ctx, req.ID) + } + return recorded, err } // connectionKindByID maps each connection id to its plugin-declared service kind @@ -2024,9 +2682,25 @@ func boolConfig(config map[string]any, key string) bool { return ok && b } -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()) +// claimLease returns the lease a claimed request holds, which fences the +// writes that end the claim. +func claimLease(claimed Request) time.Time { + if claimed.SubmitLeaseUntil == nil { + return time.Time{} + } + return *claimed.SubmitLeaseUntil +} + +// markSubmissionFailed ends a claimed submission as failed. claimed is the +// request as ClaimSubmission returned it; its lease fences the write. +func (s *Service) markSubmissionFailed(ctx context.Context, claimed Request, actor Viewer, submitErr error) (*Request, error) { + failed, err := s.store.FailSubmission(ctx, claimed.ID, claimLease(claimed), actor, submitErr.Error()) if err != nil { + if errors.Is(err, ErrInvalidState) { + // The attempt created targets before failing, or it outlived its + // lease and another claim holds the request now. + return s.store.GetRequest(ctx, claimed.ID) + } return nil, fmt.Errorf("submit request failed: %w; mark failed: %v", submitErr, err) } return failed, nil @@ -2041,18 +2715,103 @@ 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) { - 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) + } + } + return nil +} + +// presentRequests reports which requests are fulfilled by the library, with +// one presence lookup per media type and one season lookup for every season +// request: the title is in, or, for a season request, every requested season +// is complete. +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)) + // Season requests whose series is in the library, by series content ID. + seasonRequests := map[string][]*Request{} + 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 { + match := matches[req.TMDBID] + if req.MediaType != MediaTypeSeries || len(req.Seasons) == 0 { + out[req.ID] = match.Available + continue + } + out[req.ID] = false + if match.Available && match.ContentID != "" { + seasonRequests[match.ContentID] = append(seasonRequests[match.ContentID], req) + } + } + } + resolver, ok := s.presence.(SeasonPresenceResolver) + if !ok || len(seasonRequests) == 0 { + return out, nil } - if completed { + bySeries, err := resolver.SeasonAvailability(ctx, slices.Collect(maps.Keys(seasonRequests))) + if err != nil { + return nil, err + } + for series, group := range seasonRequests { + for _, req := range group { + progress := seasonProgress(req.Seasons, bySeries[series]) + out[req.ID] = seasonsDelivered(progress, req.Status == StatusCompleted) + } + } + 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 { @@ -2062,7 +2821,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 @@ -2086,6 +2850,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 } @@ -2098,64 +2866,205 @@ 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) + statuses, checkErr := s.checkTargetStatuses(ctx, req, targets, fc) + change, err := s.applyTargetStatuses(ctx, targets, statuses) if err != nil { return reconcileUnchanged, err } + return change, checkErr +} +// applyTargetStatuses writes the statuses a router reported for the given +// targets, matched by quality and connection, and the download progress of +// each target still queued or downloading afterwards. A target that reports +// no progress has any it had cleared; one that had none is not written, so an +// idle target costs no write per pass. A target with progress that got no +// status back (its server was skipped or failed, or the call did) keeps it +// until it goes stale; see settleUnansweredDownload. Both reconcile and the +// download refresh pass apply statuses here. +func (s *Service) applyTargetStatuses(ctx context.Context, targets []Target, statuses []RouterTargetStatus) (reconcileChange, error) { change := reconcileUnchanged + answered := make([]bool, len(targets)) for _, st := range statuses { // Match the returned status to the live target by (quality, connection). var target *Target for i := range targets { if targets[i].Quality == st.Quality && targets[i].IntegrationID == st.ConnectionID { target = &targets[i] + answered[i] = true break } } if target == nil || target.Status == StatusCompleted || target.Status == StatusFailed { continue } - newStatus := st.Status - if newStatus == "" || newStatus == target.Status { + status := target.Status + if newStatus := st.Status; newStatus != "" && newStatus != target.Status { + if _, err := s.store.UpdateTargetStatus(ctx, target.ID, newStatus, "", st.ExternalStatus, st.Message, Viewer{}); err != nil { + return reconcileUnchanged, err + } + status = newStatus + switch newStatus { + case StatusCompleted: + change = reconcileCompleted + case StatusDownloading: + if change == reconcileUnchanged { + change = reconcileDownloading + } + case StatusFailed: + if change == reconcileUnchanged { + change = reconcileFailed + } + } + } else if st.ExternalStatus != "" && st.ExternalStatus != target.ExternalStatus { + // The server's own state moved without changing the target's + // status (say, a download went from importing to stalled). Keep + // the raw status in step with the progress shown beside it. + if err := s.store.UpdateTargetExternalStatus(ctx, target.ID, st.ExternalStatus); err != nil { + return reconcileUnchanged, err + } + } + if (status == StatusQueued || status == StatusDownloading) && (st.Progress != nil || target.Download != nil) { + if err := s.store.UpdateTargetDownload(ctx, target.ID, st.Progress); err != nil { + return reconcileUnchanged, err + } + } + } + for i, target := range targets { + if answered[i] { continue } - if _, err := s.store.UpdateTargetStatus(ctx, target.ID, newStatus, "", st.ExternalStatus, st.Message, Viewer{}); err != nil { + if err := s.settleUnansweredDownload(ctx, target); err != nil { return reconcileUnchanged, err } - switch newStatus { - case StatusCompleted: - change = reconcileCompleted - case StatusDownloading: - if change == reconcileUnchanged { - change = reconcileDownloading + } + return change, nil +} + +// 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 the plugin returned without a connection is +// checked through the plugin that routes the media type without rules, with +// all its connections, as before routing. 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 + groupFor := func(key owner) *group { + g := groups[key] + if g == nil { + g = &group{seen: map[string]bool{}} + groups[key] = g + order = append(order, key) + } + return g + } + addConn := func(g *group, conn ResolvedRouterConnection) { + if !g.seen[conn.ID] { + g.seen[conn.ID] = true + g.conns = append(g.conns, conn) + } + } + for _, t := range targets { + if t.Status != StatusQueued && t.Status != StatusDownloading { + continue + } + ref := RouterTargetRef{Quality: t.Quality, ConnectionID: t.IntegrationID, ExternalID: t.ExternalID} + if t.IntegrationID == "" { + conns, installationID, capabilityID, err := s.resolveRouterConnections(ctx, fc, req.MediaType) + if err != nil || len(conns) == 0 { + continue + } + g := groupFor(owner{installationID, capabilityID}) + g.refs = append(g.refs, ref) + for _, conn := range conns { + addConn(g, conn) + } + continue + } + in := integrationByID(fc, t.IntegrationID) + if !statusCheckable(in) { + continue + } + g := groupFor(owner{*in.InstallationID, in.CapabilityID}) + g.refs = append(g.refs, ref) + addConn(g, 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 + } + keepProgress, err := s.keepsReportedProgress(ctx, fc, key.installationID, key.capabilityID, statuses) + if err != nil { + errs = append(errs, err) + } + // A plugin that omits connection_id from its statuses omits it from + // its targets too, and a routed target was recorded on its server + // anyway. When every target in the group is on one server, a status + // without a connection is that server's. + server := soleRefConnection(g.refs) + for _, st := range statuses { + if st.ConnectionID == "" { + st.ConnectionID = server } - case StatusFailed: - if change == reconcileUnchanged { - change = reconcileFailed + if !keepProgress { + st.Progress = nil } + out = append(out, st) } } - return change, nil + return out, errors.Join(errs...) +} + +// soleRefConnection returns the one connection all refs are on, or "" when +// they are on several or any is on none. +func soleRefConnection(refs []RouterTargetRef) string { + server := "" + for _, ref := range refs { + if ref.ConnectionID == "" || (server != "" && ref.ConnectionID != server) { + return "" + } + server = ref.ConnectionID + } + return server +} + +// statusCheckable reports whether a target's server can be asked for the +// target's status: it still exists, is enabled, is bound to a router +// capability, and has an API key. +func statusCheckable(in *Integration) bool { + return in != nil && in.Enabled && in.InstallationID != nil && in.CapabilityID != "" && strings.TrimSpace(in.APIKeyRef) != "" +} + +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) @@ -2226,14 +3135,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() @@ -2243,15 +3144,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: @@ -2267,14 +3160,32 @@ func requestStateFor(viewer Viewer, policy EffectivePolicy, available bool, req } } -func validateCreatePolicy(policy EffectivePolicy) error { +// 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.State = req.State() + state.RequestedByViewer = req.requestedBy(viewer) + 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, so +// concurrent creates cannot both take the last slot. +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 } @@ -2310,6 +3221,16 @@ func normalizeCreateInput(input CreateRequestInput) (CreateRequestInput, error) if input.Title == "" { return CreateRequestInput{}, fmt.Errorf("%w: title is required", ErrInvalidInput) } + if len(input.Seasons) > 0 && mediaType != MediaTypeSeries { + return CreateRequestInput{}, fmt.Errorf("%w: only a series request names seasons", ErrInvalidInput) + } + for _, season := range input.Seasons { + if season <= 0 { + // Refused, never dropped: an emptied list would mean every + // missing season. + return CreateRequestInput{}, &ValidationError{FieldErrors: map[string]string{"seasons": "Season numbers start at 1."}} + } + } return input, nil } diff --git a/internal/requests/service_test.go b/internal/requests/service_test.go index f7554add42..68b95f3dd7 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" @@ -195,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 @@ -209,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 @@ -238,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) @@ -276,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} @@ -292,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) } } @@ -551,15 +551,13 @@ func TestRetrySubmitsTheSavedTVDBID(t *testing.T) { saved := 111 store.requests["req-1"] = &Request{ ID: "req-1", MediaType: MediaTypeSeries, TMDBID: 240001, - Status: StatusQueued, Outcome: OutcomeFailed, + Status: StatusApproved, Outcome: OutcomeActive, } service := newTestService(store) router := &fakeRouterProvider{} service.SetRouterProvider(router) service.SetTVDBIDResolver(&fakeTVDBResolver{tvdbID: 456789}) req := *store.requests["req-1"] - req.Status = StatusApproved - req.Outcome = OutcomeActive store.requests["req-1"].TVDBID = &saved if _, err := service.submitApprovedRequest(context.Background(), req, Viewer{UserID: 1, IsAdmin: true}, nil); err != nil { @@ -697,8 +695,13 @@ func TestRetryStopsWhenResolvedTVDBIDCannotBeSaved(t *testing.T) { service.SetRouterProvider(router) service.SetTVDBIDResolver(&fakeTVDBResolver{tvdbID: 456789}) - if _, err := service.Retry(context.Background(), Viewer{UserID: 1, IsAdmin: true}, "req-1"); err == nil { - t.Fatalf("Retry succeeded, want the save error") + // The reopened approval stands; the save error defers the submission. + got, err := service.Retry(context.Background(), Viewer{UserID: 1, IsAdmin: true}, "req-1") + if err != nil { + t.Fatalf("Retry returned error: %v", err) + } + if !strings.Contains(got.LastError, "db unavailable") { + t.Fatalf("last_error = %q, want the save error", got.LastError) } if router.fulfillCalls != 0 { t.Fatalf("fulfill calls = %d, want 0 when the resolved ID was not saved", router.fulfillCalls) @@ -720,8 +723,13 @@ func TestRetryKeepsFailedTargetWhenResolvedTVDBIDCannotBeSaved(t *testing.T) { service.SetRouterProvider(&fakeRouterProvider{}) service.SetTVDBIDResolver(&fakeTVDBResolver{tvdbID: 456789}) - if _, err := service.Retry(context.Background(), Viewer{UserID: 1, IsAdmin: true}, "req-1"); err == nil { - t.Fatalf("Retry succeeded, want the save error") + // The reopened approval stands; the save error defers the submission. + got, err := service.Retry(context.Background(), Viewer{UserID: 1, IsAdmin: true}, "req-1") + if err != nil { + t.Fatalf("Retry returned error: %v", err) + } + if !strings.Contains(got.LastError, "db unavailable") { + t.Fatalf("last_error = %q, want the save error", got.LastError) } if got := store.targets["req-1"]; len(got) != 1 || got[0].ID != 7 { t.Fatalf("targets = %+v, want the failed target kept", got) @@ -851,18 +859,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) @@ -880,6 +899,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) { @@ -1650,17 +1672,18 @@ func TestLoadIntegrationOptionsDoesNotBackfillStoredKeyForChangedBaseURL(t *test service := newTestService(store) service.SetRouterProvider(router) - if _, err := service.LoadIntegrationOptions(context.Background(), Viewer{UserID: 1, IsAdmin: true}, Integration{ + _, err := service.LoadIntegrationOptions(context.Background(), Viewer{UserID: 1, IsAdmin: true}, Integration{ ID: "router-1", BaseURL: "http://attacker.example", - }); err != nil { - t.Fatalf("LoadIntegrationOptions: %v", err) - } - if router.gotOptionsConn.APIKey != "" { - t.Fatalf("probe API key = %q, want empty for changed base URL", router.gotOptionsConn.APIKey) + }) + // The stored key stays with the stored address: a changed URL needs the + // key typed again, and the plugin is never asked without one. + var ve *ValidationError + if !errors.As(err, &ve) || ve.FieldErrors["api_key_ref"] != integrationKeyMissing { + t.Fatalf("err = %v, want the api_key_ref field error", err) } - if router.gotOptionsConn.BaseURL != "http://attacker.example" { - t.Fatalf("probe base URL = %q, want submitted URL", router.gotOptionsConn.BaseURL) + if router.gotOptionsConn.APIKey != "" || router.gotOptionsConn.BaseURL != "" { + t.Fatalf("probe conn = %+v, want no probe for changed base URL without a key", router.gotOptionsConn) } } @@ -1711,6 +1734,11 @@ func TestLoadIntegrationOptionsKeepsValidationErrors(t *testing.T) { if !errors.As(err, &validation) || errors.Is(err, ErrIntegrationUnreachable) { t.Fatalf("err = %v, want the plugin validation error", err) } + // Returned as is, not as a host-classified probe error. + var probe *ProbeValidationError + if errors.As(err, &probe) { + t.Fatalf("err = %v, want the router's error unchanged", err) + } } func TestCancelOwnerCanWithdrawPendingRequest(t *testing.T) { @@ -1771,11 +1799,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}}, @@ -1798,18 +1827,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) } } @@ -1895,6 +1971,9 @@ func testViewer(userID int) Viewer { type fakeStore struct { mu sync.Mutex + adminFilters []ListFilter + viewCounts AdminViewCounts + events map[string][]RequestEvent settings Settings limit *UserLimit count int @@ -1902,6 +1981,7 @@ type fakeStore struct { created []CreateRequestRecord integrations []Integration candidates []*Request + waiting []*Request mine []*Request statusUpdates []Status requests map[string]*Request @@ -1909,13 +1989,40 @@ type fakeStore struct { targetSeq int64 unnotified []string notified []string + reconciled []string + follows map[string]Follower // key: media_type/tmdb_id/user_id/profile_id + followFor map[string]string // follow key -> the request it waits for + clearErr error // returned by ClearRequestFollowers when set + markErr error // returned by MarkFulfilledNotified when set + routes []Route + factsSet map[string]RoutingFacts + groupLimits map[int64]*GroupLimit + // userLimitReads counts policy resolutions (each reads the account's limit once). + userLimitReads int setExternalIDsErr error + // downloadWrites records each progress write UpdateTargetDownload + // applied; downloadErr fails it. downloadChecked holds when each target + // was last asked about, as the repository's download_checked_at, and + // downloadChecks the targets MarkTargetDownloadChecked stamped. + downloadWrites []downloadWrite + downloadErr error + downloadChecked map[int64]time.Time + downloadChecks []int64 + // externalStatusWrites records the targets UpdateTargetExternalStatus + // wrote. + externalStatusWrites []int64 + listIntegrationsCalls int getSettingsCalls int } +type downloadWrite struct { + targetID int64 + progress *DownloadProgress +} + type requestGroupProvider struct { group *access.GroupPolicy err error @@ -1957,6 +2064,7 @@ func (f *fakeStore) UpdateSettings(_ context.Context, settings Settings) (Settin func (f *fakeStore) GetUserLimit(context.Context, int) (*UserLimit, error) { f.mu.Lock() defer f.mu.Unlock() + f.userLimitReads++ return f.limit, nil } @@ -1970,17 +2078,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) { @@ -1995,37 +2106,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, @@ -2038,6 +2132,8 @@ func (f *fakeStore) CreateRequest(_ context.Context, input CreateRequestRecord) Status: input.Status, Outcome: input.Outcome, IsAnime: input.IsAnime, + RoutingFacts: input.Facts, + Seasons: input.Input.Seasons, RequestedByUserID: input.Requester.UserID, RequestedByProfileID: input.Requester.ProfileID, CreatedAt: input.Now, @@ -2065,6 +2161,63 @@ 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 +} + +// ListDownloadingRequests mirrors the repository: active requests with a +// downloading target that has progress, by the one asked about longest ago, +// then id. A seeded target that was never asked about counts as asked when +// its progress was reported. +func (f *fakeStore) ListDownloadingRequests(_ context.Context, limit int) ([]*Request, error) { + f.mu.Lock() + defer f.mu.Unlock() + type candidate struct { + req *Request + checked time.Time + } + var found []candidate + for id, targets := range f.targets { + req := f.requests[id] + if req == nil || req.Outcome != OutcomeActive { + continue + } + var checked *time.Time + for _, t := range targets { + if t.Status != StatusDownloading || t.Download == nil { + continue + } + at, ok := f.downloadChecked[t.ID] + if !ok { + at = t.Download.UpdatedAt + } + if checked == nil || at.Before(*checked) { + checked = &at + } + } + if checked != nil { + copy := *req + found = append(found, candidate{req: ©, checked: *checked}) + } + } + slices.SortFunc(found, func(a, b candidate) int { + if c := a.checked.Compare(b.checked); c != 0 { + return c + } + return strings.Compare(a.req.ID, b.req.ID) + }) + out := make([]*Request, 0, len(found)) + for _, c := range found { + if limit > 0 && len(out) == limit { + break + } + out = append(out, c.req) + } + return out, nil +} + func (f *fakeStore) ListFulfilledUnnotified(context.Context, int) ([]*Request, error) { f.mu.Lock() defer f.mu.Unlock() @@ -2098,9 +2251,15 @@ func (f *fakeStore) SetExternalIDs(_ context.Context, id string, tvdbID int, imd return *req.TVDBID, nil } -func (f *fakeStore) MarkFulfilledNotified(_ context.Context, id string) error { +func (f *fakeStore) MarkFulfilledNotified(_ context.Context, id string) (bool, error) { f.mu.Lock() defer f.mu.Unlock() + if f.markErr != nil { + return false, f.markErr + } + if slices.Contains(f.notified, id) { + return false, nil + } kept := f.unnotified[:0] for _, pending := range f.unnotified { if pending != id { @@ -2109,7 +2268,7 @@ func (f *fakeStore) MarkFulfilledNotified(_ context.Context, id string) error { } f.unnotified = kept f.notified = append(f.notified, id) - return nil + return true, nil } func (f *fakeStore) ListMine(context.Context, int, ListFilter) ([]*Request, error) { @@ -2118,34 +2277,253 @@ func (f *fakeStore) ListMine(context.Context, int, ListFilter) ([]*Request, erro return append([]*Request(nil), f.mine...), nil } -func (f *fakeStore) ListAdmin(context.Context, ListFilter) ([]*Request, error) { +func (f *fakeStore) ListAdmin(_ context.Context, filter ListFilter) ([]*Request, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.adminFilters = append(f.adminFilters, filter) return nil, nil } -func (f *fakeStore) SetStatus(_ context.Context, id string, status Status, _ Viewer) (*Request, error) { +func (f *fakeStore) GetGroupLimit(_ context.Context, groupID int64) (*GroupLimit, error) { f.mu.Lock() defer f.mu.Unlock() - f.statusUpdates = append(f.statusUpdates, status) - req := f.requests[id] + if limit := f.groupLimits[groupID]; limit != nil { + out := *limit + return &out, nil + } + return nil, nil +} + +func (f *fakeStore) GroupExists(_ context.Context, groupID int64) (bool, error) { + return groupID == 1, nil +} + +func (f *fakeStore) UpsertGroupLimitConditional(_ context.Context, in GroupLimit, expected int64) (*GroupLimit, error) { + f.mu.Lock() + defer f.mu.Unlock() + current := f.groupLimits[in.GroupID] + var revision int64 + if current != nil { + revision = current.Revision + } + if expected != -1 && expected != revision { + return nil, ErrStaleRevision + } + if f.groupLimits == nil { + f.groupLimits = map[int64]*GroupLimit{} + } + in.Revision = revision + 1 + f.groupLimits[in.GroupID] = &in + out := in + return &out, nil +} + +func (f *fakeStore) CountAdminViews(context.Context) (AdminViewCounts, error) { + return f.viewCounts, nil +} + +func (f *fakeStore) ListEvents(_ context.Context, requestID string, _ int) ([]RequestEvent, error) { + f.mu.Lock() + defer f.mu.Unlock() + return append([]RequestEvent(nil), f.events[requestID]...), nil +} + +// 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 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 append(append([]*Request(nil), f.candidates...), f.waiting...) { + 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() + req := f.lookupLocked(id) if req == nil { req = &Request{ID: id, Outcome: OutcomeActive} f.requests[id] = req + } else if !f.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 !f.guardAccepts(from, req) { + return nil, ErrInvalidState } req.Outcome = outcome req.LastError = message + if outcome == OutcomeDeclined || outcome == OutcomeCancelled { + req.OutcomeReason = message + for key := range f.follows { + if f.followFor[key] == req.ID { + delete(f.follows, key) + } + } + } + copy := *req + 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, leaseUntil time.Time, 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 || + req.SubmitLeaseUntil == nil || !req.SubmitLeaseUntil.Equal(leaseUntil) { + return nil, ErrInvalidState + } + next := time.Now().Add(delay) + req.NextSubmitAt = &next + req.SubmitLeaseUntil = nil + req.LastError = message + copy := *req + return ©, nil +} + +func (f *fakeStore) FailSubmission(_ context.Context, id string, leaseUntil time.Time, _ Viewer, 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 || + req.SubmitLeaseUntil == nil || !req.SubmitLeaseUntil.Equal(leaseUntil) { + return nil, ErrInvalidState + } + req.Outcome = OutcomeFailed + 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 + } + 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()) + 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 +} + +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 } @@ -2227,12 +2605,173 @@ func (f *fakeStore) DeleteIntegration(_ context.Context, id string) error { return ErrNotFound } +// followKey names one profile's follow of one request of a title. +func followKey(mediaType MediaType, tmdbID int, userID int, profileID, requestID string) string { + return fmt.Sprintf("%s/%d/%d/%s/%s", mediaType, tmdbID, userID, profileID, requestID) +} + +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) +} + +// seedFollowLocked records a follow for the title's open request, or else +// for the title's request in f.requests with the lowest id. +func (f *fakeStore) seedFollowLocked(mediaType MediaType, tmdbID int, viewer Viewer) { + requestID := "" + if req := f.active[mediaType][tmdbID]; req != nil { + requestID = req.ID + } else { + for id, req := range f.requests { + if req.MediaType == mediaType && req.TMDBID == tmdbID && (requestID == "" || id < requestID) { + requestID = id + } + } + } + f.seedFollowForLocked(mediaType, tmdbID, viewer, requestID) +} + +// seedFollowFor records a follow waiting for a given request. +func (f *fakeStore) seedFollowFor(mediaType MediaType, tmdbID int, viewer Viewer, requestID string) { + f.mu.Lock() + defer f.mu.Unlock() + f.seedFollowForLocked(mediaType, tmdbID, viewer, requestID) +} + +func (f *fakeStore) seedFollowForLocked(mediaType MediaType, tmdbID int, viewer Viewer, requestID string) { + if f.follows == nil { + f.follows = map[string]Follower{} + } + if f.followFor == nil { + f.followFor = map[string]string{} + } + key := followKey(mediaType, tmdbID, viewer.UserID, viewer.ProfileID, requestID) + f.follows[key] = Follower{UserID: viewer.UserID, ProfileID: viewer.ProfileID} + f.followFor[key] = requestID +} + +func (f *fakeStore) UnfollowTitle(_ context.Context, mediaType MediaType, tmdbID int, viewer Viewer) error { + f.mu.Lock() + defer f.mu.Unlock() + prefix := fmt.Sprintf("%s/%d/%d/%s/", mediaType, tmdbID, viewer.UserID, viewer.ProfileID) + for key := range f.follows { + if strings.HasPrefix(key, prefix) { + delete(f.follows, key) + } + } + return nil +} + +func (f *fakeStore) FollowedRequests(_ context.Context, requestIDs []string, viewer Viewer) (map[string]bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + out := map[string]bool{} + for key, follower := range f.follows { + if follower.UserID == viewer.UserID && follower.ProfileID == viewer.ProfileID && slices.Contains(requestIDs, f.followFor[key]) { + out[f.followFor[key]] = true + } + } + return out, nil +} + +func (f *fakeStore) ListRequestFollowers(_ context.Context, req Request) ([]Follower, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []Follower + for key, follower := range f.follows { + if f.followFor[key] == req.ID { + out = append(out, follower) + } + } + slices.SortFunc(out, func(a, b Follower) int { + if c := strings.Compare(a.ProfileID, b.ProfileID); c != 0 { + return c + } + return a.UserID - b.UserID + }) + return out, nil +} + +// titleFollowers lists every follow on a title, whichever request it waits for. +func (f *fakeStore) titleFollowers(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) + } + } + return out, nil +} + +func (f *fakeStore) ClearRequestFollowers(_ context.Context, req Request, followers []Follower) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.clearErr != nil { + return f.clearErr + } + for _, follower := range followers { + delete(f.follows, followKey(req.MediaType, req.TMDBID, follower.UserID, follower.ProfileID, req.ID)) + } + 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() return append([]Target(nil), f.targets[requestID]...), nil } +func (f *fakeStore) ListTargetsForRequests(_ context.Context, requestIDs []string) (map[string][]Target, error) { + f.mu.Lock() + defer f.mu.Unlock() + out := map[string][]Target{} + for _, id := range requestIDs { + if targets := f.targets[id]; len(targets) > 0 { + out[id] = append([]Target(nil), targets...) + } + } + return out, nil +} + func (f *fakeStore) CreateTarget(_ context.Context, t Target) (Target, error) { f.mu.Lock() defer f.mu.Unlock() @@ -2262,6 +2801,129 @@ func (f *fakeStore) DeleteTarget(_ context.Context, id int64) error { func (f *fakeStore) UpdateTargetStatus(_ context.Context, targetID int64, status Status, externalID, externalStatus, lastErr string, _ Viewer) (*Request, error) { f.mu.Lock() defer f.mu.Unlock() + return f.updateTargetLocked(targetID, status, externalID, externalStatus, lastErr) +} + +// UpdateTargetDownload mirrors the repository: it writes only while the +// target is queued or downloading, and touches nothing but the progress. +// Every write it applies is recorded in downloadWrites. +func (f *fakeStore) UpdateTargetDownload(_ context.Context, targetID int64, progress *DownloadProgress) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.downloadErr != nil { + return f.downloadErr + } + for rid, ts := range f.targets { + for i := range ts { + if ts[i].ID != targetID { + continue + } + if ts[i].Status != StatusQueued && ts[i].Status != StatusDownloading { + return nil + } + var stored *DownloadProgress + if f.downloadChecked == nil { + f.downloadChecked = map[int64]time.Time{} + } + delete(f.downloadChecked, targetID) + if progress != nil { + copy := *progress + copy.UpdatedAt = time.Now().UTC() + stored = © + f.downloadChecked[targetID] = copy.UpdatedAt + } + f.targets[rid][i].Download = stored + f.downloadWrites = append(f.downloadWrites, downloadWrite{targetID: targetID, progress: stored}) + return nil + } + } + return nil +} + +// MarkTargetDownloadChecked mirrors the repository: it stamps a queued or +// downloading target that has progress as asked about now, leaving the +// progress alone, and records the stamp in downloadChecks. +func (f *fakeStore) MarkTargetDownloadChecked(_ context.Context, targetID int64) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.downloadErr != nil { + return f.downloadErr + } + for _, ts := range f.targets { + for _, t := range ts { + if t.ID != targetID { + continue + } + if t.Download == nil || (t.Status != StatusQueued && t.Status != StatusDownloading) { + return nil + } + if f.downloadChecked == nil { + f.downloadChecked = map[int64]time.Time{} + } + f.downloadChecked[targetID] = time.Now().UTC() + f.downloadChecks = append(f.downloadChecks, targetID) + return nil + } + } + return nil +} + +// UpdateTargetExternalStatus mirrors the repository: it writes only a changed +// raw status on a queued or downloading target, and touches nothing else. +func (f *fakeStore) UpdateTargetExternalStatus(_ context.Context, targetID int64, externalStatus string) error { + f.mu.Lock() + defer f.mu.Unlock() + for rid, ts := range f.targets { + for i := range ts { + if ts[i].ID != targetID { + continue + } + if (ts[i].Status != StatusQueued && ts[i].Status != StatusDownloading) || ts[i].ExternalStatus == externalStatus { + return nil + } + f.targets[rid][i].ExternalStatus = externalStatus + f.externalStatusWrites = append(f.externalStatusWrites, targetID) + return nil + } + } + return nil +} + +// RecordSubmission mirrors the repository's lease fence, then records each +// target the way a create followed by a status update would. +func (f *fakeStore) RecordSubmission(_ context.Context, id string, leaseUntil time.Time, targets []Target, _ 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 || + req.SubmitLeaseUntil == nil || !req.SubmitLeaseUntil.Equal(leaseUntil) { + return nil, ErrInvalidState + } + req.SubmitLeaseUntil = nil + if f.targets == nil { + f.targets = map[string][]Target{} + } + latest := req + for _, t := range targets { + f.targetSeq++ + t.ID = f.targetSeq + t.RequestID = id + f.targets[id] = append(f.targets[id], t) + updated, err := f.updateTargetLocked(t.ID, t.Status, t.ExternalID, t.ExternalStatus, t.LastError) + if err != nil { + return nil, err + } + latest = updated + } + copy := *latest + return ©, nil +} + +// updateTargetLocked is UpdateTargetStatus for callers holding f.mu. +func (f *fakeStore) updateTargetLocked(targetID int64, status Status, externalID, externalStatus, lastErr string) (*Request, error) { var requestID string for rid, ts := range f.targets { for i := range ts { @@ -2274,6 +2936,10 @@ func (f *fakeStore) UpdateTargetStatus(_ context.Context, targetID int64, status } f.targets[rid][i].Status = status f.targets[rid][i].LastError = lastErr + if status == StatusCompleted || status == StatusFailed { + f.targets[rid][i].Download = nil + delete(f.downloadChecked, targetID) + } requestID = rid } } @@ -2492,6 +3158,22 @@ type fakePresence struct { available map[MediaType]map[int]bool byTVDB map[MediaType]map[int]int got []PresenceCandidate + // seasons holds per-season counts by series content ID. + seasons map[string]map[int]SeasonCounts + seasonLookups int +} + +func (f *fakePresence) SeasonAvailability(_ context.Context, seriesContentIDs []string) (map[string]map[int]SeasonCounts, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.seasonLookups++ + out := map[string]map[int]SeasonCounts{} + for _, id := range seriesContentIDs { + if counts, ok := f.seasons[id]; ok { + out[id] = counts + } + } + return out, nil } func (f *fakePresence) Lookup(_ context.Context, mediaType MediaType, candidates []PresenceCandidate) (map[int]PresenceMatch, error) { @@ -2547,6 +3229,7 @@ type fakeTMDBClient struct { externalIDsByID map[int]*tmdb.ExternalIDs externalIDCalls []int detail *tmdb.MediaDetail + detailErr error discoverPage *tmdb.MediaPage discoverErr error searchMediaType string @@ -2589,7 +3272,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 @@ -2613,6 +3296,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 } @@ -2635,6 +3325,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 @@ -2647,15 +3351,33 @@ type fakeRouterProvider struct { gotConns []ResolvedRouterConnection gotInstallationID int fulfillCalls int + fulfillLog []fulfillCall gotRequesterEmail string gotRequesterUsername string - gotTVDBID *int + // gotSeasons records the seasons of each Fulfill call's request. + gotSeasons [][]int + + // seasonCapable marks the installations whose router declares + // supports_seasons; RouterFeatures answers from it. + seasonCapable map[int]bool + // progressCapable marks the installations whose router declares + // reports_download_progress. + progressCapable map[int]bool + featuresErr error + gotTVDBID *int // CheckStatus behavior. - statuses []RouterTargetStatus - statusErr error - statusCalls int + statuses []RouterTargetStatus + statusErr error + // statusErrFor fails CheckStatus for one installation only. + statusErrFor map[int]error + // statusHangFor makes CheckStatus for an installation wait until its + // context ends, the way a call to a server that stopped answering runs to + // its deadline. + statusHangFor map[int]bool + statusCalls int + statusLog []statusCall // ListConfigOptions behavior. options map[string][]RouterOption @@ -2671,13 +3393,28 @@ type fakeRouterProvider struct { gotValidateSiblings []ResolvedRouterConnection } +func (f *fakeRouterProvider) RouterFeatures(_ context.Context, installationID int, _ string) (RouterFeatures, error) { + f.mu.Lock() + defer f.mu.Unlock() + return RouterFeatures{ + SupportsSeasons: f.seasonCapable[installationID], + ReportsDownloadProgress: f.progressCapable[installationID], + }, f.featuresErr +} + func (f *fakeRouterProvider) Fulfill(_ context.Context, installationID int, _ string, req Request, qualities []Quality, conns []ResolvedRouterConnection) ([]RouterTarget, string, error) { f.mu.Lock() defer f.mu.Unlock() f.gotRequesterEmail = req.RequesterEmail f.gotRequesterUsername = req.RequesterUsername + var seasons []int + for _, season := range routerDescriptor(req).GetSeasons() { + seasons = append(seasons, int(season)) + } + f.gotSeasons = append(f.gotSeasons, seasons) f.gotTVDBID = req.TVDBID 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 @@ -2707,10 +3444,21 @@ 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(ctx 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)}) + hang := f.statusHangFor[installationID] + f.mu.Unlock() + if hang { + <-ctx.Done() + return nil, ctx.Err() + } + f.mu.Lock() + defer f.mu.Unlock() + if err := f.statusErrFor[installationID]; err != nil { + return nil, err + } return f.statuses, f.statusErr } @@ -2757,9 +3505,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 @@ -2808,7 +3555,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) } @@ -2820,14 +3567,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) } @@ -2836,7 +3583,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) } @@ -2923,7 +3670,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 @@ -2934,12 +3681,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{}) @@ -2950,8 +3697,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) } } @@ -3029,7 +3776,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) } @@ -3037,9 +3784,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) } @@ -3146,8 +3897,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) @@ -3200,8 +3952,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) @@ -3323,7 +4075,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). @@ -3339,9 +4091,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) } } @@ -3373,3 +4125,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/store.go b/internal/requests/store.go index 5811646776..8614183417 100644 --- a/internal/requests/store.go +++ b/internal/requests/store.go @@ -12,30 +12,114 @@ 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 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) + // ListDownloadingRequests returns active requests with a downloading + // target that has download progress, for the download refresh pass, the + // least recently asked about first. Targets without progress are ignored. + ListDownloadingRequests(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) // MarkFulfilledNotified stamps a request's fulfillment-notification - // marker so the notify pass stops considering it. Idempotent. - MarkFulfilledNotified(ctx context.Context, id string) error + // marker so the notify pass stops considering it. Idempotent; stamped + // reports whether this call set it. + MarkFulfilledNotified(ctx context.Context, id string) (stamped bool, err 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) + CountAdminViews(ctx context.Context) (AdminViewCounts, error) + ListEvents(ctx context.Context, requestID string, limit int) ([]RequestEvent, error) + // SetStatus and SetOutcome apply a transition only while the request is in + // a state the guard accepts; otherwise they return ErrInvalidState. + // Declining or withdrawing also clears the title's follows in the same + // transaction. + 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. + // leaseUntil is the claim's SubmitLeaseUntil: a caller whose lease expired + // and was claimed again gets ErrInvalidState and leaves the newer claim + // alone. + DeferSubmission(ctx context.Context, id string, leaseUntil time.Time, delay time.Duration, message string) (*Request, error) + // FailSubmission marks a still-approved request failed after its last + // submission attempt and releases the claim. It is fenced on leaseUntil + // the same way as DeferSubmission, so an attempt that outlived its lease + // cannot fail a newer claim's attempt. + FailSubmission(ctx context.Context, id string, leaseUntil time.Time, actor Viewer, message string) (*Request, error) + // RecordSubmission stores the targets a claimed submission created, + // releases the claim, and re-derives the request's status from its + // targets, in one transaction. It is fenced on leaseUntil the same way as + // DeferSubmission: an attempt that outlived its lease while the request + // was withdrawn, completed or claimed again gets ErrInvalidState and + // writes nothing. + RecordSubmission(ctx context.Context, id string, leaseUntil time.Time, targets []Target, actor Viewer) (*Request, error) + // MarkReconciled stamps last_reconciled_at so the reconcile pass rotates + // through every candidate. + MarkReconciled(ctx context.Context, id string) error + // 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. + RecomputeStatus(ctx context.Context, id string, actor Viewer) (*Request, error) + // FollowTitle, UnfollowTitle and FollowedRequests manage a profile's + // follows, keyed by account, profile and request; ListRequestFollowers and + // ClearRequestFollowers serve a request's 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, viewer Viewer) error + FollowedRequests(ctx context.Context, requestIDs []string, viewer Viewer) (map[string]bool, error) + ListRequestFollowers(ctx context.Context, req Request) ([]Follower, error) + ClearRequestFollowers(ctx context.Context, req Request, followers []Follower) 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) // SetExternalIDs records a TVDB ID resolved after the request was created, // and fills the IMDb ID when the row has none. An existing positive TVDB ID // is kept; the TVDB ID the row holds afterwards is returned. SetExternalIDs(ctx context.Context, id string, tvdbID int, imdbID string) (int, error) ListTargets(ctx context.Context, requestID string) ([]Target, error) + ListTargetsForRequests(ctx context.Context, requestIDs []string) (map[string][]Target, error) CreateTarget(ctx context.Context, target Target) (Target, error) DeleteTarget(ctx context.Context, id int64) error + // UpdateTargetStatus also clears the target's download progress when it + // completes or fails. UpdateTargetStatus(ctx context.Context, targetID int64, status Status, externalID, externalStatus, lastErr string, actor Viewer) (*Request, error) + // UpdateTargetDownload stores a target's download progress, or clears it + // when progress is nil, only while the target is queued or downloading. It + // leaves updated_at, the request aggregate and the request's history alone. + UpdateTargetDownload(ctx context.Context, targetID int64, progress *DownloadProgress) error + // MarkTargetDownloadChecked records that a pass asked about a target + // with progress and got no status back, without changing the progress. + MarkTargetDownloadChecked(ctx context.Context, targetID int64) error + // UpdateTargetExternalStatus records the raw status a queued or + // downloading target's server reported when the target's own status did + // not change. Like UpdateTargetDownload, it leaves updated_at, the + // request aggregate and the request's history alone. + UpdateTargetExternalStatus(ctx context.Context, targetID int64, externalStatus string) error ListIntegrations(ctx context.Context) ([]Integration, error) GetIntegration(ctx context.Context, id string) (*Integration, error) CreateIntegration(ctx context.Context, integration Integration) (*Integration, error) @@ -52,6 +136,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 @@ -59,6 +144,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/targets.go b/internal/requests/targets.go index 242e2dba93..8651c08745 100644 --- a/internal/requests/targets.go +++ b/internal/requests/targets.go @@ -4,13 +4,21 @@ import ( "context" "errors" "fmt" + "time" "github.com/jackc/pgx/v5" ) 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, + t.download_phase, t.download_bytes_total, t.download_bytes_left, t.download_eta, + t.download_count, t.download_updated_at` + +// clearTargetDownload empties a target's download progress columns. +const clearTargetDownload = `download_phase = NULL, download_bytes_total = NULL, + download_bytes_left = NULL, download_eta = NULL, download_count = NULL, + download_updated_at = NULL, download_checked_at = NULL` // aggregateStatus derives a request's status/outcome from its targets. func aggregateStatus(targets []Target) (Status, Outcome) { @@ -53,15 +61,35 @@ func aggregateStatus(targets []Target) (Status, Outcome) { func scanTarget(row requestScanner) (Target, error) { var t Target - var integrationID *string + var integrationID, downloadPhase *string + var bytesTotal, bytesLeft *int64 + var downloadCount *int + var downloadETA, downloadUpdatedAt *time.Time 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, + &downloadPhase, &bytesTotal, &bytesLeft, &downloadETA, &downloadCount, &downloadUpdatedAt); err != nil { return Target{}, err } if integrationID != nil { t.IntegrationID = *integrationID } + if downloadPhase != nil { + d := &DownloadProgress{Phase: DownloadPhase(*downloadPhase), EstimatedCompletion: downloadETA} + if bytesTotal != nil { + d.BytesTotal = *bytesTotal + } + if bytesLeft != nil { + d.BytesLeft = *bytesLeft + } + if downloadCount != nil { + d.Downloads = *downloadCount + } + if downloadUpdatedAt != nil { + d.UpdatedAt = *downloadUpdatedAt + } + t.Download = d + } return t, nil } @@ -85,25 +113,95 @@ func (r *Repository) ListTargets(ctx context.Context, requestID string) ([]Targe return out, rows.Err() } +// ListTargetsForRequests reads the targets of many requests in one query. +func (r *Repository) ListTargetsForRequests(ctx context.Context, requestIDs []string) (map[string][]Target, error) { + out := map[string][]Target{} + if len(requestIDs) == 0 { + return out, nil + } + rows, err := r.pool.Query(ctx, `SELECT `+targetColumns+` + FROM media_request_targets t + LEFT JOIN request_integrations ri ON ri.id = t.integration_id + WHERE t.request_id = ANY($1) ORDER BY t.request_id, t.quality`, requestIDs) + if err != nil { + return nil, fmt.Errorf("list targets: %w", err) + } + defer rows.Close() + for rows.Next() { + t, err := scanTarget(rows) + if err != nil { + return nil, err + } + out[t.RequestID] = append(out[t.RequestID], t) + } + return out, rows.Err() +} + func (r *Repository) CreateTarget(ctx context.Context, t Target) (Target, error) { - var integrationID any + return insertTarget(ctx, r.pool, t) +} + +func insertTarget(ctx context.Context, exec requestExecutor, t Target) (Target, error) { + var integrationID, routeID any if t.IntegrationID != "" { integrationID = t.IntegrationID } - row := r.pool.QueryRow(ctx, ` + if t.RouteID != "" { + routeID = t.RouteID + } + row := exec.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) } return t, nil } +func (r *Repository) RecordSubmission(ctx context.Context, id string, leaseUntil time.Time, targets []Target, actor Viewer) (*Request, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin submission record transaction: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + // Releasing the claim also locks the row, so a withdrawal or a newer + // claim cannot slip in before the targets land. + var claimedID string + if err := tx.QueryRow(ctx, ` + UPDATE media_requests + SET submit_lease_until = NULL + WHERE id = $1 + AND status = 'approved' + AND outcome = 'active' + AND submit_lease_until = $2 + RETURNING id`, id, leaseUntil).Scan(&claimedID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, guardMiss(ctx, tx, id) + } + return nil, fmt.Errorf("release request submission claim: %w", err) + } + for _, t := range targets { + t.RequestID = id + if _, err := insertTarget(ctx, tx, t); err != nil { + return nil, err + } + } + 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 submission record transaction: %w", err) + } + return req, nil +} + func (r *Repository) DeleteTarget(ctx context.Context, id int64) error { tag, err := r.pool.Exec(ctx, `DELETE FROM media_request_targets WHERE id = $1`, id) if err != nil { @@ -116,7 +214,8 @@ func (r *Repository) DeleteTarget(ctx context.Context, id int64) error { } // UpdateTargetStatus updates one target and recomputes the parent request's -// aggregate status/outcome, all in one transaction. +// aggregate status/outcome, all in one transaction. A target that completes +// or fails loses its download progress. func (r *Repository) UpdateTargetStatus(ctx context.Context, targetID int64, status Status, externalID, externalStatus, lastErr string, actor Viewer) (*Request, error) { tx, err := r.pool.Begin(ctx) @@ -139,6 +238,11 @@ func (r *Repository) UpdateTargetStatus(ctx context.Context, targetID int64, sta } return nil, fmt.Errorf("update target: %w", err) } + if status == StatusCompleted || status == StatusFailed { + if _, err := tx.Exec(ctx, `UPDATE media_request_targets SET `+clearTargetDownload+` WHERE id = $1`, targetID); err != nil { + return nil, fmt.Errorf("clear target download progress: %w", err) + } + } req, err := r.recomputeAggregate(ctx, tx, requestID, actor) if err != nil { @@ -150,7 +254,77 @@ func (r *Repository) UpdateTargetStatus(ctx context.Context, targetID int64, sta return req, nil } +// UpdateTargetDownload stores a target's download progress, stamped now as +// both heard from and asked about, or clears it when progress is nil. It +// writes only while the target is queued or downloading, so a late report +// cannot give a finished target progress again, and it touches nothing else: +// not updated_at, which dates status changes for the stalled-target backstop, +// not the request, and not its history. +func (r *Repository) UpdateTargetDownload(ctx context.Context, targetID int64, progress *DownloadProgress) error { + const live = ` WHERE id = $1 AND status IN ('queued', 'downloading')` + var err error + if progress == nil { + _, err = r.pool.Exec(ctx, `UPDATE media_request_targets SET `+clearTargetDownload+live, targetID) + } else { + _, err = r.pool.Exec(ctx, ` + UPDATE media_request_targets + SET download_phase = $2, download_bytes_total = $3, download_bytes_left = $4, + download_eta = $5, download_count = $6, download_updated_at = now(), + download_checked_at = now()`+live, + targetID, string(progress.Phase), progress.BytesTotal, progress.BytesLeft, + progress.EstimatedCompletion, progress.Downloads) + } + if err != nil { + return fmt.Errorf("update target download progress: %w", err) + } + return nil +} + +// MarkTargetDownloadChecked records that a pass asked about a target's +// download without getting its status back. It moves the target to the back +// of the download refresh rotation and leaves the progress, and when it was +// last heard from, alone. A target without progress, or no longer queued or +// downloading, is not written. +func (r *Repository) MarkTargetDownloadChecked(ctx context.Context, targetID int64) error { + if _, err := r.pool.Exec(ctx, ` + UPDATE media_request_targets SET download_checked_at = now() + WHERE id = $1 AND status IN ('queued', 'downloading') AND download_phase IS NOT NULL`, targetID); err != nil { + return fmt.Errorf("mark target download checked: %w", err) + } + return nil +} + +// UpdateTargetExternalStatus records the raw status a target's server last +// reported, while the target is queued or downloading. It leaves updated_at +// alone, since that dates the target's last status change, and writes nothing +// when the status is unchanged. +func (r *Repository) UpdateTargetExternalStatus(ctx context.Context, targetID int64, externalStatus string) error { + if _, err := r.pool.Exec(ctx, ` + UPDATE media_request_targets SET external_status = $2 + WHERE id = $1 AND status IN ('queued', 'downloading') AND external_status <> $2`, targetID, externalStatus); err != nil { + return fmt.Errorf("update target external status: %w", err) + } + return nil +} + func (r *Repository) recomputeAggregate(ctx context.Context, exec requestExecutor, requestID string, actor Viewer) (*Request, error) { + // The request's history records what changed, once: a request with two + // targets moving to queued is one event, not two. + var prevStatus Status + var prevOutcome Outcome + if err := exec.QueryRow(ctx, `SELECT status, outcome FROM media_requests WHERE id = $1 FOR UPDATE`, requestID). + Scan(&prevStatus, &prevOutcome); err != nil { + return nil, fmt.Errorf("load request status: %w", err) + } + if prevOutcome == OutcomeDeclined || prevOutcome == OutcomeCancelled { + // A closed request stays closed: a target reporting late (say, after + // an admin closed a failed request) updates only itself. + req, err := scanRequest(exec.QueryRow(ctx, requestSelectSQL()+` WHERE id = $1`, requestID)) + if err != nil { + return nil, fmt.Errorf("load closed request: %w", err) + } + return req, nil + } rows, err := exec.Query(ctx, `SELECT status FROM media_request_targets WHERE request_id = $1`, requestID) if err != nil { return nil, fmt.Errorf("load target statuses: %w", err) @@ -187,6 +361,11 @@ func (r *Repository) recomputeAggregate(ctx context.Context, exec requestExecuto if err != nil { return nil, fmt.Errorf("recompute aggregate: %w", err) } - _ = r.recordEvent(ctx, exec, requestID, "status_"+string(status), actor, string(req.ExternalStatus)) + if status != prevStatus { + _ = r.recordEvent(ctx, exec, requestID, "status_"+string(status), actor, req.ExternalStatus) + } + if outcome != prevOutcome { + _ = r.recordEvent(ctx, exec, requestID, "outcome_"+string(outcome), actor, lastErr) + } return req, nil } diff --git a/internal/requests/types.go b/internal/requests/types.go index f118d07c7e..2ba5223941 100644 --- a/internal/requests/types.go +++ b/internal/requests/types.go @@ -56,6 +56,105 @@ 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:"-"` + // Download is how far the target's downloads are, while its plugin reports + // any (v2 only). + Download *DownloadProgress `json:"-"` +} + +// DownloadPhase is where a target's downloads are, as its router plugin +// reports them. The set is open: clients read an unknown phase as downloading. +type DownloadPhase string + +const ( + DownloadPhaseQueued DownloadPhase = "queued" + DownloadPhaseDownloading DownloadPhase = "downloading" + DownloadPhasePaused DownloadPhase = "paused" + DownloadPhaseStalled DownloadPhase = "stalled" + DownloadPhaseImporting DownloadPhase = "importing" + DownloadPhaseImportBlocked DownloadPhase = "import_blocked" +) + +// downloadPhaseRank orders phases for aggregation, highest first: +// import_blocked > stalled > downloading > importing > paused > queued. A +// download that needs attention outranks the rest; otherwise downloading wins +// while anything still downloads. An unknown phase ranks as downloading. +func downloadPhaseRank(phase DownloadPhase) int { + switch phase { + case DownloadPhaseImportBlocked: + return 5 + case DownloadPhaseStalled: + return 4 + case DownloadPhaseImporting: + return 2 + case DownloadPhasePaused: + return 1 + case DownloadPhaseQueued: + return 0 + default: + return 3 + } +} + +// DownloadProgress is how far a target's downloads are, as the downstream +// service last reported them. BytesTotal is 0 while the size is unknown. +// UpdatedAt is when the server last heard from the plugin. +type DownloadProgress struct { + Phase DownloadPhase + BytesTotal int64 + BytesLeft int64 + EstimatedCompletion *time.Time + Downloads int + UpdatedAt time.Time +} + +// Download aggregates the progress of the request's live (queued or +// downloading) targets, 1080p and 4K together: bytes and downloads are +// summed, the phase is the highest ranked, the estimate is the latest, and +// UpdatedAt is the oldest report, so the figure is only as fresh as its +// stalest part. The total is unknown (0) when any target's is. It is nil when +// no live target reports progress. +func (r *Request) Download() *DownloadProgress { + var out *DownloadProgress + sizeKnown := true + for _, t := range r.Targets { + if t.Status != StatusQueued && t.Status != StatusDownloading { + continue + } + if t.Download == nil { + // A live target reporting nothing yet still has a size to come; a + // percentage without it would cover only part of the request. + sizeKnown = false + continue + } + d := *t.Download + if d.BytesTotal <= 0 { + sizeKnown = false + } + if out == nil { + out = &d + continue + } + if downloadPhaseRank(d.Phase) > downloadPhaseRank(out.Phase) { + out.Phase = d.Phase + } + out.BytesTotal += d.BytesTotal + out.BytesLeft += d.BytesLeft + out.Downloads += d.Downloads + if d.EstimatedCompletion != nil && (out.EstimatedCompletion == nil || d.EstimatedCompletion.After(*out.EstimatedCompletion)) { + out.EstimatedCompletion = d.EstimatedCompletion + } + if d.UpdatedAt.Before(out.UpdatedAt) { + out.UpdatedAt = d.UpdatedAt + } + } + if out != nil && !sizeKnown { + out.BytesTotal, out.BytesLeft = 0, 0 + } + return out } type Availability string @@ -106,6 +205,9 @@ type FeatureStatus struct { // detail (404) and create (403) requests are rejected. Additive v1 // capability field so clients can feature-detect instead of version-sniff. RatingRestrictionsEnforced bool `json:"rating_restrictions_enforced"` + // MissingSeasonsRequestable reports whether a series already in the + // library can be requested for its missing seasons (v2 only). + MissingSeasonsRequestable bool `json:"-"` } type UserLimit struct { @@ -148,10 +250,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"` @@ -163,6 +264,22 @@ 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:"-"` + // RoutingFacts is the TMDB snapshot routing rules match on. + RoutingFacts RoutingFacts `json:"-"` + // Seasons are the season numbers a series request asks for; empty means + // the whole series. SeasonProgress is attached on reads: each requested + // season's episode counts once the series is in the library. + Seasons []int `json:"-"` + SeasonProgress []SeasonProgress `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:"-"` // externalIDsResolved marks a request whose external IDs were just looked // up in this call (CreateRequest), so an immediate submission does not @@ -173,12 +290,110 @@ type Request struct { tvdbLookupFailed bool } +// 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 + // 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, +} + +// guardFailed accepts a failed request, which an admin may close instead of +// retrying it. +var guardFailed = StateGuard{Outcomes: []Outcome{OutcomeFailed}} + +// 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" + // StatePartiallyAvailable: some of a season request's seasons are in the + // library, not all. + StatePartiallyAvailable State = "partially_available" + 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. A season request is +// available when every requested season is complete (SeasonProgress +// attached), and partially available while only some of their episodes are +// in. +func (r *Request) State() State { + switch r.Outcome { + case OutcomeDeclined: + return StateDeclined + case OutcomeCancelled: + return StateCancelled + case OutcomeFailed: + return StateFailed + } + if r.Status == StatusPending { + return StatePending + } + if len(r.SeasonProgress) > 0 { + if seasonsDelivered(r.SeasonProgress, r.Status == StatusCompleted) { + return StateAvailable + } + for _, p := range r.SeasonProgress { + if p.Have > 0 { + return StatePartiallyAvailable + } + } + } + switch r.Status { + case StatusApproved: + return StateApproved + case StatusCompleted: + if r.LibraryContentID != "" && len(r.Seasons) == 0 { + return StateAvailable + } + return StateProcessing + default: + return StateProcessing + } +} + +// requestedBy reports whether the viewer's profile made the request. A profile +// id is unique only within its account, so the account must match too. +func (r *Request) requestedBy(viewer Viewer) bool { + return r.RequestedByUserID == viewer.UserID && r.RequestedByProfileID == viewer.ProfileID +} + +// RequestEvent is one entry of a request's history. ActorUsername is set +// when the actor's account still exists. type RequestEvent struct { ID int64 `json:"id"` RequestID string `json:"request_id"` EventType string `json:"event_type"` ActorUserID *int `json:"actor_user_id,omitempty"` ActorProfileID string `json:"actor_profile_id,omitempty"` + ActorUsername string `json:"-"` Message string `json:"message,omitempty"` CreatedAt time.Time `json:"created_at"` } @@ -188,6 +403,17 @@ 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:"-"` + // State is the active request's user-facing state (v2 only). + State State `json:"-"` + // Download is the active request's download progress. Only the title + // detail fills it; search and discovery do not load targets (v2 only). + Download *DownloadProgress `json:"-"` } type MediaResult struct { @@ -253,6 +479,9 @@ type MediaDetail struct { Availability Availability `json:"availability"` LibraryContentID string `json:"library_content_id,omitempty"` Request RequestState `json:"request"` + // Seasons lists a series' regular seasons with their library + // availability and whether the active request covers them. + Seasons []RequestSeason `json:"-"` } type CreateRequestInput struct { @@ -265,6 +494,14 @@ type CreateRequestInput struct { Overview string `json:"overview,omitempty"` PosterPath string `json:"poster_path,omitempty"` BackdropPath string `json:"backdrop_path,omitempty"` + // Seasons are the season numbers a series request asks for; none means + // every aired season still missing. v2 only; the frozen v1 body does not + // carry it. + Seasons []int `json:"-"` + // WholeSeries keeps the rule from before season requests, for v1: a + // series request asks for the whole series and is refused once the + // series is in the library. + WholeSeries bool `json:"-"` } // RequestPageKey identifies the last emitted request in descending creation order. @@ -277,8 +514,44 @@ type ListFilter struct { Before *RequestPageKey Status Status Outcome Outcome - Limit int - Offset int + // Admin queue filters. + View AdminView + Query string + MediaType MediaType + RequestedByUserID int + Limit int + Offset int +} + +// AdminView groups the admin queue by what an admin does next. +type AdminView string + +const ( + // AdminViewNeedsApproval: pending, waiting for an admin. + AdminViewNeedsApproval AdminView = "needs_approval" + // AdminViewInProgress: approved and on its way to the library. + AdminViewInProgress AdminView = "in_progress" + // AdminViewFailed: failed; Retry sends it again. + AdminViewFailed AdminView = "failed" + // AdminViewDone: completed, or closed by a decline or cancellation. + AdminViewDone AdminView = "done" +) + +// Valid reports whether v names a view. +func (v AdminView) Valid() bool { + switch v { + case AdminViewNeedsApproval, AdminViewInProgress, AdminViewFailed, AdminViewDone: + return true + } + return false +} + +// AdminViewCounts counts the requests in each admin view. +type AdminViewCounts struct { + NeedsApproval int + InProgress int + Failed int + Done int } type Integration struct { @@ -320,5 +593,16 @@ 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"` +} + +// DownloadRefreshResult counts one download refresh pass. Checked counts the +// requests whose targets were asked about, Updated those where a target's +// status moved. +type DownloadRefreshResult struct { + Checked int `json:"checked"` + Updated int `json:"updated"` + Errors int `json:"errors"` } diff --git a/internal/taskmanager/tasks/reconcile_requests.go b/internal/taskmanager/tasks/reconcile_requests.go index deb6751e97..702483e82d 100644 --- a/internal/taskmanager/tasks/reconcile_requests.go +++ b/internal/taskmanager/tasks/reconcile_requests.go @@ -4,25 +4,116 @@ import ( "context" "encoding/json" "fmt" + "log/slog" + "time" + "github.com/Silo-Server/silo-server/internal/database/pglock" "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". It lets one server run each +// reconcile pass. +const requestReconcileAdvisoryLock int64 = 0x53494C4F52515243 + +// requestTargetWriteAdvisoryLock spells "SILORQTW". It keeps the reconcile pass +// and the download refresh pass from writing request targets at the same time: +// reconcile waits for it, and the refresh skips while it is held. +const requestTargetWriteAdvisoryLock int64 = 0x53494C4F52515457 + +// requestTargetWriteWait bounds how long a reconcile pass waits for a download +// refresh pass to finish. A refresh pass asks about at most 200 requests and +// normally ends well inside a minute; requestDownloadRefreshBudget cuts it off +// before this wait runs out, even while a download server stops answering. +const requestTargetWriteWait = 2 * time.Minute + +// requestDownloadRefreshBudget bounds a download refresh pass. A plugin call +// may take the router's full 60-second deadline, and one server that stops +// answering can hang every call on it, so without a bound a pass could hold +// the target write lock past requestTargetWriteWait and fail the reconcile +// pass waiting for it. The margin covers the writes that apply the last +// answer after the budget cuts the call in flight. +const requestDownloadRefreshBudget = 90 * time.Second + 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. The pass then waits for the request target write lock, +// so it runs after a download refresh pass in progress instead of skipping. type ReconcileRequestsTask struct { reconciler RequestReconciler limit int + locks reconcileLocker +} + +// reconcileLockOutcome is how taking the reconcile pass's locks went. +type reconcileLockOutcome int + +const ( + reconcileLocksHeld reconcileLockOutcome = iota + // reconcileLocksBusy: another server's reconcile pass is running. + reconcileLocksBusy + // reconcileLocksWaitedOut: a download refresh pass held the request target + // write lock past requestTargetWriteWait. + reconcileLocksWaitedOut +) + +// reconcileLocker takes both of the reconcile pass's locks. release is set +// only when outcome is reconcileLocksHeld. +type reconcileLocker interface { + Acquire(ctx context.Context) (release func(), outcome reconcileLockOutcome, err error) +} + +// requestReconcileLocks takes requestReconcileAdvisoryLock without waiting, +// then requestTargetWriteAdvisoryLock with a wait of requestTargetWriteWait, +// both on one database session. One session leaves the rest of the pool to +// the pass itself: a session per lock would take both connections of a +// two-connection pool and leave the pass none. +type requestReconcileLocks struct { + pool *pgxpool.Pool +} + +func (l requestReconcileLocks) Acquire(ctx context.Context) (func(), reconcileLockOutcome, error) { + lock, acquired, err := pglock.TryAcquire(ctx, l.pool, requestReconcileAdvisoryLock) + if err != nil { + return nil, reconcileLocksBusy, fmt.Errorf("acquiring request reconcile lock: %w", err) + } + if !acquired { + return nil, reconcileLocksBusy, nil + } + release := func() { + if err := lock.Release(ctx); err != nil { + slog.WarnContext(ctx, "releasing request reconcile locks failed", "component", "taskmanager", "error", err) + } + } + held, err := lock.AcquireAlso(ctx, requestTargetWriteAdvisoryLock, requestTargetWriteWait) + if err != nil || !held { + release() + if err != nil { + return nil, reconcileLocksWaitedOut, fmt.Errorf("acquiring request target write lock: %w", err) + } + return nil, reconcileLocksWaitedOut, nil + } + return release, reconcileLocksHeld, nil } -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.locks = requestReconcileLocks{pool: pool} + } + return t } func (t *ReconcileRequestsTask) Key() string { return "reconcile_requests" } @@ -47,6 +138,20 @@ func (t *ReconcileRequestsTask) Execute(ctx context.Context, progress taskmanage progress.Report(100, "Request reconciliation unavailable") return nil } + if t.locks != nil { + release, outcome, err := t.locks.Acquire(ctx) + if err != nil { + return err + } + switch outcome { + case reconcileLocksBusy: + progress.Report(100, "Another server is reconciling media requests") + return nil + case reconcileLocksWaitedOut: + return fmt.Errorf("request download refresh held the request target write lock for over %s", requestTargetWriteWait) + } + defer release() + } result, err := t.reconciler.ReconcileRequests(ctx, t.limit) if err != nil { return fmt.Errorf("reconcile media requests: %w", err) diff --git a/internal/taskmanager/tasks/refresh_anime_ids.go b/internal/taskmanager/tasks/refresh_anime_ids.go new file mode 100644 index 0000000000..dd018982d5 --- /dev/null +++ b/internal/taskmanager/tasks/refresh_anime_ids.go @@ -0,0 +1,70 @@ +package tasks + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/Silo-Server/silo-server/internal/animeids" + "github.com/Silo-Server/silo-server/internal/taskmanager" +) + +// AnimeIDsRefresher replaces the stored anime list. Satisfied by +// *animeids.Refresher. +type AnimeIDsRefresher interface { + Refresh(ctx context.Context) (animeids.Result, error) +} + +// RefreshAnimeIDsTask downloads the AniDB-based anime list request routing +// uses to tell anime TMDB does not tag. One server downloads it at a time. +type RefreshAnimeIDsTask struct { + refresher AnimeIDsRefresher +} + +// NewRefreshAnimeIDsTask creates a new RefreshAnimeIDsTask. +func NewRefreshAnimeIDsTask(refresher AnimeIDsRefresher) *RefreshAnimeIDsTask { + return &RefreshAnimeIDsTask{refresher: refresher} +} + +func (t *RefreshAnimeIDsTask) Key() string { return "refresh_anime_ids" } +func (t *RefreshAnimeIDsTask) Name() string { return "Refresh Anime List" } +func (t *RefreshAnimeIDsTask) Description() string { + return "Downloads the AniDB-based anime list that request routing uses to recognize anime TMDB does not tag" +} + +func (t *RefreshAnimeIDsTask) Category() taskmanager.TaskCategory { + return taskmanager.TaskCategoryMetadata +} + +func (t *RefreshAnimeIDsTask) IsHidden() bool { return false } + +func (t *RefreshAnimeIDsTask) DefaultTriggers() []taskmanager.TriggerConfig { + return []taskmanager.TriggerConfig{ + {Type: taskmanager.TriggerTypeStartup}, + {Type: taskmanager.TriggerTypeInterval, IntervalMs: 24 * 60 * 60 * 1000}, // daily + } +} + +func (t *RefreshAnimeIDsTask) Execute(ctx context.Context, progress taskmanager.ProgressReporter) error { + if t.refresher == nil { + return errors.New("anime list refresh: refresher not configured") + } + progress.Report(0, "Downloading the anime list") + result, err := t.refresher.Refresh(ctx) + if err != nil { + return fmt.Errorf("anime list refresh: %w", err) + } + if data, err := json.Marshal(result); err == nil { + progress.SetResultData(data) + } + switch { + case result.Skipped: + progress.Report(100, "Another server is refreshing the anime list") + case result.Unchanged: + progress.Report(100, "The anime list has not changed") + default: + progress.Report(100, fmt.Sprintf("Stored %d anime IDs", result.Entries)) + } + return nil +} diff --git a/internal/taskmanager/tasks/refresh_request_downloads.go b/internal/taskmanager/tasks/refresh_request_downloads.go new file mode 100644 index 0000000000..87a115e1c7 --- /dev/null +++ b/internal/taskmanager/tasks/refresh_request_downloads.go @@ -0,0 +1,97 @@ +package tasks + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/Silo-Server/silo-server/internal/requests" + "github.com/Silo-Server/silo-server/internal/taskmanager" + "github.com/jackc/pgx/v5/pgxpool" +) + +type RequestDownloadRefresher interface { + RefreshDownloads(ctx context.Context, limit int, budget time.Duration) (requests.DownloadRefreshResult, error) + HasDownloadsToRefresh(ctx context.Context) (bool, error) +} + +// RefreshRequestDownloadsTask refreshes the status and download progress of +// downloading request targets whose router plugin reports progress, between +// reconcile passes. It tries the request target write lock without waiting, +// so one server runs each pass, and it skips while a reconcile pass holds the +// lock anywhere in the cluster: the two never write the same target at once, +// and a reconcile pass waits for a refresh pass instead of skipping. Each pass +// runs within requestDownloadRefreshBudget, so that wait always ends. +type RefreshRequestDownloadsTask struct { + refresher RequestDownloadRefresher + limit int + lock clusterLock +} + +// NewRefreshRequestDownloadsTask constructs the task. A nil pool runs without +// the cluster lock. +func NewRefreshRequestDownloadsTask(refresher RequestDownloadRefresher, limit int, pool *pgxpool.Pool) *RefreshRequestDownloadsTask { + if limit <= 0 { + limit = 200 + } + t := &RefreshRequestDownloadsTask{refresher: refresher, limit: limit} + if pool != nil { + t.lock = advisoryClusterLock{pool: pool, key: requestTargetWriteAdvisoryLock, name: "request download refresh"} + } + return t +} + +func (t *RefreshRequestDownloadsTask) Key() string { return "refresh_request_downloads" } +func (t *RefreshRequestDownloadsTask) Name() string { return "Refresh Request Downloads" } +func (t *RefreshRequestDownloadsTask) Description() string { + return "Refreshes the download progress of media requests that Radarr, Sonarr, or another request plugin is downloading" +} +func (t *RefreshRequestDownloadsTask) Category() taskmanager.TaskCategory { + return taskmanager.TaskCategoryLibrary +} +func (t *RefreshRequestDownloadsTask) IsHidden() bool { return true } + +func (t *RefreshRequestDownloadsTask) DefaultTriggers() []taskmanager.TriggerConfig { + return []taskmanager.TriggerConfig{ + {Type: taskmanager.TriggerTypeInterval, IntervalMs: 60 * 1000}, + } +} + +// ShouldRun skips a scheduled run while no downloading target has progress to +// refresh, so an idle server records no run every minute. The reconcile pass +// finds a download's first progress. +func (t *RefreshRequestDownloadsTask) ShouldRun(ctx context.Context) (bool, error) { + if t == nil || t.refresher == nil { + return false, nil + } + return t.refresher.HasDownloadsToRefresh(ctx) +} + +func (t *RefreshRequestDownloadsTask) Execute(ctx context.Context, progress taskmanager.ProgressReporter) error { + progress.Report(0, "Refreshing request downloads") + if t.refresher == nil { + progress.Report(100, "Request download refresh unavailable") + return nil + } + if t.lock != nil { + release, acquired, err := t.lock.TryAcquire(ctx) + if err != nil { + return fmt.Errorf("acquiring request target write lock: %w", err) + } + if !acquired { + progress.Report(100, "A request reconcile or download refresh pass is already running") + return nil + } + defer release() + } + result, err := t.refresher.RefreshDownloads(ctx, t.limit, requestDownloadRefreshBudget) + if err != nil { + return fmt.Errorf("refresh request downloads: %w", err) + } + if data, err := json.Marshal(result); err == nil { + progress.SetResultData(data) + } + progress.Report(100, "Request download refresh complete") + return nil +} diff --git a/internal/taskmanager/tasks/refresh_request_downloads_test.go b/internal/taskmanager/tasks/refresh_request_downloads_test.go new file mode 100644 index 0000000000..7bdca82744 --- /dev/null +++ b/internal/taskmanager/tasks/refresh_request_downloads_test.go @@ -0,0 +1,368 @@ +package tasks + +import ( + "context" + "encoding/json" + "errors" + "os" + "sync/atomic" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/database/pglock" + "github.com/Silo-Server/silo-server/internal/requests" +) + +type downloadRefresherStub struct { + result requests.DownloadRefreshResult + err error + runs atomic.Int32 + limit int + budget time.Duration + + hasWork bool + hasWorkErr error +} + +func (s *downloadRefresherStub) RefreshDownloads(_ context.Context, limit int, budget time.Duration) (requests.DownloadRefreshResult, error) { + s.runs.Add(1) + s.limit, s.budget = limit, budget + return s.result, s.err +} + +func (s *downloadRefresherStub) HasDownloadsToRefresh(context.Context) (bool, error) { + return s.hasWork, s.hasWorkErr +} + +// reconcilerStub counts reconcile passes. With proceed set, a pass reports +// on entered and then blocks until proceed closes. +type reconcilerStub struct { + runs atomic.Int32 + entered chan struct{} + proceed chan struct{} +} + +func (s *reconcilerStub) ReconcileRequests(ctx context.Context, _ int) (requests.ReconcileResult, error) { + s.runs.Add(1) + if s.proceed != nil { + s.entered <- struct{}{} + select { + case <-s.proceed: + case <-ctx.Done(): + return requests.ReconcileResult{}, ctx.Err() + } + } + return requests.ReconcileResult{}, nil +} + +// fakeReconcileLocks stands in for the reconcile pass's two locks. +type fakeReconcileLocks struct { + outcome reconcileLockOutcome + err error + takes int + released int +} + +func (f *fakeReconcileLocks) Acquire(context.Context) (func(), reconcileLockOutcome, error) { + f.takes++ + if f.err != nil || f.outcome != reconcileLocksHeld { + return nil, f.outcome, f.err + } + return func() { f.released++ }, reconcileLocksHeld, nil +} + +func TestRefreshRequestDownloadsTaskRunsUnderTheLock(t *testing.T) { + refresher := &downloadRefresherStub{result: requests.DownloadRefreshResult{Checked: 3, Updated: 1}} + lock := &fakeClusterLock{acquired: true} + task := NewRefreshRequestDownloadsTask(refresher, 200, nil) + task.lock = lock + progress := &bulkEnrichmentTaskProgress{} + + if err := task.Execute(context.Background(), progress); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if refresher.runs.Load() != 1 || refresher.limit != 200 || lock.released != 1 { + t.Fatalf("runs = %d, limit = %d, lock releases = %d; want 1, 200 and 1", refresher.runs.Load(), refresher.limit, lock.released) + } + if refresher.budget != requestDownloadRefreshBudget { + t.Fatalf("budget = %s, want %s", refresher.budget, requestDownloadRefreshBudget) + } + var result requests.DownloadRefreshResult + if err := json.Unmarshal(progress.result, &result); err != nil || result.Checked != 3 || result.Updated != 1 { + t.Fatalf("result data %s: %v", progress.result, err) + } +} + +func TestRefreshRequestDownloadsTaskSkipsWhileTheWriteLockIsHeld(t *testing.T) { + refresher := &downloadRefresherStub{} + task := NewRefreshRequestDownloadsTask(refresher, 200, nil) + task.lock = &fakeClusterLock{acquired: false} + + if err := task.Execute(context.Background(), &bulkEnrichmentTaskProgress{}); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if refresher.runs.Load() != 0 { + t.Fatalf("runs = %d, want 0 while the lock is held", refresher.runs.Load()) + } +} + +func TestRefreshRequestDownloadsTaskReportsFailure(t *testing.T) { + task := NewRefreshRequestDownloadsTask(&downloadRefresherStub{err: errors.New("database unavailable")}, 0, nil) + if err := task.Execute(context.Background(), &bulkEnrichmentTaskProgress{}); err == nil { + t.Fatal("Execute() error = nil, want the pass's error") + } +} + +// Scheduled runs happen only while some downloading target has progress, so +// an idle server records no run every minute. +func TestRefreshRequestDownloadsTaskShouldRun(t *testing.T) { + ctx := context.Background() + if run, err := NewRefreshRequestDownloadsTask(&downloadRefresherStub{}, 0, nil).ShouldRun(ctx); err != nil || run { + t.Fatalf("ShouldRun() without work = %v, %v; want false", run, err) + } + if run, err := NewRefreshRequestDownloadsTask(&downloadRefresherStub{hasWork: true}, 0, nil).ShouldRun(ctx); err != nil || !run { + t.Fatalf("ShouldRun() with work = %v, %v; want true", run, err) + } + failure := errors.New("database unavailable") + if _, err := NewRefreshRequestDownloadsTask(&downloadRefresherStub{hasWorkErr: failure}, 0, nil).ShouldRun(ctx); !errors.Is(err, failure) { + t.Fatalf("ShouldRun() err = %v, want the check's", err) + } + if run, err := NewRefreshRequestDownloadsTask(nil, 0, nil).ShouldRun(ctx); err != nil || run { + t.Fatalf("ShouldRun() without a refresher = %v, %v; want false", run, err) + } +} + +// The refresh tries only the target write lock; reconcile takes its own lock +// and the write lock together on one session. The pool never connects here. +func TestRequestTaskLockKeys(t *testing.T) { + pool, err := pgxpool.New(context.Background(), "postgres://silo@127.0.0.1:1/silo?sslmode=disable") + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + refresh, _ := NewRefreshRequestDownloadsTask(&downloadRefresherStub{}, 0, pool).lock.(advisoryClusterLock) + if refresh.key != requestTargetWriteAdvisoryLock { + t.Fatalf("refresh lock key = %#x, want the target write lock %#x", refresh.key, requestTargetWriteAdvisoryLock) + } + if locks, ok := NewReconcileRequestsTask(nil, 0, pool).locks.(requestReconcileLocks); !ok || locks.pool != pool { + t.Fatalf("reconcile locks = %#v, want requestReconcileLocks on the pool", locks) + } +} + +// A refresh pass must end inside the reconcile pass's wait for the write lock +// even when a download server stops answering, or a reconcile tick that lands +// during it fails. The budget cuts the call in flight, so the pass ends at the +// budget plus the writes that apply the last answer; the margin covers those. +func TestRequestDownloadRefreshBudgetEndsInsideTheReconcileWait(t *testing.T) { + const writeMargin = 30 * time.Second + if requestDownloadRefreshBudget <= 0 || requestDownloadRefreshBudget+writeMargin > requestTargetWriteWait { + t.Fatalf("refresh budget %s leaves under %s of the reconcile's %s wait", requestDownloadRefreshBudget, writeMargin, requestTargetWriteWait) + } +} + +func TestReconcileRequestsTaskRunsUnderItsLocks(t *testing.T) { + reconciler := &reconcilerStub{} + task := NewReconcileRequestsTask(reconciler, 0, nil) + locks := &fakeReconcileLocks{outcome: reconcileLocksHeld} + task.locks = locks + + if err := task.Execute(context.Background(), &bulkEnrichmentTaskProgress{}); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if reconciler.runs.Load() != 1 || locks.takes != 1 || locks.released != 1 { + t.Fatalf("runs = %d, lock takes = %d, releases = %d; want 1 each", reconciler.runs.Load(), locks.takes, locks.released) + } +} + +func TestReconcileRequestsTaskSkipsWhileAnotherReconcileRuns(t *testing.T) { + reconciler := &reconcilerStub{} + task := NewReconcileRequestsTask(reconciler, 0, nil) + task.locks = &fakeReconcileLocks{outcome: reconcileLocksBusy} + + if err := task.Execute(context.Background(), &bulkEnrichmentTaskProgress{}); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if runs := reconciler.runs.Load(); runs != 0 { + t.Fatalf("runs = %d while another reconcile runs, want 0", runs) + } +} + +// A refresh that holds the write lock past the wait fails the pass loudly +// instead of skipping it quietly. +func TestReconcileRequestsTaskFailsWhenTheWaitRunsOut(t *testing.T) { + reconciler := &reconcilerStub{} + task := NewReconcileRequestsTask(reconciler, 0, nil) + task.locks = &fakeReconcileLocks{outcome: reconcileLocksWaitedOut} + + if err := task.Execute(context.Background(), &bulkEnrichmentTaskProgress{}); err == nil { + t.Fatal("Execute() error = nil, want the wait to fail the pass") + } + if runs := reconciler.runs.Load(); runs != 0 { + t.Fatalf("runs = %d, want 0", runs) + } +} + +func requestLockTestPool(t *testing.T) *pgxpool.Pool { + t.Helper() + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + pool, err := pgxpool.New(t.Context(), dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + return pool +} + +// waitForLockWaiter returns once a session is queued for advisory lock key, +// failing the test if done fires first. +func waitForLockWaiter(t *testing.T, pool *pgxpool.Pool, key int64, done <-chan error) { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + for { + var waiting bool + if err := pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM pg_locks + WHERE locktype = 'advisory' AND NOT granted + AND ((classid::bigint << 32) | objid::bigint) = $1 + )`, key).Scan(&waiting); err != nil { + t.Fatalf("inspect pg_locks: %v", err) + } + if waiting { + return + } + select { + case err := <-done: + t.Fatalf("Execute() returned %v before queueing for the lock", err) + case <-ctx.Done(): + t.Fatal("nothing queued for the lock") + case <-time.After(10 * time.Millisecond): + } + } +} + +// A reconcile tick that lands while a download refresh holds the target write +// lock waits for it and then runs. +func TestReconcileWaitsForADownloadRefreshPostgres(t *testing.T) { + pool := requestLockTestPool(t) + reconciler := &reconcilerStub{} + task := NewReconcileRequestsTask(reconciler, 0, pool) + + refresh, acquired, err := pglock.TryAcquire(t.Context(), pool, requestTargetWriteAdvisoryLock) + if err != nil || !acquired { + t.Fatalf("take the target write lock: acquired = %v, err = %v", acquired, err) + } + done := make(chan error, 1) + go func() { done <- task.Execute(t.Context(), &bulkEnrichmentTaskProgress{}) }() + waitForLockWaiter(t, pool, requestTargetWriteAdvisoryLock, done) + if runs := reconciler.runs.Load(); runs != 0 { + t.Fatalf("runs = %d while the refresh held the write lock, want 0", runs) + } + if err := refresh.Release(t.Context()); err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatalf("Execute() after the refresh released the lock: %v", err) + } + if runs := reconciler.runs.Load(); runs != 1 { + t.Fatalf("runs = %d, want 1 once the refresh finished", runs) + } +} + +// The reconcile pass holds both of its locks on one session, so a pool of two +// connections still has one for the pass itself. With a session per lock, the +// pass's first query would wait forever for a connection. +func TestReconcileLeavesAConnectionForItsPassPostgres(t *testing.T) { + base := requestLockTestPool(t) + config := base.Config().Copy() + config.MaxConns = 2 + pool, err := pgxpool.NewWithConfig(t.Context(), config) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + reconciler := &queryingReconcilerStub{pool: pool} + task := NewReconcileRequestsTask(reconciler, 0, pool) + + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + if err := task.Execute(ctx, &bulkEnrichmentTaskProgress{}); err != nil { + t.Fatalf("Execute() with a two-connection pool: %v", err) + } + if reconciler.queries.Load() != 1 { + t.Fatalf("queries = %d, want the pass to have run one", reconciler.queries.Load()) + } +} + +// queryingReconcilerStub runs one query on pool, as a real pass would. +type queryingReconcilerStub struct { + pool *pgxpool.Pool + queries atomic.Int32 +} + +func (s *queryingReconcilerStub) ReconcileRequests(ctx context.Context, _ int) (requests.ReconcileResult, error) { + var one int + if err := s.pool.QueryRow(ctx, `SELECT 1`).Scan(&one); err != nil { + return requests.ReconcileResult{}, err + } + s.queries.Add(1) + return requests.ReconcileResult{}, nil +} + +// Reconcile stays one pass at a time across the cluster. +func TestReconcileSkipsWhileAnotherReconcileRunsPostgres(t *testing.T) { + pool := requestLockTestPool(t) + reconciler := &reconcilerStub{} + task := NewReconcileRequestsTask(reconciler, 0, pool) + + other, acquired, err := pglock.TryAcquire(t.Context(), pool, requestReconcileAdvisoryLock) + if err != nil || !acquired { + t.Fatalf("take the reconcile lock: acquired = %v, err = %v", acquired, err) + } + defer func() { _ = other.Release(t.Context()) }() + if err := task.Execute(t.Context(), &bulkEnrichmentTaskProgress{}); err != nil { + t.Fatalf("Execute() while another reconcile runs: %v", err) + } + if runs := reconciler.runs.Load(); runs != 0 { + t.Fatalf("runs = %d while another reconcile ran, want 0", runs) + } +} + +// A refresh tick skips while a reconcile pass runs, and runs once it ends. +func TestRefreshRequestDownloadsSkipsDuringReconcilePostgres(t *testing.T) { + pool := requestLockTestPool(t) + reconciler := &reconcilerStub{entered: make(chan struct{}, 1), proceed: make(chan struct{})} + reconcile := NewReconcileRequestsTask(reconciler, 0, pool) + refresher := &downloadRefresherStub{} + refresh := NewRefreshRequestDownloadsTask(refresher, 200, pool) + + done := make(chan error, 1) + go func() { done <- reconcile.Execute(t.Context(), &bulkEnrichmentTaskProgress{}) }() + select { + case <-reconciler.entered: + case err := <-done: + t.Fatalf("reconcile returned %v before its pass started", err) + } + if err := refresh.Execute(t.Context(), &bulkEnrichmentTaskProgress{}); err != nil { + t.Fatalf("Execute() while reconcile runs: %v", err) + } + if runs := refresher.runs.Load(); runs != 0 { + t.Fatalf("refresh runs = %d while reconcile ran, want 0", runs) + } + close(reconciler.proceed) + if err := <-done; err != nil { + t.Fatalf("reconcile: %v", err) + } + if err := refresh.Execute(t.Context(), &bulkEnrichmentTaskProgress{}); err != nil { + t.Fatalf("Execute() after reconcile: %v", err) + } + if runs := refresher.runs.Load(); runs != 1 { + t.Fatalf("refresh runs = %d after reconcile ended, want 1", runs) + } +} diff --git a/internal/workmetrics/work.go b/internal/workmetrics/work.go index d7b62ce3d9..5dcaad0c65 100644 --- a/internal/workmetrics/work.go +++ b/internal/workmetrics/work.go @@ -70,7 +70,7 @@ func Category(key string) string { return "recommendations" case "seed_content_availability", "rebuild_release_interest": return workloadNotifications - case "reconcile_requests", "reconcile_watch_history", "database_maintenance", "cleanup_client_diagnostics", "cleanup_operational_log", "cleanup_orphaned_media_items", "setting_mutations_retention", "backfill_media_item_aliases", "repair_provider_id_integrity", "sync_collections", "sync_user_collections", "check_plugin_updates": + case "reconcile_requests", "refresh_request_downloads", "reconcile_watch_history", "database_maintenance", "cleanup_client_diagnostics", "cleanup_operational_log", "cleanup_orphaned_media_items", "setting_mutations_retention", "backfill_media_item_aliases", "repair_provider_id_integrity", "sync_collections", "sync_user_collections", "check_plugin_updates": return "maintenance" default: if strings.HasPrefix(key, "plugin:") || strings.HasPrefix(key, "plugin_") { 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/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/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/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; 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/migrations/sql/20260926163857_request_seasons.sql b/migrations/sql/20260926163857_request_seasons.sql new file mode 100644 index 0000000000..5ee09d7202 --- /dev/null +++ b/migrations/sql/20260926163857_request_seasons.sql @@ -0,0 +1,8 @@ +-- +goose Up +-- The seasons a series request asks for. Empty means the whole series: every +-- request from before season requests, whose old rule (any episode in the +-- library fulfills it) still applies. +ALTER TABLE media_requests ADD COLUMN seasons integer[] NOT NULL DEFAULT '{}'; + +-- +goose Down +ALTER TABLE media_requests DROP COLUMN seasons; diff --git a/migrations/sql/20260926181741_request_group_limits.sql b/migrations/sql/20260926181741_request_group_limits.sql new file mode 100644 index 0000000000..26441120fc --- /dev/null +++ b/migrations/sql/20260926181741_request_group_limits.sql @@ -0,0 +1,36 @@ +-- +goose Up +-- Request approval and quota per access group, between an account's own +-- override and the server-wide settings. Blocking is not one of its modes: +-- the group's requests_allowed switch is the one way to block a group. +CREATE TABLE request_group_limits ( + group_id bigint PRIMARY KEY REFERENCES access_groups(id) ON DELETE CASCADE, + limit_mode text NOT NULL DEFAULT 'inherit' + CONSTRAINT request_group_limits_limit_mode_check CHECK (limit_mode IN ('inherit', 'custom', 'unlimited')), + max_requests integer + CONSTRAINT request_group_limits_max_nonnegative CHECK (max_requests IS NULL OR max_requests >= 0), + window_days integer + CONSTRAINT request_group_limits_window_positive CHECK (window_days IS NULL OR window_days > 0), + approval_mode text NOT NULL DEFAULT 'inherit' + CONSTRAINT request_group_limits_approval_mode_check CHECK (approval_mode IN ('inherit', 'manual', 'auto')), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + revision bigint NOT NULL DEFAULT nextval('request_editor_revision_seq') +); +CREATE TRIGGER request_group_limits_revision BEFORE INSERT OR UPDATE ON request_group_limits + FOR EACH ROW EXECUTE FUNCTION advance_request_editor_revision(); + +-- An account blocked through its request limits is blocked through its +-- requests switch instead, the one per-account way to block it. +UPDATE users SET requests_allowed = false +WHERE id IN ( + SELECT user_id FROM request_user_limits + WHERE limit_mode = 'blocked' OR approval_mode = 'blocked' +); +UPDATE request_user_limits +SET limit_mode = CASE WHEN limit_mode = 'blocked' THEN 'inherit' ELSE limit_mode END, + approval_mode = CASE WHEN approval_mode = 'blocked' THEN 'inherit' ELSE approval_mode END, + updated_at = now() +WHERE limit_mode = 'blocked' OR approval_mode = 'blocked'; + +-- +goose Down +-- Accounts moved to requests_allowed = false stay blocked that way. +DROP TABLE request_group_limits; diff --git a/migrations/sql/20260926205431_request_routes_single_server_fallback.sql b/migrations/sql/20260926205431_request_routes_single_server_fallback.sql new file mode 100644 index 0000000000..a23fb424fd --- /dev/null +++ b/migrations/sql/20260926205431_request_routes_single_server_fallback.sql @@ -0,0 +1,41 @@ +-- +goose Up +-- A server added from now on becomes Everything else for its media type when +-- no other server takes that media type. Give installs that already have +-- exactly one usable Radarr (Sonarr), no other enabled server for movies +-- (series) and no Everything else the same setup, so a single-server install +-- needs no routing. Usable is what the routing migration required: enabled, +-- bound to a plugin installation, with an API key. A server flagged 4K is left +-- alone, since Everything else needs an HD server. +-- +goose StatementBegin +WITH kinds (media_type, kind) AS ( + VALUES ('movie', 'radarr'), ('series', 'sonarr') +), +sole AS ( + SELECT k.media_type, min(i.id) AS id + FROM kinds k + JOIN request_integrations i ON i.plugin_config->>'service_kind' = k.kind + WHERE i.enabled AND i.installation_id IS NOT NULL AND i.api_key_ref <> '' + AND NOT coalesce((i.plugin_config->>'is_4k')::boolean, false) + AND NOT coalesce((i.plugin_config->>'is_default_4k')::boolean, false) + GROUP BY k.media_type + HAVING count(*) = 1 +) +INSERT INTO request_routes (id, media_type, position, name, is_fallback, hd_integration_id) +SELECT 'fallback-' || s.media_type, s.media_type, 1000, 'Everything else', true, s.id +FROM sole s +WHERE NOT EXISTS (SELECT 1 FROM request_routes r WHERE r.media_type = s.media_type AND r.is_fallback) + -- No other enabled server takes the media type: another of the kind, or a + -- connection of another plugin (Seerr, say) that serves it. + AND NOT EXISTS ( + SELECT 1 FROM request_integrations i + WHERE i.id <> s.id AND i.enabled + AND CASE WHEN coalesce(i.plugin_config->>'service_kind', '') <> '' + THEN i.plugin_config->>'service_kind' = CASE s.media_type WHEN 'movie' THEN 'radarr' ELSE 'sonarr' END + ELSE cardinality(i.supported_media_types) = 0 OR s.media_type = ANY(i.supported_media_types) END) +ON CONFLICT DO NOTHING; +-- +goose StatementEnd + +-- +goose Down +-- The routes it made are ordinary routes by now; an admin may have edited +-- them. Nothing to undo. +SELECT 1; diff --git a/migrations/sql/20260926223444_request_routing_mode.sql b/migrations/sql/20260926223444_request_routing_mode.sql new file mode 100644 index 0000000000..67d38959c2 --- /dev/null +++ b/migrations/sql/20260926223444_request_routing_mode.sql @@ -0,0 +1,51 @@ +-- +goose Up +-- How requests find their server. Standard sends each media type to its one +-- server, and 4K copies to its one server marked 4K, with each server's own +-- settings; the routing rules are kept but paused. Advanced routes with the +-- rules. One row. +CREATE TABLE request_routing ( + id boolean PRIMARY KEY DEFAULT true CONSTRAINT request_routing_one_row CHECK (id), + mode text NOT NULL CONSTRAINT request_routing_mode_check CHECK (mode IN ('standard', 'advanced')), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + revision bigint NOT NULL DEFAULT nextval('request_editor_revision_seq') +); +CREATE TRIGGER request_routing_revision BEFORE INSERT OR UPDATE ON request_routing + FOR EACH ROW EXECUTE FUNCTION advance_request_editor_revision(); + +-- An install keeps routing with its rules (Advanced) when Standard would send +-- a request somewhere else: it has an enabled rule; a media type has more than +-- one enabled normal or 4K server; or Everything else changes a server +-- setting, sends HD copies anywhere but the media type's one normal Radarr or +-- Sonarr, or sends 4K copies anywhere (Standard would send them to the server +-- marked 4K) or nowhere while a server marked 4K takes the media type. +-- Everyone else starts on Standard. +-- +goose StatementBegin +WITH servers AS ( + SELECT i.id, m.media_type, + coalesce((i.plugin_config->>'is_4k')::boolean, false) + OR coalesce((i.plugin_config->>'is_default_4k')::boolean, false) AS is_4k, + coalesce(i.plugin_config->>'service_kind', '') <> '' AS arr + FROM request_integrations i + CROSS JOIN (VALUES ('movie', 'radarr'), ('series', 'sonarr')) AS m (media_type, kind) + WHERE i.enabled + AND CASE WHEN coalesce(i.plugin_config->>'service_kind', '') <> '' + THEN i.plugin_config->>'service_kind' = m.kind + ELSE cardinality(i.supported_media_types) = 0 OR m.media_type = ANY(i.supported_media_types) END +) +INSERT INTO request_routing (id, mode) +SELECT true, CASE WHEN + EXISTS (SELECT 1 FROM request_routes WHERE enabled AND NOT is_fallback) + OR EXISTS (SELECT 1 FROM servers GROUP BY media_type, is_4k HAVING count(*) > 1) + OR EXISTS ( + SELECT 1 FROM request_routes f + WHERE f.is_fallback AND f.hd_integration_id IS NOT NULL + AND (f.hd_overrides <> '{}'::jsonb OR f.uhd_overrides <> '{}'::jsonb + OR f.uhd_integration_id IS NOT NULL + OR NOT EXISTS (SELECT 1 FROM servers s WHERE s.id = f.hd_integration_id + AND s.media_type = f.media_type AND s.arr AND NOT s.is_4k) + OR EXISTS (SELECT 1 FROM servers s WHERE s.media_type = f.media_type AND s.is_4k))) + THEN 'advanced' ELSE 'standard' END; +-- +goose StatementEnd + +-- +goose Down +DROP TABLE request_routing; diff --git a/migrations/sql/20260927005655_anime_ids.sql b/migrations/sql/20260927005655_anime_ids.sql new file mode 100644 index 0000000000..533f6e1b7b --- /dev/null +++ b/migrations/sql/20260927005655_anime_ids.sql @@ -0,0 +1,26 @@ +-- +goose Up +-- The titles an AniDB-based anime list names, by the IDs TMDB also reports: +-- TVDB series IDs and IMDb IDs. Request routing counts a listed title as +-- anime. A scheduled task replaces the rows from the published list; the +-- request path only reads them. +CREATE TABLE anime_ids ( + source text NOT NULL CONSTRAINT anime_ids_source_check CHECK (source IN ('tvdb', 'imdb')), + external_id text NOT NULL, + PRIMARY KEY (source, external_id) +); + +-- One row: when the list was last replaced, and the claim a refresh holds so +-- only one server downloads it at a time. A failed refresh keeps the rows. +CREATE TABLE anime_ids_refresh ( + id boolean PRIMARY KEY DEFAULT true CONSTRAINT anime_ids_refresh_one_row CHECK (id), + refreshed_at timestamp with time zone, + last_attempt_at timestamp with time zone, + last_status text NOT NULL DEFAULT '', + last_error text NOT NULL DEFAULT '', + etag text NOT NULL DEFAULT '', + entry_count integer NOT NULL DEFAULT 0 +); + +-- +goose Down +DROP TABLE anime_ids_refresh; +DROP TABLE anime_ids; diff --git a/migrations/sql/20260927150633_request_follows_account_key.sql b/migrations/sql/20260927150633_request_follows_account_key.sql new file mode 100644 index 0000000000..7889c37c2b --- /dev/null +++ b/migrations/sql/20260927150633_request_follows_account_key.sql @@ -0,0 +1,26 @@ +-- +goose Up +-- A profile id is only unique within its account: accounts migrated from +-- before profiles all have a profile named 'default'. Key follows by account +-- and profile, so two accounts' profiles with the same id can follow the same +-- title without colliding. +ALTER TABLE media_request_follows DROP CONSTRAINT media_request_follows_pkey; +ALTER TABLE media_request_follows ADD PRIMARY KEY (media_type, tmdb_id, user_id, profile_id); + +DROP INDEX media_request_follows_profile_idx; +CREATE INDEX media_request_follows_profile_idx ON media_request_follows (user_id, profile_id); + +-- +goose Down +-- The narrower key cannot hold two accounts' follows for one profile id and +-- title; keep the earliest. +DELETE FROM media_request_follows f +USING media_request_follows keep +WHERE f.media_type = keep.media_type + AND f.tmdb_id = keep.tmdb_id + AND f.profile_id = keep.profile_id + AND (keep.created_at, keep.user_id) < (f.created_at, f.user_id); + +DROP INDEX media_request_follows_profile_idx; +CREATE INDEX media_request_follows_profile_idx ON media_request_follows (profile_id); + +ALTER TABLE media_request_follows DROP CONSTRAINT media_request_follows_pkey; +ALTER TABLE media_request_follows ADD PRIMARY KEY (media_type, tmdb_id, profile_id); diff --git a/migrations/sql/20260927170233_request_routes_keep_legacy_owner.sql b/migrations/sql/20260927170233_request_routes_keep_legacy_owner.sql new file mode 100644 index 0000000000..2f6baa33ed --- /dev/null +++ b/migrations/sql/20260927170233_request_routes_keep_legacy_owner.sql @@ -0,0 +1,56 @@ +-- +goose Up +-- The routing migration seeded routes from the default Radarr and Sonarr +-- servers of any plugin. Before routing, a media type's requests all went to +-- the plugin that owns the first usable connection by name (enabled, bound to a +-- plugin installation, with an API key, serving the media type), and that +-- plugin picked among its own servers. Where a seeded route sends requests to +-- a server outside that owner (the first connection is Seerr, say), remove the +-- media type's seeded routes together: a media type without routes goes back +-- to that plugin, as before, while one left with only some of them would be +-- routed by Silo with no fallback for the titles they do not match. +-- A media type with a route an admin has saved or added since is left alone: +-- the admin has set up routing for it, and removing the seeded fallback would +-- leave the titles their routes do not match with nowhere to go. +-- +goose StatementBegin +WITH owners AS ( + SELECT m.media_type, o.installation_id, o.capability_id + FROM (VALUES ('movie'), ('series')) AS m (media_type) + LEFT JOIN LATERAL ( + SELECT i.installation_id, i.capability_id + FROM request_integrations i + WHERE i.enabled AND i.capability_id <> '' AND i.installation_id IS NOT NULL + AND i.api_key_ref <> '' + AND (cardinality(i.supported_media_types) = 0 OR m.media_type = ANY(i.supported_media_types)) + ORDER BY i.name, i.id + LIMIT 1 + ) o ON true +), +routes AS ( + SELECT r.*, + r.id IN ('fallback-' || r.media_type, 'anime-' || r.media_type) + AND r.updated_at = r.created_at AS seeded + FROM request_routes r +), +repaired AS ( + SELECT o.media_type + FROM owners o + WHERE EXISTS ( + SELECT 1 FROM routes r + JOIN request_integrations i ON i.id IN (r.hd_integration_id, r.uhd_integration_id) + WHERE r.media_type = o.media_type AND r.seeded + AND (i.installation_id IS DISTINCT FROM o.installation_id + OR i.capability_id IS DISTINCT FROM o.capability_id)) + AND NOT EXISTS ( + SELECT 1 FROM routes r WHERE r.media_type = o.media_type AND NOT r.seeded) +) +DELETE FROM request_routes r +USING repaired p +WHERE r.media_type = p.media_type + AND r.id IN ('fallback-' || r.media_type, 'anime-' || r.media_type) + AND r.updated_at = r.created_at; +-- +goose StatementEnd + +-- +goose Down +-- The removed routes are not recreated: the plugin that owned the media type +-- routes it again, as it did before routing existed. +SELECT 1; diff --git a/migrations/sql/20260928142920_request_fulfilled_delivery_account_key.sql b/migrations/sql/20260928142920_request_fulfilled_delivery_account_key.sql new file mode 100644 index 0000000000..118bbc1085 --- /dev/null +++ b/migrations/sql/20260928142920_request_fulfilled_delivery_account_key.sql @@ -0,0 +1,30 @@ +-- +goose Up +-- +goose StatementBegin +-- A profile id is only unique within its account (accounts migrated from +-- before profiles all have a profile named 'default'), and request.fulfilled +-- now reaches followers on other accounts as well as the requester. Key the +-- at-most-once index by account too, so a second account's profile with the +-- same id gets its own delivery instead of deduping against the first. +CREATE UNIQUE INDEX notification_deliveries_account_profile_request_key + ON public.notification_deliveries (user_id, profile_id, (reason_flags->>'request_id')) + WHERE type = 'request.fulfilled'; +DROP INDEX IF EXISTS public.notification_deliveries_profile_request_key; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +-- The narrower key cannot hold two accounts' deliveries for one profile id +-- and request; keep the earliest. +DELETE FROM public.notification_deliveries d +USING public.notification_deliveries keep +WHERE d.type = 'request.fulfilled' + AND keep.type = 'request.fulfilled' + AND d.profile_id = keep.profile_id + AND d.reason_flags->>'request_id' = keep.reason_flags->>'request_id' + AND (keep.created_at, keep.id) < (d.created_at, d.id); + +CREATE UNIQUE INDEX notification_deliveries_profile_request_key + ON public.notification_deliveries (profile_id, (reason_flags->>'request_id')) + WHERE type = 'request.fulfilled'; +DROP INDEX IF EXISTS public.notification_deliveries_account_profile_request_key; +-- +goose StatementEnd diff --git a/migrations/sql/20260928143912_request_target_download_progress.sql b/migrations/sql/20260928143912_request_target_download_progress.sql new file mode 100644 index 0000000000..35a4f34c6a --- /dev/null +++ b/migrations/sql/20260928143912_request_target_download_progress.sql @@ -0,0 +1,29 @@ +-- +goose Up +-- How far a request target's downloads are, as its router plugin last reported +-- them (request_router.reports_download_progress). Set only while the target is +-- queued or downloading, and cleared when it completes or fails. +-- download_updated_at is when the server last heard from the plugin, and +-- download_checked_at when a pass last asked about the target, answered or +-- not; the download refresh pass takes targets in that order. A progress write +-- leaves updated_at alone: that column dates the target's last status change, +-- and the stalled-target backstop reads it. +ALTER TABLE media_request_targets + ADD COLUMN download_phase text, + ADD COLUMN download_bytes_total bigint + CONSTRAINT media_request_targets_download_bytes_total_check CHECK (download_bytes_total >= 0), + ADD COLUMN download_bytes_left bigint + CONSTRAINT media_request_targets_download_bytes_left_check CHECK (download_bytes_left >= 0), + ADD COLUMN download_eta timestamptz, + ADD COLUMN download_count integer, + ADD COLUMN download_updated_at timestamptz, + ADD COLUMN download_checked_at timestamptz; + +-- +goose Down +ALTER TABLE media_request_targets + DROP COLUMN download_checked_at, + DROP COLUMN download_updated_at, + DROP COLUMN download_count, + DROP COLUMN download_eta, + DROP COLUMN download_bytes_left, + DROP COLUMN download_bytes_total, + DROP COLUMN download_phase; diff --git a/migrations/sql/20260928212208_request_target_download_refresh_index.sql b/migrations/sql/20260928212208_request_target_download_refresh_index.sql new file mode 100644 index 0000000000..ec8209fa00 --- /dev/null +++ b/migrations/sql/20260928212208_request_target_download_refresh_index.sql @@ -0,0 +1,13 @@ +-- +goose NO TRANSACTION +-- +goose Up +-- The download refresh pass, and its idle check on every API node, look for +-- downloading targets that have progress. Only those few rows are indexed, so +-- neither has to scan a request history. A failed concurrent build leaves an +-- invalid index, so drop before building. +DROP INDEX CONCURRENTLY IF EXISTS public.idx_media_request_targets_download_refresh; +CREATE INDEX CONCURRENTLY idx_media_request_targets_download_refresh +ON public.media_request_targets USING btree (request_id, download_checked_at) +WHERE status = 'downloading' AND download_phase IS NOT NULL; + +-- +goose Down +DROP INDEX CONCURRENTLY IF EXISTS public.idx_media_request_targets_download_refresh; diff --git a/migrations/sql/20260929001953_request_follows_request_key.sql b/migrations/sql/20260929001953_request_follows_request_key.sql new file mode 100644 index 0000000000..1d18fc09af --- /dev/null +++ b/migrations/sql/20260929001953_request_follows_request_key.sql @@ -0,0 +1,87 @@ +-- +goose Up +-- +goose StatementBegin +-- A series can have completed requests still waiting for the library beside a +-- newer open request for other seasons, so a follow belongs to the request +-- that was open when it was made, and a request's notification goes to its +-- own follows. A profile can follow each of a title's requests. +ALTER TABLE public.media_request_follows ADD COLUMN request_id text; + +-- A title has one open request at a time, so the request open when a follow +-- was made is the title's latest request created before it, provided that +-- request could still have been open then: still active, or completed no +-- earlier than the follow. A completed request that has not notified also +-- keeps a follow stamped just after its completion when the title has no +-- request since: a completion's timestamp is taken when its transaction +-- begins, so a follow that committed during it can look later, and a deleted +-- request always has a replacement created after the follow. Otherwise that +-- request had closed (a failed request, or one its requester replaced and +-- deleted), and the follow goes to the title's first request since that still +-- has a notification to send, as a new request takes such follows over. With +-- none, it stays with the title's failed request, the one it was made for or +-- else the latest, for the title's next request to take over. +UPDATE public.media_request_follows f +SET request_id = CASE + WHEN made_for.outcome = 'active' + AND (made_for.status <> 'completed' OR made_for.completed_at >= f.created_at + OR (made_for.fulfilled_notified_at IS NULL AND NOT EXISTS ( + SELECT 1 FROM public.media_requests r + WHERE r.media_type = f.media_type AND r.provider = 'tmdb' AND r.tmdb_id = f.tmdb_id + AND r.created_at > f.created_at))) + THEN made_for.id + ELSE coalesce( + (SELECT r.id FROM public.media_requests r + WHERE r.media_type = f.media_type AND r.provider = 'tmdb' AND r.tmdb_id = f.tmdb_id + AND r.created_at > f.created_at AND r.outcome = 'active' + AND (r.status <> 'completed' OR r.fulfilled_notified_at IS NULL) + ORDER BY r.created_at, r.id + LIMIT 1), + CASE WHEN made_for.outcome = 'failed' THEN made_for.id END, + (SELECT r.id FROM public.media_requests r + WHERE r.media_type = f.media_type AND r.provider = 'tmdb' AND r.tmdb_id = f.tmdb_id + AND r.outcome = 'failed' + ORDER BY r.created_at DESC, r.id DESC + LIMIT 1)) + END +FROM ( + SELECT DISTINCT ON (f2.media_type, f2.tmdb_id, f2.user_id, f2.profile_id) + f2.media_type, f2.tmdb_id, f2.user_id, f2.profile_id, + r.id, r.outcome, r.status, r.completed_at, r.fulfilled_notified_at + FROM public.media_request_follows f2 + LEFT JOIN public.media_requests r + ON r.media_type = f2.media_type AND r.provider = 'tmdb' AND r.tmdb_id = f2.tmdb_id + AND r.created_at <= f2.created_at + ORDER BY f2.media_type, f2.tmdb_id, f2.user_id, f2.profile_id, r.created_at DESC NULLS LAST, r.id DESC +) made_for +WHERE made_for.media_type = f.media_type AND made_for.tmdb_id = f.tmdb_id + AND made_for.user_id = f.user_id AND made_for.profile_id = f.profile_id; + +-- A follow no request is left to tell has nothing to wait for. +DELETE FROM public.media_request_follows WHERE request_id IS NULL; + +ALTER TABLE public.media_request_follows + ALTER COLUMN request_id SET NOT NULL, + ADD CONSTRAINT media_request_follows_request_fkey FOREIGN KEY (request_id) + REFERENCES public.media_requests (id) ON DELETE CASCADE, + DROP CONSTRAINT media_request_follows_pkey, + ADD PRIMARY KEY (user_id, profile_id, request_id); +DROP INDEX public.media_request_follows_profile_idx; +CREATE INDEX media_request_follows_request_idx ON public.media_request_follows (request_id); +CREATE INDEX media_request_follows_title_idx ON public.media_request_follows (media_type, tmdb_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +-- The title-wide key holds one follow per profile and title; keep the earliest. +DELETE FROM public.media_request_follows f +USING public.media_request_follows keep +WHERE keep.media_type = f.media_type AND keep.tmdb_id = f.tmdb_id + AND keep.user_id = f.user_id AND keep.profile_id = f.profile_id + AND (keep.created_at, keep.request_id) < (f.created_at, f.request_id); +DROP INDEX public.media_request_follows_title_idx; +DROP INDEX public.media_request_follows_request_idx; +ALTER TABLE public.media_request_follows + DROP CONSTRAINT media_request_follows_pkey, + ADD PRIMARY KEY (media_type, tmdb_id, user_id, profile_id), + DROP COLUMN request_id; +CREATE INDEX media_request_follows_profile_idx ON public.media_request_follows (user_id, profile_id); +-- +goose StatementEnd diff --git a/web/perf-budget.json b/web/perf-budget.json index 7e935f83d1..5df0715a86 100644 --- a/web/perf-budget.json +++ b/web/perf-budget.json @@ -1,4 +1,4 @@ { - "eagerBrotliBytes": 318928, + "eagerBrotliBytes": 317516, "crossOriginRenderBlocking": 0 } diff --git a/web/src/App.tsx b/web/src/App.tsx index 02303060a2..94750d639a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -62,6 +62,7 @@ import { buildUserCollectionCatalogHref, } from "@/pages/catalogSearchParams"; import { buildLegacyAutoscanRedirectTarget } from "@/pages/autoscanSearchParams"; +import LegacyRequestDetailRedirect from "@/pages/LegacyRequestDetailRedirect"; import { buildLegacyWebhookSyncRedirectTarget } from "@/lib/webhookSync"; import { guardRedirectTarget } from "@/lib/authRedirect"; import { toast } from "sonner"; @@ -95,7 +96,8 @@ const PlaybackSettings = lazy(() => import("@/pages/settings/PlaybackSettings")) const NotificationsSettings = lazy(() => import("@/pages/settings/NotificationsSettings")); const Requests = lazy(() => import("@/pages/Requests")); const RequestBrowse = lazy(() => import("@/pages/RequestBrowse")); -const RequestDetail = lazy(() => import("@/pages/RequestDetail")); +const RequestDiscoverSection = lazy(() => import("@/pages/RequestDiscoverSection")); +const TitleDetail = lazy(() => import("@/pages/TitleDetail")); const AdminDashboard = lazy(() => import("@/pages/AdminDashboard")); const AdminActivity = lazy(() => import("@/pages/AdminActivity")); const AdminLogs = lazy(() => import("@/pages/AdminLogs")); @@ -649,9 +651,21 @@ function AppRoutes() { /> } + /> + + + + } + /> + - + } /> diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 947d9638d0..1eb3639eea 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1827,6 +1827,16 @@ 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" + | "partially_available" + | "available" + | "declined" + | "cancelled" + | "failed"; export type RequestLimitMode = "inherit" | "custom" | "unlimited" | "blocked"; export type RequestApprovalMode = "inherit" | "manual" | "auto" | "blocked"; @@ -1835,6 +1845,36 @@ 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; + /** User-facing state of the active request. */ + state?: RequestUserState; + /** How far the active request's downloads are. Only the title detail carries it. */ + download?: RequestDownload; +} + +/** + * How far a request's downloads are, while its download server reports them: + * for one server on a target, summed over its servers on a request. + */ +export interface RequestDownload { + /** + * queued, downloading, paused, stalled, importing or import_blocked. The + * server may add phases; read one this client does not know as downloading. + */ + phase: string; + /** Rounded down; absent while the size is unknown. */ + percent?: number; + bytes_total?: number; + bytes_left?: number; + /** Absent when the download server cannot tell. */ + estimated_completion_at?: string; + /** Distinct downloads in flight; a season pack counts once. */ + downloads: number; + /** When the server last heard from the download server. */ + updated_at: string; } export interface RequestMediaResult { @@ -1897,14 +1937,45 @@ export interface RequestMediaDetail { director?: string; creators?: string[]; recommendations?: RequestMediaResult[]; + /** Series: the regular seasons with library availability and request coverage. */ + seasons?: RequestMediaSeason[]; availability: RequestAvailability; library_content_id?: string; request: RequestState; } +/** One regular season of a series, as the request detail reports it. */ +export interface RequestMediaSeason { + season_number: number; + name?: string; + /** YYYY-MM-DD; absent until TMDB dates the season. */ + air_date?: string; + /** Episodes TMDB lists for the season, aired or not. */ + episode_count: number; + poster_path?: string; + /** Whether every aired episode is in the library. */ + availability: "missing" | "partial" | "available"; + /** The title's active request covers this season. */ + requested: boolean; +} + +/** How far one requested season is, once the series is in the library. */ +export interface RequestSeasonProgress { + season_number: number; + /** Aired episodes by the library's own metadata; 0 when it has no air dates yet. */ + episodes_aired: number; + episodes_available: number; +} + export interface RequestDiscoverySection extends RequestMediaPage { key: string; title: string; + /** + * The page to ask for next when a rating-restricted viewer's page read + * several TMDB pages (page + 1 would repeat them). Absent when page + 1 + * applies, or when a restricted viewer has reached the end. + */ + next_page?: number; } export interface RequestDiscoveryResponse { @@ -1957,24 +2028,31 @@ export interface CreateMediaRequestInput { overview?: string; poster_path?: string; backdrop_path?: string; + /** Series only: the seasons to request. Omitted: every aired season not yet in the library. */ + seasons?: number[]; } +/** The download server details (integration_*, instance_name, route_name, external_*, last_error) reach admins only. */ export interface RequestTarget { id: number; request_id: string; integration_id?: string; integration_kind?: string; instance_name?: string; + /** The routing rule that sent this target to its server, as named when it was sent. */ + route_name?: string; quality: "1080p" | "2160p"; is_anime: boolean; external_id?: string; external_status?: string; status: MediaRequestStatus | "failed"; last_error?: string; + download?: RequestDownload; created_at: string; updated_at: string; } +/** integration_kind, external_id, external_status and last_error reach admins only. */ export interface MediaRequest { id: string; provider: string; @@ -1989,10 +2067,20 @@ 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; + /** Series: the requested seasons; empty means the whole series. */ + seasons?: number[]; + /** Series season requests: each requested season's episodes, once the series is in the library. */ + season_progress?: RequestSeasonProgress[]; targets?: RequestTarget[]; + /** Over every server of the request: the phase that needs the most attention, the latest estimate. */ + download?: RequestDownload; integration_kind?: string; external_id?: string; external_status?: string; @@ -2826,6 +2914,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/adminRequests.test.ts b/web/src/api/v2/adminRequests.test.ts index b98588fc3a..51560caec4 100644 --- a/web/src/api/v2/adminRequests.test.ts +++ b/web/src/api/v2/adminRequests.test.ts @@ -3,8 +3,8 @@ import { captureProfileRequestContext, isProfileRequestContextCurrent } from "@/ import { v2, V2ProblemError } from "./request"; import { getAdminRequestIntegrationV2, - listAdminMediaRequestsV2, listAdminRequestIntegrationsV2, + listAdminRequestQueuePageV2, putAdminRequestSettingsV2, putAdminRequestUserLimitV2, requestValidationErrors, @@ -124,7 +124,7 @@ describe("admin request v2 adapter", () => { vi.mocked(captureProfileRequestContext).mockReturnValue(authority); vi.mocked(isProfileRequestContextCurrent).mockReturnValue(true); vi.mocked(v2).mockResolvedValue({ items: [], page: { has_more: true } } as never); - await expect(listAdminMediaRequestsV2({ limit: 100 })).rejects.toThrow( + await expect(listAdminRequestQueuePageV2({ view: "failed" })).rejects.toThrow( "Incomplete request page", ); vi.mocked(v2).mockResolvedValue({ @@ -133,7 +133,36 @@ describe("admin request v2 adapter", () => { } as never); await expect(listAdminRequestIntegrationsV2()).rejects.toThrow("Incomplete integration page"); vi.mocked(isProfileRequestContextCurrent).mockReturnValue(false); - await expect(listAdminMediaRequestsV2()).rejects.toThrow("account or server changed"); + await expect(listAdminRequestQueuePageV2({ view: "failed" })).rejects.toThrow( + "account or server changed", + ); + }); + it("sends the queue filters as the server names them and returns the next cursor", async () => { + vi.mocked(captureProfileRequestContext).mockReturnValue(authority); + vi.mocked(isProfileRequestContextCurrent).mockReturnValue(true); + vi.mocked(v2).mockResolvedValue({ + items: [], + page: { has_more: true, next_cursor: "next" }, + } as never); + const page = await listAdminRequestQueuePageV2( + { view: "needs_approval", q: " dune ", mediaType: "movie", requestedByUserId: 7 }, + { limit: 25, cursor: "here" }, + ); + expect(page).toEqual({ items: [], nextCursor: "next" }); + expect(vi.mocked(v2).mock.calls[0]).toEqual([ + "GET /api/v2/admin/requests", + expect.objectContaining({ + profileContext: authority, + query: { + view: "needs_approval", + q: "dune", + media_type: "movie", + requested_by_user_id: "7", + limit: 25, + cursor: "here", + }, + }), + ]); }); it("maps problem field details inline and unwraps options with string installation IDs", async () => { const error = new V2ProblemError("save", { diff --git a/web/src/api/v2/adminRequests.ts b/web/src/api/v2/adminRequests.ts index d98338b1f2..96836a1fb0 100644 --- a/web/src/api/v2/adminRequests.ts +++ b/web/src/api/v2/adminRequests.ts @@ -6,13 +6,35 @@ import { } from "@/api/client"; import type { LoadRequestIntegrationOptionsRequest, + MediaRequest, RequestIntegration, - RequestListParams, + RequestMediaType, RequestSettings, RequestUserLimit, } from "@/api/types"; -import { v2, type V2Body, type V2Result, V2ProblemError } from "./request"; +import { v2, type V2Body, type V2Query, 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"]; +export type RequestRoutePreviewRule = Schemas["AdminRequestRoutePreviewRule"]; +export type RequestRouteFacts = Schemas["AdminRequestRouteFacts"]; +export type RequestRouteTitle = Schemas["AdminRequestRouteTitle"]; + +/** + * 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."); @@ -63,6 +85,32 @@ export async function putAdminRequestSettingsV2( }); return { ...body, updated_at: "", etag: requireETag(etag) }; } +/** How requests find their server: Standard, or Advanced with the rules. */ +export type RequestRouting = Schemas["AdminRequestRouting"] & { etag: string }; +export type RequestRoutingMode = RequestRouting["mode"]; +export async function getAdminRequestRoutingV2(): Promise { + let etag = ""; + const body = await v2("GET /api/v2/admin/request-routing", { + onResponse: (r) => { + etag = r.headers.get("ETag") ?? ""; + }, + }); + return { ...body, etag: requireETag(etag) }; +} +export async function putAdminRequestRoutingV2( + mode: RequestRoutingMode, + current: Pick, +): Promise { + let etag = ""; + const body = await v2("PUT /api/v2/admin/request-routing", { + headers: { "If-Match": requireETag(current.etag) }, + body: { mode }, + onResponse: (r) => { + etag = r.headers.get("ETag") ?? ""; + }, + }); + return { ...body, etag: requireETag(etag) }; +} export async function getAdminRequestUserLimitV2(userId: number): Promise { let etag = ""; const body = await v2("GET /api/v2/admin/request-users/{user_id}/limit", { @@ -93,6 +141,53 @@ export async function putAdminRequestUserLimitV2( }); return { ...body, user_id: Number(body.user_id), etag: requireETag(etag) }; } + +/** + * An access group's request approval and limit, with the validator of the + * read it came from. A group with nothing saved reads as inherit at revision + * zero, and its first save sends that tag. + */ +export type RequestGroupLimit = Omit & { + group_id: number; + etag: string; +}; +export type RequestGroupLimitBody = Schemas["AdminRequestGroupLimitBody"]; + +/** + * The validator names the profile that read it, so the limit is read and + * saved under one captured authority. + */ +export async function getAdminRequestGroupLimitV2( + groupId: number, + profileContext?: ProfileRequestContextSnapshot, +): Promise { + let etag = ""; + const body = await v2("GET /api/v2/admin/request-groups/{group_id}/limit", { + path: { group_id: String(groupId) }, + profileContext, + onResponse: (r) => { + etag = r.headers.get("ETag") ?? ""; + }, + }); + return { ...body, group_id: Number(body.group_id), etag: requireETag(etag) }; +} +export async function putAdminRequestGroupLimitV2( + limit: Pick, + body: RequestGroupLimitBody, + profileContext?: ProfileRequestContextSnapshot, +): Promise { + let etag = ""; + const saved = await v2("PUT /api/v2/admin/request-groups/{group_id}/limit", { + path: { group_id: String(limit.group_id) }, + headers: { "If-Match": requireETag(limit.etag) }, + body, + profileContext, + onResponse: (r) => { + etag = r.headers.get("ETag") ?? ""; + }, + }); + return { ...saved, group_id: Number(saved.group_id), etag: requireETag(etag) }; +} function integrationBody( integration: RequestIntegration, ): V2Body<"POST /api/v2/admin/request-integrations"> { @@ -185,33 +280,68 @@ export async function listAdminRequestIntegrationsV2(): Promise["view"]>; +export type AdminRequestCounts = Schemas["AdminRequestCounts"]; +export type AdminRequestEvent = Schemas["AdminRequestEvent"]; + +export interface AdminRequestQueueFilter { + view: AdminRequestQueueView; + /** A title substring, or an exact TMDB ID. */ + q?: string; + mediaType?: RequestMediaType; + requestedByUserId?: number; +} + +export interface AdminRequestQueuePage { + items: MediaRequest[]; + /** Where the next page starts; absent on the last page. */ + nextCursor?: string; +} + +/** One page of the admin queue, newest request first. */ +export async function listAdminRequestQueuePageV2( + filter: AdminRequestQueueFilter, + options: { limit?: number; cursor?: string; signal?: AbortSignal } = {}, +): Promise { const profileContext = captureProfileRequestContext(); if (!profileContext) throw new StaleApiRequestContextError(); - const seen = new Set(); - const wanted = Math.min(100, Math.max(1, params.limit ?? 50)); - const out = []; - let cursor: string | undefined; - while (out.length < wanted) { - if (!isProfileRequestContextCurrent(profileContext)) throw new StaleApiRequestContextError(); - const page: V2Result<"GET /api/v2/admin/requests"> = await v2("GET /api/v2/admin/requests", { - profileContext, - query: { - limit: Math.min(50, wanted - out.length), - cursor, - status: params.status && params.status !== "all" ? params.status : undefined, - outcome: params.outcome && params.outcome !== "all" ? params.outcome : undefined, - }, - }); - out.push(...page.items.map(mediaRequestFromV2)); - if (!page.page?.has_more) break; - const next = page.page.next_cursor; - if (!next || seen.has(next)) throw new Error("Incomplete request page. Reload to try again."); - seen.add(next); - cursor = next; - } + const q = filter.q?.trim(); + const page = await v2("GET /api/v2/admin/requests", { + profileContext, + signal: options.signal, + query: { + view: filter.view, + q: q || undefined, + media_type: filter.mediaType, + requested_by_user_id: + filter.requestedByUserId === undefined ? undefined : String(filter.requestedByUserId), + limit: options.limit, + cursor: options.cursor, + }, + }); if (!isProfileRequestContextCurrent(profileContext)) throw new StaleApiRequestContextError(); - return out; + if (page.page?.has_more && !page.page.next_cursor) { + throw new Error("Incomplete request page. Reload to try again."); + } + return { + items: page.items.map(mediaRequestFromV2), + nextCursor: page.page?.has_more ? page.page.next_cursor : undefined, + }; +} +export function getAdminRequestCountsV2(): Promise { + return v2("GET /api/v2/admin/requests/counts"); +} +/** A request's history, newest first. */ +export function listAdminRequestEventsV2(id: string): Promise { + return v2("GET /api/v2/admin/requests/{id}/events", { path: { id } }).then( + (result) => result.items, + ); +} +export function cancelAdminRequestV2(id: string, reason?: string) { + return v2("POST /api/v2/admin/requests/{id}/cancel", { path: { id }, body: { reason } }).then( + mediaRequestFromV2, + ); } export function approveAdminRequestV2(id: string) { return v2("POST /api/v2/admin/requests/{id}/approve", { path: { id }, body: {} }).then( @@ -228,6 +358,103 @@ 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); +} +/** + * Where each quality tier of a request for the title would go now. With a + * requester, rules that match on the account apply as they would to that + * account's request; without one they are skipped. + */ +export function previewAdminRequestRouteV2( + mediaType: RequestRouteMediaType, + tmdbId: number, + requesterUserId?: number, +): Promise { + return v2("POST /api/v2/admin/request-routes/preview", { + body: { + media_type: mediaType, + tmdb_id: tmdbId, + requester_user_id: requesterUserId === undefined ? undefined : String(requesterUserId), + }, + }); +} +/** + * Titles to try the routing rules on, from TMDB. Admin-only, and answers + * whether or not requests are turned on. + */ +export function searchAdminRequestRouteTitlesV2( + mediaType: RequestRouteMediaType, + q: string, + signal?: AbortSignal, +): Promise { + return v2("GET /api/v2/admin/request-routes/titles", { + signal, + query: { media_type: mediaType, q }, + }).then((result) => result.items); +} export function loadAdminRequestIntegrationOptionsV2( id: string, body: LoadRequestIntegrationOptionsRequest, diff --git a/web/src/api/v2/operations.ts b/web/src/api/v2/operations.ts index 6f3e651c25..03671e9bb1 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", @@ -67,6 +68,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", @@ -177,12 +179,19 @@ export const v2Operations = { "GET /api/v2/admin/rate-limits/config": "getAdminRateLimitConfig", "GET /api/v2/admin/rate-limits/status": "getAdminRateLimitStatus", "GET /api/v2/admin/recommendations/status": "getAdminRecommendationsStatus", + "GET /api/v2/admin/request-groups/{group_id}/limit": "getAdminRequestGroupLimit", "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/titles": "searchRequestRouteTitles", + "GET /api/v2/admin/request-routes/{id}": "getRequestRoute", + "GET /api/v2/admin/request-routing": "getRequestRouting", "GET /api/v2/admin/request-settings": "getAdminRequestSettings", "GET /api/v2/admin/request-users/{user_id}/limit": "getAdminRequestUserLimit", "GET /api/v2/admin/requests": "listAdminRequests", "GET /api/v2/admin/requests/capabilities": "getAdminRequestCapabilities", + "GET /api/v2/admin/requests/counts": "getAdminRequestCounts", + "GET /api/v2/admin/requests/{id}/events": "listAdminRequestEvents", "GET /api/v2/admin/sections": "listAdminSections", "GET /api/v2/admin/sections/capabilities": "getAdminSectionCapabilities", "GET /api/v2/admin/sections/order": "getAdminSectionOrder", @@ -580,6 +589,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", @@ -741,7 +753,10 @@ export const v2Operations = { "PUT /api/v2/admin/plugins/uploads/chunked/{upload_id}/chunks/{chunk_index}": "putAdminPluginUploadChunk", "PUT /api/v2/admin/policy/documents/{id}/active-version": "activateAdminPolicyVersion", + "PUT /api/v2/admin/request-groups/{group_id}/limit": "updateAdminRequestGroupLimit", "PUT /api/v2/admin/request-integrations/{id}": "updateRequestIntegration", + "PUT /api/v2/admin/request-routes/{id}": "updateRequestRoute", + "PUT /api/v2/admin/request-routing": "updateRequestRouting", "PUT /api/v2/admin/request-settings": "updateAdminRequestSettings", "PUT /api/v2/admin/request-users/{user_id}/limit": "updateAdminRequestUserLimit", "PUT /api/v2/admin/sections/defaults": "restoreAdminSections", @@ -780,6 +795,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.test.ts b/web/src/api/v2/requests.test.ts new file mode 100644 index 0000000000..fc5ccd25cb --- /dev/null +++ b/web/src/api/v2/requests.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import listMyRequestsOk from "../../../../contracts/api/v2/fixtures/list_my_requests_ok.json"; +import { v2 } from "./request"; +import { getRequestMediaDetailV2, listMyMediaRequestsV2 } from "./requests"; +import type { components } from "./schema"; + +vi.mock("./request", async (importOriginal) => ({ + ...(await importOriginal()), + v2: vi.fn(), +})); + +type Schemas = components["schemas"]; + +afterEach(() => vi.resetAllMocks()); + +// The adapters rebuild each body, so a field they leave out never reaches a +// page. Download progress must survive every one of them. +describe("request v2 adapters keep download progress", () => { + const download: Schemas["RequestDownload"] = { + phase: "downloading", + percent: 43, + bytes_total: 4294967296, + bytes_left: 2448131358, + estimated_completion_at: "2026-01-02T03:16:05.678Z", + downloads: 1, + updated_at: "2026-01-02T03:04:05.678Z", + }; + + it("on a request and on each of its servers", async () => { + vi.mocked(v2).mockResolvedValue(listMyRequestsOk as never); + + const [waiting, downloading] = await listMyMediaRequestsV2(); + + expect(downloading!.download).toEqual(download); + expect(downloading!.targets![0]!.download).toEqual(download); + expect(waiting!.download).toBeUndefined(); + expect(waiting!.targets![0]).not.toHaveProperty("download"); + }); + + it("on the title detail's request state", async () => { + const detail: Schemas["RequestMediaDetail"] = { + media_type: "movie", + tmdb_id: 949, + title: "Heat", + availability: "missing", + cast: [], + creators: [], + genres: [], + networks: [], + production_companies: [], + recommendations: [], + seasons: [], + request: { + requestable: false, + following: true, + requested_by_viewer: true, + status: "downloading", + state: "processing", + request_id: "r-1", + download, + }, + }; + vi.mocked(v2).mockResolvedValue(detail as never); + + const mapped = await getRequestMediaDetailV2("movie", 949); + + expect(mapped.request.download).toEqual(download); + }); +}); diff --git a/web/src/api/v2/requests.ts b/web/src/api/v2/requests.ts index e00e476034..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) } : {}), @@ -51,6 +53,27 @@ 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, + ); +} + +// Following is keyed by title: a viewer asks to hear when a title someone else +// already requested becomes available. +export function followRequestMediaV2(mediaType: RequestMediaType, tmdbID: number) { + return v2("PUT /api/v2/requests/follows/{media_type}/{tmdb_id}", { + path: { media_type: mediaType, tmdb_id: tmdbID }, + }); +} + +export function unfollowRequestMediaV2(mediaType: RequestMediaType, tmdbID: number) { + return v2("DELETE /api/v2/requests/follows/{media_type}/{tmdb_id}", { + path: { media_type: mediaType, tmdb_id: tmdbID }, + }); +} + // 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/api/v2/schema.ts b/web/src/api/v2/schema.ts index 836fec80ed..15120a2f52 100644 --- a/web/src/api/v2/schema.ts +++ b/web/src/api/v2/schema.ts @@ -3280,6 +3280,24 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v2/admin/request-groups/{group_id}/limit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get an access group's request approval and limit. */ + get: operations["getAdminRequestGroupLimit"]; + /** Replace an access group's request approval and limit. */ + put: operations["updateAdminRequestGroupLimit"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v2/admin/request-integrations": { parameters: { query?: never; @@ -3334,6 +3352,112 @@ 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-routes/titles": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Search TMDB for titles to try the routing rules on; works while requests are turned off. */ + get: operations["searchRequestRouteTitles"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v2/admin/request-routing": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get the request routing mode and where Standard routing would send each media type. */ + get: operations["getRequestRouting"]; + /** Switch request routing between Standard and Advanced; Standard is refused while a media type has more than one server of a kind. */ + put: operations["updateRequestRouting"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v2/admin/request-settings": { parameters: { query?: never; @@ -3438,6 +3562,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v2/admin/requests/{id}/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List a request's history, newest first (at most 200 entries). */ + get: operations["listAdminRequestEvents"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v2/admin/requests/{id}/retry": { parameters: { query?: never; @@ -3472,6 +3613,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v2/admin/requests/counts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Count the requests in each admin queue view. */ + get: operations["getAdminRequestCounts"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v2/admin/sections": { parameters: { query?: never; @@ -8993,6 +9151,24 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v2/requests/follows/{media_type}/{tmdb_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Get notified when a title that already has an active request becomes available. */ + put: operations["followRequestMedia"]; + post?: never; + /** Stop following a title. */ + delete: operations["unfollowRequestMedia"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v2/requests/mine": { parameters: { query?: never; @@ -14470,12 +14646,117 @@ export interface components { guarded_configuration: boolean; /** @description Opaque revision of this document */ revision: string; + /** @description Whether request routing is available: the routing rules under /admin/request-routes and the Standard/Advanced routing mode under /admin/request-routing */ + routing: boolean; /** * @description Support and configuration state, not health * @enum {string} */ state: "available" | "disabled" | "not_configured" | "unsupported"; }; + AdminRequestCounts: { + /** + * Format: int64 + * @description Completed requests, and those closed by a decline or cancellation + * @example 42 + */ + done: number; + /** + * Format: int64 + * @description Failed requests; Retry sends them again + * @example 1 + */ + failed: number; + /** + * Format: int64 + * @description Approved requests on their way to the library + * @example 5 + */ + in_progress: number; + /** + * Format: int64 + * @description Pending requests waiting for an admin + * @example 3 + */ + needs_approval: number; + }; + AdminRequestEvent: { + /** + * @description The account that acted; absent for the server itself + * @example 1 + */ + actor_user_id?: string; + /** + * @description The acting account's username, while the account exists + * @example admin + */ + actor_username?: string; + /** + * Format: date-time + * @description RFC 3339 instant in UTC with millisecond precision + */ + created_at: string; + /** + * @description Opaque identifier + * @example 981 + */ + id: string; + /** + * @description A reason or error that came with the event + * @example auto approved + */ + message?: string; + /** + * @description What happened: created, approved, retried, submit_deferred, available_in_library, status_ or outcome_; clients show unknown types as they are + * @example approved + */ + type: string; + }; + AdminRequestEventCollection: { + /** @description The page's items; empty, never null */ + items: components["schemas"]["AdminRequestEvent"][]; + /** @description Cursor state; absent for bounded unpaginated collections */ + page?: components["schemas"]["PageInfo"]; + }; + AdminRequestGroupLimit: { + /** + * @description inherit uses the server-wide approval setting + * @example manual + * @enum {string} + */ + approval_mode: "inherit" | "manual" | "auto"; + /** + * @description Opaque identifier + * @example 2 + */ + group_id: string; + /** + * @description inherit uses the server-wide limit; custom uses max_requests per window_days + * @example custom + * @enum {string} + */ + limit_mode: "inherit" | "custom" | "unlimited"; + /** + * Format: int64 + * @example 10 + */ + max_requests: number | null; + /** + * Format: int64 + * @example 7 + */ + window_days: number | null; + }; + AdminRequestGroupLimitBody: { + /** @enum {string} */ + approval_mode: "inherit" | "manual" | "auto"; + /** @enum {string} */ + limit_mode: "inherit" | "custom" | "unlimited"; + /** Format: int64 */ + max_requests: number | null; + /** Format: int64 */ + window_days: number | null; + }; AdminRequestIntegration: { base_url: string; capability_id: string; @@ -14555,6 +14836,232 @@ 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 Everything else: it has no conditions, comes last and cannot be deleted; with no 4K server it makes no 4K copy */ + 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 anime (true) or not (false): Japanese animation, and titles TMDB tags anime or an AniDB-based list names */ + anime?: boolean; + /** @description TMDB production company IDs (movies) */ + company_ids?: number[]; + /** @description Match movies from none of these TMDB companies */ + exclude_company_ids?: number[]; + /** @description Match titles with none of these TMDB genre IDs */ + exclude_genre_ids?: number[]; + /** @description Match titles with none of these TMDB keyword IDs */ + exclude_keyword_ids?: number[]; + /** @description Match series on none of these TMDB networks */ + exclude_network_ids?: number[]; + /** @description Match titles from none of these ISO 3166-1 countries */ + exclude_origin_countries?: string[]; + /** + * @description Match titles whose original language is none of these ISO 639-1 codes + * @example [ + * "en" + * ] + */ + exclude_original_languages?: string[]; + /** @description Match requests from none of these accounts */ + exclude_requester_user_ids?: number[]; + /** @description TMDB genre IDs */ + genre_ids?: number[]; + /** @description TMDB keyword IDs */ + keyword_ids?: number[]; + /** + * @description Match titles whose rating is at most this one, by minimum age: the US rating, or the title's own country's when it has none; a title with neither does not match + * @example PG + */ + max_content_rating?: string; + /** @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: { + /** @description Japanese animation, or a title TMDB tags anime or an AniDB-based list names */ + anime: boolean; + company_ids: number[]; + /** + * @description The title's US rating, or its own country's prefixed with the country code (JP:PG12) when it has none; absent when TMDB has neither + * @example TV-14 + */ + content_rating?: string; + 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; without it, rules for certain accounts do not match + * @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"]; + /** @description Every route of the media type in evaluation order, with what it did */ + rules: components["schemas"]["AdminRequestRoutePreviewRule"][]; + tiers: components["schemas"]["AdminRequestRoutePreviewTier"][]; + }; + AdminRequestRoutePreviewRule: { + enabled: boolean; + /** + * @description What the route did for the HD copy + * @enum {string} + */ + hd: "sends" | "skips" | "passes" | "no_match" | "already_decided"; + is_fallback: boolean; + route_id: string; + route_name: string; + /** + * @description What the route did for the 4K copy + * @enum {string} + */ + uhd: "sends" | "skips" | "passes" | "no_match" | "already_decided"; + /** @description The conditions the title fails, by field name (e.g. genre_ids); empty when it matches */ + unmet_conditions: string[]; + }; + 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"; + }; + AdminRequestRouteTitle: { + /** @enum {string} */ + media_type: "movie" | "series"; + /** @description TMDB image path */ + poster_path?: string; + /** @example Spirited Away */ + title: string; + /** + * Format: int64 + * @description TMDB identifier (external, not a Silo ID) + * @example 129 + */ + tmdb_id: number; + /** + * Format: int64 + * @example 2001 + */ + year?: number; + }; + AdminRequestRouteTitleCollection: { + /** @description The page's items; empty, never null */ + items: components["schemas"]["AdminRequestRouteTitle"][]; + /** @description Cursor state; absent for bounded unpaginated collections */ + page?: components["schemas"]["PageInfo"]; + }; + AdminRequestRouting: { + /** + * @description standard sends each media type to its one server, and 4K copies to its one server marked 4K, with each server's own settings; the routing rules are kept but paused. advanced routes with the rules. + * @enum {string} + */ + mode: "standard" | "advanced"; + /** @description Where Standard sends each media type that has a server; empty when Standard cannot be used */ + standard: components["schemas"]["AdminRequestStandardDestination"][]; + /** @description Why Standard cannot be used (a media type has more than one server of a kind); absent when it can. Adding or enabling such a server turns Advanced on. */ + standard_unavailable_reason?: string; + }; + AdminRequestRoutingUpdateInputBody: { + /** @enum {string} */ + mode: "standard" | "advanced"; + }; AdminRequestSettings: { force_dual_quality: boolean; global_auto_approval_enabled: boolean; @@ -14564,6 +15071,14 @@ export interface components { global_window_days: number; requests_enabled: boolean; }; + AdminRequestStandardDestination: { + /** @description The media type's one server that is not marked 4K; absent when it has none */ + hd_integration_id?: string; + /** @enum {string} */ + media_type: "movie" | "series"; + /** @description The media type's one server marked 4K; absent when it has none, and then there is no 4K copy */ + uhd_integration_id?: string; + }; AdminRequestUserLimit: { /** @enum {string} */ approval_mode: "inherit" | "manual" | "auto" | "blocked"; @@ -17224,6 +17739,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"][]; @@ -19027,10 +19548,16 @@ export interface components { FeatureStatus: { /** @description Whether the current principal may use the capability */ allowed: boolean; + /** @description Whether the server reports download progress (download on requests, their targets, and the title detail's request state). Whether a given request has any depends on its download server's request plugin. */ + download_progress_supported: boolean; + follow_supported: boolean; + /** @description Whether a series already in the library can be requested for the seasons it is missing. False while a download server that takes series uses a request plugin that cannot fetch individual seasons, so such a series stays already_available. */ + missing_seasons_requestable: boolean; rating_restrictions_enforced: boolean; requests_enabled: boolean; /** @description Opaque revision of this document */ revision: string; + season_requests_supported: boolean; /** * @description Support and configuration state, not health * @enum {string} @@ -20672,7 +21199,11 @@ export interface components { * @example 2026-01-02T03:04:05.000Z */ created_at: string; + /** @description How far the request's downloads are over all its servers (1080p and 4K together), while any reports them: bytes summed, the phase that needs the most attention, the latest estimate, and the oldest report's time */ + download?: components["schemas"]["RequestDownload"]; + /** @description Admins only: the integration's own identifier */ external_id?: string; + /** @description Admins only: the status as the download server reports it */ external_status?: string; /** * @description Opaque identifier @@ -20681,9 +21212,13 @@ export interface components { id: string; /** @example tt0113277 */ imdb_id?: string; - /** @example radarr */ + /** + * @description Admins only: the download server's kind + * @example radarr + */ integration_kind?: string; is_anime: boolean; + /** @description Admins only: why the last submission to a download server failed. It can name servers and routing rules */ last_error?: string; /** @description The catalog item once the media is in the library */ library_content_id?: string; @@ -20697,6 +21232,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; @@ -20712,6 +21249,15 @@ export interface components { * @example 1 */ requested_by_user_id?: string; + /** @description Series season requests: each requested season's episodes, once the series is in the library; empty otherwise */ + season_progress: components["schemas"]["RequestSeasonProgress"][]; + /** @description Series: the requested season numbers; empty means the whole series (requests made through v1 or before season requests) */ + seasons: number[]; + /** + * @description The one state to show a user: pending, approved, processing, partially_available (some requested seasons are in the library), available (in the library), declined, cancelled or failed + * @example pending + */ + state: string; /** * @description pending, approved, queued, downloading, completed * @example pending @@ -20763,6 +21309,14 @@ export interface components { overview?: string; /** @description TMDB image path */ poster_path?: string; + /** + * @description Series only: the season numbers to request, starting at 1 (a season below 1 is refused). Omitted: every aired season not yet complete in the library + * @example [ + * 2, + * 3 + * ] + */ + seasons?: number[]; /** @example Heat */ title: string; /** @@ -23835,6 +24389,49 @@ export interface components { RequestCancelInputBody: { reason?: string; }; + RequestDownload: { + /** + * Format: int64 + * @description Bytes still to download; present whenever bytes_total is + * @example 2448131358 + */ + bytes_left?: number; + /** + * Format: int64 + * @description Size of the downloads in bytes; absent while unknown + * @example 4294967296 + */ + bytes_total?: number; + /** + * Format: int64 + * @description Distinct downloads in flight; a season pack counts once + * @example 1 + */ + downloads: number; + /** + * Format: date-time + * @description When the download server expects the downloads to finish; absent when it cannot tell + * @example 2026-01-02T03:16:05.000Z + */ + estimated_completion_at?: string; + /** + * Format: int64 + * @description How much has downloaded, rounded down; absent while the size is unknown + * @example 43 + */ + percent?: number; + /** + * @description queued, downloading, paused, stalled, importing or import_blocked. More values may be added: read an unknown one as downloading, without a percentage + * @example downloading + */ + phase: string; + /** + * Format: date-time + * @description When the server last heard from the download server. A client may hide figures older than about ten minutes + * @example 2026-01-02T03:04:05.000Z + */ + updated_at: string; + }; RequestMediaCastMember: { /** @example Vincent Hanna */ character?: string; @@ -23902,6 +24499,8 @@ export interface components { * @example 170 */ runtime?: number; + /** @description Series: the regular seasons (specials excluded) with library availability and request coverage; empty for movies */ + seasons: components["schemas"]["RequestMediaSeason"][]; /** * @description TMDB release status * @example Released @@ -23998,7 +24597,44 @@ export interface components { */ year?: number; }; + RequestMediaSeason: { + /** + * @description Calendar date, YYYY-MM-DD + * @example 2025-01-17 + */ + air_date?: string; + /** + * @description Whether every aired episode is in the library + * @example partial + * @enum {string} + */ + availability: "missing" | "partial" | "available"; + /** + * Format: int64 + * @description Episodes TMDB lists for the season, aired or not + * @example 10 + */ + episode_count: number; + /** @example Season 2 */ + name?: string; + /** @description TMDB image path */ + poster_path?: string; + /** @description The title's active request covers this season */ + requested: boolean; + /** + * Format: int64 + * @example 2 + */ + season_number: number; + }; RequestMediaState: { + /** @description How far the active request's downloads are, while its download server reports them. Only the title detail (getRequestMediaDetail) carries it */ + download?: components["schemas"]["RequestDownload"]; + /** + * @description Whether the viewer will be notified when the media becomes available: they requested it or follow it + * @example false + */ + following: boolean; /** * @description Why the media is not requestable * @example already_requested @@ -24014,12 +24650,41 @@ export interface components { * @example true */ requestable: boolean; + /** + * @description Whether the viewing profile made the active request, so there is nothing to follow + * @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 */ status?: string; }; + RequestSeasonProgress: { + /** + * Format: int64 + * @description Aired episodes by the library's own metadata; 0 when it has no air dates yet + * @example 10 + */ + episodes_aired: number; + /** + * Format: int64 + * @description Episodes with a file in an enabled library + * @example 4 + */ + episodes_available: number; + /** + * Format: int64 + * @example 2 + */ + season_number: number; + }; RequestTarget: { /** * Format: date-time @@ -24027,19 +24692,28 @@ export interface components { * @example 2026-01-02T03:04:05.000Z */ created_at: string; - /** @description The integration's own identifier */ + /** @description How far this target's downloads are, while its download server reports them */ + download?: components["schemas"]["RequestDownload"]; + /** @description Admins only: the integration's own identifier */ external_id?: string; + /** @description Admins only: the status as the download server reports it */ external_status?: string; /** * @description Opaque identifier * @example 42 */ id: string; + /** @description Admins only: the download server's name */ instance_name?: string; + /** @description Admins only: the download server holding this target */ integration_id?: string; - /** @example radarr */ + /** + * @description Admins only: the download server's kind + * @example radarr + */ integration_kind?: string; is_anime: boolean; + /** @description Admins only: why the download server failed this target */ last_error?: string; /** @example 1080p */ quality: string; @@ -24048,6 +24722,8 @@ export interface components { * @example 1834729 */ request_id: string; + /** @description Admins only: the routing rule that sent this target to its server, as named when it was sent */ + route_name?: string; /** @example queued */ status: string; /** @@ -27435,15 +28111,2869 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Conflict */ - 409: { - 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: { + "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"]; + }; + }; + }; + }; + deleteAdminAPIKey: { + 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"]; + }; + }; + }; + }; + updateAdminAPIKeyTier: { + 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"]["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"]["AdminAPIKey"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + 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 */ + "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"]["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: { + [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"]; + }; + }; + }; + }; + listAdminAutoscanConnections: { + 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"]["CollectionAdminAutoscanConnection"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + createAdminAutoscanConnection: { + 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"]["AdminAutoscanConnectionCreateBody"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminAutoscanConnection"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + updateAdminAutoscanConnection: { + 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: { + content: { + "application/json": components["schemas"]["AdminAutoscanConnectionCreateBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminAutoscanConnection"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + deleteAdminAutoscanConnection: { + 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 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 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"]; + }; + }; + }; + }; + testAdminAutoscanConnection: { + 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"]["AdminAutoscanConnectionTestBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminAutoscanConnectionTestResult"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + listAdminAutoscanEvents: { + parameters: { + query?: { + cursor?: string; + limit?: number; + q?: string; + source_id?: string; + status?: "" | "running" | "success" | "error" | "unresolved"; + }; + 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"]["AdminAutoscanEventsPage"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + 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: { + [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"]; + }; + }; + }; + }; + listAdminAutoscanScans: { + parameters: { + 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?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminAutoscanScansPage"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + getAdminAutoscanSettings: { + 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: { + /** @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; + }; + 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"]; + }; + }; + }; + }; + updateAdminAutoscanSettings: { + 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"]["AdminAutoscanSettingsUpdateBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminAutoscanSettingsUpdateResult"]; + }; + }; + /** @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: { @@ -27500,14 +31030,15 @@ export interface operations { }; }; }; - getAccountPasswordCapability: { + listAdminAutoscanSources: { 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 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; @@ -27520,23 +31051,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"]["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; + "application/json": components["schemas"]["CollectionAdminAutoscanSource"]; }; - content?: never; }; /** @description Bad Request */ 400: { @@ -27583,17 +31102,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: { @@ -27632,14 +31140,9 @@ export interface operations { }; }; }; - listAdminAccessGroups: { + createAdminAutoscanSource: { 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; @@ -27649,15 +31152,19 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminAutoscanSourceCreateBody"]; + }; + }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminAccessGroupListItem"]; + "application/json": components["schemas"]["AdminAutoscanSource"]; }; }; /** @description Bad Request */ @@ -27705,6 +31212,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: { @@ -27743,7 +31277,7 @@ export interface operations { }; }; }; - createAdminAccessGroup: { + updateAdminAutoscanSource: { parameters: { query?: never; header?: { @@ -27752,23 +31286,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"]["AdminAccessGroupBody"]; + "application/json": components["schemas"]["AdminAutoscanSourceWriteBody"]; }; }; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { - Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminAccessGroup"]; + "application/json": components["schemas"]["AdminAutoscanSource"]; }; }; /** @description Bad Request */ @@ -27881,42 +31416,25 @@ export interface operations { }; }; }; - getAdminAccessGroup: { + deleteAdminAutoscanSource: { 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: { + /** @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"]["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; @@ -27966,17 +31484,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: { @@ -28015,40 +31522,29 @@ export interface operations { }; }; }; - updateAdminAccessGroup: { + getAdminAutoscanRewriteSuggestions: { 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: { - content: { - "application/json": components["schemas"]["AdminAccessGroupBody"]; - }; - }; + 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"]; + "application/json": components["schemas"]["AdminAutoscanRewriteSuggestions"]; }; }; /** @description Bad Request */ @@ -28096,8 +31592,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -28105,19 +31601,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; }; @@ -28125,8 +31619,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unsupported Media Type */ - 415: { + /** @description Service Unavailable */ + 503: { headers: { [name: string]: unknown; }; @@ -28134,8 +31628,35 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + }; + }; + createAdminAutoscanSourceWebhook: { + 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"]["AdminAutoscanSource"]; + }; + }; + /** @description Bad Request */ + 400: { headers: { [name: string]: unknown; }; @@ -28143,8 +31664,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; }; @@ -28181,21 +31738,16 @@ export interface operations { }; }; }; - deleteAdminAccessGroup: { + deleteAdminAutoscanSourceWebhook: { 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; @@ -28254,17 +31806,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: { @@ -28274,15 +31815,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: { @@ -28312,21 +31844,18 @@ export interface operations { }; }; }; - listAdminAPIKeys: { + rotateAdminAutoscanSourceWebhook: { 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; @@ -28337,7 +31866,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminAPIKeyListItem"]; + "application/json": components["schemas"]["AdminAutoscanSource"]; }; }; /** @description Bad Request */ @@ -28423,7 +31952,7 @@ export interface operations { }; }; }; - createAdminAPIKey: { + getAdminAutoscanStatus: { parameters: { query?: never; header?: { @@ -28435,20 +31964,15 @@ export interface operations { path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminAPIKeyCreateInputBody"]; - }; - }; + requestBody?: never; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { - Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminAPIKeyCreated"]; + "application/json": components["schemas"]["AdminAutoscanStatus"]; }; }; /** @description Bad Request */ @@ -28496,8 +32020,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -28505,8 +32029,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; }; @@ -28514,8 +32038,96 @@ 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"]; + }; + }; + }; + }; + triggerAdminAutoscan: { + 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"]["AdminTask"]; + }; + }; + /** @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; }; @@ -28561,45 +32173,35 @@ export interface operations { }; }; }; - getAdminAPIKey: { + uploadAdminBrandingAsset: { 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; + /** @description Branding asset slot */ + kind: "wordmark" | "mark" | "favicon" | "login_bg"; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["AdminBrandingAssetForm"]; + }; + }; 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; + "application/json": components["schemas"]["AdminBrandingAsset"]; }; - content?: never; }; /** @description Bad Request */ 400: { @@ -28646,11 +32248,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: { @@ -28695,22 +32313,18 @@ export interface operations { }; }; }; - deleteAdminAPIKey: { + deleteAdminBrandingAsset: { 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; + /** @description Branding asset slot */ + kind: "wordmark" | "mark" | "favicon" | "login_bg"; }; cookie?: never; }; @@ -28768,17 +32382,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: { @@ -28788,15 +32391,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: { @@ -28826,40 +32420,34 @@ export interface operations { }; }; }; - updateAdminAPIKeyTier: { + exportAdminCatalog: { 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"]["AdminAPIKeyTierInputBody"]; + "application/json": components["schemas"]["AdminCatalogExportRequest"]; }; }; responses: { - /** @description OK */ + /** @description Compressed catalog seed */ 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; + "Content-Disposition"?: string; + "Content-Length"?: string; + "Content-Type"?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminAPIKey"]; + "application/gzip": string; }; }; /** @description Bad Request */ @@ -28916,17 +32504,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: { @@ -28954,15 +32531,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: { @@ -28992,13 +32560,10 @@ export interface operations { }; }; }; - getAdminAPIKeyCapabilities: { + createCatalogExportJob: { 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 */ @@ -29007,28 +32572,22 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminCatalogExportRequest"]; + }; + }; responses: { - /** @description OK */ - 200: { + /** @description Accepted */ + 202: { 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"]["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"]["AdminTaskJob"]; }; - content?: never; }; /** @description Bad Request */ 400: { @@ -29075,11 +32634,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: { @@ -29124,20 +32708,18 @@ export interface operations { }; }; }; - listAdminAutoscanConnections: { + publishCatalogExportJob: { 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; @@ -29148,7 +32730,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminAutoscanConnection"]; + "application/json": components["schemas"]["AdminCatalogPublished"]; }; }; /** @description Bad Request */ @@ -29196,6 +32778,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: { @@ -29234,7 +32825,7 @@ export interface operations { }; }; }; - createAdminAutoscanConnection: { + importAdminCatalog: { parameters: { query?: never; header?: { @@ -29248,17 +32839,17 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AdminAutoscanConnectionCreateBody"]; + "application/json": components["schemas"]["AdminCatalogImportRequest"]; }; }; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminAutoscanConnection"]; + "application/json": components["schemas"]["ImportResult"]; }; }; /** @description Bad Request */ @@ -29371,7 +32962,7 @@ export interface operations { }; }; }; - updateAdminAutoscanConnection: { + createCatalogImportJob: { parameters: { query?: never; header?: { @@ -29380,24 +32971,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"]["AdminAutoscanConnectionCreateBody"]; + "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"]["AdminAutoscanConnection"]; + "application/json": components["schemas"]["AdminTaskJob"]; }; }; /** @description Bad Request */ @@ -29454,105 +33045,8 @@ 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: { - [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"]; - }; - }; - }; - }; - deleteAdminAutoscanConnection: { - 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 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: { + /** @description Conflict */ + 409: { headers: { [name: string]: unknown; }; @@ -29560,8 +33054,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Found */ - 404: { + /** @description Request Entity Too Large */ + 413: { headers: { [name: string]: unknown; }; @@ -29569,8 +33063,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Acceptable */ - 406: { + /** @description Unsupported Media Type */ + 415: { headers: { [name: string]: unknown; }; @@ -29616,9 +33110,12 @@ export interface operations { }; }; }; - testAdminAutoscanConnection: { + 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; @@ -29628,11 +33125,7 @@ export interface operations { path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminAutoscanConnectionTestBody"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -29640,7 +33133,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminAutoscanConnectionTestResult"]; + "application/json": components["schemas"]["CollectionAdminCatalogSource"]; }; }; /** @description Bad Request */ @@ -29688,33 +33181,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: { @@ -29753,14 +33219,11 @@ export interface operations { }; }; }; - listAdminAutoscanEvents: { + listLocalCatalogImportSources: { parameters: { query?: { cursor?: string; limit?: number; - q?: string; - source_id?: string; - status?: "" | "running" | "success" | "error" | "unresolved"; }; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ @@ -29779,7 +33242,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminAutoscanEventsPage"]; + "application/json": components["schemas"]["CollectionAdminCatalogSource"]; }; }; /** @description Bad Request */ @@ -29865,13 +33328,9 @@ export interface operations { }; }; }; - listAdminAutoscanAvailableSources: { + getAdminCatalogSearchStatus: { 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; @@ -29889,7 +33348,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminAutoscanAvailableSource"]; + "application/json": components["schemas"]["AdminCatalogSearchRuntimeStatus"]; }; }; /** @description Bad Request */ @@ -29975,13 +33434,11 @@ export interface operations { }; }; }; - listAdminAutoscanScans: { + getAdminGroupCollectionOrder: { parameters: { query?: { - cursor?: string; - limit?: number; - q?: string; - status?: "" | "accepted" | "running" | "completed" | "failed" | "cancelled"; + /** @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. */ @@ -29989,7 +33446,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 */ + group_id: string; + }; cookie?: never; }; requestBody?: never; @@ -29997,10 +33457,11 @@ export interface operations { /** @description OK */ 200: { headers: { + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminAutoscanScansPage"]; + "application/json": components["schemas"]["AdminCollectionOrder"]; }; }; /** @description Bad Request */ @@ -30086,22 +33547,34 @@ export interface operations { }; }; }; - getAdminAutoscanSettings: { + moveAndReorderAdminGroupCollections: { 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; + 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?: never; + path: { + /** @description Opaque identifier */ + group_id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminCollectionIDsOrder"]; + }; + }; responses: { /** @description OK */ 200: { @@ -30111,17 +33584,8 @@ export interface operations { [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"]["AdminCollectionOrder"]; }; - content?: never; }; /** @description Bad Request */ 400: { @@ -30168,6 +33632,15 @@ 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: { @@ -30179,6 +33652,24 @@ 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: { @@ -30188,6 +33679,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: { @@ -30217,31 +33717,33 @@ export interface operations { }; }; }; - updateAdminAutoscanSettings: { + 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?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminAutoscanSettingsUpdateBody"]; + path: { + /** @description Opaque identifier */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description OK */ 200: { headers: { + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminAutoscanSettingsUpdateResult"]; + "application/json": components["schemas"]["AdminCollectionGroup"]; }; }; /** @description Bad Request */ @@ -30289,33 +33791,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: { @@ -30354,32 +33829,33 @@ export interface operations { }; }; }; - listAdminAutoscanSources: { + deleteAdminCollectionGroup: { parameters: { - query?: { - 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; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["CollectionAdminAutoscanSource"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -30426,6 +33902,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: { @@ -30435,6 +33922,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: { @@ -30464,31 +33960,40 @@ export interface operations { }; }; }; - createAdminAutoscanSource: { + 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"]["AdminAutoscanSourceCreateBody"]; + "application/json": components["schemas"]["CollectionGroupUpdate"]; }; }; 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"]["AdminAutoscanSource"]; + "application/json": components["schemas"]["AdminCollectionGroup"]; }; }; /** @description Bad Request */ @@ -30545,6 +34050,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: { @@ -30572,6 +34088,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: { @@ -30601,34 +34126,46 @@ export interface operations { }; }; }; - updateAdminAutoscanSource: { + 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; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminAutoscanSourceWriteBody"]; - }; - }; + 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"]["AdminAutoscanSource"]; + "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: { @@ -30675,27 +34212,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: { @@ -30740,28 +34261,31 @@ export interface operations { }; }; }; - deleteAdminAutoscanSource: { + 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; /** @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"]["AdminCollectionList"]; + }; }; /** @description Bad Request */ 400: { @@ -30846,7 +34370,7 @@ export interface operations { }; }; }; - getAdminAutoscanRewriteSuggestions: { + createAdminCollection: { parameters: { query?: never; header?: { @@ -30855,20 +34379,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: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminCollectionCreate"]; + }; + }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { + Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminAutoscanRewriteSuggestions"]; + "application/json": components["schemas"]["AdminCollection"]; }; }; /** @description Bad Request */ @@ -30916,89 +34443,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"]; - }; - }; - }; - }; - createAdminAutoscanSourceWebhook: { - 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"]["AdminAutoscanSource"]; - }; - }; - /** @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; }; @@ -31006,8 +34452,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Found */ - 404: { + /** @description Request Entity Too Large */ + 413: { headers: { [name: string]: unknown; }; @@ -31015,8 +34461,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Acceptable */ - 406: { + /** @description Unsupported Media Type */ + 415: { headers: { [name: string]: unknown; }; @@ -31062,28 +34508,34 @@ export interface operations { }; }; }; - deleteAdminAutoscanSourceWebhook: { + getAdminCollection: { 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"]["AdminCollection"]; + }; }; /** @description Bad Request */ 400: { @@ -31168,30 +34620,33 @@ export interface operations { }; }; }; - rotateAdminAutoscanSourceWebhook: { + deleteAdminCollection: { 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; }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["AdminAutoscanSource"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -31238,6 +34693,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: { @@ -31247,6 +34713,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: { @@ -31276,27 +34751,40 @@ export interface operations { }; }; }; - getAdminAutoscanStatus: { + 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"]["AdminAutoscanStatus"]; + "application/json": components["schemas"]["AdminCollection"]; }; }; /** @description Bad Request */ @@ -31344,78 +34832,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"]; - }; - }; - }; - }; - triggerAdminAutoscan: { - 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"]["AdminTask"]; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unauthorized */ - 401: { + /** @description Request Timeout */ + 408: { headers: { [name: string]: unknown; }; @@ -31423,17 +34841,19 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Forbidden */ - 403: { + /** @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 Not Found */ - 404: { + /** @description Request Entity Too Large */ + 413: { headers: { [name: string]: unknown; }; @@ -31441,8 +34861,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Acceptable */ - 406: { + /** @description Unsupported Media Type */ + 415: { headers: { [name: string]: unknown; }; @@ -31450,8 +34870,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Conflict */ - 409: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -31459,8 +34879,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + /** @description Precondition Required */ + 428: { headers: { [name: string]: unknown; }; @@ -31497,7 +34917,7 @@ export interface operations { }; }; }; - uploadAdminBrandingAsset: { + uploadAdminCollectionBackdrop: { parameters: { query?: never; header?: { @@ -31507,24 +34927,25 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - /** @description Branding asset slot */ - kind: "wordmark" | "mark" | "favicon" | "login_bg"; + /** @description Opaque identifier */ + id: string; }; cookie?: never; }; requestBody: { content: { - "multipart/form-data": components["schemas"]["AdminBrandingAssetForm"]; + "multipart/form-data": components["schemas"]["AdminCollectionBackdropForm"]; }; }; responses: { /** @description OK */ 200: { headers: { + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminBrandingAsset"]; + "application/json": components["schemas"]["AdminCollection"]; }; }; /** @description Bad Request */ @@ -31637,9 +35058,11 @@ export interface operations { }; }; }; - deleteAdminBrandingAsset: { + deleteAdminCollectionImage: { parameters: { - query?: never; + query: { + type: "poster" | "backdrop"; + }; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; @@ -31647,8 +35070,8 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - /** @description Branding asset slot */ - kind: "wordmark" | "mark" | "favicon" | "login_bg"; + /** @description Opaque identifier */ + id: string; }; cookie?: never; }; @@ -31744,34 +35167,34 @@ export interface operations { }; }; }; - exportAdminCatalog: { + getAdminCollectionItems: { 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?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminCatalogExportRequest"]; + path: { + /** @description Opaque identifier */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Compressed catalog seed */ + /** @description OK */ 200: { headers: { - "Content-Disposition"?: string; - "Content-Length"?: string; - "Content-Type"?: string; [name: string]: unknown; }; content: { - "application/gzip": string; + "application/json": components["schemas"]["CollectionAdminCollectionMember"]; }; }; /** @description Bad Request */ @@ -31819,33 +35242,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: { @@ -31884,7 +35280,7 @@ export interface operations { }; }; }; - createCatalogExportJob: { + addAdminCollectionItem: { parameters: { query?: never; header?: { @@ -31893,25 +35289,26 @@ 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; + /** @description Opaque identifier */ + item_id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminCatalogExportRequest"]; + "application/json": components["schemas"]["AdminCollectionMemberInputBody"]; }; }; responses: { - /** @description Accepted */ - 202: { + /** @description No Content */ + 204: { headers: { - Location?: string; - "Retry-After"?: string; [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["AdminTaskJob"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -31967,15 +35364,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: { @@ -32032,7 +35420,7 @@ export interface operations { }; }; }; - publishCatalogExportJob: { + removeAdminCollectionItem: { parameters: { query?: never; header?: { @@ -32042,20 +35430,21 @@ export interface operations { "X-Profile-Token"?: string; }; path: { + /** @description Opaque identifier */ id: string; + /** @description Opaque identifier */ + item_id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["AdminCatalogPublished"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -32102,15 +35491,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: { @@ -32149,31 +35529,33 @@ export interface operations { }; }; }; - importAdminCatalog: { + getAdminCollectionItemsOrder: { 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?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminCatalogImportRequest"]; + path: { + /** @description Opaque identifier */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description OK */ 200: { headers: { + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ImportResult"]; + "application/json": components["schemas"]["AdminCollectionOrder"]; }; }; /** @description Bad Request */ @@ -32221,33 +35603,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: { @@ -32286,33 +35641,40 @@ export interface operations { }; }; }; - createCatalogImportJob: { + 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"]["AdminCatalogImportRequest"]; + "application/json": components["schemas"]["AdminCollectionIDsOrder"]; }; }; 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"]["AdminCollectionOrder"]; }; }; /** @description Bad Request */ @@ -32369,9 +35731,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: { @@ -32405,6 +35769,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: { @@ -32434,30 +35807,35 @@ export interface operations { }; }; }; - listCatalogImportSources: { + uploadAdminCollectionPoster: { parameters: { - query?: { - cursor?: string; - 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: { + "multipart/form-data": components["schemas"]["AdminCollectionArtworkForm"]; + }; + }; responses: { /** @description OK */ 200: { headers: { + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminCatalogSource"]; + "application/json": components["schemas"]["AdminCollection"]; }; }; /** @description Bad Request */ @@ -32505,90 +35883,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"]; - }; - }; - }; - }; - listLocalCatalogImportSources: { - 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"]["CollectionAdminCatalogSource"]; - }; - }; - /** @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; }; @@ -32596,8 +35892,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Found */ - 404: { + /** @description Request Entity Too Large */ + 413: { headers: { [name: string]: unknown; }; @@ -32605,8 +35901,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Acceptable */ - 406: { + /** @description Unsupported Media Type */ + 415: { headers: { [name: string]: unknown; }; @@ -32652,16 +35948,21 @@ export interface operations { }; }; }; - getAdminCatalogSearchStatus: { + syncAdminCollection: { 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?: never; + path: { + /** @description Opaque identifier */ + id: string; + }; cookie?: never; }; requestBody?: never; @@ -32672,7 +35973,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminCatalogSearchRuntimeStatus"]; + "application/json": components["schemas"]["AdminCollectionSyncRun"]; }; }; /** @description Bad Request */ @@ -32758,22 +36059,19 @@ export interface operations { }; }; }; - getAdminGroupCollectionOrder: { + getAdminCollectionCapabilities: { parameters: { - query?: { - /** @description Opaque identifier */ - library_id?: string; - }; + 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 */ - group_id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -32781,13 +36079,24 @@ 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"]["AdminCollectionOrder"]; + "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: { @@ -32833,6 +36142,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: { @@ -32871,44 +36191,32 @@ export interface operations { }; }; }; - moveAndReorderAdminGroupCollections: { + importAdminMDBList: { parameters: { - 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; + 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 */ - group_id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminCollectionIDsOrder"]; + "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"]["AdminCollectionOrder"]; + "application/json": components["schemas"]["AdminCollectionImportResult"]; }; }; /** @description Bad Request */ @@ -32965,17 +36273,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: { @@ -33003,15 +36300,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: { @@ -33041,33 +36329,32 @@ export interface operations { }; }; }; - getAdminCollectionGroup: { + importAdminTMDB: { 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"]["AdminTMDBImport"]; + }; + }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { - ETag?: string; + Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminCollectionGroup"]; + "application/json": components["schemas"]["AdminCollectionImportResult"]; }; }; /** @description Bad Request */ @@ -33115,101 +36402,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"]; - }; - }; - }; - }; - 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; - }; - 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: { + /** @description Request Timeout */ + 408: { headers: { [name: string]: unknown; }; @@ -33217,8 +36411,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Acceptable */ - 406: { + /** @description Request Entity Too Large */ + 413: { headers: { [name: string]: unknown; }; @@ -33226,11 +36420,9 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { + /** @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: { @@ -33246,15 +36438,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: { @@ -33284,40 +36467,32 @@ export interface operations { }; }; }; - updateAdminCollectionGroup: { + importAdminTMDBList: { 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"]["CollectionGroupUpdate"]; + "application/json": components["schemas"]["AdminTMDBListImport"]; }; }; 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"]["AdminCollectionGroup"]; + "application/json": components["schemas"]["AdminCollectionImportResult"]; }; }; /** @description Bad Request */ @@ -33374,17 +36549,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: { @@ -33412,15 +36576,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: { @@ -33450,46 +36605,33 @@ export interface operations { }; }; }; - getAdminCollectionJob: { + importAdminTrakt: { 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; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminTraktImport"]; + }; + }; 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; - "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: { - 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"]["AdminCollectionImportResult"]; }; - content?: never; }; /** @description Bad Request */ 400: { @@ -33536,11 +36678,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: { @@ -33585,11 +36743,13 @@ export interface operations { }; }; }; - listAdminCollections: { + getAdminCollectionOrder: { parameters: { - query?: { + query: { /** @description Opaque identifier */ - library_id?: string; + 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. */ @@ -33605,10 +36765,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 */ @@ -33694,10 +36855,14 @@ export interface operations { }; }; }; - createAdminCollection: { + 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 */ @@ -33708,18 +36873,19 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AdminCollectionCreate"]; + "application/json": components["schemas"]["AdminCollectionOrderBody"]; }; }; 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 */ @@ -33776,6 +36942,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: { @@ -33803,6 +36980,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: { @@ -33832,33 +37018,31 @@ export interface operations { }; }; }; - getAdminCollection: { + previewAdminCollection: { 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"]["PersonalCollectionPreviewInputBody"]; + }; + }; responses: { /** @description OK */ 200: { headers: { - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminCollection"]; + "application/json": components["schemas"]["AdminCollectionPreviewOutputBody"]; }; }; /** @description Bad Request */ @@ -33906,6 +37090,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: { @@ -33944,33 +37155,28 @@ export interface operations { }; }; }; - deleteAdminCollection: { + listAdminCollectionTemplateBundles: { 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?: never; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["BundleCatalog"]; + }; }; /** @description Bad Request */ 400: { @@ -34017,17 +37223,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: { @@ -34037,15 +37232,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: { @@ -34075,40 +37261,33 @@ export interface operations { }; }; }; - updateAdminCollection: { + 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"]["AdminCollectionUpdate"]; + "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"]["AdminCollection"]; + "application/json": components["schemas"]["AdminTemplateResult"]; }; }; /** @description Bad Request */ @@ -34165,17 +37344,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: { @@ -34203,15 +37371,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: { @@ -34241,7 +37400,7 @@ export interface operations { }; }; }; - uploadAdminCollectionBackdrop: { + startAdminCollectionTemplateBundleJob: { parameters: { query?: never; header?: { @@ -34251,25 +37410,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"]["AdminCollectionBackdropForm"]; + "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 */ @@ -34382,31 +37541,28 @@ export interface operations { }; }; }; - deleteAdminCollectionImage: { + listAdminCollectionTemplates: { 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; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["CollectionTemplateCatalog"]; + }; }; /** @description Bad Request */ 400: { @@ -34491,23 +37647,19 @@ export interface operations { }; }; }; - getAdminCollectionItems: { + getAdminDashboardCapabilities: { parameters: { - query?: { - cursor?: string; - /** @description Page size; default 50, maximum 200 */ - limit?: number; - }; + 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; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -34515,11 +37667,23 @@ 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"]["CollectionAdminCollectionMember"]; + "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; }; + content?: never; }; /** @description Bad Request */ 400: { @@ -34566,6 +37730,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: { @@ -34604,32 +37779,39 @@ export interface operations { }; }; }; - addAdminCollectionItem: { + 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 */ "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"]; - }; - }; + 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"]["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; @@ -34679,27 +37861,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: { @@ -34744,28 +37910,33 @@ export interface operations { }; }; }; - removeAdminCollectionItem: { + 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 */ "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"]["AdminDashboardLayoutSaveBody"]; + }; + }; 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; @@ -34815,6 +37986,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: { @@ -34824,6 +38033,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: { @@ -34853,34 +38071,26 @@ export interface operations { }; }; }; - getAdminCollectionItemsOrder: { + resetAdminDashboardLayout: { 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; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { - ETag?: string; [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["AdminCollectionOrder"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -34965,40 +38175,31 @@ export interface operations { }; }; }; - reorderAdminCollectionItems: { + listAdminDevices: { 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: { - /** @description Opaque identifier */ - id: string; - }; + path?: never; 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"]["CollectionAdminDeviceMetadata"]; }; }; /** @description Bad Request */ @@ -35046,8 +38247,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -35055,19 +38256,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; }; @@ -35075,8 +38274,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unsupported Media Type */ - 415: { + /** @description Service Unavailable */ + 503: { headers: { [name: string]: unknown; }; @@ -35084,8 +38283,37 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + }; + }; + getAdminDevice: { + 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: { + device_id: string; + /** @description Opaque identifier */ + user_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminDeviceDetail"]; + }; + }; + /** @description Bad Request */ + 400: { headers: { [name: string]: unknown; }; @@ -35093,8 +38321,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; }; @@ -35131,37 +38395,44 @@ export interface operations { }; }; }; - uploadAdminCollectionPoster: { + 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 */ "X-Profile-Token"?: string; }; - path: { - /** @description Opaque identifier */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody: { - content: { - "multipart/form-data": components["schemas"]["AdminCollectionArtworkForm"]; - }; - }; + 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"]["AdminCollection"]; + "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: { @@ -35207,27 +38478,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: { @@ -35272,21 +38527,26 @@ export interface operations { }; }; }; - syncAdminCollection: { + 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?: { - "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; @@ -35297,7 +38557,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminCollectionSyncRun"]; + "application/json": components["schemas"]["CollectionAdminDiagnosticSummary"]; }; }; /** @description Bad Request */ @@ -35383,19 +38643,18 @@ export interface operations { }; }; }; - getAdminCollectionCapabilities: { + getAdminDiagnosticReport: { 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: { + id: string; + }; cookie?: never; }; requestBody?: never; @@ -35403,23 +38662,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"]["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; + "application/json": components["schemas"]["AdminDiagnosticDetail"]; }; - content?: never; }; /** @description Bad Request */ 400: { @@ -35466,17 +38713,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: { @@ -35515,7 +38751,7 @@ export interface operations { }; }; }; - importAdminMDBList: { + deleteAdminDiagnosticReport: { parameters: { query?: never; header?: { @@ -35524,24 +38760,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?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminMDBListImport"]; + path: { + id: string; }; + cookie?: never; }; + 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: { @@ -35588,8 +38819,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -35597,8 +38828,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; }; @@ -35606,8 +38837,92 @@ 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"]; + }; + }; + }; + }; + downloadAdminDiagnosticReport: { + 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 Complete gzip-compressed diagnostic bundle */ + 200: { + headers: { + "Accept-Ranges"?: string; + "Content-Disposition"?: string; + "Content-Length"?: number; + [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 Conflict */ + 409: { headers: { [name: string]: unknown; }; @@ -35653,7 +38968,7 @@ export interface operations { }; }; }; - importAdminTMDB: { + sendAdminTestEmail: { parameters: { query?: never; header?: { @@ -35667,18 +38982,17 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AdminTMDBImport"]; + "application/json": components["schemas"]["AdminEmailTestInputBody"]; }; }; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { - Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminCollectionImportResult"]; + "application/json": components["schemas"]["AdminEmailTestResponse"]; }; }; /** @description Bad Request */ @@ -35791,7 +39105,7 @@ export interface operations { }; }; }; - importAdminTMDBList: { + contributeAdminFileMarkers: { parameters: { query?: never; header?: { @@ -35800,23 +39114,25 @@ 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 */ + fileId: string; + }; cookie?: never; }; - requestBody: { + requestBody?: { content: { - "application/json": components["schemas"]["AdminTMDBListImport"]; + "application/json": components["schemas"]["AdminMarkerContributionRequest"]; }; }; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { - Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminCollectionImportResult"]; + "application/json": components["schemas"]["AdminMarkerContributionOutcomes"]; }; }; /** @description Bad Request */ @@ -35929,32 +39245,34 @@ export interface operations { }; }; }; - importAdminTrakt: { + listAdminFileMarkerContributions: { 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?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminTraktImport"]; + path: { + /** @description Opaque identifier */ + fileId: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { - Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminCollectionImportResult"]; + "application/json": components["schemas"]["CollectionAdminMarkerContribution"]; }; }; /** @description Bad Request */ @@ -36002,33 +39320,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: { @@ -36067,13 +39358,13 @@ export interface operations { }; }; }; - getAdminCollectionOrder: { + browseAdminFilesystem: { parameters: { - query: { - /** @description Opaque identifier */ - group_id?: string; - /** @description Opaque identifier */ - library_id: string; + 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. */ @@ -36089,11 +39380,10 @@ export interface operations { /** @description OK */ 200: { headers: { - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminCollectionOrder"]; + "application/json": components["schemas"]["AdminFilesystemPage"]; }; }; /** @description Bad Request */ @@ -36179,14 +39469,15 @@ export interface operations { }; }; }; - reorderAdminCollections: { + listAdminHistoryImportSources: { 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?: { + /** @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 */ @@ -36195,21 +39486,15 @@ export interface operations { path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminCollectionOrderBody"]; - }; - }; + 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"]["CollectionAdminHistoryImportSource"]; }; }; /** @description Bad Request */ @@ -36257,44 +39542,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: { @@ -36304,15 +39551,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: { @@ -36342,7 +39580,7 @@ export interface operations { }; }; }; - previewAdminCollection: { + createAdminHistoryImportSource: { parameters: { query?: never; header?: { @@ -36356,17 +39594,19 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["PersonalCollectionPreviewInputBody"]; + "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"]["AdminCollectionPreviewOutputBody"]; + "application/json": components["schemas"]["AdminHistoryImportSource"]; }; }; /** @description Bad Request */ @@ -36479,7 +39719,7 @@ export interface operations { }; }; }; - listAdminCollectionTemplateBundles: { + getAdminHistoryImportSource: { parameters: { query?: never; header?: { @@ -36488,7 +39728,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; @@ -36496,10 +39739,12 @@ export interface operations { /** @description OK */ 200: { headers: { + ETag?: string; + Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["BundleCatalog"]; + "application/json": components["schemas"]["AdminHistoryImportSource"]; }; }; /** @description Bad Request */ @@ -36585,33 +39830,41 @@ export interface operations { }; }; }; - applyAdminCollectionTemplateBundle: { + updateAdminHistoryImportSource: { 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: { - bundle_id: string; + /** @description Opaque identifier */ + id: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminTemplateApply"]; + "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"]["AdminTemplateResult"]; + "application/json": components["schemas"]["AdminHistoryImportSource"]; }; }; /** @description Bad Request */ @@ -36668,140 +39921,19 @@ 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: { - [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"]; - }; - }; - }; - }; - startAdminCollectionTemplateBundleJob: { - 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: { - bundle_id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminTemplateApply"]; - }; - }; - responses: { - /** @description Accepted */ - 202: { - headers: { - Location?: string; - "Retry-After"?: string; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["AdminJob"]; - }; - }; - /** @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: { + /** @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 Timeout */ - 408: { + /** @description Request Entity Too Large */ + 413: { headers: { [name: string]: unknown; }; @@ -36809,8 +39941,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Entity Too Large */ - 413: { + /** @description Unsupported Media Type */ + 415: { headers: { [name: string]: unknown; }; @@ -36818,8 +39950,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unsupported Media Type */ - 415: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -36827,8 +39959,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + /** @description Precondition Required */ + 428: { headers: { [name: string]: unknown; }; @@ -36865,28 +39997,33 @@ export interface operations { }; }; }; - listAdminCollectionTemplates: { + 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?: 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"]["CollectionTemplateCatalog"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -36933,6 +40070,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: { @@ -36942,6 +40090,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: { @@ -36971,7 +40128,7 @@ export interface operations { }; }; }; - getAdminDashboardCapabilities: { + getAdminHistoryImportCapabilities: { parameters: { query?: never; header?: { @@ -36997,7 +40154,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminDashboardCapabilities"]; + "application/json": components["schemas"]["AdminHistoryImportCapabilitiesOutputBody"]; }; }; /** @description The representation named by If-None-Match is current; no body. */ @@ -37103,13 +40260,16 @@ export interface operations { }; }; }; - getAdminDashboardLayout: { + 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 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 */ @@ -37123,22 +40283,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"]["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"]["CollectionAdminHistoryImportMapping"]; }; - content?: never; }; /** @description Bad Request */ 400: { @@ -37185,17 +40334,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: { @@ -37234,14 +40372,10 @@ export interface operations { }; }; }; - saveAdminDashboardLayout: { + createAdminHistoryImportMapping: { 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 */ @@ -37252,18 +40386,20 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AdminDashboardLayoutSaveBody"]; + "application/json": components["schemas"]["AdminHistoryImportMappingCreateInputBody"]; }; }; responses: { - /** @description No Content */ - 204: { + /** @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?: never; + content: { + "application/json": components["schemas"]["AdminHistoryImportMapping"]; + }; }; /** @description Bad Request */ 400: { @@ -37319,17 +40455,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: { @@ -37357,15 +40482,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: { @@ -37395,7 +40511,7 @@ export interface operations { }; }; }; - resetAdminDashboardLayout: { + getAdminHistoryImportMapping: { parameters: { query?: never; header?: { @@ -37404,17 +40520,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: { + /** @description Opaque identifier */ + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { + ETag?: string; + Location?: string; [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["AdminHistoryImportMapping"]; + }; }; /** @description Bad Request */ 400: { @@ -37499,31 +40622,41 @@ export interface operations { }; }; }; - listAdminDevices: { + updateAdminHistoryImportMapping: { parameters: { - query?: { - 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"]["AdminHistoryImportMappingUpdateInputBody"]; + }; + }; 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"]["CollectionAdminDeviceMetadata"]; + "application/json": components["schemas"]["AdminHistoryImportMapping"]; }; }; /** @description Bad Request */ @@ -37571,73 +40704,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"]; - }; - }; - }; - }; - getAdminDevice: { - 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: { - device_id: string; - /** @description Opaque identifier */ - user_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["AdminDeviceDetail"]; - }; - }; - /** @description Bad Request */ - 400: { + /** @description Request Timeout */ + 408: { headers: { [name: string]: unknown; }; @@ -37645,17 +40713,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; }; @@ -37663,8 +40733,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Found */ - 404: { + /** @description Unsupported Media Type */ + 415: { headers: { [name: string]: unknown; }; @@ -37672,8 +40742,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Acceptable */ - 406: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -37681,8 +40751,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + /** @description Precondition Required */ + 428: { headers: { [name: string]: unknown; }; @@ -37719,40 +40789,30 @@ export interface operations { }; }; }; - getAdminDeviceCapabilities: { + deleteAdminHistoryImportMapping: { 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 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"]["AdminDeviceCapabilitiesOutputBody"]; - }; - }; - /** @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; @@ -37822,6 +40882,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: { @@ -37851,37 +40920,33 @@ export interface operations { }; }; }; - listAdminDiagnosticReports: { + createAdminHistoryImportRun: { 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; - }; + 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; responses: { - /** @description OK */ - 200: { + /** @description Accepted */ + 202: { headers: { + ETag?: string; + Location?: string; + "Retry-After"?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminDiagnosticSummary"]; + "application/json": components["schemas"]["AdminHistoryImportRun"]; }; }; /** @description Bad Request */ @@ -37967,7 +41032,7 @@ export interface operations { }; }; }; - getAdminDiagnosticReport: { + loginAdminHistoryImportPlex: { parameters: { query?: never; header?: { @@ -37976,12 +41041,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"]["AdminHistoryImportPlexLoginInputBody"]; + }; + }; responses: { /** @description OK */ 200: { @@ -37989,7 +41056,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminDiagnosticDetail"]; + "application/json": components["schemas"]["AdminHistoryImportPlexLoginOutputBody"]; }; }; /** @description Bad Request */ @@ -38037,87 +41104,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"]; - }; - }; - }; - }; - deleteAdminDiagnosticReport: { - 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 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: { + /** @description Request Timeout */ + 408: { headers: { [name: string]: unknown; }; @@ -38125,8 +41113,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Found */ - 404: { + /** @description Request Entity Too Large */ + 413: { headers: { [name: string]: unknown; }; @@ -38134,8 +41122,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Acceptable */ - 406: { + /** @description Unsupported Media Type */ + 415: { headers: { [name: string]: unknown; }; @@ -38181,32 +41169,33 @@ export interface operations { }; }; }; - downloadAdminDiagnosticReport: { + listAdminHistoryImportRuns: { 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; /** @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; [name: string]: unknown; }; content: { - "application/gzip": string; + "application/json": components["schemas"]["CollectionAdminHistoryImportRun"]; }; }; /** @description Bad Request */ @@ -38245,8 +41234,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Conflict */ - 409: { + /** @description Not Acceptable */ + 406: { headers: { [name: string]: unknown; }; @@ -38292,33 +41281,48 @@ export interface operations { }; }; }; - sendAdminTestEmail: { + getAdminHistoryImportRun: { 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: { - content: { - "application/json": components["schemas"]["AdminEmailTestInputBody"]; + path: { + /** @description The run */ + 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"]["AdminEmailTestResponse"]; + "application/json": components["schemas"]["AdminHistoryImportRun"]; }; }; + /** @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: { @@ -38364,27 +41368,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: { @@ -38429,7 +41417,7 @@ export interface operations { }; }; }; - contributeAdminFileMarkers: { + cancelAdminHistoryImportRun: { parameters: { query?: never; header?: { @@ -38439,24 +41427,32 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - /** @description Opaque identifier */ - fileId: string; + /** @description The run */ + id: string; }; cookie?: never; }; - requestBody?: { - content: { - "application/json": components["schemas"]["AdminMarkerContributionRequest"]; - }; - }; + requestBody?: never; responses: { - /** @description OK */ + /** @description The run is already canceled. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminMarkerContributionOutcomes"]; + "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 */ @@ -38504,33 +41500,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: { @@ -38569,13 +41538,9 @@ export interface operations { }; }; }; - listAdminFileMarkerContributions: { + bulkCreateAdminHistoryImportRuns: { 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; @@ -38584,7 +41549,7 @@ export interface operations { }; path: { /** @description Opaque identifier */ - fileId: string; + id: string; }; cookie?: never; }; @@ -38596,7 +41561,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminMarkerContribution"]; + "application/json": components["schemas"]["AdminHistoryImportBulkOutputBody"]; }; }; /** @description Bad Request */ @@ -38682,32 +41647,41 @@ export interface operations { }; }; }; - browseAdminFilesystem: { + setAdminHistoryImportToken: { 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; + path: { + /** @description Opaque identifier */ + id: string; + }; 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"]["AdminFilesystemPage"]; + "application/json": components["schemas"]["AdminHistoryImportSource"]; }; }; /** @description Bad Request */ @@ -38755,6 +41729,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: { @@ -38764,6 +41776,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: { @@ -38793,33 +41814,33 @@ export interface operations { }; }; }; - listAdminHistoryImportSources: { + clearAdminHistoryImportToken: { 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; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["CollectionAdminHistoryImportSource"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -38866,6 +41887,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: { @@ -38875,6 +41907,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: { @@ -38904,33 +41945,34 @@ export interface operations { }; }; }; - createAdminHistoryImportSource: { + listAdminHistoryImportExternalUsers: { 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?: 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 OK */ + 200: { headers: { - ETag?: string; - Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminHistoryImportSource"]; + "application/json": components["schemas"]["CollectionExternalUser"]; }; }; /** @description Bad Request */ @@ -38978,26 +42020,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; }; @@ -39043,19 +42067,21 @@ export interface operations { }; }; }; - getAdminHistoryImportSource: { + 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; @@ -39063,12 +42089,10 @@ export interface operations { /** @description OK */ 200: { headers: { - ETag?: string; - Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminHistoryImportSource"]; + "application/json": components["schemas"]["CollectionAdminInvitation"]; }; }; /** @description Bad Request */ @@ -39116,6 +42140,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: { @@ -39154,41 +42187,32 @@ export interface operations { }; }; }; - updateAdminHistoryImportSource: { + createAdminInvitation: { 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"]["AdminInvitationCreateInputBody"]; }; }; 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"]["InvitationDelivery"]; }; }; /** @description Bad Request */ @@ -39245,11 +42269,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: { @@ -39283,8 +42305,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Required */ - 428: { + /** @description Too Many Requests */ + 429: { headers: { [name: string]: unknown; }; @@ -39292,8 +42314,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Too Many Requests */ - 429: { + /** @description Internal Server Error */ + 500: { headers: { [name: string]: unknown; }; @@ -39301,8 +42323,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal Server Error */ - 500: { + /** @description Not Implemented */ + 501: { headers: { [name: string]: unknown; }; @@ -39321,14 +42343,10 @@ export interface operations { }; }; }; - deleteAdminHistoryImportSource: { + getAdminInvitation: { 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 */ @@ -39342,12 +42360,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"]["AdminInvitation"]; + }; }; /** @description Bad Request */ 400: { @@ -39394,11 +42414,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: { @@ -39414,15 +42432,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: { @@ -39452,40 +42461,26 @@ export interface operations { }; }; }; - getAdminHistoryImportCapabilities: { + 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?: 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"]["AdminHistoryImportCapabilitiesOutputBody"]; - }; - }; - /** @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; @@ -39535,122 +42530,8 @@ 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: { - [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"]; - }; - }; - }; - }; - listAdminHistoryImportMappings: { - parameters: { - 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?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["CollectionAdminHistoryImportMapping"]; - }; - }; - /** @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: { + /** @description Conflict */ + 409: { headers: { [name: string]: unknown; }; @@ -39696,7 +42577,7 @@ export interface operations { }; }; }; - createAdminHistoryImportMapping: { + resendAdminInvitation: { parameters: { query?: never; header?: { @@ -39705,24 +42586,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"]["AdminHistoryImportMappingCreateInputBody"]; + path: { + /** @description Opaque identifier */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description Created */ 201: { headers: { - ETag?: string; Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminHistoryImportMapping"]; + "application/json": components["schemas"]["InvitationDelivery"]; }; }; /** @description Bad Request */ @@ -39770,17 +42649,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: { + /** @description Conflict */ + 409: { headers: { [name: string]: unknown; }; @@ -39788,8 +42658,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unsupported Media Type */ - 415: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -39797,8 +42667,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + /** @description Too Many Requests */ + 429: { headers: { [name: string]: unknown; }; @@ -39806,8 +42676,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Too Many Requests */ - 429: { + /** @description Internal Server Error */ + 500: { headers: { [name: string]: unknown; }; @@ -39815,8 +42685,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal Server Error */ - 500: { + /** @description Not Implemented */ + 501: { headers: { [name: string]: unknown; }; @@ -39835,19 +42705,19 @@ export interface operations { }; }; }; - getAdminHistoryImportMapping: { + 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 Opaque identifier */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -39855,13 +42725,23 @@ 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"]["AdminHistoryImportMapping"]; + "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; }; + content?: never; }; /** @description Bad Request */ 400: { @@ -39908,6 +42788,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: { @@ -39946,41 +42846,32 @@ export interface operations { }; }; }; - updateAdminHistoryImportMapping: { + listAdminInviteCodes: { 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?: { + /** @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: { - content: { - "application/json": components["schemas"]["AdminHistoryImportMappingUpdateInputBody"]; - }; - }; + 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"]["AdminHistoryImportMapping"]; + "application/json": components["schemas"]["CollectionAdminInviteCode"]; }; }; /** @description Bad Request */ @@ -40028,8 +42919,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -40037,19 +42928,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; }; @@ -40057,8 +42946,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unsupported Media Type */ - 415: { + /** @description Service Unavailable */ + 503: { headers: { [name: string]: unknown; }; @@ -40066,8 +42955,37 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + }; + }; + createAdminInviteCode: { + 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"]["AdminInviteCodeCreateInputBody"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminInviteCode"]; + }; + }; + /** @description Bad Request */ + 400: { headers: { [name: string]: unknown; }; @@ -40075,8 +42993,71 @@ 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 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; }; @@ -40113,14 +43094,10 @@ export interface operations { }; }; }; - deleteAdminHistoryImportMapping: { + 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 */ @@ -40132,7 +43109,11 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminInviteCodeUpdateInputBody"]; + }; + }; responses: { /** @description No Content */ 204: { @@ -40186,19 +43167,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; }; @@ -40206,8 +43185,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; }; @@ -40244,7 +43232,7 @@ export interface operations { }; }; }; - createAdminHistoryImportRun: { + deleteAdminInviteCode: { parameters: { query?: never; header?: { @@ -40261,17 +43249,12 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Accepted */ - 202: { + /** @description No Content */ + 204: { headers: { - ETag?: string; - Location?: string; - "Retry-After"?: string; [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["AdminHistoryImportRun"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -40356,7 +43339,7 @@ export interface operations { }; }; }; - loginAdminHistoryImportPlex: { + topUpAdminInviteCode: { parameters: { query?: never; header?: { @@ -40365,12 +43348,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"]["AdminHistoryImportPlexLoginInputBody"]; + "application/json": components["schemas"]["AdminInviteCodeTopUpInputBody"]; }; }; responses: { @@ -40380,7 +43366,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminHistoryImportPlexLoginOutputBody"]; + "application/json": components["schemas"]["AdminInviteCode"]; }; }; /** @description Bad Request */ @@ -40493,16 +43479,13 @@ export interface operations { }; }; }; - listAdminHistoryImportRuns: { + getAdminInviteCodeCapabilities: { parameters: { - query?: { - cursor?: string; - /** @description Page size; default 50, maximum 200 */ - limit?: number; - /** @description Opaque identifier */ - source_id?: string; - }; + 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 */ @@ -40516,12 +43499,24 @@ 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"]["CollectionAdminHistoryImportRun"]; + "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: { headers: { @@ -40567,6 +43562,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: { @@ -40605,22 +43611,22 @@ export interface operations { }; }; }; - getAdminHistoryImportRun: { + listAdminIPUsers: { parameters: { - query?: never; + query: { + cursor?: string; + days?: number; + ip: 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 The run */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -40628,24 +43634,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; - Location?: string; - "Retry-After"?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminHistoryImportRun"]; - }; - }; - /** @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"]["CollectionAdminIPUser"]; }; - content?: never; }; /** @description Bad Request */ 400: { @@ -40692,17 +43685,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: { @@ -40741,9 +43723,13 @@ export interface operations { }; }; }; - cancelAdminHistoryImportRun: { + 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; @@ -40751,32 +43737,19 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - /** @description The run */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The run is already canceled. */ + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content: { - "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"]; + "application/json": components["schemas"]["CollectionAdminItemFile"]; }; }; /** @description Bad Request */ @@ -40862,9 +43835,13 @@ export interface operations { }; }; }; - bulkCreateAdminHistoryImportRuns: { + 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; @@ -40872,7 +43849,6 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - /** @description Opaque identifier */ id: string; }; cookie?: never; @@ -40885,7 +43861,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminHistoryImportBulkOutputBody"]; + "application/json": components["schemas"]["AdminImagesPage"]; }; }; /** @description Bad Request */ @@ -40971,41 +43947,33 @@ export interface operations { }; }; }; - setAdminHistoryImportToken: { + applyAdminItemImage: { 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: { content: { - "application/json": components["schemas"]["AdminHistoryImportTokenInputBody"]; + "application/json": components["schemas"]["AdminImageApplyInputBody"]; }; }; 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"]["AdminImageApplied"]; }; }; /** @description Bad Request */ @@ -41062,17 +44030,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: { @@ -41100,15 +44057,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: { @@ -41138,157 +44086,25 @@ export interface operations { }; }; }; - clearAdminHistoryImportToken: { + applyAdminItemMatch: { 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. */ + header?: { + /** @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; - 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"]; - }; - }; - }; - }; - listAdminHistoryImportExternalUsers: { - 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: { - /** @description Opaque identifier */ - id: string; + requestBody: { + content: { + "application/json": components["schemas"]["AdminMatchApplyInputBody"]; }; - cookie?: never; }; - requestBody?: never; responses: { /** @description OK */ 200: { @@ -41296,7 +44112,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionExternalUser"]; + "application/json": components["schemas"]["AdminMatchApplied"]; }; }; /** @description Bad Request */ @@ -41344,8 +44160,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; }; @@ -41391,24 +44225,25 @@ export interface operations { }; }; }; - listAdminInvitations: { + 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: { @@ -41416,7 +44251,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminInvitation"]; + "application/json": components["schemas"]["AdminMatchCandidates"]; }; }; /** @description Bad Request */ @@ -41464,8 +44299,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; }; @@ -41511,7 +44364,7 @@ export interface operations { }; }; }; - createAdminInvitation: { + mergeAdminItem: { parameters: { query?: never; header?: { @@ -41520,23 +44373,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"]["AdminInvitationCreateInputBody"]; + "application/json": components["schemas"]["AdminMergeInputBody"]; }; }; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { - Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["InvitationDelivery"]; + "application/json": components["schemas"]["AdminMergeResult"]; }; }; /** @description Bad Request */ @@ -41593,15 +44447,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: { @@ -41647,15 +44492,6 @@ 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: { @@ -41667,22 +44503,25 @@ export interface operations { }; }; }; - getAdminInvitation: { + 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?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminItemMetadataUpdate"]; + }; + }; responses: { /** @description OK */ 200: { @@ -41690,7 +44529,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminInvitation"]; + "application/json": components["schemas"]["CatalogItemDetail"]; }; }; /** @description Bad Request */ @@ -41738,8 +44577,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; }; @@ -41785,29 +44642,35 @@ export interface operations { }; }; }; - revokeAdminInvitation: { + 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: { @@ -41854,8 +44717,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; }; @@ -41901,31 +44782,29 @@ export interface operations { }; }; }; - resendAdminInvitation: { + 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?: never; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { - Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["InvitationDelivery"]; + "application/json": components["schemas"]["AdminMetadataTranslationJobs"]; }; }; /** @description Bad Request */ @@ -41973,15 +44852,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: { @@ -42009,15 +44879,6 @@ 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: { @@ -42029,40 +44890,27 @@ export interface operations { }; }; }; - getAdminInvitationCapabilities: { + 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"]["InvitationCapabilities"]; - }; - }; - /** @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; @@ -42112,26 +44960,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 Unprocessable Entity */ 422: { headers: { @@ -42170,32 +44998,29 @@ export interface operations { }; }; }; - listAdminInviteCodes: { + redetectAdminEpisodeIntro: { 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 Accepted */ + 202: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminInviteCode"]; + "application/json": components["schemas"]["AdminEpisodeMarkersStatus"]; }; }; /** @description Bad Request */ @@ -42281,7 +45106,7 @@ export interface operations { }; }; }; - createAdminInviteCode: { + redetectAdminItemMarkers: { parameters: { query?: never; header?: { @@ -42290,22 +45115,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: { + requestBody?: { content: { - "application/json": components["schemas"]["AdminInviteCodeCreateInputBody"]; + "application/json": components["schemas"]["AdminItemMarkersRedetect"]; }; }; responses: { - /** @description Created */ - 201: { + /** @description Accepted */ + 202: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminInviteCode"]; + "application/json": components["schemas"]["AdminEpisodeMarkersStatus"]; }; }; /** @description Bad Request */ @@ -42362,6 +45189,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: { @@ -42418,7 +45254,7 @@ export interface operations { }; }; }; - updateAdminInviteCode: { + refreshAdminEpisodeMarkers: { parameters: { query?: never; header?: { @@ -42428,23 +45264,20 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - /** @description Opaque identifier */ id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminInviteCodeUpdateInputBody"]; - }; - }; + requestBody?: never; responses: { - /** @description No Content */ - 204: { + /** @description Accepted */ + 202: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["AdminEpisodeMarkersStatus"]; + }; }; /** @description Bad Request */ 400: { @@ -42491,26 +45324,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; }; @@ -42556,29 +45371,36 @@ export interface operations { }; }; }; - deleteAdminInviteCode: { + refreshAdminItemMetadata: { 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"]["AdminItemMetadataRefreshInputBody"]; + }; + }; responses: { - /** @description No Content */ - 204: { + /** @description Accepted */ + 202: { headers: { + Location?: string; + "Retry-After"?: string; [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["AdminTaskJob"]; + }; }; /** @description Bad Request */ 400: { @@ -42625,6 +45447,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: { @@ -42663,7 +45512,7 @@ export interface operations { }; }; }; - topUpAdminInviteCode: { + splitAdminItem: { parameters: { query?: never; header?: { @@ -42673,14 +45522,13 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - /** @description Opaque identifier */ id: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminInviteCodeTopUpInputBody"]; + "application/json": components["schemas"]["AdminSplitRequest"]; }; }; responses: { @@ -42690,7 +45538,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminInviteCode"]; + "application/json": components["schemas"]["AdminSplitResult"]; }; }; /** @description Bad Request */ @@ -42803,12 +45651,13 @@ export interface operations { }; }; }; - getAdminInviteCodeCapabilities: { + updateAdminJellyfinCompatSettings: { 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 the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; @@ -42818,28 +45667,22 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminJellyfinCompatSettingsPatch"]; + }; + }; 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: { - 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"]["AdminJellyfinCompatStatus"]; }; - content?: never; }; /** @description Bad Request */ 400: { @@ -42886,86 +45729,8 @@ 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: { - [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"]; - }; - }; - }; - }; - 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: { + /** @description Request Timeout */ + 408: { headers: { [name: string]: unknown; }; @@ -42973,17 +45738,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; }; @@ -42991,8 +45758,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Found */ - 404: { + /** @description Unsupported Media Type */ + 415: { headers: { [name: string]: unknown; }; @@ -43000,8 +45767,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Acceptable */ - 406: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -43009,8 +45776,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + /** @description Precondition Required */ + 428: { headers: { [name: string]: unknown; }; @@ -43047,22 +45814,16 @@ export interface operations { }; }; }; - listAdminItemFiles: { + getAdminJellyfinCompatStatus: { 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: { - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -43073,7 +45834,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminItemFile"]; + "application/json": components["schemas"]["AdminJellyfinCompatStatus"]; }; }; /** @description Bad Request */ @@ -43159,33 +45920,31 @@ export interface operations { }; }; }; - listAdminItemImages: { + installAdminJellyfinCompatWeb: { 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: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminJellyfinWebInstallBody"]; + }; + }; responses: { - /** @description OK */ - 200: { + /** @description Accepted */ + 202: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminImagesPage"]; + "application/json": components["schemas"]["AdminJellyfinCompatStatus"]; }; }; /** @description Bad Request */ @@ -43233,6 +45992,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: { @@ -43271,7 +46066,7 @@ export interface operations { }; }; }; - applyAdminItemImage: { + removeAdminJellyfinCompatWeb: { parameters: { query?: never; header?: { @@ -43280,24 +46075,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: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminImageApplyInputBody"]; - }; - }; + requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description Accepted */ + 202: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminImageApplied"]; + "application/json": components["schemas"]["AdminJellyfinCompatStatus"]; }; }; /** @description Bad Request */ @@ -43345,26 +46134,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; }; @@ -43410,25 +46181,23 @@ export interface operations { }; }; }; - applyAdminItemMatch: { + 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"]["AdminMatchApplyInputBody"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -43436,7 +46205,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminMatchApplied"]; + "application/json": components["schemas"]["CollectionAdminTaskJob"]; }; }; /** @description Bad Request */ @@ -43484,33 +46253,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: { @@ -43549,33 +46291,25 @@ export interface operations { }; }; }; - searchAdminItemMatches: { + getAdminJob: { parameters: { query?: never; - header?: { - /** @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; - }; + header?: never; path: { id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminMatchSearchBody"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { headers: { + "Retry-After"?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminMatchCandidates"]; + "application/json": components["schemas"]["AdminTaskJob"]; }; }; /** @description Bad Request */ @@ -43596,15 +46330,6 @@ 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: { @@ -43623,8 +46348,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -43632,8 +46357,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; }; @@ -43641,8 +46366,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unsupported Media Type */ - 415: { + /** @description Internal Server Error */ + 500: { headers: { [name: string]: unknown; }; @@ -43650,8 +46375,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + /** @description Service Unavailable */ + 503: { headers: { [name: string]: unknown; }; @@ -43659,36 +46384,50 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Too Many Requests */ - 429: { + }; + }; + 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/problem+json": components["schemas"]["Problem"]; + "application/gzip": string; }; }; - /** @description Internal Server Error */ - 500: { + /** @description Artifact not found, or the capability is invalid or expired */ + 404: { headers: { [name: string]: unknown; }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; + content?: never; }; - /** @description Service Unavailable */ + /** @description Artifact storage unavailable */ 503: { headers: { [name: string]: unknown; }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; + content?: never; }; }; }; - mergeAdminItem: { + cancelAdminJob: { parameters: { query?: never; header?: { @@ -43702,19 +46441,25 @@ export interface operations { }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminMergeInputBody"]; - }; - }; + requestBody?: never; responses: { - /** @description OK */ + /** @description The job was already canceled. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminMergeResult"]; + "application/json": components["schemas"]["AdminTaskJob"]; + }; + }; + /** @description Accepted */ + 202: { + headers: { + "Retry-After"?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminTaskJob"]; }; }; /** @description Bad Request */ @@ -43762,26 +46507,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; }; @@ -43827,34 +46554,43 @@ export interface operations { }; }; }; - updateAdminItemMetadata: { + 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; - }; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminItemMetadataUpdate"]; - }; - }; + 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"]["CatalogItemDetail"]; + "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; }; /** @description Bad Request */ 400: { @@ -43901,17 +46637,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 Request Entity Too Large */ - 413: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -43919,8 +46657,99 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unsupported Media Type */ - 415: { + /** @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"]; + }; + }; + }; + }; + listAdminCollectionGroups: { + 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?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminGroups"]; + }; + }; + /** @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; }; @@ -43966,34 +46795,35 @@ export interface operations { }; }; }; - translateAdminItemMetadata: { + createAdminCollectionGroup: { 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"]["AdminTranslateMetadataInputBody"]; + "application/json": components["schemas"]["CollectionGroupCreate"]; }; }; responses: { - /** @description Accepted */ - 202: { + /** @description Created */ + 201: { headers: { Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["MetadataTranslationJob"]; + "application/json": components["schemas"]["AdminCollectionGroup"]; }; }; /** @description Bad Request */ @@ -44106,17 +46936,18 @@ export interface operations { }; }; }; - listAdminMetadataTranslationJobs: { + 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; }; @@ -44125,10 +46956,11 @@ export interface operations { /** @description OK */ 200: { headers: { + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminMetadataTranslationJobs"]; + "application/json": components["schemas"]["AdminCollectionOrder"]; }; }; /** @description Bad Request */ @@ -44214,30 +47046,41 @@ export interface operations { }; }; }; - cancelAdminMetadataTranslation: { + reorderAdminCollectionGroups: { 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; /** @description Opaque identifier */ - job_id: string; + library_id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminCollectionIDsOrder"]; + }; + }; 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: { @@ -44284,6 +47127,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: { @@ -44293,6 +47174,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: { @@ -44322,7 +47212,7 @@ export interface operations { }; }; }; - redetectAdminEpisodeIntro: { + unlinkAdminLiteraryItem: { parameters: { query?: never; header?: { @@ -44332,20 +47222,19 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - id: string; + content_id: string; + work_id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Accepted */ - 202: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["AdminEpisodeMarkersStatus"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -44430,9 +47319,11 @@ export interface operations { }; }; }; - redetectAdminItemMarkers: { + listAdminLiteraryCandidates: { 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; @@ -44440,23 +47331,19 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - id: string; + content_id: string; }; cookie?: never; }; - requestBody?: { - content: { - "application/json": components["schemas"]["AdminItemMarkersRedetect"]; - }; - }; + requestBody?: never; responses: { - /** @description Accepted */ - 202: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminEpisodeMarkersStatus"]; + "application/json": components["schemas"]["AdminLiteraryCandidates"]; }; }; /** @description Bad Request */ @@ -44504,42 +47391,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: { @@ -44578,7 +47429,7 @@ export interface operations { }; }; }; - refreshAdminEpisodeMarkers: { + linkAdminLiteraryItems: { parameters: { query?: never; header?: { @@ -44587,20 +47438,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"]["AdminLiteraryLinkInputBody"]; + }; + }; responses: { - /** @description Accepted */ - 202: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminEpisodeMarkersStatus"]; + "application/json": components["schemas"]["AdminLiteraryLink"]; }; }; /** @description Bad Request */ @@ -44648,8 +47501,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; }; @@ -44695,35 +47566,31 @@ export interface operations { }; }; }; - refreshAdminItemMetadata: { + confirmAdminLiteraryMatch: { 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"]["AdminItemMetadataRefreshInputBody"]; + "application/json": components["schemas"]["AdminLiteraryDecisionInputBody"]; }; }; responses: { - /** @description Accepted */ - 202: { + /** @description OK */ + 200: { headers: { - Location?: string; - "Retry-After"?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminTaskJob"]; + "application/json": components["schemas"]["AdminLiteraryDecision"]; }; }; /** @description Bad Request */ @@ -44836,7 +47703,7 @@ export interface operations { }; }; }; - splitAdminItem: { + ignoreAdminLiteraryMatch: { parameters: { query?: never; header?: { @@ -44845,14 +47712,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: { - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminSplitRequest"]; + "application/json": components["schemas"]["AdminLiteraryDecisionInputBody"]; }; }; responses: { @@ -44862,7 +47727,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminSplitResult"]; + "application/json": components["schemas"]["AdminLiteraryDecision"]; }; }; /** @description Bad Request */ @@ -44975,14 +47840,144 @@ export interface operations { }; }; }; - updateAdminJellyfinCompatSettings: { + listAdminOperationalLogs: { + 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; + }; + 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?: 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?: { + 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 */ @@ -44991,21 +47986,15 @@ export interface operations { path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminJellyfinCompatSettingsPatch"]; - }; - }; + 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"]["AdminJellyfinCompatStatus"]; + "application/json": components["schemas"]["CollectionAdminAuditLog"]; }; }; /** @description Bad Request */ @@ -45053,8 +48042,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -45062,19 +48051,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; }; @@ -45082,8 +48069,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unsupported Media Type */ - 415: { + /** @description Service Unavailable */ + 503: { headers: { [name: string]: unknown; }; @@ -45091,54 +48078,111 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + }; + }; + connectAdminLogsSocket: { + 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. */ + 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: { + /** @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: { - "application/problem+json": components["schemas"]["Problem"]; - }; + content?: never; }; - /** @description Precondition Required */ - 428: { + /** @description Handshake refused. */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "text/plain": string; }; }; - /** @description Too Many Requests */ - 429: { + /** @description Handshake refused. */ + 401: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "text/plain": string; }; }; - /** @description Internal Server Error */ - 500: { + /** @description Handshake refused. */ + 403: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "text/plain": string; }; }; - /** @description Service Unavailable */ + /** @description Handshake refused. */ 503: { headers: { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["Problem"]; + "text/plain": string; }; }; }; }; - getAdminJellyfinCompatStatus: { + createAdminLogsSocketTicket: { parameters: { query?: never; header?: { @@ -45155,10 +48199,11 @@ export interface operations { /** @description OK */ 200: { headers: { + "Cache-Control"?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminJellyfinCompatStatus"]; + "application/json": components["schemas"]["EventsSocketTicket"]; }; }; /** @description Bad Request */ @@ -45244,10 +48289,13 @@ export interface operations { }; }; }; - installAdminJellyfinCompatWeb: { + getAdminLogsSocketCapabilities: { 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 */ @@ -45256,21 +48304,29 @@ export interface operations { path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminJellyfinWebInstallBody"]; - }; - }; + requestBody?: never; responses: { - /** @description Accepted */ - 202: { + /** @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"]["AdminJellyfinCompatStatus"]; + "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; + }; + content?: never; + }; /** @description Bad Request */ 400: { headers: { @@ -45316,36 +48372,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 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 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: { @@ -45390,10 +48421,13 @@ export interface operations { }; }; }; - removeAdminJellyfinCompatWeb: { + getAdminMarkerCapabilities: { 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 */ @@ -45404,14 +48438,26 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Accepted */ - 202: { + /** @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"]["AdminJellyfinCompatStatus"]; + "application/json": components["schemas"]["AdminMarkerCapabilities"]; + }; + }; + /** @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: { @@ -45458,9 +48504,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: { @@ -45505,11 +48553,9 @@ export interface operations { }; }; }; - listAdminJobs: { + listAdminFileMarkerHistory: { parameters: { query?: { - cursor?: string; - kind?: string; limit?: number; }; header?: { @@ -45518,7 +48564,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 */ + fileId: string; + }; cookie?: never; }; requestBody?: never; @@ -45529,7 +48578,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminTaskJob"]; + "application/json": components["schemas"]["AdminMarkerHistory"]; }; }; /** @description Bad Request */ @@ -45615,13 +48664,18 @@ export interface operations { }; }; }; - getAdminJob: { + listAdminMarkerHistory: { parameters: { - query?: never; - header?: never; - path: { - id: 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?: never; cookie?: never; }; requestBody?: never; @@ -45629,11 +48683,10 @@ export interface operations { /** @description OK */ 200: { headers: { - "Retry-After"?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminTaskJob"]; + "application/json": components["schemas"]["AdminMarkerHistory"]; }; }; /** @description Bad Request */ @@ -45654,6 +48707,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: { @@ -45710,50 +48772,11 @@ export interface operations { }; }; }; - downloadAdminJobArtifact: { + listAdminItemMarkerHistory: { 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; + query?: { + limit?: number; }; - }; - }; - cancelAdminJob: { - 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; @@ -45767,23 +48790,13 @@ export interface operations { }; requestBody?: never; responses: { - /** @description The job was already canceled. */ + /** @description OK */ 200: { headers: { [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"]["AdminMarkerHistory"]; }; }; /** @description Bad Request */ @@ -45831,15 +48844,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: { @@ -45878,13 +48882,10 @@ export interface operations { }; }; }; - getAdminJobCapabilities: { + listAdminMarkerProviders: { 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 */ @@ -45898,23 +48899,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"]["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; + "application/json": components["schemas"]["AdminMarkerProviders"]; }; - content?: never; }; /** @description Bad Request */ 400: { @@ -45961,17 +48950,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: { @@ -46010,7 +48988,7 @@ export interface operations { }; }; }; - listAdminCollectionGroups: { + updateAdminMarkerProvider: { parameters: { query?: never; header?: { @@ -46020,12 +48998,15 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - /** @description Opaque identifier */ - library_id: string; + provider: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminMarkerProviderUpdate"]; + }; + }; responses: { /** @description OK */ 200: { @@ -46033,7 +49014,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminGroups"]; + "application/json": components["schemas"]["AdminMarkerProvider"]; }; }; /** @description Bad Request */ @@ -46081,6 +49062,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: { @@ -46119,7 +49127,7 @@ export interface operations { }; }; }; - createAdminCollectionGroup: { + validateAdminMarkerProvider: { parameters: { query?: never; header?: { @@ -46129,25 +49137,19 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - /** @description Opaque identifier */ - library_id: string; + provider: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["CollectionGroupCreate"]; - }; - }; + requestBody?: never; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { - Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminCollectionGroup"]; + "application/json": components["schemas"]["AdminMarkerProviderValidation"]; }; }; /** @description Bad Request */ @@ -46195,33 +49197,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: { @@ -46260,7 +49235,7 @@ export interface operations { }; }; }; - getAdminCollectionGroupOrder: { + connectNetworkAccess: { parameters: { query?: never; header?: { @@ -46270,21 +49245,23 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - /** @description Opaque identifier */ - library_id: string; + provider: string; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["NetworkAccessCommand"]; + }; + }; responses: { - /** @description OK */ - 200: { + /** @description Accepted */ + 202: { headers: { - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminCollectionOrder"]; + "application/json": components["schemas"]["NetworkAccessStatus"]; }; }; /** @description Bad Request */ @@ -46332,6 +49309,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: { @@ -46370,40 +49374,33 @@ export interface operations { }; }; }; - reorderAdminCollectionGroups: { + disconnectNetworkAccess: { 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 */ - library_id: string; + provider: string; }; cookie?: never; }; - requestBody: { + requestBody?: { content: { - "application/json": components["schemas"]["AdminCollectionIDsOrder"]; + "application/json": components["schemas"]["NetworkAccessCommand"]; }; }; responses: { - /** @description OK */ - 200: { + /** @description Accepted */ + 202: { 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"]["NetworkAccessStatus"]; }; }; /** @description Bad Request */ @@ -46460,17 +49457,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: { @@ -46498,15 +49484,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: { @@ -46536,7 +49513,7 @@ export interface operations { }; }; }; - unlinkAdminLiteraryItem: { + getAdminNetworkAccessStatus: { parameters: { query?: never; header?: { @@ -46546,19 +49523,20 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - content_id: string; - work_id: string; + provider: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["NetworkAccessStatus"]; + }; }; /** @description Bad Request */ 400: { @@ -46643,10 +49621,14 @@ export interface operations { }; }; }; - listAdminLiteraryCandidates: { + 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. */ @@ -46654,9 +49636,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: { - content_id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -46667,7 +49647,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminLiteraryCandidates"]; + "application/json": components["schemas"]["AdminNodeSessionsOutputBody"]; }; }; /** @description Bad Request */ @@ -46753,9 +49733,13 @@ export interface operations { }; }; }; - linkAdminLiteraryItems: { + listAdminNodes: { 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; @@ -46765,11 +49749,7 @@ export interface operations { path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminLiteraryLinkInputBody"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -46777,7 +49757,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminLiteraryLink"]; + "application/json": components["schemas"]["CollectionAdminNode"]; }; }; /** @description Bad Request */ @@ -46825,33 +49805,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: { @@ -46890,7 +49843,7 @@ export interface operations { }; }; }; - confirmAdminLiteraryMatch: { + createAdminNode: { parameters: { query?: never; header?: { @@ -46904,17 +49857,18 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AdminLiteraryDecisionInputBody"]; + "application/json": components["schemas"]["AdminNodeCreateBody"]; }; }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminLiteraryDecision"]; + "application/json": components["schemas"]["AdminNode"]; }; }; /** @description Bad Request */ @@ -46971,136 +49925,8 @@ 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: { - [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"]; - }; - }; - }; - }; - ignoreAdminLiteraryMatch: { - 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"]["AdminLiteraryDecisionInputBody"]; - }; - }; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["AdminLiteraryDecision"]; - }; - }; - /** @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: { + /** @description Conflict */ + 409: { headers: { [name: string]: unknown; }; @@ -47164,41 +49990,39 @@ export interface operations { }; }; }; - listAdminOperationalLogs: { + updateAdminNode: { 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; - }; - 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"]["CollectionAdminOperationalLog"]; + "application/json": components["schemas"]["AdminNode"]; }; }; /** @description Bad Request */ @@ -47246,6 +50070,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: { @@ -47255,6 +50126,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: { @@ -47284,42 +50164,32 @@ export interface operations { }; }; }; - listAdminAuditLogs: { + deleteAdminNode: { 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?: { + 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; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["CollectionAdminAuditLog"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -47366,17 +50236,19 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + /** @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 Too Many Requests */ - 429: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -47384,8 +50256,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Internal Server Error */ - 500: { + /** @description Precondition Required */ + 428: { headers: { [name: string]: unknown; }; @@ -47393,8 +50265,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Service Unavailable */ - 503: { + /** @description Too Many Requests */ + 429: { headers: { [name: string]: unknown; }; @@ -47402,111 +50274,27 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - }; - }; - connectAdminLogsSocket: { - 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. */ - 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: { - /** @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: { + /** @description Internal Server Error */ + 500: { headers: { [name: string]: unknown; }; content: { - "text/plain": string; + "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Handshake refused. */ + /** @description Service Unavailable */ 503: { headers: { [name: string]: unknown; }; content: { - "text/plain": string; + "application/problem+json": components["schemas"]["Problem"]; }; }; }; }; - createAdminLogsSocketTicket: { + checkAdminNode: { parameters: { query?: never; header?: { @@ -47515,7 +50303,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; @@ -47523,11 +50313,10 @@ export interface operations { /** @description OK */ 200: { headers: { - "Cache-Control"?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EventsSocketTicket"]; + "application/json": components["schemas"]["AdminNodeHealth"]; }; }; /** @description Bad Request */ @@ -47613,19 +50402,18 @@ export interface operations { }; }; }; - getAdminLogsSocketCapabilities: { + forceReloadAdminNode: { 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: { + id: string; + }; cookie?: never; }; requestBody?: never; @@ -47633,23 +50421,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"]["AdminNodeReloadOutputBody"]; }; - content?: never; }; /** @description Bad Request */ 400: { @@ -47696,17 +50472,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: { @@ -47745,19 +50510,18 @@ export interface operations { }; }; }; - getAdminMarkerCapabilities: { + reprobeAdminNode: { 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: { + id: string; + }; cookie?: never; }; requestBody?: never; @@ -47765,23 +50529,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"]["AdminMarkerCapabilities"]; - }; - }; - /** @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"]["AdminNodeReprobe"]; }; - content?: never; }; /** @description Bad Request */ 400: { @@ -47828,17 +50580,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: { @@ -47877,21 +50618,16 @@ export interface operations { }; }; }; - listAdminFileMarkerHistory: { + forceReloadAdminNodes: { 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; @@ -47902,7 +50638,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminMarkerHistory"]; + "application/json": components["schemas"]["AdminNodeReloadOutputBody"]; }; }; /** @description Bad Request */ @@ -47988,11 +50724,9 @@ export interface operations { }; }; }; - listAdminMarkerHistory: { + testAdminDiscordNotification: { 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; @@ -48010,7 +50744,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminMarkerHistory"]; + "application/json": components["schemas"]["AdminNotificationDiscordTestResult"]; }; }; /** @description Bad Request */ @@ -48096,23 +50830,23 @@ export interface operations { }; }; }; - listAdminItemMarkerHistory: { + testAdminApplePushNotification: { 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: { - id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminNotificationPushTestInputBody"]; + }; + }; responses: { /** @description OK */ 200: { @@ -48120,7 +50854,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminMarkerHistory"]; + "application/json": components["schemas"]["AdminNotificationPushTestResult"]; }; }; /** @description Bad Request */ @@ -48168,6 +50902,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: { @@ -48206,7 +50967,7 @@ export interface operations { }; }; }; - listAdminMarkerProviders: { + testAdminAndroidPushNotification: { parameters: { query?: never; header?: { @@ -48218,7 +50979,11 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminNotificationPushTestInputBody"]; + }; + }; responses: { /** @description OK */ 200: { @@ -48226,7 +50991,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminMarkerProviders"]; + "application/json": components["schemas"]["AdminNotificationPushTestResult"]; }; }; /** @description Bad Request */ @@ -48274,6 +51039,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: { @@ -48312,7 +51104,7 @@ export interface operations { }; }; }; - updateAdminMarkerProvider: { + clearAdminNotificationRelay: { parameters: { query?: never; header?: { @@ -48321,14 +51113,116 @@ 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; + 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 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"]; + }; + }; + }; + }; + registerAdminNotificationRelay: { + 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"]["AdminMarkerProviderUpdate"]; + "application/json": components["schemas"]["NotificationRelayRegisterInputBody"]; }; }; responses: { @@ -48338,7 +51232,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminMarkerProvider"]; + "application/json": components["schemas"]["NotificationRelayRegistration"]; }; }; /** @description Bad Request */ @@ -48451,18 +51345,20 @@ export interface operations { }; }; }; - validateAdminMarkerProvider: { + listAdminNotificationServerChannels: { 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: { - provider: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -48473,7 +51369,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminMarkerProviderValidation"]; + "application/json": components["schemas"]["CollectionNotificationServerChannel"]; }; }; /** @description Bad Request */ @@ -48559,7 +51455,7 @@ export interface operations { }; }; }; - connectNetworkAccess: { + createAdminNotificationServerChannel: { parameters: { query?: never; header?: { @@ -48568,24 +51464,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: { - provider: string; - }; + path?: never; cookie?: never; }; - requestBody?: { + requestBody: { content: { - "application/json": components["schemas"]["NetworkAccessCommand"]; + "application/json": components["schemas"]["NotificationServerChannelCreateInputBody"]; }; }; responses: { - /** @description Accepted */ - 202: { + /** @description Created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["NetworkAccessStatus"]; + "application/json": components["schemas"]["NotificationDestinationCreated"]; }; }; /** @description Bad Request */ @@ -48698,7 +51592,7 @@ export interface operations { }; }; }; - disconnectNetworkAccess: { + updateAdminNotificationServerChannel: { parameters: { query?: never; header?: { @@ -48708,23 +51602,23 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - provider: string; + id: string; }; cookie?: never; }; - requestBody?: { + requestBody: { content: { - "application/json": components["schemas"]["NetworkAccessCommand"]; + "application/json": components["schemas"]["NotificationServerChannelUpdateInputBody"]; }; }; responses: { - /** @description Accepted */ - 202: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["NetworkAccessStatus"]; + "application/json": components["schemas"]["NotificationServerChannel"]; }; }; /** @description Bad Request */ @@ -48837,7 +51731,7 @@ export interface operations { }; }; }; - getAdminNetworkAccessStatus: { + deleteAdminNotificationServerChannel: { parameters: { query?: never; header?: { @@ -48847,20 +51741,18 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - provider: string; + id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["NetworkAccessStatus"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -48945,22 +51837,18 @@ export interface operations { }; }; }; - listAdminNodeSessions: { + rotateAdminNotificationServerChannelSecret: { parameters: { - query?: { - cursor?: string; - /** @description Page size; default 50, maximum 200 */ - limit?: number; - /** @description Opaque identifier */ - node_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: { + id: string; + }; cookie?: never; }; requestBody?: never; @@ -48968,10 +51856,11 @@ export interface operations { /** @description OK */ 200: { headers: { + "Cache-Control"?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminNodeSessionsOutputBody"]; + "application/json": components["schemas"]["NotificationWebhookSecretOutputBody"]; }; }; /** @description Bad Request */ @@ -49057,20 +51946,19 @@ export interface operations { }; }; }; - listAdminNodes: { + testAdminNotificationServerChannel: { 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; @@ -49081,7 +51969,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminNode"]; + "application/json": components["schemas"]["NotificationDestinationTestResult"]; }; }; /** @description Bad Request */ @@ -49167,7 +52055,7 @@ export interface operations { }; }; }; - createAdminNode: { + updateAdminPerson: { parameters: { query?: never; header?: { @@ -49176,23 +52064,25 @@ 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"]["AdminNodeCreateBody"]; + "application/json": components["schemas"]["AdminPersonUpdate"]; }; }; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminNode"]; + "application/json": components["schemas"]["Person"]; }; }; /** @description Bad Request */ @@ -49249,15 +52139,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: { @@ -49314,39 +52195,30 @@ export interface operations { }; }; }; - updateAdminNode: { + refreshAdminPerson: { 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 Person identifier */ id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminNodeUpdateBody"]; - }; - }; + 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"]["AdminNode"]; + "application/json": components["schemas"]["Person"]; }; }; /** @description Bad Request */ @@ -49394,8 +52266,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -49403,8 +52275,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Conflict */ - 409: { + /** @description Too Many Requests */ + 429: { headers: { [name: string]: unknown; }; @@ -49412,19 +52284,17 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { + /** @description Internal Server Error */ + 500: { 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 Service Unavailable */ + 503: { headers: { [name: string]: unknown; }; @@ -49432,8 +52302,46 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unsupported Media Type */ - 415: { + }; + }; + listAdminPlaybackHistory: { + 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; + }; + 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"]["AdminPlaybackHistoryCollection"]; + }; + }; + /** @description Bad Request */ + 400: { headers: { [name: string]: unknown; }; @@ -49441,8 +52349,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + /** @description Unauthorized */ + 401: { headers: { [name: string]: unknown; }; @@ -49450,8 +52358,35 @@ 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 Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -49488,29 +52423,40 @@ export interface operations { }; }; }; - deleteAdminNode: { + getAdminPlaybackRoutingCapabilities: { 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: { - 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"]["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; @@ -49580,15 +52526,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: { @@ -49618,18 +52555,20 @@ export interface operations { }; }; }; - checkAdminNode: { + listAdminPluginCatalog: { 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; @@ -49640,7 +52579,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminNodeHealth"]; + "application/json": components["schemas"]["CollectionAdminPluginCatalogEntry"]; }; }; /** @description Bad Request */ @@ -49726,18 +52665,19 @@ export interface operations { }; }; }; - forceReloadAdminNode: { + 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 */ "X-Profile-Token"?: string; }; - path: { - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -49745,11 +52685,22 @@ 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"]["AdminNodeReloadOutputBody"]; + "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: { @@ -49796,6 +52747,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: { @@ -49834,29 +52796,37 @@ export interface operations { }; }; }; - reprobeAdminNode: { + 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?: never; + requestBody: { + content: { + "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"]["AdminNodeReprobe"]; + "application/json": components["schemas"]["AdminPluginCatalogSettings"]; }; }; /** @description Bad Request */ @@ -49904,6 +52874,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: { @@ -49913,6 +52921,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: { @@ -49942,7 +52959,7 @@ export interface operations { }; }; }; - forceReloadAdminNodes: { + getAdminPluginCatalogStatus: { parameters: { query?: never; header?: { @@ -49962,7 +52979,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminNodeReloadOutputBody"]; + "application/json": components["schemas"]["AdminPluginCatalogStatus"]; }; }; /** @description Bad Request */ @@ -50048,9 +53065,13 @@ export interface operations { }; }; }; - testAdminDiscordNotification: { + 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; @@ -50068,7 +53089,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminNotificationDiscordTestResult"]; + "application/json": components["schemas"]["CollectionAdminPluginInstallation"]; }; }; /** @description Bad Request */ @@ -50154,7 +53175,7 @@ export interface operations { }; }; }; - testAdminApplePushNotification: { + createAdminPluginInstallation: { parameters: { query?: never; header?: { @@ -50168,17 +53189,17 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AdminNotificationPushTestInputBody"]; + "application/json": components["schemas"]["AdminPluginInstallCreate"]; }; }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminNotificationPushTestResult"]; + "application/json": components["schemas"]["AdminPluginInstallation"]; }; }; /** @description Bad Request */ @@ -50291,7 +53312,7 @@ export interface operations { }; }; }; - testAdminAndroidPushNotification: { + updateAdminPluginInstallation: { parameters: { query?: never; header?: { @@ -50300,12 +53321,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"]["AdminPluginInstallationUpdate"]; }; }; responses: { @@ -50315,7 +53339,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminNotificationPushTestResult"]; + "application/json": components["schemas"]["AdminPluginInstallation"]; }; }; /** @description Bad Request */ @@ -50372,6 +53396,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: { @@ -50428,7 +53461,7 @@ export interface operations { }; }; }; - clearAdminNotificationRelay: { + deleteAdminPluginInstallation: { parameters: { query?: never; header?: { @@ -50437,7 +53470,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; @@ -50494,6 +53530,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: { @@ -50532,7 +53577,7 @@ export interface operations { }; }; }; - registerAdminNotificationRelay: { + updateAdminPluginAuthBinding: { parameters: { query?: never; header?: { @@ -50541,23 +53586,25 @@ 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"]["NotificationRelayRegisterInputBody"]; + "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"]["NotificationRelayRegistration"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -50613,109 +53660,8 @@ 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: { - [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"]; - }; - }; - }; - }; - listAdminNotificationServerChannels: { - 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"]["CollectionNotificationServerChannel"]; - }; - }; - /** @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 Conflict */ + 409: { headers: { [name: string]: unknown; }; @@ -50723,8 +53669,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Found */ - 404: { + /** @description Request Entity Too Large */ + 413: { headers: { [name: string]: unknown; }; @@ -50732,8 +53678,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Acceptable */ - 406: { + /** @description Unsupported Media Type */ + 415: { headers: { [name: string]: unknown; }; @@ -50779,7 +53725,7 @@ export interface operations { }; }; }; - createAdminNotificationServerChannel: { + updateAdminPluginInstallationConfig: { parameters: { query?: never; header?: { @@ -50788,23 +53734,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: { + /** @description Opaque identifier */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["NotificationServerChannelCreateInputBody"]; + "application/json": components["schemas"]["AdminPluginConfigWrite"]; }; }; responses: { - /** @description Created */ - 201: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["NotificationDestinationCreated"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -50860,6 +53807,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: { @@ -50916,7 +53872,7 @@ export interface operations { }; }; }; - updateAdminNotificationServerChannel: { + testAdminPluginInstallationConfig: { parameters: { query?: never; header?: { @@ -50926,13 +53882,14 @@ export interface operations { "X-Profile-Token"?: string; }; path: { + /** @description Opaque identifier */ id: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["NotificationServerChannelUpdateInputBody"]; + "application/json": components["schemas"]["AdminPluginConfigWrite"]; }; }; responses: { @@ -50942,7 +53899,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["NotificationServerChannel"]; + "application/json": components["schemas"]["AdminPluginConnectionCheck"]; }; }; /** @description Bad Request */ @@ -50999,105 +53956,8 @@ 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: { - [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"]; - }; - }; - }; - }; - deleteAdminNotificationServerChannel: { - 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 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: { + /** @description Conflict */ + 409: { headers: { [name: string]: unknown; }; @@ -51105,8 +53965,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Found */ - 404: { + /** @description Request Entity Too Large */ + 413: { headers: { [name: string]: unknown; }; @@ -51114,8 +53974,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Acceptable */ - 406: { + /** @description Unsupported Media Type */ + 415: { headers: { [name: string]: unknown; }; @@ -51161,7 +54021,7 @@ export interface operations { }; }; }; - rotateAdminNotificationServerChannelSecret: { + restartAdminPluginInstallation: { parameters: { query?: never; header?: { @@ -51171,6 +54031,7 @@ export interface operations { "X-Profile-Token"?: string; }; path: { + /** @description Opaque identifier */ id: string; }; cookie?: never; @@ -51180,11 +54041,10 @@ export interface operations { /** @description OK */ 200: { headers: { - "Cache-Control"?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["NotificationWebhookSecretOutputBody"]; + "application/json": components["schemas"]["AdminPluginInstallation"]; }; }; /** @description Bad Request */ @@ -51232,108 +54092,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"]; - }; - }; - }; - }; - testAdminNotificationServerChannel: { - 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 OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["NotificationDestinationTestResult"]; - }; - }; - /** @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: { + /** @description Conflict */ + 409: { headers: { [name: string]: unknown; }; @@ -51379,7 +54139,7 @@ export interface operations { }; }; }; - updateAdminPerson: { + updateAdminPluginTaskBinding: { parameters: { query?: never; header?: { @@ -51389,6 +54149,7 @@ export interface operations { "X-Profile-Token"?: string; }; path: { + capability_id: string; /** @description Opaque identifier */ id: string; }; @@ -51396,7 +54157,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AdminPersonUpdate"]; + "application/json": components["schemas"]["AdminPluginTaskBindingWrite"]; }; }; responses: { @@ -51406,7 +54167,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Person"]; + "application/json": components["schemas"]["AdminPluginTaskBindingResult"]; }; }; /** @description Bad Request */ @@ -51463,6 +54224,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: { @@ -51519,7 +54289,7 @@ export interface operations { }; }; }; - refreshAdminPerson: { + applyAdminPluginUpdate: { parameters: { query?: never; header?: { @@ -51529,7 +54299,7 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - /** @description Person identifier */ + /** @description Opaque identifier */ id: string; }; cookie?: never; @@ -51542,7 +54312,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Person"]; + "application/json": components["schemas"]["AdminPluginInstallation"]; }; }; /** @description Bad Request */ @@ -51590,6 +54360,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: { @@ -51628,21 +54407,12 @@ export interface operations { }; }; }; - listAdminPlaybackHistory: { + listAdminPluginRepositories: { 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; }; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ @@ -51661,7 +54431,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPlaybackHistoryCollection"]; + "application/json": components["schemas"]["CollectionAdminPluginRepository"]; }; }; /** @description Bad Request */ @@ -51747,13 +54517,10 @@ export interface operations { }; }; }; - getAdminPlaybackRoutingCapabilities: { + createAdminPluginRepository: { 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 */ @@ -51762,28 +54529,20 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminPluginRepositoryCreateBody"]; + }; + }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { 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"]; - }; - }; - /** @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"]["AdminPluginRepository"]; }; - content?: never; }; /** @description Bad Request */ 400: { @@ -51830,11 +54589,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: { @@ -51879,23 +54654,25 @@ export interface operations { }; }; }; - listAdminPluginCatalog: { + updateAdminPluginRepository: { 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; + requestBody: { + content: { + "application/json": components["schemas"]["AdminPluginRepositoryUpdateBody"]; + }; + }; responses: { /** @description OK */ 200: { @@ -51903,7 +54680,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminPluginCatalogEntry"]; + "application/json": components["schemas"]["AdminPluginRepository"]; }; }; /** @description Bad Request */ @@ -51951,6 +54728,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: { @@ -51989,39 +54793,25 @@ export interface operations { }; }; }; - getAdminPluginCatalogSettings: { + deleteAdminPluginRepository: { 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: { + 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"]["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; @@ -52071,17 +54861,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: { @@ -52120,14 +54899,10 @@ export interface operations { }; }; }; - updateAdminPluginCatalogSettings: { + uploadAdminPluginInstallation: { 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 */ @@ -52138,19 +54913,17 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AdminPluginCatalogSettings"]; + "multipart/form-data": components["schemas"]["AdminPluginUploadForm"]; }; }; 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"]["AdminPluginCatalogSettings"]; + "application/json": components["schemas"]["AdminPluginInstallation"]; }; }; /** @description Bad Request */ @@ -52207,17 +54980,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: { @@ -52245,15 +55007,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: { @@ -52283,7 +55036,7 @@ export interface operations { }; }; }; - getAdminPluginCatalogStatus: { + createAdminPluginUpload: { parameters: { query?: never; header?: { @@ -52295,15 +55048,19 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminPluginChunkedUploadCreate"]; + }; + }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPluginCatalogStatus"]; + "application/json": components["schemas"]["AdminPluginUploadSession"]; }; }; /** @description Bad Request */ @@ -52351,6 +55108,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: { @@ -52389,32 +55173,28 @@ export interface operations { }; }; }; - listAdminPluginInstallations: { + cancelAdminPluginUpload: { 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: { + upload_id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["CollectionAdminPluginInstallation"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -52499,7 +55279,7 @@ export interface operations { }; }; }; - createAdminPluginInstallation: { + putAdminPluginUploadChunk: { parameters: { query?: never; header?: { @@ -52508,22 +55288,25 @@ 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: { + chunk_index: number; + upload_id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminPluginInstallCreate"]; + "application/octet-stream": string; }; }; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPluginInstallation"]; + "application/json": components["schemas"]["AdminPluginUploadSession"]; }; }; /** @description Bad Request */ @@ -52580,6 +55363,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: { @@ -52636,7 +55428,7 @@ export interface operations { }; }; }; - updateAdminPluginInstallation: { + completeAdminPluginUpload: { parameters: { query?: never; header?: { @@ -52646,19 +55438,14 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - /** @description Opaque identifier */ - id: string; + upload_id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminPluginInstallationUpdate"]; - }; - }; + requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { [name: string]: unknown; }; @@ -52711,15 +55498,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: { @@ -52729,24 +55507,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: { @@ -52785,29 +55545,37 @@ export interface operations { }; }; }; - deleteAdminPluginInstallation: { + listAdminPolicyDecisions: { parameters: { - query?: never; + query?: { + allowed?: "true" | "false"; + cursor?: string; + 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. */ "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"]["CollectionAdminPolicyDecision"]; + }; }; /** @description Bad Request */ 400: { @@ -52854,15 +55622,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: { @@ -52901,10 +55660,12 @@ export interface operations { }; }; }; - updateAdminPluginAuthBinding: { + 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 */ @@ -52916,19 +55677,16 @@ export interface operations { }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminPluginAuthBindingWrite"]; - }; - }; + requestBody?: never; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { - "X-Silo-Restart-Required"?: string; [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["AdminPolicyDecision"]; + }; }; /** @description Bad Request */ 400: { @@ -52975,42 +55733,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: { @@ -53049,33 +55771,31 @@ export interface operations { }; }; }; - updateAdminPluginInstallationConfig: { + 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; /** @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"]; - }; - }; + requestBody?: never; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["CollectionAdminPolicyDocument"]; + }; }; /** @description Bad Request */ 400: { @@ -53122,42 +55842,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: { @@ -53196,7 +55880,7 @@ export interface operations { }; }; }; - testAdminPluginInstallationConfig: { + createAdminPolicyDocument: { parameters: { query?: never; header?: { @@ -53205,25 +55889,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 */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminPluginConfigWrite"]; + "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"]["AdminPluginConnectionCheck"]; + "application/json": components["schemas"]["AdminPolicyDocument"]; }; }; /** @description Bad Request */ @@ -53280,15 +55963,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: { @@ -53345,10 +56019,12 @@ export interface operations { }; }; }; - restartAdminPluginInstallation: { + 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 */ @@ -53365,10 +56041,11 @@ export interface operations { /** @description OK */ 200: { headers: { + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPluginInstallation"]; + "application/json": components["schemas"]["AdminPolicySnapshot"]; }; }; /** @description Bad Request */ @@ -53416,15 +56093,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: { @@ -53463,36 +56131,33 @@ export interface operations { }; }; }; - updateAdminPluginTaskBinding: { + 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: { - capability_id: string; /** @description Opaque identifier */ id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminPluginTaskBindingWrite"]; - }; - }; + requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["AdminPluginTaskBindingResult"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -53539,35 +56204,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 Conflict */ - 409: { - 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; }; @@ -53575,8 +56224,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + /** @description Precondition Required */ + 428: { headers: { [name: string]: unknown; }; @@ -53613,10 +56262,14 @@ export interface operations { }; }; }; - applyAdminPluginUpdate: { + 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 */ @@ -53628,15 +56281,21 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminPolicyEnabled"]; + }; + }; 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"]["AdminPluginInstallation"]; + "application/json": components["schemas"]["AdminPolicyApplyResult"]; }; }; /** @description Bad Request */ @@ -53684,8 +56343,37 @@ 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 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; }; @@ -53702,6 +56390,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: { @@ -53731,31 +56428,40 @@ export interface operations { }; }; }; - listAdminPluginRepositories: { + activateAdminPolicyVersion: { parameters: { - query?: { - 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"]["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"]["CollectionAdminPluginRepository"]; + "application/json": components["schemas"]["AdminPolicyApplyResult"]; }; }; /** @description Bad Request */ @@ -53803,6 +56509,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: { @@ -53812,6 +56556,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: { @@ -53841,31 +56594,33 @@ export interface operations { }; }; }; - createAdminPluginRepository: { + 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; /** @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"]["AdminPluginRepositoryCreateBody"]; + path: { + /** @description Opaque identifier */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPluginRepository"]; + "application/json": components["schemas"]["CollectionAdminPolicyVersion"]; }; }; /** @description Bad Request */ @@ -53913,33 +56668,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: { @@ -53978,7 +56706,7 @@ export interface operations { }; }; }; - updateAdminPluginRepository: { + createAdminPolicyVersion: { parameters: { query?: never; header?: { @@ -53988,23 +56716,25 @@ export interface operations { "X-Profile-Token"?: string; }; path: { + /** @description Opaque identifier */ id: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminPluginRepositoryUpdateBody"]; + "application/json": components["schemas"]["AdminPolicyVersionCreate"]; }; }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { + Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPluginRepository"]; + "application/json": components["schemas"]["AdminPolicyVersion"]; }; }; /** @description Bad Request */ @@ -54117,7 +56847,7 @@ export interface operations { }; }; }; - deleteAdminPluginRepository: { + getAdminPolicyVersion: { parameters: { query?: never; header?: { @@ -54127,18 +56857,23 @@ export interface operations { "X-Profile-Token"?: string; }; path: { + /** @description Opaque identifier */ id: string; + /** @description Opaque identifier */ + version: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["AdminPolicyVersion"]; + }; }; /** @description Bad Request */ 400: { @@ -54223,7 +56958,7 @@ export interface operations { }; }; }; - uploadAdminPluginInstallation: { + simulateAdminPolicy: { parameters: { query?: never; header?: { @@ -54237,17 +56972,17 @@ export interface operations { }; requestBody: { content: { - "multipart/form-data": components["schemas"]["AdminPluginUploadForm"]; + "application/json": components["schemas"]["AdminPolicySimulation"]; }; }; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPluginInstallation"]; + "application/json": components["schemas"]["AdminPolicySimulationResult"]; }; }; /** @description Bad Request */ @@ -54360,7 +57095,7 @@ export interface operations { }; }; }; - createAdminPluginUpload: { + validateAdminPolicy: { parameters: { query?: never; header?: { @@ -54374,17 +57109,17 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AdminPluginChunkedUploadCreate"]; + "application/json": components["schemas"]["AdminPolicySource"]; }; }; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPluginUploadSession"]; + "application/json": components["schemas"]["AdminPolicyValidation"]; }; }; /** @description Bad Request */ @@ -54497,7 +57232,7 @@ export interface operations { }; }; }; - cancelAdminPluginUpload: { + listAdminPolicyVendor: { parameters: { query?: never; header?: { @@ -54506,19 +57241,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: { - upload_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"]["CollectionAdminPolicyVendor"]; + }; }; /** @description Bad Request */ 400: { @@ -54603,35 +57338,42 @@ export interface operations { }; }; }; - putAdminPluginUploadChunk: { + getAdminRateLimitConfig: { 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: { - chunk_index: number; - upload_id: string; - }; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/octet-stream": string; - }; - }; + 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"]["AdminPluginUploadSession"]; + "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; }; /** @description Bad Request */ 400: { @@ -54678,36 +57420,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 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 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: { @@ -54752,29 +57469,35 @@ export interface operations { }; }; }; - completeAdminPluginUpload: { + updateAdminRateLimitConfig: { 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; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminRateLimitUpdate"]; + }; + }; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPluginInstallation"]; + "application/json": components["schemas"]["AdminRateLimitUpdateResult"]; }; }; /** @description Bad Request */ @@ -54822,87 +57545,8 @@ 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: { - [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"]; - }; - }; - }; - }; - listAdminPolicyDecisions: { - parameters: { - query?: { - allowed?: "true" | "false"; - cursor?: string; - 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. */ - "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"]["CollectionAdminPolicyDecision"]; - }; - }; - /** @description Bad Request */ - 400: { + /** @description Request Timeout */ + 408: { headers: { [name: string]: unknown; }; @@ -54910,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; }; @@ -54928,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; }; @@ -54937,8 +57583,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Acceptable */ - 406: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -54946,8 +57592,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + /** @description Precondition Required */ + 428: { headers: { [name: string]: unknown; }; @@ -54984,21 +57630,16 @@ export interface operations { }; }; }; - getAdminPolicyDecision: { + getAdminRateLimitStatus: { 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; @@ -55009,7 +57650,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPolicyDecision"]; + "application/json": components["schemas"]["AdminRateLimitStatus"]; }; }; /** @description Bad Request */ @@ -55095,12 +57736,9 @@ export interface operations { }; }; }; - listAdminPolicyDocuments: { + getAdminRecommendationsStatus: { parameters: { - query?: { - cursor?: string; - 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; @@ -55118,7 +57756,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminPolicyDocument"]; + "application/json": components["schemas"]["AdminRecommendationsStatus"]; }; }; /** @description Bad Request */ @@ -55204,7 +57842,7 @@ export interface operations { }; }; }; - createAdminPolicyDocument: { + triggerAdminRecommendationCowatch: { parameters: { query?: never; header?: { @@ -55216,21 +57854,15 @@ export interface operations { path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminPolicyDocumentCreate"]; - }; - }; + requestBody?: never; 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"]["AdminRecommendationStarted"]; }; }; /** @description Bad Request */ @@ -55278,33 +57910,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: { @@ -55343,21 +57948,16 @@ export interface operations { }; }; }; - getAdminPolicyDocument: { + triggerAdminRecommendationEmbeddings: { 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; @@ -55365,11 +57965,10 @@ export interface operations { /** @description OK */ 200: { headers: { - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPolicySnapshot"]; + "application/json": components["schemas"]["AdminRecommendationStarted"]; }; }; /** @description Bad Request */ @@ -55455,33 +58054,28 @@ export interface operations { }; }; }; - deleteAdminPolicyDocument: { + triggerAdminRecommendationRefresh: { 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?: never; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["AdminRecommendationStarted"]; + }; }; /** @description Bad Request */ 400: { @@ -55528,17 +58122,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: { @@ -55548,15 +58131,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: { @@ -55586,40 +58160,27 @@ export interface operations { }; }; }; - setAdminPolicyEnabled: { + triggerAdminRecommendationTasteProfiles: { 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"]["AdminPolicyEnabled"]; - }; - }; + 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"]["AdminPolicyApplyResult"]; + "application/json": components["schemas"]["AdminRecommendationStarted"]; }; }; /** @description Bad Request */ @@ -55667,8 +58228,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -55676,19 +58237,73 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { + /** @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"]; + }; + }; + }; + }; + getAdminRequestGroupLimit: { + 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 access group */ + group_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"]["AdminRequestGroupLimit"]; + }; + }; + /** @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; }; @@ -55696,8 +58311,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unsupported Media Type */ - 415: { + /** @description Forbidden */ + 403: { headers: { [name: string]: unknown; }; @@ -55705,8 +58320,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + /** @description Not Found */ + 404: { headers: { [name: string]: unknown; }; @@ -55714,8 +58329,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; }; @@ -55752,7 +58376,7 @@ export interface operations { }; }; }; - activateAdminPolicyVersion: { + updateAdminRequestGroupLimit: { parameters: { query?: never; header: { @@ -55766,14 +58390,14 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - /** @description Opaque identifier */ - id: string; + /** @description The access group */ + group_id: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminPolicyActivation"]; + "application/json": components["schemas"]["AdminRequestGroupLimitBody"]; }; }; responses: { @@ -55785,7 +58409,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPolicyApplyResult"]; + "application/json": components["schemas"]["AdminRequestGroupLimit"]; }; }; /** @description Bad Request */ @@ -55918,10 +58542,12 @@ export interface operations { }; }; }; - listAdminPolicyVersions: { + listRequestIntegrations: { parameters: { query?: { + /** @description Opaque cursor from page.next_cursor */ cursor?: string; + /** @description Page size; default 50, maximum 200 */ limit?: number; }; header?: { @@ -55930,10 +58556,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; @@ -55944,7 +58567,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminPolicyVersion"]; + "application/json": components["schemas"]["CollectionAdminRequestIntegration"]; }; }; /** @description Bad Request */ @@ -56030,7 +58653,7 @@ export interface operations { }; }; }; - createAdminPolicyVersion: { + createRequestIntegration: { parameters: { query?: never; header?: { @@ -56039,26 +58662,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 */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminPolicyVersionCreate"]; + "application/json": components["schemas"]["AdminRequestIntegrationBody"]; }; }; responses: { /** @description Created */ 201: { headers: { + ETag?: string; Location?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPolicyVersion"]; + "application/json": components["schemas"]["AdminRequestIntegration"]; }; }; /** @description Bad Request */ @@ -56171,7 +58792,7 @@ export interface operations { }; }; }; - getAdminPolicyVersion: { + getRequestIntegration: { parameters: { query?: never; header?: { @@ -56181,10 +58802,8 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - /** @description Opaque identifier */ + /** @description The request */ id: string; - /** @description Opaque identifier */ - version: string; }; cookie?: never; }; @@ -56193,10 +58812,11 @@ export interface operations { /** @description OK */ 200: { headers: { + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminPolicyVersion"]; + "application/json": components["schemas"]["AdminRequestIntegration"]; }; }; /** @description Bad Request */ @@ -56282,31 +58902,40 @@ export interface operations { }; }; }; - simulateAdminPolicy: { + 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: { content: { - "application/json": components["schemas"]["AdminPolicySimulation"]; + "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"]["AdminPolicySimulationResult"]; + "application/json": components["schemas"]["AdminRequestIntegration"]; }; }; /** @description Bad Request */ @@ -56363,6 +58992,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: { @@ -56390,6 +59030,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: { @@ -56419,32 +59068,33 @@ export interface operations { }; }; }; - validateAdminPolicy: { + 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; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminPolicySource"]; + 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"]["AdminPolicyValidation"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -56491,26 +59141,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; }; @@ -56518,8 +59161,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + /** @description Precondition Required */ + 428: { headers: { [name: string]: unknown; }; @@ -56556,7 +59199,7 @@ export interface operations { }; }; }; - listAdminPolicyVendor: { + loadRequestIntegrationOptions: { parameters: { query?: never; header?: { @@ -56565,10 +59208,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: { @@ -56576,7 +59226,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminPolicyVendor"]; + "application/json": components["schemas"]["AdminRequestOptions"]; }; }; /** @description Bad Request */ @@ -56624,6 +59274,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: { @@ -56662,13 +59339,10 @@ export interface operations { }; }; }; - getAdminRateLimitConfig: { + listRequestRoutes: { 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 */ @@ -56682,22 +59356,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"]["CollectionAdminRequestRoute"]; }; - content?: never; }; /** @description Bad Request */ 400: { @@ -56744,11 +59407,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: { @@ -56793,14 +59454,10 @@ export interface operations { }; }; }; - updateAdminRateLimitConfig: { + createRequestRoute: { 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 */ @@ -56811,17 +59468,19 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AdminRateLimitUpdate"]; + "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"]["AdminRateLimitUpdateResult"]; + "application/json": components["schemas"]["AdminRequestRoute"]; }; }; /** @description Bad Request */ @@ -56878,11 +59537,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: { @@ -56916,121 +59573,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: { - [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"]; - }; - }; - }; - }; - getAdminRateLimitStatus: { - 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"]["AdminRateLimitStatus"]; - }; - }; - /** @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: { @@ -57060,7 +59602,7 @@ export interface operations { }; }; }; - getAdminRecommendationsStatus: { + getRequestRoute: { parameters: { query?: never; header?: { @@ -57069,7 +59611,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 route */ + id: string; + }; cookie?: never; }; requestBody?: never; @@ -57077,10 +59622,11 @@ export interface operations { /** @description OK */ 200: { headers: { + ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminRecommendationsStatus"]; + "application/json": components["schemas"]["AdminRequestRoute"]; }; }; /** @description Bad Request */ @@ -57128,6 +59674,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: { @@ -57166,27 +59721,40 @@ export interface operations { }; }; }; - triggerAdminRecommendationCowatch: { + 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?: never; + path: { + /** @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"]["AdminRecommendationStarted"]; + "application/json": components["schemas"]["AdminRequestRoute"]; }; }; /** @description Bad Request */ @@ -57234,35 +59802,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: { + /** @description Request Timeout */ + 408: { headers: { [name: string]: unknown; }; @@ -57270,33 +59811,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - }; - }; - triggerAdminRecommendationEmbeddings: { - 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: { + /** @description Conflict */ + 409: { headers: { [name: string]: unknown; }; @@ -57304,17 +59820,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; }; @@ -57322,8 +59840,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Found */ - 404: { + /** @description Unsupported Media Type */ + 415: { headers: { [name: string]: unknown; }; @@ -57331,8 +59849,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Acceptable */ - 406: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -57340,8 +59858,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Unprocessable Entity */ - 422: { + /** @description Precondition Required */ + 428: { headers: { [name: string]: unknown; }; @@ -57378,28 +59896,33 @@ export interface operations { }; }; }; - triggerAdminRecommendationRefresh: { + deleteRequestRoute: { 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 route */ + 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: { @@ -57446,6 +59969,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: { @@ -57455,6 +59998,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: { @@ -57484,7 +60036,7 @@ export interface operations { }; }; }; - triggerAdminRecommendationTasteProfiles: { + reorderRequestRoutes: { parameters: { query?: never; header?: { @@ -57496,7 +60048,11 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminRequestRouteReorderInputBody"]; + }; + }; responses: { /** @description OK */ 200: { @@ -57504,7 +60060,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminRecommendationStarted"]; + "application/json": components["schemas"]["CollectionAdminRequestRoute"]; }; }; /** @description Bad Request */ @@ -57552,6 +60108,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: { @@ -57590,14 +60182,9 @@ export interface operations { }; }; }; - listRequestIntegrations: { + previewRequestRoute: { 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; @@ -57607,7 +60194,11 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminRequestRoutePreviewInputBody"]; + }; + }; responses: { /** @description OK */ 200: { @@ -57615,7 +60206,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CollectionAdminRequestIntegration"]; + "application/json": components["schemas"]["AdminRequestRoutePreviewOutputBody"]; }; }; /** @description Bad Request */ @@ -57663,6 +60254,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: { @@ -57701,9 +60328,13 @@ export interface operations { }; }; }; - createRequestIntegration: { + searchRequestRouteTitles: { parameters: { - query?: never; + query: { + media_type: "movie" | "series"; + /** @description Title to search TMDB for */ + q: string; + }; header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; @@ -57713,21 +60344,15 @@ export interface operations { path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminRequestIntegrationBody"]; - }; - }; + 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"]["AdminRequestRouteTitleCollection"]; }; }; /** @description Bad Request */ @@ -57775,26 +60400,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; }; @@ -57840,7 +60447,7 @@ export interface operations { }; }; }; - getRequestIntegration: { + getRequestRouting: { parameters: { query?: never; header?: { @@ -57849,10 +60456,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 The request */ - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -57864,7 +60468,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminRequestIntegration"]; + "application/json": components["schemas"]["AdminRequestRouting"]; }; }; /** @description Bad Request */ @@ -57912,6 +60516,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: { @@ -57950,7 +60563,7 @@ export interface operations { }; }; }; - updateRequestIntegration: { + updateRequestRouting: { parameters: { query?: never; header: { @@ -57963,15 +60576,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"]["AdminRequestIntegrationBody"]; + "application/json": components["schemas"]["AdminRequestRoutingUpdateInputBody"]; }; }; responses: { @@ -57983,7 +60593,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminRequestIntegration"]; + "application/json": components["schemas"]["AdminRequestRouting"]; }; }; /** @description Bad Request */ @@ -58040,6 +60650,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 Precondition Failed */ 412: { headers: { @@ -58116,33 +60735,29 @@ export interface operations { }; }; }; - deleteRequestIntegration: { + getAdminRequestSettings: { 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; responses: { - /** @description No Content */ - 204: { + /** @description OK */ + 200: { headers: { + ETag?: string; [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["AdminRequestSettings"]; + }; }; /** @description Bad Request */ 400: { @@ -58189,17 +60804,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: { @@ -58209,15 +60813,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: { @@ -58247,34 +60842,37 @@ export interface operations { }; }; }; - loadRequestIntegrationOptions: { + updateAdminRequestSettings: { 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 */ - id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminRequestOptionsInputBody"]; + "application/json": components["schemas"]["AdminRequestSettings"]; }; }; 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"]["AdminRequestOptions"]; + "application/json": components["schemas"]["AdminRequestSettings"]; }; }; /** @description Bad Request */ @@ -58331,6 +60929,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: { @@ -58358,6 +60967,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: { @@ -58387,7 +61005,7 @@ export interface operations { }; }; }; - getAdminRequestSettings: { + getAdminRequestUserLimit: { parameters: { query?: never; header?: { @@ -58396,7 +61014,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 */ + user_id: string; + }; cookie?: never; }; requestBody?: never; @@ -58408,7 +61029,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminRequestSettings"]; + "application/json": components["schemas"]["AdminRequestUserLimit"]; }; }; /** @description Bad Request */ @@ -58494,7 +61115,7 @@ export interface operations { }; }; }; - updateAdminRequestSettings: { + updateAdminRequestUserLimit: { parameters: { query?: never; header: { @@ -58507,12 +61128,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 */ + user_id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminRequestSettings"]; + "application/json": components["schemas"]["AdminRequestLimitBody"]; }; }; responses: { @@ -58524,7 +61148,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminRequestSettings"]; + "application/json": components["schemas"]["AdminRequestUserLimit"]; }; }; /** @description Bad Request */ @@ -58657,19 +61281,33 @@ export interface operations { }; }; }; - getAdminRequestUserLimit: { + listAdminRequests: { parameters: { - query?: never; + query?: { + /** @description Opaque cursor from page.next_cursor */ + cursor?: string; + /** @description Page size; default 50, maximum 50 */ + limit?: number; + /** @description Only requests for this media type */ + media_type?: "movie" | "series"; + /** @description Only requests with this outcome */ + outcome?: "active" | "declined" | "cancelled" | "failed"; + /** @description Only requests whose title contains this text, or whose TMDB ID equals it */ + q?: string; + /** @description Only requests made by this account; at most 2147483647 */ + requested_by_user_id?: string; + /** @description Only requests in this status */ + status?: "pending" | "approved" | "queued" | "downloading" | "completed"; + /** @description Only requests in this queue view: needs_approval (pending), in_progress (approved, queued or downloading), failed, or done (completed, or closed by a decline or cancellation) */ + view?: "needs_approval" | "in_progress" | "failed" | "done"; + }; 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 */ - user_id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -58677,11 +61315,10 @@ export interface operations { /** @description OK */ 200: { headers: { - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AdminRequestUserLimit"]; + "application/json": components["schemas"]["MediaRequestCollection"]; }; }; /** @description Bad Request */ @@ -58767,40 +61404,34 @@ export interface operations { }; }; }; - updateAdminRequestUserLimit: { + adminApproveRequest: { 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 */ - user_id: string; + /** @description The request */ + id: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminRequestLimitBody"]; + "application/json": components["schemas"]["AdminRequestActionInputBody"]; }; }; 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"]["AdminRequestUserLimit"]; + "application/json": components["schemas"]["MediaRequest"]; }; }; /** @description Bad Request */ @@ -58857,17 +61488,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: { @@ -58895,15 +61515,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: { @@ -58933,28 +61544,26 @@ export interface operations { }; }; }; - listAdminRequests: { + adminCancelRequest: { parameters: { - query?: { - /** @description Opaque cursor from page.next_cursor */ - cursor?: string; - /** @description Page size; default 50, maximum 50 */ - limit?: number; - /** @description Only requests with this outcome */ - outcome?: "active" | "declined" | "cancelled" | "failed"; - /** @description Only requests in this status */ - status?: "pending" | "approved" | "queued" | "downloading" | "completed"; - }; + 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 request */ + id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminRequestActionInputBody"]; + }; + }; responses: { /** @description OK */ 200: { @@ -58962,7 +61571,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["MediaRequestCollection"]; + "application/json": components["schemas"]["MediaRequest"]; }; }; /** @description Bad Request */ @@ -59010,6 +61619,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: { @@ -59048,7 +61684,7 @@ export interface operations { }; }; }; - adminApproveRequest: { + adminDeclineRequest: { parameters: { query?: never; header?: { @@ -59188,7 +61824,7 @@ export interface operations { }; }; }; - adminCancelRequest: { + listAdminRequestEvents: { parameters: { query?: never; header?: { @@ -59203,11 +61839,7 @@ export interface operations { }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminRequestActionInputBody"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -59215,7 +61847,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["MediaRequest"]; + "application/json": components["schemas"]["AdminRequestEventCollection"]; }; }; /** @description Bad Request */ @@ -59263,33 +61895,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: { @@ -59328,7 +61933,7 @@ export interface operations { }; }; }; - adminDeclineRequest: { + adminRetryRequest: { parameters: { query?: never; header?: { @@ -59468,35 +62073,43 @@ export interface operations { }; }; }; - adminRetryRequest: { + getAdminRequestCapabilities: { 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 request */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminRequestActionInputBody"]; - }; - }; + 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"]["MediaRequest"]; + "application/json": components["schemas"]["AdminRequestCapabilitiesOutputBody"]; + }; + }; + /** @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: { @@ -59543,27 +62156,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: { @@ -59608,13 +62205,10 @@ export interface operations { }; }; }; - getAdminRequestCapabilities: { + getAdminRequestCounts: { 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 */ @@ -59628,24 +62222,12 @@ 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"]["AdminRequestCapabilitiesOutputBody"]; + "application/json": components["schemas"]["AdminRequestCounts"]; }; }; - /** @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: { @@ -59691,17 +62273,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: { @@ -104599,7 +107170,116 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["RecommendationRow"]; + "application/json": components["schemas"]["RecommendationRow"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + listForYouRows: { + parameters: { + query?: { + /** @description Cards to answer; default 20, maximum 50 */ + limit?: number; + }; + header: { + /** @description The household profile acting for this request; it must belong to 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: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RecommendationRowCollection"]; }; }; /** @description Bad Request */ @@ -104685,9 +107365,11 @@ export interface operations { }; }; }; - listForYouRows: { + listPopular: { parameters: { query?: { + /** @description Window in days; default 30 */ + days?: number; /** @description Cards to answer; default 20, maximum 50 */ limit?: number; }; @@ -104708,7 +107390,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["RecommendationRowCollection"]; + "application/json": components["schemas"]["CatalogItemCollection"]; }; }; /** @description Bad Request */ @@ -104794,10 +107476,10 @@ export interface operations { }; }; }; - listPopular: { + listRecentlyAdded: { parameters: { query?: { - /** @description Window in days; default 30 */ + /** @description Window in days; default 14 */ days?: number; /** @description Cards to answer; default 20, maximum 50 */ limit?: number; @@ -104905,12 +107587,12 @@ export interface operations { }; }; }; - listRecentlyAdded: { + getRecommendationSection: { parameters: { query?: { - /** @description Window in days; default 14 */ - days?: number; - /** @description Cards to answer; default 20, maximum 50 */ + /** @description Section key: the cluster index of a cluster section or the genre name of a genre section */ + key?: string; + /** @description Cards to answer; default and maximum 60 */ limit?: number; }; header: { @@ -104919,7 +107601,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 Section kind, as a discover row's kind names it */ + kind: + | "for-you-main" + | "cluster" + | "similar-users" + | "popular" + | "recently-added" + | "top-rated" + | "genre"; + }; cookie?: never; }; requestBody?: never; @@ -104930,7 +107622,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CatalogItemCollection"]; + "application/json": components["schemas"]["RecommendationRow"]; }; }; /** @description Bad Request */ @@ -105016,12 +107708,10 @@ export interface operations { }; }; }; - getRecommendationSection: { + listSimilarUsersLiked: { parameters: { query?: { - /** @description Section key: the cluster index of a cluster section or the genre name of a genre section */ - key?: string; - /** @description Cards to answer; default and maximum 60 */ + /** @description Cards to answer; default 20, maximum 50 */ limit?: number; }; header: { @@ -105030,17 +107720,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 Section kind, as a discover row's kind names it */ - kind: - | "for-you-main" - | "cluster" - | "similar-users" - | "popular" - | "recently-added" - | "top-rated" - | "genre"; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -105051,7 +107731,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["RecommendationRow"]; + "application/json": components["schemas"]["CatalogItemCollection"]; }; }; /** @description Bad Request */ @@ -105137,7 +107817,7 @@ export interface operations { }; }; }; - listSimilarUsersLiked: { + listSimilar: { parameters: { query?: { /** @description Cards to answer; default 20, maximum 50 */ @@ -105149,7 +107829,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 item similar items are drawn from */ + item_id: string; + }; cookie?: never; }; requestBody?: never; @@ -105246,22 +107929,16 @@ export interface operations { }; }; }; - listSimilar: { + getTasteProfile: { parameters: { - query?: { - /** @description Cards to answer; default 20, maximum 50 */ - limit?: number; - }; + query?: never; header: { /** @description The household profile acting for this request; it must belong to 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 The item similar items are drawn from */ - item_id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -105272,7 +107949,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CatalogItemCollection"]; + "application/json": components["schemas"]["TasteProfile"]; }; }; /** @description Bad Request */ @@ -105358,7 +108035,7 @@ export interface operations { }; }; }; - getTasteProfile: { + createTasteSeed: { parameters: { query?: never; header: { @@ -105370,7 +108047,11 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["TasteSeedSubmission"]; + }; + }; responses: { /** @description OK */ 200: { @@ -105378,7 +108059,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TasteProfile"]; + "application/json": components["schemas"]["TasteSeedResult"]; }; }; /** @description Bad Request */ @@ -105426,6 +108107,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: { @@ -105464,9 +108172,14 @@ export interface operations { }; }; }; - createTasteSeed: { + listTasteSeedItems: { parameters: { - query?: never; + query?: { + /** @description Opaque cursor from page.next_cursor */ + cursor?: string; + /** @description Cards per page; default 30, maximum 60 */ + limit?: number; + }; header: { /** @description The household profile acting for this request; it must belong to the authenticated account. */ "X-Profile-Id": string; @@ -105476,11 +108189,7 @@ export interface operations { path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["TasteSeedSubmission"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -105488,7 +108197,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TasteSeedResult"]; + "application/json": components["schemas"]["CatalogItemCollection"]; }; }; /** @description Bad Request */ @@ -105536,8 +108245,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { + /** @description Unprocessable Entity */ + 422: { headers: { [name: string]: unknown; }; @@ -105545,8 +108254,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; }; @@ -105554,8 +108263,90 @@ 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"]; + }; + }; + }; + }; + getWatchTonight: { + parameters: { + query?: { + /** @description Cards to answer; default 5, maximum 20 */ + limit?: number; + }; + header: { + /** @description The household profile acting for this request; it must belong to 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: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WatchTonight"]; + }; + }; + /** @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; }; @@ -105601,13 +108392,17 @@ export interface operations { }; }; }; - listTasteSeedItems: { + listWatchTonightCards: { parameters: { - query?: { - /** @description Opaque cursor from page.next_cursor */ - cursor?: string; - /** @description Cards per page; default 30, maximum 60 */ + query: { + /** @description Catalog identifiers already swiped, repeated (exclude_ids=a&exclude_ids=b) */ + exclude_ids?: string[]; + /** @description Discover-mode genre filter, repeated (genres=Crime&genres=Thriller); each must be a known genre */ + genres?: string[]; + /** @description Cards per page; default 12, maximum 20 */ limit?: number; + /** @description continue answers in-progress and next-up cards; discover answers taste candidates */ + mode: "continue" | "discover"; }; header: { /** @description The household profile acting for this request; it must belong to the authenticated account. */ @@ -105626,7 +108421,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CatalogItemCollection"]; + "application/json": components["schemas"]["WatchTonightCardPage"]; }; }; /** @description Bad Request */ @@ -105712,12 +108507,9 @@ export interface operations { }; }; }; - getWatchTonight: { + createRequest: { parameters: { - query?: { - /** @description Cards to answer; default 5, maximum 20 */ - limit?: number; - }; + query?: never; header: { /** @description The household profile acting for this request; it must belong to the authenticated account. */ "X-Profile-Id": string; @@ -105727,15 +108519,19 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["MediaRequestCreate"]; + }; + }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WatchTonight"]; + "application/json": components["schemas"]["MediaRequest"]; }; }; /** @description Bad Request */ @@ -105783,6 +108579,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: { @@ -105821,25 +108653,19 @@ export interface operations { }; }; }; - listWatchTonightCards: { + getRequest: { parameters: { - query: { - /** @description Catalog identifiers already swiped, repeated (exclude_ids=a&exclude_ids=b) */ - exclude_ids?: string[]; - /** @description Discover-mode genre filter, repeated (genres=Crime&genres=Thriller); each must be a known genre */ - genres?: string[]; - /** @description Cards per page; default 12, maximum 20 */ - limit?: number; - /** @description continue answers in-progress and next-up cards; discover answers taste candidates */ - mode: "continue" | "discover"; - }; + query?: never; header: { /** @description The household profile acting for this request; it must belong to 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: { + /** @description The request */ + id: string; + }; cookie?: never; }; requestBody?: never; @@ -105850,7 +108676,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WatchTonightCardPage"]; + "application/json": components["schemas"]["MediaRequest"]; }; }; /** @description Bad Request */ @@ -105898,6 +108724,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: { @@ -105936,7 +108771,7 @@ export interface operations { }; }; }; - createRequest: { + cancelRequest: { parameters: { query?: never; header: { @@ -105945,17 +108780,20 @@ 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"]["MediaRequestCreate"]; + "application/json": components["schemas"]["RequestCancelInputBody"]; }; }; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; @@ -106082,7 +108920,7 @@ export interface operations { }; }; }; - getRequest: { + getRequestMediaDetail: { parameters: { query?: never; header: { @@ -106092,8 +108930,10 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - /** @description The request */ - id: string; + /** @description The media type */ + media_type: "movie" | "series"; + /** @description TMDB identifier (external, not a Silo ID) */ + tmdb_id: number; }; cookie?: never; }; @@ -106105,7 +108945,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["MediaRequest"]; + "application/json": components["schemas"]["RequestMediaDetail"]; }; }; /** @description Bad Request */ @@ -106200,7 +109040,7 @@ export interface operations { }; }; }; - cancelRequest: { + listDiscoverSections: { parameters: { query?: never; header: { @@ -106209,17 +109049,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: { - /** @description Opaque identifier */ - id: string; - }; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["RequestCancelInputBody"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -106227,7 +109060,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["MediaRequest"]; + "application/json": components["schemas"]["DiscoverSectionCollection"]; }; }; /** @description Bad Request */ @@ -106275,15 +109108,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: { @@ -106293,24 +109117,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: { @@ -106349,9 +109155,12 @@ export interface operations { }; }; }; - getRequestMediaDetail: { + getDiscoverSection: { parameters: { - query?: never; + query?: { + /** @description Provider result page, 1-based */ + page?: number; + }; header: { /** @description The household profile acting for this request; it must belong to the authenticated account. */ "X-Profile-Id": string; @@ -106359,10 +109168,14 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - /** @description The media type */ - media_type: "movie" | "series"; - /** @description TMDB identifier (external, not a Silo ID) */ - tmdb_id: number; + /** @description The discovery row */ + section: + | "trending_movies" + | "trending_series" + | "popular_movies" + | "popular_series" + | "upcoming_movies" + | "on_air_series"; }; cookie?: never; }; @@ -106374,7 +109187,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["RequestMediaDetail"]; + "application/json": components["schemas"]["DiscoverSection"]; }; }; /** @description Bad Request */ @@ -106469,16 +109282,26 @@ export interface operations { }; }; }; - listDiscoverSections: { + browseDiscoverGenre: { parameters: { - query?: never; + query: { + /** @description The media type to browse */ + media_type: "movie" | "series"; + /** @description Provider result page, 1-based */ + page?: number; + /** @description Result order */ + sort?: "popularity" | "vote_average" | "release_date"; + }; header: { /** @description The household profile acting for this request; it must belong to 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: { + /** @description The genre slug from the list operation */ + slug: string; + }; cookie?: never; }; requestBody?: never; @@ -106489,7 +109312,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DiscoverSectionCollection"]; + "application/json": components["schemas"]["DiscoverBrowsePage"]; }; }; /** @description Bad Request */ @@ -106584,11 +109407,13 @@ export interface operations { }; }; }; - getDiscoverSection: { + browseDiscoverNetwork: { parameters: { query?: { /** @description Provider result page, 1-based */ page?: number; + /** @description Result order */ + sort?: "popularity" | "vote_average" | "release_date"; }; header: { /** @description The household profile acting for this request; it must belong to the authenticated account. */ @@ -106597,14 +109422,8 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - /** @description The discovery row */ - section: - | "trending_movies" - | "trending_series" - | "popular_movies" - | "popular_series" - | "upcoming_movies" - | "on_air_series"; + /** @description The brand slug from the list operation */ + slug: string; }; cookie?: never; }; @@ -106616,7 +109435,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DiscoverSection"]; + "application/json": components["schemas"]["DiscoverBrowsePage"]; }; }; /** @description Bad Request */ @@ -106711,11 +109530,9 @@ export interface operations { }; }; }; - browseDiscoverGenre: { + browseDiscoverStudio: { parameters: { - query: { - /** @description The media type to browse */ - media_type: "movie" | "series"; + query?: { /** @description Provider result page, 1-based */ page?: number; /** @description Result order */ @@ -106728,7 +109545,7 @@ export interface operations { "X-Profile-Token"?: string; }; path: { - /** @description The genre slug from the list operation */ + /** @description The brand slug from the list operation */ slug: string; }; cookie?: never; @@ -106836,24 +109653,16 @@ export interface operations { }; }; }; - browseDiscoverNetwork: { + listDiscoverGenres: { parameters: { - query?: { - /** @description Provider result page, 1-based */ - page?: number; - /** @description Result order */ - sort?: "popularity" | "vote_average" | "release_date"; - }; + query?: never; header: { /** @description The household profile acting for this request; it must belong to 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 The brand slug from the list operation */ - slug: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -106864,7 +109673,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DiscoverBrowsePage"]; + "application/json": components["schemas"]["DiscoverBrandCollection"]; }; }; /** @description Bad Request */ @@ -106959,24 +109768,16 @@ export interface operations { }; }; }; - browseDiscoverStudio: { + listDiscoverNetworks: { parameters: { - query?: { - /** @description Provider result page, 1-based */ - page?: number; - /** @description Result order */ - sort?: "popularity" | "vote_average" | "release_date"; - }; + query?: never; header: { /** @description The household profile acting for this request; it must belong to 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 The brand slug from the list operation */ - slug: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -106987,7 +109788,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DiscoverBrowsePage"]; + "application/json": components["schemas"]["DiscoverBrandCollection"]; }; }; /** @description Bad Request */ @@ -107082,7 +109883,7 @@ export interface operations { }; }; }; - listDiscoverGenres: { + listDiscoverStudios: { parameters: { query?: never; header: { @@ -107197,7 +109998,7 @@ export interface operations { }; }; }; - listDiscoverNetworks: { + followRequestMedia: { parameters: { query?: never; header: { @@ -107206,7 +110007,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; + path: { + /** @description The media type */ + media_type: "movie" | "series"; + /** @description TMDB identifier (external, not a Silo ID) */ + tmdb_id: number; + }; cookie?: never; }; requestBody?: never; @@ -107217,7 +110023,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DiscoverBrandCollection"]; + "application/json": components["schemas"]["RequestMediaState"]; }; }; /** @description Bad Request */ @@ -107312,7 +110118,7 @@ export interface operations { }; }; }; - listDiscoverStudios: { + unfollowRequestMedia: { parameters: { query?: never; header: { @@ -107321,19 +110127,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; + path: { + /** @description The media type */ + media_type: "movie" | "series"; + /** @description TMDB identifier (external, not a Silo ID) */ + tmdb_id: number; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["DiscoverBrandCollection"]; - }; + content?: never; }; /** @description Bad Request */ 400: { @@ -107380,15 +110189,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: { diff --git a/web/src/components/AdminSidebar.test.tsx b/web/src/components/AdminSidebar.test.tsx index 60fb1a0027..eb7c51b811 100644 --- a/web/src/components/AdminSidebar.test.tsx +++ b/web/src/components/AdminSidebar.test.tsx @@ -30,6 +30,9 @@ const mockUseBuildInfo = vi.fn<() => MockBuildInfoResult>(() => ({ })); const mockUseAdminSessions = vi.fn(() => ({ data: [] })); const mockUseAdminPluginInstallations = vi.fn(() => ({ data: [] })); +const mockUseAdminRequestCounts = vi.fn<() => { data?: { needs_approval: number } }>(() => ({ + data: { needs_approval: 0 }, +})); const mockUsePolicyCapability = vi.fn(() => ({ data: { enabled: true, @@ -59,6 +62,10 @@ vi.mock("@/hooks/queries/admin/policy", () => ({ usePolicyCapability: () => mockUsePolicyCapability(), })); +vi.mock("@/hooks/queries/admin/requests", () => ({ + useAdminRequestCounts: () => mockUseAdminRequestCounts(), +})); + function renderSidebar(embedded = false) { return renderToStaticMarkup( @@ -79,6 +86,23 @@ describe("AdminSidebar", () => { }); }); + it("shows how many requests need approval on the Requests entry", () => { + mockUseAdminRequestCounts.mockReturnValueOnce({ data: { needs_approval: 3 } }); + const markup = renderSidebar(); + const requestsLink = markup.match(/]*href="\/admin\/requests"[^>]*>.*?<\/a>/)?.[0]; + + expect(requestsLink).toContain(''); + expect(requestsLink).toContain(", 3 need approval"); + }); + + it("leaves the Requests entry plain when nothing needs approval", () => { + const markup = renderSidebar(); + const requestsLink = markup.match(/]*href="\/admin\/requests"[^>]*>.*?<\/a>/)?.[0]; + + expect(requestsLink).toBeDefined(); + expect(requestsLink).not.toContain("need approval"); + }); + it("renders the grouped navigation sections", () => { const markup = renderSidebar(); diff --git a/web/src/components/AdminSidebar.tsx b/web/src/components/AdminSidebar.tsx index 05de00306f..c1500b8dd5 100644 --- a/web/src/components/AdminSidebar.tsx +++ b/web/src/components/AdminSidebar.tsx @@ -15,6 +15,7 @@ import { useAdminPluginInstallations } from "@/hooks/queries/admin/plugins"; import { usePolicyCapability } from "@/hooks/queries/admin/policy"; import { useAdminSessions } from "@/hooks/queries/admin/stats"; import { useBuildInfo } from "@/hooks/queries/admin/system"; +import { useAdminRequestCounts } from "@/hooks/queries/admin/requests"; import { cn } from "@/lib/utils"; interface SidebarItem extends AdminNavItem { @@ -57,12 +58,24 @@ export default function AdminSidebar({ onNavigate, embedded = false }: AdminSide const activityBadge = sessionCount > 0 ? {sessionCount} live : undefined; + const pendingRequests = useAdminRequestCounts().data?.needs_approval ?? 0; + const requestsBadge = + pendingRequests > 0 ? ( + + + , {pendingRequests} need approval + + ) : undefined; + const badges: Record = { + "/admin/activity": activityBadge, + "/admin/requests": requestsBadge, + }; const sections: SidebarSection[] = buildAdminNavSections({ policyEditorAvailable: policyCapability.data?.editor_available === true, }).map((section) => ({ ...section, items: section.items.map((item) => - item.href === "/admin/activity" ? { ...item, badge: activityBadge } : item, + badges[item.href] ? { ...item, badge: badges[item.href] } : item, ), })); diff --git a/web/src/components/BrandCard.test.tsx b/web/src/components/BrandCard.test.tsx new file mode 100644 index 0000000000..b4b4edc97c --- /dev/null +++ b/web/src/components/BrandCard.test.tsx @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import type { DiscoverBrandCard } from "@/api/types"; +import BrandCard from "./BrandCard"; + +function renderCard(props: Parameters[0]) { + render( + + + , + ); + return screen.getByRole("link", { name: props.card.display_name }); +} + +const card = (overrides: Partial = {}): DiscoverBrandCard => ({ + slug: "a24", + display_name: "A24", + ...overrides, +}); + +describe("BrandCard", () => { + it("links a studio or network tile to its browse page", () => { + expect(renderCard({ kind: "studio", card: card() })).toHaveAttribute( + "href", + "/requests/browse/studio/a24", + ); + }); + + it("opens a genre on movies unless series are asked for and supported", () => { + expect( + renderCard({ kind: "genre", card: card({ slug: "sci fi", display_name: "Sci-Fi" }) }), + ).toHaveAttribute("href", "/requests/browse/genre/sci%20fi?media_type=movie"); + }); + + it("opens a genre on series when asked and supported", () => { + expect( + renderCard({ + kind: "genre", + card: card({ slug: "drama", display_name: "Drama", series_supported: true }), + defaultMediaTypeForGenre: "series", + }), + ).toHaveAttribute("href", "/requests/browse/genre/drama?media_type=series"); + }); +}); diff --git a/web/src/components/BrandCard.tsx b/web/src/components/BrandCard.tsx index b92d331531..63c3f7a974 100644 --- a/web/src/components/BrandCard.tsx +++ b/web/src/components/BrandCard.tsx @@ -1,4 +1,4 @@ -import { useNavigate } from "react-router"; +import { Link } from "react-router"; import type { DiscoverBrandCard, DiscoverBrowseKind } from "@/api/types"; import { cn } from "@/lib/utils"; @@ -8,34 +8,34 @@ interface BrandCardProps { defaultMediaTypeForGenre?: "movie" | "series"; } +/** The browse page of a studio, network, or genre; a genre opens on movies unless asked otherwise. */ +function brandBrowseHref( + kind: DiscoverBrowseKind, + card: DiscoverBrandCard, + defaultMediaTypeForGenre: "movie" | "series" = "movie", +): string { + const base = `/requests/browse/${kind}/${encodeURIComponent(card.slug)}`; + if (kind !== "genre") return base; + const initial = + card.series_supported && defaultMediaTypeForGenre === "series" ? "series" : "movie"; + return `${base}?media_type=${initial}`; +} + export default function BrandCard({ kind, card, defaultMediaTypeForGenre = "movie", }: BrandCardProps) { - const navigate = useNavigate(); - const isGenre = kind === "genre"; - - function handleClick() { - const base = `/requests/browse/${kind}/${encodeURIComponent(card.slug)}`; - if (kind === "genre") { - const initial = - card.series_supported && defaultMediaTypeForGenre === "series" ? "series" : "movie"; - navigate(`${base}?media_type=${initial}`); - return; - } - navigate(base); - } + const href = brandBrowseHref(kind, card, defaultMediaTypeForGenre); const baseClasses = "group relative flex h-28 w-52 flex-none transform-gpu cursor-pointer items-center justify-center overflow-hidden rounded-xl shadow-sm ring-1 transition duration-300 ease-in-out hover:scale-[1.03] focus:scale-[1.03] focus:outline-none sm:h-32 sm:w-64"; - if (isGenre) { + if (kind === "genre") { const background = `linear-gradient(135deg, ${card.gradient_from ?? "#475569"}, ${card.gradient_to ?? "#0f172a"})`; return ( - + ); } return ( - + ); } diff --git a/web/src/components/BrandCarousel.tsx b/web/src/components/BrandCarousel.tsx index 689ea74899..ce98c65369 100644 --- a/web/src/components/BrandCarousel.tsx +++ b/web/src/components/BrandCarousel.tsx @@ -30,9 +30,10 @@ export default function BrandCarousel({ : (cards ?? []).map((card) => ); return ( -
-
-

{title}

+
+ {/* The same header as MediaCarousel, so brand rows read as rows of the page. */} +
+

{title}

{isError && onRetry ? ( - )} + ) : null}
diff --git a/web/src/components/RealtimeEventsProvider.test.tsx b/web/src/components/RealtimeEventsProvider.test.tsx index 98064ebea5..37a84580e1 100644 --- a/web/src/components/RealtimeEventsProvider.test.tsx +++ b/web/src/components/RealtimeEventsProvider.test.tsx @@ -10,7 +10,13 @@ import { useRealtimeEvents } from "./realtimeEventsContext"; import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query"; import { act, cleanup, render, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { adminKeys, catalogKeys, libraryKeys, sectionKeys } from "@/hooks/queries/keys"; +import { + adminKeys, + catalogKeys, + libraryKeys, + requestKeys, + sectionKeys, +} from "@/hooks/queries/keys"; import type { ItemDetail, TaskInfo } from "@/api/types"; import { invalidateCatalogState } from "./realtimeCatalogInvalidation"; import { buildEventsUrl, RealtimeEventsProvider } from "./RealtimeEventsProvider"; @@ -839,4 +845,117 @@ describe("RealtimeEventsProvider", () => { user_state: { played: true, is_favorite: true }, }); }); + + it("refetches request state once per burst of request notifications, for any profile", async () => { + const queryClient = new QueryClient(); + const refreshed = [ + requestKeys.mine({ status: "all", outcome: "all", limit: 100, offset: 0 }), + requestKeys.detail("movie", 1), + requestKeys.discovery(), + requestKeys.discoverySection("trending_movies"), + requestKeys.discoverBrowse("genre", "drama", "movie", "popularity"), + requestKeys.search("all", "dune", 1, "profile-1"), + ]; + const untouched = [requestKeys.status(), requestKeys.discoverStudios()]; + for (const key of [...refreshed, ...untouched]) queryClient.setQueryData(key, {}); + const invalidate = vi.spyOn(queryClient, "invalidateQueries"); + const invalidations = (key: readonly unknown[]) => + invalidate.mock.calls.filter( + ([filters]) => JSON.stringify(filters?.queryKey) === JSON.stringify(key), + ).length; + mockState.profile = { id: "profile-1", has_pin: false }; + + render( + + +
+ + , + ); + + await act(async () => {}); + const emit = (type: string, profileID: string, index = 0) => + FakeWebSocket.instances[0]?.emitMessage({ + type: "event", + channel: "notifications", + event: "notification.created", + data: { id: `${type}-${index}`, type, profile_id: profileID, created_at: "" }, + }); + + await act(async () => { + emit("episode.available", "profile-1"); + }); + for (const key of refreshed) expect(invalidations(key)).toBe(0); + + // A scan fulfils 30 requests at once, some for another profile. + await act(async () => { + for (let index = 0; index < 30; index++) { + emit( + index % 3 === 0 ? "request.approved" : "request.fulfilled", + index % 2 ? "profile-2" : "profile-1", + index, + ); + } + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(6_000); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(6_000); + }); + + // One refetch right away and one catch-up for the rest of the burst. + for (const key of refreshed) { + expect(invalidations(key)).toBeGreaterThanOrEqual(1); + expect(invalidations(key)).toBeLessThanOrEqual(2); + } + for (const key of untouched) expect(invalidations(key)).toBe(0); + expect(invalidations(requestKeys.all)).toBe(0); + }); + + it("refetches request state when a reconnect snapshot holds request notifications", async () => { + const queryClient = new QueryClient(); + const mine = requestKeys.mine({ status: "all", outcome: "all", limit: 100, offset: 0 }); + const search = requestKeys.search("all", "dune", 1, "profile-1"); + for (const key of [mine, search]) queryClient.setQueryData(key, {}); + const invalidate = vi.spyOn(queryClient, "invalidateQueries"); + const invalidations = (key: readonly unknown[]) => + invalidate.mock.calls.filter( + ([filters]) => JSON.stringify(filters?.queryKey) === JSON.stringify(key), + ).length; + mockState.profile = { id: "profile-1", has_pin: false }; + + render( + + +
+ + , + ); + + await act(async () => {}); + const snapshot = (types: string[]) => + FakeWebSocket.instances[0]?.emitMessage({ + type: "snapshot", + channel: "notifications", + data: types.map((type, index) => ({ + id: `${type}-${index}`, + type, + profile_id: "profile-1", + created_at: "", + })), + }); + + await act(async () => { + snapshot(["episode.available"]); + }); + expect(invalidations(mine)).toBe(0); + expect(invalidations(search)).toBe(0); + + await act(async () => { + snapshot(["episode.available", "request.approved"]); + }); + expect(invalidations(mine)).toBe(1); + expect(invalidations(search)).toBe(1); + }); }); diff --git a/web/src/components/RealtimeEventsProvider.tsx b/web/src/components/RealtimeEventsProvider.tsx index bd8de374ac..76c24ce956 100644 --- a/web/src/components/RealtimeEventsProvider.tsx +++ b/web/src/components/RealtimeEventsProvider.tsx @@ -48,7 +48,13 @@ import { adminStatsKey } from "@/hooks/queries/admin/stats"; import { useAuth } from "@/hooks/useAuth"; import { useIsActingAdmin } from "@/hooks/useIsActingAdmin"; import { usePageActivity } from "@/hooks/usePageActivity"; -import { adminKeys, historyImportKeys, libraryKeys, sectionKeys } from "@/hooks/queries/keys"; +import { + adminKeys, + historyImportKeys, + libraryKeys, + requestKeys, + sectionKeys, +} from "@/hooks/queries/keys"; import { scheduleMediaSurfaceInvalidation, updateCatalogItemDetail, @@ -78,6 +84,22 @@ const CATALOG_ITEM_CHANGED_EVENTS = new Set([ "library.item_added", "metadata.updated", ]); + +// Everything that shows a title's request state: the request list, title +// pages, Discover, and request search results. The feature status and brand +// lists don't depend on it. +const REQUEST_STATE_QUERIES: QueryFilters[] = [ + { queryKey: requestKeys.mineAll() }, + { queryKey: requestKeys.detailAll() }, + { queryKey: requestKeys.discovery() }, + { queryKey: requestKeys.discoverBrowseAll() }, + { queryKey: requestKeys.searchAll() }, +]; + +function isRequestNotification(notification: Pick) { + return notification.type?.startsWith("request.") ?? false; +} + function buildEventsUrl(location: Pick) { const protocol = location.protocol === "https:" ? "wss:" : "ws:"; return `${protocol}//${location.host}/api/v2/events/ws`; @@ -562,7 +584,13 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) { break; case "notifications": if (Array.isArray(message.data)) { - applyNotificationsSnapshot(queryClient, message.data as AppNotification[]); + const rows = message.data as AppNotification[]; + applyNotificationsSnapshot(queryClient, rows); + // A reconnect sends request changes made while the socket was down + // as unread rows here, not as notification.created events. + if (rows.some(isRequestNotification)) { + refreshQueries(...REQUEST_STATE_QUERIES); + } } break; default: @@ -571,9 +599,17 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) { dispatchChannelMessage(message.channel, "snapshot", message); } - function handleNotificationEvent(message: EventsEventMessage) { + function handleNotificationEvent( + message: EventsEventMessage, + refreshQueries: (...filters: QueryFilters[]) => void, + ) { if (message.event === "notification.created") { const notification = message.data as AppNotification; + // A request changed state (approved, declined, arrived). The scheduler + // batches a burst (a scan fulfilling many requests) into one refetch. + if (isRequestNotification(notification)) { + refreshQueries(...REQUEST_STATE_QUERIES); + } if ( notification.profile_id && activeProfileIDRef.current && @@ -588,11 +624,18 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) { description: [episodeCode, notification.episode_title].filter(Boolean).join(" — "), }); } else if (notification.type === "request.fulfilled") { + const follower = notification.reason_flags?.follower === true; toast( notification.series_title ? `${notification.series_title} is now available` - : "Your request is now available", - { description: "Your media request has arrived in the library." }, + : follower + ? "A title you followed is now available" + : "Your request is now available", + { + description: follower + ? "A title you asked to hear about has arrived in the library." + : "Your media request has arrived in the library.", + }, ); } else if ( notification.type === "request.approved" || @@ -692,7 +735,7 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) { ); break; case "notifications": - handleNotificationEvent(message); + handleNotificationEvent(message, refreshQueries); break; default: break; diff --git a/web/src/components/RecommendationGrid.tsx b/web/src/components/RecommendationGrid.tsx index 1856d27164..e860aedc40 100644 --- a/web/src/components/RecommendationGrid.tsx +++ b/web/src/components/RecommendationGrid.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import ViewTransitionLink from "@/components/ViewTransitionLink"; import MediaCarousel from "@/components/MediaCarousel"; import { useCatalogItemDetail } from "@/hooks/queries/catalogRead"; @@ -62,21 +63,49 @@ function RecommendationItemCard({ itemId, showCaption }: RecommendationItemCardP ); } -export default function RecommendationGrid({ items, maxItems = 12 }: RecommendationGridProps) { +interface MoreLikeThisRowProps { + items: T[]; + itemKey: (item: T) => string; + renderItem: (item: T, showCaption: boolean) => ReactNode; + maxItems?: number; +} + +/** + * The "More Like This" rail on detail pages: poster-width slides inside the + * page shell, sized by the viewer's card settings. Library items and titles + * known only from TMDB supply their own cards. + */ +export function MoreLikeThisRow({ + items, + itemKey, + renderItem, + maxItems = MAX_MORE_LIKE_THIS_ITEMS, +}: MoreLikeThisRowProps) { const { cardPresentation } = useUICustomization(); const itemLimit = Math.max(0, Math.min(maxItems, MAX_MORE_LIKE_THIS_ITEMS)); const posterWidthClasses = carouselCardWidthClasses(cardPresentation.poster_size); + const showCaption = cardPresentation.caption !== "artwork"; return ( - {items.slice(0, itemLimit).map((si) => ( -
- + {items.slice(0, itemLimit).map((item) => ( +
+ {renderItem(item, showCaption)}
))} ); } + +export default function RecommendationGrid({ items, maxItems = 12 }: RecommendationGridProps) { + return ( + item.content_id} + renderItem={(item, showCaption) => ( + + )} + /> + ); +} diff --git a/web/src/components/RequestDownloadProgress.test.tsx b/web/src/components/RequestDownloadProgress.test.tsx new file mode 100644 index 0000000000..656ae64dc1 --- /dev/null +++ b/web/src/components/RequestDownloadProgress.test.tsx @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, render, screen } from "@testing-library/react"; +import type { RequestDownload } from "@/api/types"; +import { RequestDownloadProgress } from "./RequestDownloadProgress"; + +const download = (overrides: Partial = {}): RequestDownload => ({ + phase: "downloading", + percent: 43, + bytes_total: 4294967296, + bytes_left: 2448131358, + estimated_completion_at: "2026-01-02T03:16:05Z", + downloads: 1, + updated_at: "2026-01-02T03:03:05Z", + ...overrides, +}); + +describe("RequestDownloadProgress", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-01-02T03:04:05Z")); + }); + afterEach(() => vi.useRealTimers()); + + it("draws a bar at the percentage over the label", () => { + render(); + + expect(screen.getByRole("progressbar", { name: "Download progress" })).toHaveAttribute( + "aria-valuenow", + "43", + ); + expect(screen.getByText("Downloading · 43% · about 12 min left")).toBeInTheDocument(); + }); + + it("shows only the label while the size is unknown, or the phase is new", () => { + const { rerender } = render( + , + ); + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + expect(screen.getByText("Waiting to download")).toBeInTheDocument(); + + rerender(); + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + expect(screen.getByText("Downloading")).toBeInTheDocument(); + }); + + // Polls that return the same figures do not render the card again, so the + // estimate keeps time on its own: it counts down, and it goes once the + // figures are more than ten minutes old. + it("keeps the estimate true while the figures stay the same", () => { + vi.useRealTimers(); + vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); + vi.setSystemTime(new Date("2026-01-02T03:04:05Z")); + render(); + expect(screen.getByText("Downloading · 43% · about 12 min left")).toBeInTheDocument(); + + act(() => vi.advanceTimersByTime(5 * 60_000)); + expect(screen.getByText("Downloading · 43% · about 7 min left")).toBeInTheDocument(); + + act(() => vi.advanceTimersByTime(6 * 60_000)); + expect(screen.getByText("Downloading · 43%")).toBeInTheDocument(); + }); + + it("keeps no clock without an estimate", () => { + vi.useRealTimers(); + vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); + vi.setSystemTime(new Date("2026-01-02T03:04:05Z")); + const { unmount } = render( + , + ); + expect(vi.getTimerCount()).toBe(0); + unmount(); + + render(); + expect(vi.getTimerCount()).toBe(1); + }); + + it("names a blocked import for admins and a wait for requesters", () => { + const blocked = download({ phase: "import_blocked", percent: 100 }); + const { rerender } = render(); + expect(screen.getByText("Waiting for import")).toBeInTheDocument(); + + rerender(); + expect(screen.getByText("Import blocked")).toBeInTheDocument(); + expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "100"); + }); +}); diff --git a/web/src/components/RequestDownloadProgress.tsx b/web/src/components/RequestDownloadProgress.tsx new file mode 100644 index 0000000000..9d8cc22a7e --- /dev/null +++ b/web/src/components/RequestDownloadProgress.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from "react"; +import type { RequestDownload } from "@/api/types"; +import { Progress } from "@/components/ui/progress"; +import { formatRequestDownload, requestDownloadPercent } from "@/lib/requestDownload"; +import { cn } from "@/lib/utils"; + +/** How often a shown estimate is checked against the clock again. */ +const ESTIMATE_CLOCK_INTERVAL_MS = 30_000; + +/** + * Renders again every 30 seconds while enabled. The estimate is worked out + * against the clock, and a poll that returns the same figures does not + * render its readers again, so without this an estimate would neither count + * down nor disappear once it has passed or its figures have gone stale. + */ +function useEstimateClock(enabled: boolean) { + const [, setTick] = useState(0); + useEffect(() => { + if (!enabled) return; + const id = window.setInterval(() => setTick((tick) => tick + 1), ESTIMATE_CLOCK_INTERVAL_MS); + return () => window.clearInterval(id); + }, [enabled]); +} + +/** + * How far a request's or one server's downloads are: a bar once the size is + * known, over the phase with its percentage and estimate. Admin views name a + * blocked import as such. + */ +export function RequestDownloadProgress({ + download, + admin = false, + className, +}: { + download: RequestDownload; + admin?: boolean; + className?: string; +}) { + useEstimateClock(Boolean(download.estimated_completion_at)); + const percent = requestDownloadPercent(download); + return ( +
+ {percent !== undefined ? : null} +

+ {formatRequestDownload(download, { admin })} +

+
+ ); +} diff --git a/web/src/components/RequestPosterCard.test.tsx b/web/src/components/RequestPosterCard.test.tsx index 41cc63b5d8..8625e5cfa1 100644 --- a/web/src/components/RequestPosterCard.test.tsx +++ b/web/src/components/RequestPosterCard.test.tsx @@ -1,9 +1,12 @@ -import { describe, expect, it } from "vitest"; +import type { ReactNode } from "react"; +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"; +import { UICustomizationContext } from "@/contexts/uiCustomizationContext"; +import type { CardPresentation } from "@/lib/uiCustomization"; const requestable: RequestMediaResult = { media_type: "movie", @@ -13,51 +16,107 @@ const requestable: RequestMediaResult = { request: { requestable: true }, }; +function withCardPresentation(cardPresentation: CardPresentation, children: ReactNode) { + return ( + + {children} + + ); +} + describe("RequestPosterCard (discover variant)", () => { - it("renders the hover Request button when onRequest is provided", () => { - const markup = renderToStaticMarkup( + it("draws the title on the library card frame", () => { + const { container } = render( - {}} - /> + , ); - // Must render an actual -
- )} - - {item.library_content_id ? ( - - ) : null} -
+ + ) : !requestable ? ( + + ) : null + } + action={ + requestable && onRequest ? ( + + ) : null + } + /> ); } -function MineCard({ request, fluid }: { request: MediaRequest; fluid?: boolean }) { - const poster = tmdbImageURL(request.poster_path); - const isCompleted = request.status === "completed"; - const isFailed = +function MineCard({ request, fluid, onCancel, isCancelling }: Omit) { + const state = requestDisplayState(request.status, request.outcome, request.state); + 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 - -
- )} -
- - - - {request.last_error ? ( -

- {request.last_error} -

- ) : null} - - - {request.library_content_id ? ( - - ) : null} -
+ const seasons = request.seasons?.length ? formatSeasonList(request.seasons) : ""; + const progress = + state === "partially_available" && request.season_progress?.length + ? formatSeasonProgress(request.season_progress) + : ""; + const hasDetails = Boolean( + seasons || progress || request.last_error || request.outcome_reason || onCancel, ); -} -function LibraryCardLink({ contentID, title }: { contentID: string; title: string }) { return ( - : null} > - - Library - + {hasDetails ? ( + <> + {seasons ?

{seasons}

: null} + {progress ?

{progress}

: null} + {request.last_error ? ( +

+ {request.last_error} +

+ ) : request.outcome_reason ? ( +

+ {request.outcome_reason} +

+ ) : null} + {onCancel ? ( + + ) : null} + + ) : null} +
); } -function PosterFrame({ - poster, +/** + * The library poster card for a title outside the library: the same artwork + * frame, hover reveal, and caption, with a request badge where library cards + * carry their overlay badges and a Request action where they carry Play. + */ +function ExternalTitleCard({ title, mediaType, + year, + posterPath, + href, + libraryContentId, + fluid, dim, + badge, + action, children, }: { - poster: string | null; title: string; - mediaType: "movie" | "series"; + mediaType: RequestMediaType; + year?: number; + posterPath?: string; + href: string; + /** Adds a Library chip linking to the title's library item. */ + libraryContentId?: string; + fluid?: boolean; dim?: boolean; - children?: React.ReactNode; + badge?: ReactNode; + /** The hover action in the card's centre slot. */ + action?: ReactNode; + /** Request details below the caption. Shown whatever the caption setting; pass null for none. */ + children?: ReactNode; }) { - return ( -
- {poster ? ( - {title - ) : ( - - )} - {/* subtle bottom vignette for legibility behind ribbons / hover overlays */} -
- {children} -
- ); -} + const { cardPresentation } = useUICustomization(); + const showCaption = cardPresentation.caption !== "artwork"; + // Unlike a library card, a TMDB title always names its type and year under + // a caption: a movie and a series can share a title, and the viewer is + // choosing which one to request. + const showMetadata = showCaption; + const meta = [formatMediaType(mediaType), year].filter(Boolean).join(" · "); + const label = `${title} (${meta})`; -function PosterFallback({ - title, - mediaType, - dim, -}: { - title: string; - mediaType: "movie" | "series"; - dim?: boolean; -}) { - const hue = stringHue(title); - const Icon = mediaType === "series" ? Tv : Film; return (
-
- -
-
-
-
- - {mediaType === "series" ? "Series" : "Motion picture"} - -

- {title} -

+
+ + + + {libraryContentId || badge ? ( +
+ {libraryContentId ? : null} + {badge} +
+ ) : null} + {action}
-
- ); -} - -function stringHue(input: string): number { - let hash = 0; - for (let i = 0; i < input.length; i++) { - hash = (Math.imul(hash, 31) + input.charCodeAt(i)) | 0; - } - return Math.abs(hash) % 360; -} - -function CardMeta({ - title, - year, - rating, - mediaType, -}: { - title: string; - year?: number; - rating?: number; - mediaType?: "movie" | "series"; -}) { - const Icon = mediaType === "series" ? Tv : Film; - const hasMeta = mediaType || year !== undefined || rating !== undefined; - return ( -
-

- {title} -

- {hasMeta && ( -
- {mediaType && ( - <> - - {mediaType === "series" ? "Series" : "Movie"} - - )} - {mediaType && year ? ( - - · - - ) : null} - {year ? {year} : null} - {(year || mediaType) && rating ? ( - - · - + {showCaption || children ? ( +
+ {showCaption ? ( + + {title} + ) : null} - {rating ? ( - - ★ {rating.toFixed(1)} - + {showMetadata ? ( + + {meta} + ) : null} + {children}
- )} + ) : null}
); } -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, +function RequestAction({ + title, + pending, + onRequest, }: { - status: string; - label: string; - reserveLibrarySpace?: boolean; + title: string; + pending: boolean; + onRequest: () => void; }) { - const kind = (RIBBON_STYLES[status as RibbonKind] ? status : "blocked") as RibbonKind; return ( - { + event.preventDefault(); + event.stopPropagation(); + onRequest(); + }} className={cn( - "absolute top-2 right-2 inline-flex items-center gap-1.5 rounded-full px-2 py-[3px] text-[10px] leading-none font-medium tracking-[0.06em] uppercase shadow-sm ring-1 shadow-black/40 backdrop-blur-md", - reserveLibrarySpace ? "max-w-[calc(100%-5.75rem)]" : "max-w-[calc(100%-1rem)]", - RIBBON_STYLES[kind], + MEDIA_CARD_CENTER_ACTION_CLASS, + "h-9 gap-1.5 px-3.5 text-[12px] font-semibold whitespace-nowrap hover:scale-105", + // Keep the pending state in view after the pointer leaves the card. + pending && "pointer-events-auto opacity-100", )} > - - {label} - + {pending ? ( + <> + + Sending + + ) : ( + <> + + Request + + )} + ); } -function formatOutcome(outcome: MediaRequest["outcome"]): string { - switch (outcome) { - case "declined": - return "Declined"; - case "cancelled": - return "Cancelled"; - case "failed": - return "Failed"; - default: - return "Active"; - } +function LibraryChip({ contentID, title }: { contentID: string; title: string }) { + return ( + + + Library + + ); } diff --git a/web/src/components/RequestResultsGrid.tsx b/web/src/components/RequestResultsGrid.tsx new file mode 100644 index 0000000000..4d1525dae6 --- /dev/null +++ b/web/src/components/RequestResultsGrid.tsx @@ -0,0 +1,154 @@ +import type { RequestMediaResult } from "@/api/types"; +import RequestPosterCard from "@/components/RequestPosterCard"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useIntersectionObserver } from "@/hooks/useIntersectionObserver"; +import { useSubmitMediaRequest } from "@/hooks/useSubmitMediaRequest"; +import { useUICustomization } from "@/hooks/useUICustomization"; +import { cardGridClasses } from "@/lib/uiCustomization"; +import { cn } from "@/lib/utils"; + +/** + * TMDB titles on the library's poster grid, each with the hover Request + * action. Used by the Discover, studio/network/genre, and search "Request to + * add" grids. + */ +export default function RequestResultsGrid({ + results, + pendingCount = 0, + className, +}: { + results: RequestMediaResult[]; + /** + * Placeholder cards for a page that is loading. They continue the last row + * rather than starting a grid of their own, so the grid does not jump when + * the titles arrive. + */ + pendingCount?: number; + className?: string; +}) { + const { cardPresentation } = useUICustomization(); + const { submit, isSubmitting } = useSubmitMediaRequest(); + return ( +
+ {results.map((item) => ( + submit(item)} + fluid + /> + ))} + {Array.from({ length: pendingCount }).map((_, index) => ( + + ))} +
+ ); +} + +function PosterCardSkeleton() { + return ( +
+ + + +
+ ); +} + +export function RequestResultsGridSkeleton({ count = 18 }: { count?: number }) { + const { cardPresentation } = useUICustomization(); + return ( +
+ {Array.from({ length: count }).map((_, index) => ( + + ))} +
+ ); +} + +/** + * The foot of a TMDB result grid that loads as the viewer scrolls: reaching + * it reads the next page, and a failed read offers a retry. The grid shows the + * loading page's placeholders (RequestResultsGrid pendingCount). Without + * IntersectionObserver a Load more button does the scrolling's job. + */ +export function RequestResultsLoadMore({ + hasNextPage, + isFetchingNextPage, + isError, + onLoadMore, +}: { + hasNextPage: boolean; + isFetchingNextPage: boolean; + /** The last next-page read failed. */ + isError: boolean; + onLoadMore: () => void; +}) { + const sentinelRef = useIntersectionObserver({ + onIntersect: onLoadMore, + enabled: hasNextPage && !isFetchingNextPage && !isError, + }); + if (!hasNextPage) return null; + if (isFetchingNextPage) return null; + if (isError) { + return ( +
+

Couldn’t load more titles.

+ +
+ ); + } + return ( +
+ {typeof IntersectionObserver === "undefined" ? ( + + ) : null} +
+ ); +} + +/** + * Previous / Next paging for a TMDB result list. The caller decides where + * each button leads: plain page numbers, or a server cursor that can skip + * pages. Renders nothing when there is nowhere to go. + */ +export function RequestResultsPager({ + position, + hasPrevious, + hasNext, + onPrevious, + onNext, + disabled = false, + label = "Result pages", +}: { + /** Where the viewer is, such as "Page 2 of 9". Omitted when it isn't known. */ + position?: string; + hasPrevious: boolean; + hasNext: boolean; + onPrevious: () => void; + onNext: () => void; + disabled?: boolean; + label?: string; +}) { + if (!hasPrevious && !hasNext) return null; + return ( + + ); +} diff --git a/web/src/components/RequestSeasonsDialog.test.tsx b/web/src/components/RequestSeasonsDialog.test.tsx new file mode 100644 index 0000000000..90afdc1ee2 --- /dev/null +++ b/web/src/components/RequestSeasonsDialog.test.tsx @@ -0,0 +1,94 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { RequestMediaDetail, RequestMediaSeason } from "@/api/types"; + +const mocks = vi.hoisted(() => ({ + create: vi.fn(), + loaded: { data: undefined as RequestMediaDetail | undefined, isFetching: false, isError: false }, +})); + +vi.mock("@/hooks/queries/useRequests", () => ({ + useCreateMediaRequest: () => ({ mutate: mocks.create, isPending: false }), + useRequestMediaDetail: () => mocks.loaded, +})); + +import { RequestSeasonsDialog } from "./RequestSeasonsDialog"; + +const season = (n: number, overrides: Partial = {}): RequestMediaSeason => ({ + season_number: n, + episode_count: 10, + air_date: "2020-01-01", + availability: "missing", + requested: false, + ...overrides, +}); + +const series = (overrides: Partial): RequestMediaDetail => ({ + media_type: "series", + tmdb_id: 42, + title: "Example", + availability: "missing", + request: { requestable: true }, + seasons: [], + ...overrides, +}); + +function open(detail?: RequestMediaDetail) { + render( + {}} + tmdbID={42} + title="Example" + detail={detail} + />, + ); + return screen.getByRole("dialog"); +} + +describe("RequestSeasonsDialog", () => { + beforeEach(() => { + mocks.create.mockReset(); + mocks.loaded = { data: undefined, isFetching: false, isError: false }; + }); + + it("leaves an untouched pick to the server", () => { + const dialog = open(series({ seasons: [season(1), season(2, { availability: "available" })] })); + fireEvent.click(within(dialog).getByRole("button", { name: "Request Season 1" })); + expect(mocks.create.mock.calls[0]![0]).not.toHaveProperty("seasons"); + }); + + it("requests a series with nothing aired whole", () => { + const dialog = open(series({ seasons: [season(1, { air_date: "2999-01-01" })] })); + expect(within(dialog).getByText(/covers the whole series/)).toBeInTheDocument(); + fireEvent.click(within(dialog).getByRole("button", { name: "Request series" })); + expect(mocks.create.mock.calls[0]![0]).not.toHaveProperty("seasons"); + }); + + it("asks for the upcoming seasons of a series in the library", () => { + const dialog = open( + series({ + availability: "available", + seasons: [season(1, { availability: "available" }), season(2, { air_date: "2999-01-01" })], + }), + ); + expect(within(dialog).getByText("Pick the upcoming seasons to request.")).toBeInTheDocument(); + expect(within(dialog).getByRole("button", { name: "Request" })).toBeDisabled(); + fireEvent.click(within(dialog).getByRole("switch", { name: "Season 2" })); + fireEvent.click(within(dialog).getByRole("button", { name: "Request Season 2" })); + expect(mocks.create.mock.calls[0]![0]).toMatchObject({ seasons: [2] }); + }); + + it("describes why a season can't be picked", () => { + const dialog = open(series({ seasons: [season(1, { availability: "available" })] })); + const toggle = within(dialog).getByRole("switch", { name: "Season 1" }); + expect(toggle).toBeDisabled(); + expect(toggle).toHaveAccessibleDescription(/In library/); + }); + + it("waits for a refetch before sending from loaded detail", () => { + mocks.loaded = { data: series({ seasons: [season(1)] }), isFetching: true, isError: false }; + const dialog = open(); + expect(within(dialog).getByRole("button", { name: /Request/ })).toBeDisabled(); + }); +}); diff --git a/web/src/components/RequestSeasonsDialog.tsx b/web/src/components/RequestSeasonsDialog.tsx new file mode 100644 index 0000000000..6d540e07f0 --- /dev/null +++ b/web/src/components/RequestSeasonsDialog.tsx @@ -0,0 +1,256 @@ +import { useId, useState } from "react"; +import { Loader2 } from "lucide-react"; +import type { RequestMediaDetail, RequestMediaSeason } from "@/api/types"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Switch } from "@/components/ui/switch"; +import { useCreateMediaRequest, useRequestMediaDetail } from "@/hooks/queries/useRequests"; +import { + defaultRequestSeasons, + formatRequestReason, + formatRequestSeasonMeta, + formatSeasonList, + requestInputFromMediaResult, + seasonHasAired, + seasonRequestable, +} from "@/lib/mediaRequests"; +import { cn } from "@/lib/utils"; + +interface RequestSeasonsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + tmdbID: number; + /** Names the series while its detail loads. */ + title: string; + /** The series' request detail when the caller has it; otherwise the dialog loads it when opened. */ + detail?: RequestMediaDetail; +} + +/** + * Picks the seasons of a series to request. Seasons already in the library or + * already requested are shown but cannot be picked. Aired seasons still + * missing start picked, and a request the viewer does not change leaves the + * choice to the server, which picks the same ones by its own clock, or the + * whole series when none has aired. + */ +export function RequestSeasonsDialog({ + open, + onOpenChange, + tmdbID, + title, + detail: givenDetail, +}: RequestSeasonsDialogProps) { + const loaded = useRequestMediaDetail("series", tmdbID, { enabled: open && !givenDetail }); + const detail = givenDetail ?? loaded.data; + // A reopened dialog can show the detail from before a request until the + // refetch lands; don't send from it. + const refreshing = !givenDetail && loaded.isFetching; + const rowID = useId(); + const createRequest = useCreateMediaRequest(); + // null keeps the default pick, so it follows the detail as it loads. + const [picked, setPicked] = useState(null); + + const seasons = detail?.seasons ?? []; + const requestable = detail?.request.requestable ?? false; + const choices = requestable ? seasons.filter(seasonRequestable) : []; + const defaults = defaultRequestSeasons(choices); + const selected = (picked ?? defaults).filter((number) => + choices.some((season) => season.season_number === number), + ); + const inLibrary = detail?.availability === "available"; + // Untouched, the request names no seasons and the server decides. That + // needs something for it to pick: an aired season still missing, or a + // series outside the library (requested whole). A series in the library + // with only upcoming seasons left needs them picked. + const serverChooses = + picked === null && (seasons.length === 0 || defaults.length > 0 || !inLibrary); + const allPicked = choices.length > 0 && choices.every((s) => selected.includes(s.season_number)); + + const close = (next: boolean) => { + if (!next) setPicked(null); + onOpenChange(next); + }; + const toggle = (season: number, on: boolean) => + setPicked( + on ? [...selected, season].sort((a, b) => a - b) : selected.filter((s) => s !== season), + ); + const submit = () => { + if (!detail) return; + createRequest.mutate( + { ...requestInputFromMediaResult(detail), ...(!serverChooses && { seasons: selected }) }, + { onSuccess: () => close(false) }, + ); + }; + + return ( + + + + Request seasons + + {requestable || !detail + ? `Choose the seasons of ${detail?.title ?? title} to request.` + : unavailableReason(detail.request.reason)} + + + + {!detail ? ( +
+ {loaded.isError ? ( + "Could not load the seasons of this series." + ) : ( + <> +
+ ) : seasons.length === 0 ? ( + requestable ? ( +

+ TMDB lists no seasons for this series yet; the request covers the whole series. +

+ ) : null + ) : ( + <> + {picked === null && defaults.length === 0 && choices.length > 0 ? ( +

+ {inLibrary + ? "Pick the upcoming seasons to request." + : "No season has aired yet, so the request covers the whole series unless you pick seasons."} +

+ ) : null} +
+ {choices.length > 1 ? ( + + ) : null} +
    + {seasons.map((season) => { + const choosable = choices.includes(season); + const metaID = `${rowID}-${season.season_number}-meta`; + const statusID = `${rowID}-${season.season_number}-status`; + return ( +
  • + +
  • + ); + })} +
+
+ + )} + + + + + +
+
+ ); +} + +function seasonName(season: RequestMediaSeason): string { + const number = `Season ${season.season_number}`; + return season.name && season.name !== number ? `${number}: ${season.name}` : number; +} + +/** Why no season can be picked, for a series the viewer cannot request now. */ +function unavailableReason(reason?: string): string { + switch (reason) { + case "already_available": + return "Every season is already in the library."; + case "already_requested": + return "This series already has an open request."; + default: + return `${formatRequestReason(reason)}.`; + } +} + +/** Where a season stands: in the library, requested, or not out yet. */ +export function SeasonStatus({ + season, + className, + id, +}: { + season: RequestMediaSeason; + className?: string; + id?: string; +}) { + const label = + season.availability === "available" + ? "In library" + : season.requested + ? "Requested" + : season.availability === "partial" + ? "Partly in library" + : seasonHasAired(season) + ? null + : season.air_date && season.episode_count > 0 + ? "Not aired yet" + : "Not announced"; + if (!label) return null; + return ( + + {label} + + ); +} 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..0544478e4b --- /dev/null +++ b/web/src/components/RequestStatusBadge.tsx @@ -0,0 +1,81 @@ +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" }, + partially_available: { 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..61d36a8cfc 100644 --- a/web/src/components/RequestToAddSection.test.tsx +++ b/web/src/components/RequestToAddSection.test.tsx @@ -1,6 +1,8 @@ +import { useState } from "react"; 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, within } from "@testing-library/react"; import { MemoryRouter } from "react-router"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -25,6 +27,7 @@ vi.mock("@/hooks/useDebounce", () => ({ })); import { RequestToAddSection } from "./RequestToAddSection"; +import type { RequestToAddSectionProps } from "./RequestToAddSection"; import type { RequestMediaResult } from "@/api/types"; function render(child: ReactNode) { @@ -36,6 +39,14 @@ function render(child: ReactNode) { ); } +type GridProps = Extract; + +/** Holds the grid's page the way Catalog does, minus the URL. */ +function PagedGrid(props: Omit) { + const [page, setPage] = useState(1); + return ; +} + const missingResult = (overrides: Partial = {}): RequestMediaResult => ({ media_type: "movie", tmdb_id: 1, @@ -148,7 +159,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"); }); @@ -186,8 +198,8 @@ describe("RequestToAddSection (dialog variant)", () => { isError: false, }); const markup = render(); - expect(markup).toContain("/requests/movie/1"); - expect(markup).not.toContain("/requests/movie/2"); + expect(markup).toContain("/title/movie/1"); + expect(markup).not.toContain("/title/movie/2"); }); it("renders nothing when TMDB returned an error", () => { @@ -280,30 +292,152 @@ 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)", () => { + // Never settles, so a card stays on "Sending" for the assertion. + const mutateAsync = vi.fn(() => new Promise(() => {})); + beforeEach(() => { mocks.useCanRequest.mockReset(); mocks.useRequestSearch.mockReset(); mocks.useCreateMediaRequest.mockReset(); + mutateAsync.mockClear(); mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, isResolving: false, submitDisabledReason: null, }); mocks.useCreateMediaRequest.mockReturnValue({ - mutate: vi.fn(), + mutateAsync, isPending: false, variables: undefined, }); }); - it("renders a card per result with the Request to Add header when library had hits", () => { + function pages(totalPages: number) { + mocks.useRequestSearch.mockImplementation((_type: string, query: string, page: number) => ({ + data: { + page, + total_pages: totalPages, + total_results: totalPages * 2, + results: [ + missingResult({ tmdb_id: page * 10 + 1, title: `${query} ${page}a` }), + missingResult({ tmdb_id: page * 10 + 2, title: `${query} ${page}b` }), + ], + }, + isLoading: false, + isError: false, + isPlaceholderData: false, + })); + } + + function lastSearchCall() { + return mocks.useRequestSearch.mock.calls[mocks.useRequestSearch.mock.calls.length - 1]!; + } + + it("reads like the People section: a plain Request to add heading over the cards", () => { mocks.useRequestSearch.mockReturnValue({ data: { page: 1, @@ -317,13 +451,25 @@ describe("RequestToAddSection (grid variant)", () => { isLoading: false, isError: false, }); - const markup = render(); - expect(markup).toContain("Request to Add"); - expect(markup).toContain("Dune: Prophecy"); - expect(markup).toContain("Dune (1984)"); + rtlRender( + + + , + ); + + const section = screen.getByRole("region", { name: "Request to add" }); + expect(within(section).getByRole("heading", { level: 2 })).toHaveTextContent("Request to add"); + expect(within(section).getAllByRole("link", { name: "Dune: Prophecy" })[0]).toHaveAttribute( + "href", + "/title/movie/1", + ); + expect(within(section).getAllByRole("link", { name: "Dune (1984)" })[0]).toBeInTheDocument(); + expect(within(section).queryByText(/Nothing in your library/)).not.toBeInTheDocument(); + // A single page has no pager. + expect(within(section).queryByRole("navigation")).not.toBeInTheDocument(); }); - it("renders the soft framing in the grid variant when library had 0 hits", () => { + it("says the library had no match only once that is known", () => { mocks.useRequestSearch.mockReturnValue({ data: { page: 1, @@ -334,10 +480,14 @@ describe("RequestToAddSection (grid variant)", () => { isLoading: false, isError: false, }); - const markup = render( - , + const known = render(); + expect(known).toContain("Nothing in your library matches"); + + const unknown = render( + , ); - expect(markup).toContain("Not in your library, but you can request"); + expect(unknown).toContain("Request to add"); + expect(unknown).not.toContain("Nothing in your library matches"); }); it("limits the grid to at most 20 cards", () => { @@ -349,9 +499,211 @@ describe("RequestToAddSection (grid variant)", () => { isLoading: false, isError: false, }); - const markup = render(); + const markup = render(); expect(markup).toContain("Result 0"); expect(markup).toContain("Result 19"); expect(markup).not.toContain("Result 20"); }); + + it("searches the TMDB type the host's scope asks for and keeps a page on screen while paging", () => { + pages(1); + render(); + + expect(lastSearchCall().slice(0, 3)).toEqual(["series", "dune", 1]); + expect(lastSearchCall()[3]).toMatchObject({ enabled: true, keepPreviousPage: true }); + }); + + it("pages through TMDB's results", () => { + pages(3); + rtlRender( + + + , + ); + + const pager = screen.getByRole("navigation", { name: "Request to add pages" }); + expect(pager).toHaveTextContent("Page 1 of 3"); + expect(within(pager).getByRole("button", { name: "Previous" })).toBeDisabled(); + + fireEvent.click(within(pager).getByRole("button", { name: "Next" })); + expect(lastSearchCall().slice(0, 3)).toEqual(["all", "bear", 2]); + expect(screen.getAllByRole("link", { name: "bear 2a" })[0]).toBeInTheDocument(); + expect(pager).toHaveTextContent("Page 2 of 3"); + + fireEvent.click(within(pager).getByRole("button", { name: "Next" })); + expect(within(pager).getByRole("button", { name: "Next" })).toBeDisabled(); + }); + + it("opens at the page its host restored and reports page changes to the host", () => { + pages(3); + const onPageChange = vi.fn(); + rtlRender( + + + , + ); + + expect(lastSearchCall().slice(0, 3)).toEqual(["all", "bear", 2]); + const pager = screen.getByRole("navigation", { name: "Request to add pages" }); + expect(pager).toHaveTextContent("Page 2 of 3"); + + fireEvent.click(within(pager).getByRole("button", { name: "Next" })); + expect(onPageChange).toHaveBeenLastCalledWith(3); + }); + + it("keeps the pager on a later page that holds only library titles", () => { + mocks.useRequestSearch.mockImplementation((_type: string, _query: string, page: number) => ({ + data: { + page, + total_pages: 2, + total_results: 3, + results: + page === 1 + ? [missingResult({ tmdb_id: 1, title: "Dune: Prophecy" })] + : [availableResult()], + }, + isLoading: false, + isError: false, + })); + rtlRender( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Next" })); + + expect(screen.getByText("Every title on this page is already in your library.")).toBeVisible(); + expect(screen.getByRole("button", { name: "Previous" })).toBeEnabled(); + }); + + it("keeps the pager when the first page holds only library titles but more pages exist", () => { + mocks.useRequestSearch.mockImplementation((_type: string, _query: string, page: number) => ({ + data: { + page, + total_pages: 2, + total_results: 3, + results: + page === 1 + ? [availableResult()] + : [missingResult({ tmdb_id: 1, title: "Dune: Prophecy" })], + }, + isLoading: false, + isError: false, + })); + rtlRender( + + + , + ); + + const section = screen.getByRole("region", { name: "Request to add" }); + expect( + within(section).getByText("Every title on this page is already in your library."), + ).toBeVisible(); + const pager = within(section).getByRole("navigation", { name: "Request to add pages" }); + expect(pager).toHaveTextContent("Page 1 of 2"); + + fireEvent.click(within(pager).getByRole("button", { name: "Next" })); + expect(screen.getAllByRole("link", { name: "Dune: Prophecy" })[0]).toBeInTheDocument(); + }); + + it("still hides the section when its only page holds only library titles", () => { + mocks.useRequestSearch.mockReturnValue({ + data: { page: 1, total_pages: 1, total_results: 1, results: [availableResult()] }, + isLoading: false, + isError: false, + }); + + expect(render()).toBe(""); + }); + + it("keeps the section when a later page fails, with Retry and a way back", () => { + const refetch = vi.fn(); + mocks.useRequestSearch.mockImplementation((_type: string, query: string, page: number) => + page === 1 + ? { + data: { + page, + total_pages: 4, + total_results: 8, + results: [missingResult({ tmdb_id: 1, title: `${query} 1a` })], + }, + isLoading: false, + isError: false, + isPlaceholderData: false, + } + : { + data: undefined, + isLoading: false, + isError: true, + isFetching: false, + isPlaceholderData: false, + refetch, + }, + ); + rtlRender( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Next" })); + + const section = screen.getByRole("region", { name: "Request to add" }); + expect(within(section).getByRole("heading", { level: 2 })).toHaveTextContent("Request to add"); + expect(within(section).getByRole("alert")).toHaveTextContent("Couldn’t load page 2."); + const pager = within(section).getByRole("navigation", { name: "Request to add pages" }); + expect(pager).toHaveTextContent("Page 2"); + expect(within(pager).getByRole("button", { name: "Previous" })).toBeEnabled(); + expect(within(pager).getByRole("button", { name: "Next" })).toBeDisabled(); + + fireEvent.click(within(section).getByRole("button", { name: "Retry" })); + expect(refetch).toHaveBeenCalledOnce(); + + fireEvent.click(within(section).getByRole("button", { name: "Back to page 1" })); + expect(lastSearchCall().slice(0, 3)).toEqual(["all", "dune", 1]); + expect(screen.getAllByRole("link", { name: "dune 1a" })[0]).toBeInTheDocument(); + }); + + it("still hides the section when the first page fails", () => { + mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: true }); + + expect(render()).toBe(""); + }); + + it("stops at TMDB's 500-page cap whatever total it reports", () => { + pages(900); + rtlRender( + + + , + ); + + expect(screen.getByRole("navigation", { name: "Request to add pages" })).toHaveTextContent( + "Page 1 of 500", + ); + }); + + it("requests a card's title from its hover action", () => { + pages(1); + rtlRender( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: /^Request dune 1a/ })); + + expect(mutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ media_type: "movie", tmdb_id: 11, title: "dune 1a" }), + ); + expect(screen.getByRole("button", { name: /^Sending request for dune 1a/ })).toBeDisabled(); + }); }); diff --git a/web/src/components/RequestToAddSection.tsx b/web/src/components/RequestToAddSection.tsx index ee04cb2b4d..e61b64f114 100644 --- a/web/src/components/RequestToAddSection.tsx +++ b/web/src/components/RequestToAddSection.tsx @@ -1,39 +1,54 @@ -import { useState } from "react"; +import { useRef } from "react"; +import type { MouseEvent } from "react"; import { Link } from "react-router"; -import { Film, Sparkles, Tv } from "lucide-react"; +import { Film, RefreshCw, Tv } from "lucide-react"; +import { Button } from "@/components/ui/button"; import { useCanRequest } from "@/hooks/useCanRequest"; -import { useCreateMediaRequest, useRequestSearch } from "@/hooks/queries/useRequests"; -import type { RequestMediaResult } from "@/api/types"; +import { useRequestSearch } from "@/hooks/queries/useRequests"; +import type { RequestMediaResult, RequestSearchMediaType } from "@/api/types"; import { + formatMediaType, + formatRequestDisplayState, formatRequestReason, - formatRequestStatus, - requestInputFromMediaResult, + REQUEST_DIALOG_SUGGESTION_LIMIT, + requestDetailHref, + requestDisplayState, + requestSuggestions, tmdbImageURL, + tmdbPageCount, } from "@/lib/mediaRequests"; import { cn } from "@/lib/utils"; -import RequestPosterCard from "./RequestPosterCard"; +import { RequestStatusBadge } from "./RequestStatusBadge"; +import RequestResultsGrid, { RequestResultsPager } from "./RequestResultsGrid"; 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; -export type RequestToAddSectionProps = { - variant: "dialog" | "grid"; +/** + * 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; +} + +type RequestToAddSectionCommonProps = { query: string; + /** The TMDB types to search, following the host's search scope. Default: movies and series. */ + mediaType?: RequestSearchMediaType; /** True when the library search returned at least one hit. Drives header copy. */ libraryHadHits: boolean; /** @@ -43,44 +58,71 @@ export type RequestToAddSectionProps = { libraryResultsKnown?: boolean; }; -export function RequestToAddSection({ - variant, - query, - libraryHadHits, - libraryResultsKnown = true, -}: RequestToAddSectionProps) { +export type RequestToAddSectionProps = RequestToAddSectionCommonProps & + ( + | { variant: "dialog"; combobox?: RequestSuggestionCombobox } + | { + variant: "grid"; + /** + * The TMDB results page to show. The host keeps it in navigation + * (Catalog uses the URL), so Back and a reload return to it, and + * starts over at 1 for another query or type. + */ + page: number; + onPageChange: (page: number) => void; + } + ); + +export function RequestToAddSection(props: RequestToAddSectionProps) { + const { variant, query, mediaType = "all", libraryHadHits, libraryResultsKnown = true } = props; const { discoveryEnabled } = useCanRequest(); - const search = useRequestSearch("all", query, 1, { + const page = props.variant === "grid" ? props.page : 1; + const search = useRequestSearch(mediaType, query, page, { enabled: discoveryEnabled, requireProfile: true, staleTime: 5 * 60 * 1000, gcTime: INTERACTIVE_SEARCH_GC_TIME_MS, retry: false, + ...(variant === "grid" ? { keepPreviousPage: true } : {}), }); if (!discoveryEnabled) return null; - if (search.isError && !search.data) return null; + // A failed first read hides the section; a failed later page keeps it, so + // the viewer can retry or go back instead of losing the results. + const failed = search.isError && !search.data; + if (failed && page === 1) return null; - const filtered = (search.data?.results ?? []).filter((item) => item.availability !== "available"); - if (filtered.length === 0) return null; + const limit = variant === "dialog" ? REQUEST_DIALOG_SUGGESTION_LIMIT : GRID_LIMIT; + const visible = requestSuggestions(search.data?.results, limit); + const totalPages = tmdbPageCount(search.data?.total_pages); + // Any page can hold only library titles. The grid keeps such a page while + // TMDB has others, so its pager can reach them; the dialog has no pager. + const hasOtherPages = variant === "grid" && totalPages > 1; + if (visible.length === 0 && page === 1 && !hasOtherPages) return null; - const limit = variant === "dialog" ? DIALOG_LIMIT : GRID_LIMIT; - const visible = filtered.slice(0, limit); - - if (variant === "dialog") { + if (props.variant === "dialog") { return ( ); } return ( void search.refetch(), isRetrying: search.isFetching } : null + } /> ); } @@ -106,13 +148,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 +162,88 @@ 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, 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 + // 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} ); } function GridVariant({ + query, items, libraryHadHits, libraryResultsKnown, + page, + totalPages, + isChangingPage, + onPageChange, + pageError, }: { + query: string; items: RequestMediaResult[]; libraryHadHits: boolean; libraryResultsKnown: boolean; + page: number; + /** Readable pages; 0 while the page failed to load and the count is unknown. */ + totalPages: number; + isChangingPage: boolean; + onPageChange: (page: number) => void; + /** Set when this (later) page failed to load. */ + pageError: { onRetry: () => void; isRetrying: boolean } | null; }) { - const count = items.length; - const createRequest = useCreateMediaRequest(); - // Track each in-flight card key independently; the shared `useMutation` - // observer overwrites its `variables` on every `mutate` call, so rapid - // clicks on different cards would otherwise trample each other's spinner. - const [pendingKeys, setPendingKeys] = useState>(new Set()); - const submitCard = (item: RequestMediaResult) => { - const key = cardKey(item); - setPendingKeys((prev) => { - const next = new Set(prev); - next.add(key); - return next; - }); - createRequest.mutate(requestInputFromMediaResult(item), { - onSettled: () => { - setPendingKeys((prev) => { - if (!prev.has(key)) return prev; - const next = new Set(prev); - next.delete(key); - return next; - }); - }, - }); - }; - return ( -
-
+ const sectionRef = useRef(null); -
-
-
- - - {libraryHadHits - ? "Discover · Outside your library" - : libraryResultsKnown - ? "Outside your library" - : "Discovery"} - -
-

- {libraryHadHits - ? "Request to Add" - : libraryResultsKnown - ? "Not in your library, but you can request" - : "More search matches"} -

-
- - {count} {count === 1 ? "result" : "results"} - -
+ function changePage(next: number) { + onPageChange(next); + // The pager sits below the grid; bring the new page's first row into view. + const section = sectionRef.current; + if (section && section.getBoundingClientRect().top < 0) { + section.scrollIntoView?.({ block: "start", behavior: "smooth" }); + } + } -
- {items.map((item) => { - const key = cardKey(item); - return ( - submitCard(item)} - fluid - /> - ); - })} + // Laid out like the People section above it: a plain heading over the results. + return ( +
+
+

Request to add

+ {libraryResultsKnown && !libraryHadHits ? ( +

+ Nothing in your library matches “{query}”. +

+ ) : null}
+ {pageError ? ( +
+

Couldn’t load page {page}.

+ + +
+ ) : items.length > 0 ? ( + + ) : ( +

+ Every title on this page is already in your library. +

+ )} + 0 ? `Page ${page} of ${totalPages}` : `Page ${page}`} + hasPrevious={page > 1} + hasNext={page < totalPages} + onPrevious={() => changePage(page - 1)} + onNext={() => changePage(page + 1)} + disabled={isChangingPage} + />
); } diff --git a/web/src/components/SearchBar.tsx b/web/src/components/SearchBar.tsx index 1ee131bc13..c713061c2d 100644 --- a/web/src/components/SearchBar.tsx +++ b/web/src/components/SearchBar.tsx @@ -14,6 +14,8 @@ interface SearchBarProps { autoFocus?: boolean; prominent?: boolean; buildSearchHref?: (query: string) => string; + /** Hint for the compact variant's input, which also names it. */ + placeholder?: string; } export default function SearchBar({ @@ -21,6 +23,7 @@ export default function SearchBar({ autoFocus = false, prominent = false, buildSearchHref = buildQueryCatalogHref, + placeholder = "Search...", }: SearchBarProps) { const [query, setQuery] = useState(initialQuery); const navigate = useViewTransitionNavigate(); @@ -124,11 +127,15 @@ export default function SearchBar({ } return ( -
- + + setQuery(e.target.value)} className="pl-9" diff --git a/web/src/components/SectionItemCard.tsx b/web/src/components/SectionItemCard.tsx index 699137e570..16987ddc6f 100644 --- a/web/src/components/SectionItemCard.tsx +++ b/web/src/components/SectionItemCard.tsx @@ -1,9 +1,12 @@ import { useRef } from "react"; -import { useImageLoaded } from "@/hooks/useImageLoaded"; import ViewTransitionLink from "@/components/ViewTransitionLink"; +import MediaCardArtwork, { + MEDIA_CARD_CAPTION_CLASS, + MEDIA_CARD_META_CLASS, + MEDIA_CARD_TITLE_CLASS, +} from "@/components/MediaCardArtwork"; import MediaItemMenu from "@/components/MediaItemMenu"; import CardOverlays from "@/components/overlays/CardOverlays"; -import { decodeThumbhash } from "@/lib/thumbhash"; import { overlayDataFromSectionItem, type CardOverlayPrefs } from "@/lib/overlays"; import type { CardQuickActionMode } from "@/lib/cardQuickActions"; import { buildEpisodeCardLabels } from "@/lib/episodeCardLabels"; @@ -32,8 +35,6 @@ export default function SectionItemCard({ overlayPrefs = null, quickActionMode = "none", }: SectionItemCardProps) { - const { loaded, onLoad, onError } = useImageLoaded(item.poster_url); - const thumbhashUrl = item.poster_thumbhash ? decodeThumbhash(item.poster_thumbhash) : ""; const itemHref = buildItemHref({ contentId: item.content_id, libraryId }); const upcomingEvent = item.upcoming_event; const subtitle = upcomingEvent ? formatUpcomingSubtitle(upcomingEvent) : ""; @@ -54,35 +55,14 @@ export default function SectionItemCard({
-
- {item.poster_url ? ( - {item.title} - ) : ( -
- {item.title || "No Poster"} -
- )} -
{item.status === "ambiguous" && ( Ambiguous @@ -105,7 +85,7 @@ export default function SectionItemCard({ ))}
)} -
+
{item.play_content_id ? (
{showCaption ? ( -
- +
+ {displayTitle} {showMetadata && upcomingEvent ? ( @@ -160,19 +137,13 @@ export default function SectionItemCard({
) : showMetadata && item.item_source === "next_in_series" && item.series_title ? ( - + {[item.badges?.find((badge) => badge.startsWith("Book ")), item.series_title] .filter(Boolean) .join(" · ")} ) : showMetadata ? ( - + {item.year ? `${item.year}` : ""} {item.type === "series" ? "Series" : ""} ) : null} 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/RequestLimitFields.tsx b/web/src/components/admin/RequestLimitFields.tsx new file mode 100644 index 0000000000..0243ac90d4 --- /dev/null +++ b/web/src/components/admin/RequestLimitFields.tsx @@ -0,0 +1,212 @@ +import { useId, useState } from "react"; + +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + isZeroRequestLimit, + type RequestApprovalChoice, + type RequestLimitChoice, + type RequestLimitDraft, + type RequestLimitErrors, +} from "@/lib/requestAccess"; +import { cn } from "@/lib/utils"; + +export interface RequestLimitFieldsProps { + draft: RequestLimitDraft; + onChange: (draft: RequestLimitDraft) => void; + /** Whose limit this is, for the hint a limit of zero shows. */ + subject: "account" | "group"; + /** The inherit option's label: "Use group default" or "Use server default". */ + inheritLabel: string; + /** What each field resolves to while it inherits, shown under it. */ + inherited?: { approval: string; limit: string }; + /** The numbers a limit starts from when it is switched to custom with none typed. */ + customSeed: { maxRequests: string; windowDays: string }; + errors: RequestLimitErrors; + disabled?: boolean; + /** One field under the other, for a narrow panel; side by side from `sm` otherwise. */ + stacked?: boolean; +} + +/** Space-separated ids for aria-describedby, or undefined when there are none. */ +function describedBy(...ids: Array) { + return ids.filter(Boolean).join(" ") || undefined; +} + +/** + * Request approval and limit for an account or an access group. Blocking is + * not offered here: that is the requests switch on the account or group. + */ +export function RequestLimitFields({ + draft, + onChange, + subject, + inheritLabel, + inherited, + customSeed, + errors, + disabled, + stacked = false, +}: RequestLimitFieldsProps) { + const approvalId = useId(); + const approvalHintId = useId(); + const limitId = useId(); + const limitHintId = useId(); + const maxId = useId(); + const maxErrorId = useId(); + const zeroHintId = useId(); + const windowId = useId(); + const windowErrorId = useId(); + // Errors are announced once the admin edits the numbers, not when a stored + // value is loaded into the form. + const [edited, setEdited] = useState(false); + + const approvalHint = draft.approval === "inherit" && inherited ? inherited.approval : undefined; + const limitHint = draft.limit === "inherit" && inherited ? inherited.limit : undefined; + const custom = draft.limit === "custom"; + const zero = isZeroRequestLimit(draft); + + function setLimit(limit: RequestLimitChoice) { + if (limit === "custom" && draft.maxRequests === "" && draft.windowDays === "") { + onChange({ ...draft, limit, ...customSeed }); + return; + } + onChange({ ...draft, limit }); + } + + function editNumbers(change: Partial) { + setEdited(true); + onChange({ ...draft, ...change }); + } + + return ( +
+
+ + + {approvalHint !== undefined ? ( +

+ {approvalHint} +

+ ) : null} +
+ +
+ + + {custom ? ( +
+ + editNumbers({ maxRequests: event.target.value })} + aria-invalid={errors.maxRequests ? true : undefined} + aria-describedby={describedBy( + Boolean(errors.maxRequests) && maxErrorId, + zero && zeroHintId, + )} + disabled={disabled} + /> + requests per + + editNumbers({ windowDays: event.target.value })} + aria-invalid={errors.windowDays ? true : undefined} + aria-describedby={describedBy(Boolean(errors.windowDays) && windowErrorId)} + disabled={disabled} + /> + days +
+ ) : null} + {limitHint !== undefined ? ( +

+ {limitHint} +

+ ) : null} + {zero ? ( +

+ 0 stops new requests; to block this {subject}, turn off Media requests instead. +

+ ) : null} + {custom && errors.maxRequests ? ( +

+ {errors.maxRequests} +

+ ) : null} + {custom && errors.windowDays ? ( +

+ {errors.windowDays} +

+ ) : null} +
+
+ ); +} 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 e35501d1ab..ca19288beb 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/admin/requests.ts b/web/src/hooks/queries/admin/requests.ts new file mode 100644 index 0000000000..d92ddb69e2 --- /dev/null +++ b/web/src/hooks/queries/admin/requests.ts @@ -0,0 +1,785 @@ +/** + * Request administration: the queue, request settings, servers, routing and + * limits. Kept apart from the requester hooks so the launch bundle, which + * loads those for search and title pages, carries none of this. + */ +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "sonner"; +import { V2ProblemError } from "@/api/v2/request"; +import { captureProfileRequestContext } from "@/api/client"; +import { adminAuthorityScope, type AdminAuthority } from "@/api/v2/adminAuthority"; +import { + getAdminRequestSettingsV2, + putAdminRequestSettingsV2, + getAdminRequestUserLimitV2, + putAdminRequestUserLimitV2, + getAdminRequestGroupLimitV2, + putAdminRequestGroupLimitV2, + listAdminRequestIntegrationsV2, + saveAdminRequestIntegrationV2, + deleteAdminRequestIntegrationV2, + listAdminRequestQueuePageV2, + getAdminRequestCountsV2, + listAdminRequestEventsV2, + approveAdminRequestV2, + cancelAdminRequestV2, + declineAdminRequestV2, + retryAdminRequestV2, + loadAdminRequestIntegrationOptionsV2, + listAdminRequestRoutesV2, + createAdminRequestRouteV2, + updateAdminRequestRouteV2, + deleteAdminRequestRouteV2, + reorderAdminRequestRoutesV2, + previewAdminRequestRouteV2, + searchAdminRequestRouteTitlesV2, + getAdminRequestRoutingV2, + putAdminRequestRoutingV2, + type AdminRequestQueueFilter, + type RequestGroupLimit, + type RequestGroupLimitBody, + type RequestRoute, + type RequestRouteBody, + type RequestRouteMediaType, + type RequestRouting, + type RequestRoutingMode, +} from "@/api/v2/adminRequests"; +import { v2 } from "@/api/v2/request"; +import type { + LoadRequestIntegrationOptionsRequest, + MediaRequest, + RequestIntegration, + RequestUserLimit, +} from "@/api/types"; +import { adminKeys, requestKeys } from "../keys"; + +import { + REQUESTS_STALE_TIME, + invalidateRequestSurfaces, + isValidationFailure, + requestDownloadRefetchInterval, +} from "../useRequests"; + +const ADMIN_QUEUE_STALE_TIME = 10_000; +/** Rows per queue page; the server answers at most 50. */ +export const ADMIN_QUEUE_PAGE_SIZE = 25; +/** How often the view counts (and so the admin nav's badge) are read again. */ +export const ADMIN_REQUEST_COUNTS_INTERVAL = 60_000; + +function requestQueueKey(filter: AdminRequestQueueFilter) { + return adminKeys.requestQueue({ + view: filter.view, + q: filter.q?.trim() ?? "", + mediaType: filter.mediaType ?? "all", + requestedByUserId: filter.requestedByUserId ?? null, + }); +} + +/** + * One queue view, a page at a time. A new search or type filter keeps the + * rows on screen until its first page arrives; a new view does not, since its + * rows take different actions. With `enabled: false` it only reads the rows + * another reader of the same view loads, and follows their refetches. While + * a loaded row downloads, the view is read again every 30 seconds. + */ +export function useAdminRequestQueue( + filter: AdminRequestQueueFilter, + options: { enabled?: boolean } = {}, +) { + return useInfiniteQuery({ + queryKey: requestQueueKey(filter), + enabled: options.enabled ?? true, + initialPageParam: undefined as string | undefined, + queryFn: ({ pageParam, signal }) => + listAdminRequestQueuePageV2(filter, { + limit: ADMIN_QUEUE_PAGE_SIZE, + cursor: pageParam, + signal, + }), + // A cursor the list already visited would loop; stop there. + getNextPageParam: (last, _pages, _lastParam, params) => + last.nextCursor && !params.includes(last.nextCursor) ? last.nextCursor : undefined, + placeholderData: (previous, previousQuery) => + (previousQuery?.queryKey[3] as { view?: string } | undefined)?.view === filter.view + ? previous + : undefined, + staleTime: ADMIN_QUEUE_STALE_TIME, + refetchInterval: (query) => + requestDownloadRefetchInterval(query.state.data?.pages.flatMap((page) => page.items)), + }); +} + +/** How many requests each queue view holds; polled for the admin nav badge. */ +export function useAdminRequestCounts(options: { enabled?: boolean } = {}) { + return useQuery({ + queryKey: adminKeys.requestCounts(), + queryFn: getAdminRequestCountsV2, + enabled: options.enabled ?? true, + staleTime: ADMIN_QUEUE_STALE_TIME, + // A server without request administration, or an admin session that lost + // its rights, answers the same every time; any other error (a node + // restarting) is worth asking again. + refetchInterval: (query) => + query.state.error instanceof V2ProblemError && + ["permission_denied", "dependency_unavailable"].includes(query.state.error.problemType) + ? false + : ADMIN_REQUEST_COUNTS_INTERVAL, + retry: false, + }); +} + +/** Reads the queue's rows and view counts again. */ +export function useRefreshRequestQueue() { + const queryClient = useQueryClient(); + const [isRefreshing, setRefreshing] = useState(false); + return { + isRefreshing, + refresh: () => { + setRefreshing(true); + void Promise.all([ + queryClient.invalidateQueries({ queryKey: adminKeys.requestQueueRoot() }), + queryClient.invalidateQueries({ queryKey: adminKeys.requestCounts() }), + ]).finally(() => setRefreshing(false)); + }, + }; +} + +/** A request's history, newest first. */ +export function useAdminRequestEvents(id: string | undefined) { + return useQuery({ + queryKey: adminKeys.requestEvents(id ?? ""), + queryFn: () => listAdminRequestEventsV2(id!), + enabled: Boolean(id), + staleTime: ADMIN_QUEUE_STALE_TIME, + }); +} + +/** Where a request's quality tiers would go if it were sent now. */ +export function useAdminRequestRoutePreview( + target: { mediaType: RequestRouteMediaType; tmdbId: number; requesterUserId?: number } | null, +) { + return useQuery({ + queryKey: adminKeys.requestRoutePreview( + target?.mediaType ?? "", + target?.tmdbId ?? 0, + target?.requesterUserId, + ), + queryFn: () => + previewAdminRequestRouteV2(target!.mediaType, target!.tmdbId, target!.requesterUserId), + enabled: target !== null, + staleTime: REQUESTS_STALE_TIME, + retry: false, + }); +} + +export function useApproveMediaRequest() { + const queryClient = useQueryClient(); + return useMutation({ + retry: false, + mutationFn: (id: string) => approveAdminRequestV2(id), + onSuccess: () => { + toast.success("Request approved"); + }, + // A refused action still refreshes the queue: another admin may have + // acted first, and the row should show what happened. + onSettled: () => invalidateRequestSurfaces(queryClient), + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to approve request"); + }, + }); +} + +export function useDeclineMediaRequest() { + const queryClient = useQueryClient(); + return useMutation({ + retry: false, + mutationFn: ({ id, reason }: { id: string; reason?: string }) => + declineAdminRequestV2(id, reason), + onSuccess: () => { + toast.success("Request declined"); + }, + // A refused action still refreshes the queue: another admin may have + // acted first, and the row should show what happened. + onSettled: () => invalidateRequestSurfaces(queryClient), + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to decline request"); + }, + }); +} + +export function useRetryMediaRequest() { + const queryClient = useQueryClient(); + return useMutation({ + retry: false, + mutationFn: (id: string) => retryAdminRequestV2(id), + onSuccess: () => { + toast.success("Request queued for retry"); + }, + // A refused action still refreshes the queue: another admin may have + // acted first, and the row should show what happened. + onSettled: () => invalidateRequestSurfaces(queryClient), + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to retry request"); + }, + }); +} + +/** An admin withdraws a request nothing has been sent for yet, or closes a failed one. */ +export function useAdminCancelMediaRequest() { + const queryClient = useQueryClient(); + return useMutation({ + retry: false, + mutationFn: ({ id, reason }: { id: string; reason?: string }) => + cancelAdminRequestV2(id, reason), + onSuccess: () => { + toast.success("Request cancelled"); + }, + // A refused action still refreshes the queue: another admin may have + // acted first, and the row should show what happened. + onSettled: () => invalidateRequestSurfaces(queryClient), + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to cancel request"); + }, + }); +} + +export type BulkRequestAction = "approve" | "decline"; + +/** Requests a bulk action sends at once; the rest wait for a free slot. */ +export const BULK_REQUEST_CONCURRENCY = 4; + +export interface BulkRequestFailure { + id: string; + title: string; + message: string; +} + +export interface BulkRequestProgress { + action: BulkRequestAction; + total: number; + /** Requests answered so far, failed ones included. */ + done: number; + failures: BulkRequestFailure[]; +} + +async function forEachWithConcurrency( + items: readonly T[], + limit: number, + run: (item: T) => Promise, +) { + let next = 0; + const worker = async () => { + while (next < items.length) await run(items[next++]!); + }; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); +} + +/** + * Approves or declines several requests through the per-request endpoints, a + * few at a time. One refusal does not stop the rest: `progress` counts every + * answer and keeps each failure with its reason. The queue and counts are + * read again once, when the last answer is in. + */ +export function useBulkRequestAction() { + const queryClient = useQueryClient(); + const [progress, setProgress] = useState(null); + const mutation = useMutation({ + retry: false, + mutationFn: async ({ + action, + requests, + reason, + }: { + action: BulkRequestAction; + requests: readonly Pick[]; + reason?: string; + }) => { + const failures: BulkRequestFailure[] = []; + let done = 0; + setProgress({ action, total: requests.length, done, failures: [] }); + await forEachWithConcurrency(requests, BULK_REQUEST_CONCURRENCY, async (request) => { + try { + if (action === "approve") await approveAdminRequestV2(request.id); + else await declineAdminRequestV2(request.id, reason); + } catch (err) { + failures.push({ + id: request.id, + title: request.title, + message: problemMessage(err, `Failed to ${action} request`), + }); + } + done += 1; + setProgress({ action, total: requests.length, done, failures: [...failures] }); + }); + return { action, total: requests.length, failures }; + }, + onSuccess: ({ action, total, failures }) => { + const verb = action === "approve" ? "approved" : "declined"; + const succeeded = total - failures.length; + if (failures.length === 0) { + toast.success(`${succeeded} ${succeeded === 1 ? "request" : "requests"} ${verb}`); + } else { + toast.error(`${failures.length} of ${total} requests couldn't be ${verb}`); + } + }, + onSettled: () => invalidateRequestSurfaces(queryClient), + }); + return { + run: mutation.mutate, + isRunning: mutation.isPending, + progress, + /** Forgets the last run's progress and failures. */ + reset: () => { + mutation.reset(); + setProgress(null); + }, + }; +} + +export function useRequestSettings() { + return useQuery({ + queryKey: adminKeys.requestSettings(), + queryFn: getAdminRequestSettingsV2, + staleTime: REQUESTS_STALE_TIME, + }); +} + +export function useUpdateRequestSettings() { + const queryClient = useQueryClient(); + return useMutation({ + retry: false, + mutationFn: putAdminRequestSettingsV2, + onSuccess: (saved) => { + toast.success("Request settings saved"); + // 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() }); + }, + }); +} + +export function useRequestIntegrations() { + return useQuery({ + queryKey: adminKeys.requestIntegrations(), + queryFn: listAdminRequestIntegrationsV2, + staleTime: REQUESTS_STALE_TIME, + }); +} + +// A saved server's connection may have changed, so the root folders, quality +// profiles, and tags read from it are stale too. Adding or deleting a server +// can also set or clear a media type's Everything else, so the routes are read +// again as well. +function invalidateRequestServers(queryClient: ReturnType) { + queryClient.invalidateQueries({ queryKey: adminKeys.requestIntegrations() }); + queryClient.invalidateQueries({ queryKey: adminKeys.requestIntegrationOptionsRoot() }); + invalidateRequestSurfaces(queryClient); + void invalidateRequestRoutes(queryClient); + // A server can turn Advanced on, and changes where Standard sends requests. + void queryClient.invalidateQueries({ queryKey: adminKeys.requestRouting() }); +} + +/** "movies" for a Radarr, "series" for a Sonarr, "requests" for anything else. */ +function serverRequestNoun(saved: RequestIntegration): string { + const kind = saved.plugin_config?.service_kind; + return kind === "radarr" ? "movies" : kind === "sonarr" ? "series" : "requests"; +} + +/** + * The toast for a saved server. Saving one can turn Advanced routing on (a + * second server of a kind), and the first server of a kind starts taking its + * media type's requests. Rather than guess the server's rules, the routing is + * read again and the toast says what changed. + */ +async function savedServerMessage( + queryClient: ReturnType, + saved: RequestIntegration, + before: RequestRouting | undefined, + added: boolean, +): Promise { + const plain = added ? "Server added" : "Server saved"; + try { + const routing = await queryClient.fetchQuery({ + queryKey: adminKeys.requestRouting(), + queryFn: getAdminRequestRoutingV2, + staleTime: 0, + }); + if (before?.mode === "standard" && routing.mode === "advanced") { + return `${saved.name} ${added ? "added" : "saved"}. Routing is now Advanced, so you can choose which ${serverRequestNoun(saved)} go to each server.`; + } + if (!added) return plain; + if (routing.mode === "standard") { + const destination = routing.standard.find( + (d) => d.hd_integration_id === saved.id || d.uhd_integration_id === saved.id, + ); + if (destination?.uhd_integration_id === saved.id) { + return `${saved.name} added. 4K versions of ${destination.media_type === "series" ? "series" : "movies"} now go to it.`; + } + if (destination) { + return `${saved.name} added. Every ${destination.media_type} request now goes to it.`; + } + return plain; + } + const routes = await queryClient.fetchQuery({ + queryKey: adminKeys.requestRoutes(), + queryFn: listAdminRequestRoutesV2, + staleTime: 0, + }); + const fallback = routes.find( + (route) => route.is_fallback && route.hd.integration_id === saved.id, + ); + if (fallback) { + return `${saved.name} added. Every ${fallback.media_type} request now goes to it.`; + } + } catch { + // The routing could not be read; the plain toast is still true. + } + return plain; +} + +function useSaveRequestIntegration(added: boolean) { + const queryClient = useQueryClient(); + return useMutation({ + retry: false, + mutationFn: (integration: RequestIntegration) => + saveAdminRequestIntegrationV2(integration, added), + // The routing before the save, to tell whether the save turned Advanced on. + onMutate: () => queryClient.getQueryData(adminKeys.requestRouting()), + onSuccess: (saved, _integration, before) => { + invalidateRequestServers(queryClient); + void savedServerMessage(queryClient, saved, before, added).then((message) => + toast.success(message), + ); + }, + onError: (err) => { + if (isValidationFailure(err)) return; + toast.error( + err instanceof Error + ? err.message + : added + ? "Failed to add server" + : "Failed to save server", + ); + }, + }); +} + +export function useCreateRequestIntegration() { + return useSaveRequestIntegration(true); +} + +export function useUpdateRequestIntegration() { + return useSaveRequestIntegration(false); +} + +export function useDeleteRequestIntegration() { + const queryClient = useQueryClient(); + return useMutation({ + retry: false, + mutationFn: deleteAdminRequestIntegrationV2, + onSuccess: () => { + toast.success("Server deleted"); + invalidateRequestServers(queryClient); + }, + onError: (err) => { + 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 useRequestRouting(enabled = true) { + return useQuery({ + queryKey: adminKeys.requestRouting(), + queryFn: getAdminRequestRoutingV2, + staleTime: REQUESTS_STALE_TIME, + enabled, + }); +} + +/** Switches between Standard and Advanced routing; it saves right away. */ +export function useUpdateRequestRouting() { + const queryClient = useQueryClient(); + return useMutation({ + retry: false, + mutationFn: ({ mode, current }: { mode: RequestRoutingMode; current: RequestRouting }) => + putAdminRequestRoutingV2(mode, current), + onSuccess: (saved) => { + queryClient.setQueryData(adminKeys.requestRouting(), saved); + toast.success( + saved.mode === "standard" ? "Standard routing is on" : "Advanced routing is on", + ); + // Advanced can fill in Everything else; a preview answers differently. + void invalidateRequestRoutes(queryClient); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to change routing"); + void queryClient.invalidateQueries({ queryKey: adminKeys.requestRouting() }); + }, + }); +} + +export function useRequestRoutes(enabled = true) { + return useQuery({ + queryKey: adminKeys.requestRoutes(), + queryFn: listAdminRequestRoutesV2, + staleTime: REQUESTS_STALE_TIME, + enabled, + }); +} + +// 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) { + // An open preview asks TMDB again, so it refreshes without holding the list up. + void queryClient.invalidateQueries({ queryKey: adminKeys.requestRoutePreviewRoot() }); + return queryClient.invalidateQueries({ queryKey: adminKeys.requestRoutes(), exact: true }); +} + +/** 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 ? "Everything else 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), + // The new order shows at once, so a dragged row stays where it was + // dropped. The list stays locked until the reorder is read back (the + // mutation is pending until then), and a refused reorder puts it back. + onMutate: async ({ mediaType, ids }) => { + const key = adminKeys.requestRoutes(); + await queryClient.cancelQueries({ queryKey: key, exact: true }); + const previous = queryClient.getQueryData(key); + queryClient.setQueryData(key, (routes) => + routes?.map((route) => + route.media_type === mediaType && !route.is_fallback && ids.includes(route.id) + ? { ...route, position: ids.indexOf(route.id) } + : route, + ), + ); + return { previous }; + }, + onError: (err, _variables, context) => { + if (context?.previous) { + queryClient.setQueryData(adminKeys.requestRoutes(), context.previous); + } + routeMutationError(err, "Failed to reorder rules"); + }, + onSettled: () => invalidateRequestRoutes(queryClient), + }); +} + +/** + * Titles to try the routing rules on. Admin-only, and it answers while + * requests are turned off, unlike the requesters' search. + */ +export function useRequestRouteTitles( + mediaType: RequestRouteMediaType, + query: string, + options: { enabled?: boolean } = {}, +) { + const q = query.trim(); + return useQuery({ + queryKey: adminKeys.requestRouteTitles(mediaType, q), + queryFn: ({ signal }) => searchAdminRequestRouteTitlesV2(mediaType, q, signal), + enabled: (options.enabled ?? true) && q.length > 1, + staleTime: 5 * 60 * 1000, + retry: false, + }); +} + +export function useLoadRequestIntegrationOptions() { + return useMutation({ + retry: false, + mutationFn: ({ id, body }: { id: string; body: LoadRequestIntegrationOptionsRequest }) => + loadAdminRequestIntegrationOptionsV2(id, body), + // Silent background probe: callers surface load failures inline (no toast). + }); +} + +export function useRequestUserLimit(userId?: number) { + return useQuery({ + queryKey: adminKeys.requestUserLimit(userId ?? 0), + queryFn: () => getAdminRequestUserLimitV2(userId!), + enabled: Boolean(userId && userId > 0), + staleTime: REQUESTS_STALE_TIME, + }); +} + +export function useUpdateRequestUserLimit() { + const queryClient = useQueryClient(); + return useMutation({ + retry: false, + mutationFn: ({ userId, body }: { userId: number; body: RequestUserLimit }) => + putAdminRequestUserLimitV2(userId, body), + onSuccess: (saved, variables) => { + toast.success("Request settings saved"); + // 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.requestUserLimit(variables.userId), saved); + invalidateRequestSurfaces(queryClient); + }, + onError: (err, variables) => { + toast.error(err instanceof Error ? err.message : "Failed to save the request settings"); + // A refused save (412) means someone else saved; read their version so + // an explicit reload starts from it. + queryClient.invalidateQueries({ queryKey: adminKeys.requestUserLimit(variables.userId) }); + }, + }); +} + +/** + * An access group's request approval and limit. An editor passes the + * authority it read the group under, so the limit it saves carries a + * validator from the same profile. + */ +export function useRequestGroupLimit(groupId?: number | null, authority?: AdminAuthority) { + const context = authority ?? captureProfileRequestContext(); + return useQuery({ + queryKey: adminKeys.requestGroupLimit(groupId ?? 0, adminAuthorityScope(context)), + queryFn: () => getAdminRequestGroupLimitV2(groupId!, context ?? undefined), + enabled: Boolean(groupId && groupId > 0), + staleTime: REQUESTS_STALE_TIME, + retry: false, + }); +} + +/** + * Saves an access group's request approval and limit. Silent: the group + * editor saves it together with the group and reports both. + */ +export function useUpdateRequestGroupLimit() { + const queryClient = useQueryClient(); + return useMutation({ + retry: false, + mutationFn: ({ + limit, + body, + profileContext, + }: { + limit: Pick; + body: RequestGroupLimitBody; + /** The authority the limit was read under; the active one when omitted. */ + profileContext?: AdminAuthority; + }) => putAdminRequestGroupLimitV2(limit, body, profileContext), + onSuccess: (saved, { profileContext }) => { + queryClient.setQueryData( + adminKeys.requestGroupLimit(saved.group_id, adminAuthorityScope(profileContext)), + saved, + ); + invalidateRequestSurfaces(queryClient); + }, + onError: (_err, { limit, profileContext }) => { + queryClient.invalidateQueries({ + queryKey: adminKeys.requestGroupLimit(limit.group_id, adminAuthorityScope(profileContext)), + }); + }, + }); +} + +export function useAdminRequestCapabilities() { + return useQuery({ + queryKey: [...adminKeys.requestsRoot(), "capabilities"], + queryFn: () => v2("GET /api/v2/admin/requests/capabilities"), + staleTime: REQUESTS_STALE_TIME, + }); +} diff --git a/web/src/hooks/queries/keys.ts b/web/src/hooks/queries/keys.ts index fed7d137ee..ee61339c71 100644 --- a/web/src/hooks/queries/keys.ts +++ b/web/src/hooks/queries/keys.ts @@ -126,8 +126,7 @@ export const requestKeys = { all: ["requests"] as const, status: () => ["requests", "status"] as const, discovery: () => ["requests", "discovery"] as const, - discoverySection: (section: string, page: number) => - ["requests", "discovery", section, page] as const, + discoverySection: (section: string) => ["requests", "discovery", section] as const, discoverStudios: () => ["requests", "discover", "studios"] as const, discoverNetworks: () => ["requests", "discover", "networks"] as const, discoverGenres: () => ["requests", "discover", "genres"] as const, @@ -136,12 +135,17 @@ export const requestKeys = { slug: string, mediaType: string | undefined, sort: string, - page: number, - ) => ["requests", "discover", "browse", kind, slug, mediaType ?? "", sort, page] as const, + ) => ["requests", "discover", "browse", kind, slug, mediaType ?? "", sort] as const, search: (mediaType: string, query: string, page: number, viewerKey: string) => ["requests", "search", viewerKey, mediaType, query, page] as const, detail: (mediaType: string, tmdbID: number) => ["requests", "detail", mediaType, tmdbID] as const, mine: (params: Record) => ["requests", "mine", params] as const, + one: (id: string) => ["requests", "one", id] as const, + // Prefixes for refreshing every params variant at once. + discoverBrowseAll: () => ["requests", "discover", "browse"] as const, + detailAll: () => ["requests", "detail"] as const, + mineAll: () => ["requests", "mine"] as const, + searchAll: () => ["requests", "search"] as const, }; export const libraryCollectionKeys = { @@ -401,10 +405,39 @@ export const adminKeys = { networkAccessStatus: (provider: string) => ["admin", "networkAccess", "status", provider] as const, requestsRoot: () => ["admin", "requests"] as const, - requests: (params: Record) => ["admin", "requests", params] as const, + // The queue, its view counts, and a request's history sit under + // requestsRoot, so every request action refreshes them. + requestQueueRoot: () => ["admin", "requests", "queue"] as const, + requestQueue: (params: Record) => + ["admin", "requests", "queue", params] as const, + requestCounts: () => ["admin", "requests", "counts"] as const, + requestEvents: (id: string) => ["admin", "requests", "events", id] as const, requestSettings: () => ["admin", "requests", "settings"] as const, requestIntegrations: () => ["admin", "requests", "integrations"] as const, + // The route and option keys sit outside requestsRoot on purpose: every request, server, + // and settings write invalidates that root. Only adding or deleting a server + // changes a route (it can set or clear Everything else), and those writes + // refresh the routes themselves; 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, + // Under requestRoutes: switching it changes the routes (Everything else can + // be filled in) and what a preview answers. + requestRouting: () => ["admin", "requestRoutes", "mode"] as const, + // Under requestRoutes, so saving a rule refreshes an open preview. + requestRoutePreviewRoot: () => ["admin", "requestRoutes", "preview"] as const, + requestRoutePreview: (mediaType: string, tmdbId: number, requesterUserId?: number) => + ["admin", "requestRoutes", "preview", mediaType, tmdbId, requesterUserId ?? null] as const, + // Outside requestRoutes: a rule save changes nothing TMDB answers. + requestRouteTitles: (mediaType: string, q: string) => + ["admin", "requestRouteTitles", mediaType, q] 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, + // Scoped to the admin authority: the limit's validator names the profile + // that read it, so another profile's cached copy would fail its save. + requestGroupLimit: (groupId: number, scope: string) => + ["admin", "requests", "groups", groupId, "limit", scope] as const, recommendationsStatus: () => ["admin", "recommendationsStatus"] as const, inviteCodes: () => ["admin", "inviteCodes"] as const, invitations: () => ["admin", "invitations"] as const, diff --git a/web/src/hooks/queries/useRequests.test.tsx b/web/src/hooks/queries/useRequests.test.tsx index 458915c830..ef83727b49 100644 --- a/web/src/hooks/queries/useRequests.test.tsx +++ b/web/src/hooks/queries/useRequests.test.tsx @@ -1,11 +1,16 @@ 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 { setAccessToken, setProfileId } from "@/api/client"; +import { adminAuthorityScope, captureAdminAuthority } from "@/api/v2/adminAuthority"; +import { installPolicyStorageMocks } from "@/pages/admin-policy/policyTestUtils"; +import { adminKeys, requestKeys } from "./keys"; const mocks = vi.hoisted(() => ({ useQuery: vi.fn(), + useInfiniteQuery: vi.fn(), useCurrentProfile: vi.fn(), api: vi.fn(), })); @@ -16,6 +21,7 @@ vi.mock("@tanstack/react-query", async () => { return { ...actual, useQuery: (...args: unknown[]) => mocks.useQuery(...args), + useInfiniteQuery: (...args: unknown[]) => mocks.useInfiniteQuery(...args), }; }); @@ -27,7 +33,24 @@ vi.mock("@/api/v2/request", () => ({ v2: (...args: unknown[]) => mocks.api(...args), })); -import { useRequestFeatureStatus, useRequestSearch } from "./useRequests"; +import type { DiscoverBrowseResponse, RequestDiscoverySection } from "@/api/types"; +import { + nextBrowsePage, + nextDiscoverySectionPage, + useCancelMediaRequest, + useCreateMediaRequest, + useMyMediaRequests, + useRequestFeatureStatus, + useRequestMediaDetail, + useRequestSearch, + useToggleRequestFollow, +} from "./useRequests"; +import { + useAdminCancelMediaRequest, + useAdminRequestQueue, + useRequestGroupLimit, + useUpdateRequestGroupLimit, +} from "./admin/requests"; function render(node: ReactNode) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -167,6 +190,43 @@ describe("useRequestSearch", () => { }); }); +describe("infinite request browse paging", () => { + function sectionPage(page: number, totalPages: number, nextPage?: number) { + return { page, total_pages: totalPages, next_page: nextPage } as RequestDiscoverySection; + } + + it("numbers a Discover row's pages up to its total", () => { + const first = sectionPage(1, 3); + expect(nextDiscoverySectionPage(first, [first], 1)).toBe(2); + const last = sectionPage(3, 3); + expect(nextDiscoverySectionPage(last, [first, last], 3)).toBeUndefined(); + }); + + it("follows a rating-restricted row's cursor and ends where the cursor does", () => { + // Page 1 covers TMDB pages 1–3 and page 4 covers 4–8; page 9 reaches the end. + const first = sectionPage(1, 40, 4); + const second = sectionPage(4, 40, 9); + const last = sectionPage(9, 40); + expect(nextDiscoverySectionPage(first, [first], 1)).toBe(4); + expect(nextDiscoverySectionPage(second, [first, second], 4)).toBe(9); + // No cursor after cursor pages: the end, even though TMDB counts 40 pages. + expect(nextDiscoverySectionPage(last, [first, second, last], 9)).toBeUndefined(); + }); + + it("ignores a cursor that does not move forward", () => { + const page = sectionPage(5, 10, 5); + expect(nextDiscoverySectionPage(page, [page], 5)).toBeUndefined(); + }); + + it("stops at TMDB's 500-page cap whatever total it reports", () => { + const section = sectionPage(500, 900); + expect(nextDiscoverySectionPage(section, [section], 500)).toBeUndefined(); + const browse = { page: 500, total_pages: 1001 } as DiscoverBrowseResponse; + expect(nextBrowsePage(browse, [browse], 500)).toBeUndefined(); + expect(nextBrowsePage(browse, [browse], 499)).toBe(500); + }); +}); + describe("requestKeys.all invalidation", () => { it("invalidates entries under requestKeys.search() when invalidating requestKeys.all", async () => { const client = new QueryClient(); @@ -194,6 +254,169 @@ 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() }); + }); +}); + +describe("useAdminCancelMediaRequest", () => { + beforeEach(() => { + mocks.api.mockReset(); + }); + + it("refreshes the queue when the cancellation is refused", async () => { + // Another admin acted first; the obsolete row must not keep its action. + mocks.api.mockRejectedValue(new Error("This request has changed.")); + const client = new QueryClient(); + const invalidations = vi.spyOn(client, "invalidateQueries"); + const { result } = renderHook(() => useAdminCancelMediaRequest(), { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); + + await expect(result.current.mutateAsync({ id: "req-1" })).rejects.toThrow(); + + expect(invalidations).toHaveBeenCalledWith({ queryKey: adminKeys.requestsRoot() }); + expect(invalidations).toHaveBeenCalledWith({ queryKey: requestKeys.all }); + }); +}); + +describe("access group request limits", () => { + const wireLimit = { + group_id: "7", + limit_mode: "inherit", + max_requests: null, + window_days: null, + approval_mode: "inherit", + }; + + function CallGroupLimitHook() { + useRequestGroupLimit(7); + return null; + } + + function lastQueryOptions() { + return mocks.useQuery.mock.calls.at(-1)![0] as { + queryKey: readonly unknown[]; + queryFn: () => Promise; + }; + } + + beforeEach(() => { + mocks.useQuery.mockReset(); + mocks.api.mockReset(); + mocks.api.mockImplementation( + async (_route: string, options: { onResponse?: (r: Response) => void }) => { + options.onResponse?.(new Response(null, { headers: { ETag: '"limit-1"' } })); + return wireLimit; + }, + ); + installPolicyStorageMocks(); + setAccessToken("account"); + setProfileId("owner"); + }); + + it("keys the limit by profile, since its validator names the profile that read it", async () => { + render(); + const owner = lastQueryOptions(); + setProfileId("kid"); + render(); + const kid = lastQueryOptions(); + + expect(kid.queryKey).not.toEqual(owner.queryKey); + await owner.queryFn(); + expect(mocks.api).toHaveBeenLastCalledWith( + "GET /api/v2/admin/request-groups/{group_id}/limit", + expect.objectContaining({ profileContext: expect.objectContaining({ profileId: "owner" }) }), + ); + }); + + it("saves under the authority the limit was read with", async () => { + const owner = captureAdminAuthority(); + setProfileId("kid"); + const client = new QueryClient(); + const { result } = renderHook(() => useUpdateRequestGroupLimit(), { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); + + await result.current.mutateAsync({ + limit: { group_id: 7, etag: '"limit-0"' }, + body: { + limit_mode: "inherit", + approval_mode: "inherit", + max_requests: null, + window_days: null, + }, + profileContext: owner, + }); + + expect(mocks.api).toHaveBeenCalledWith( + "PUT /api/v2/admin/request-groups/{group_id}/limit", + expect.objectContaining({ profileContext: owner }), + ); + expect( + client.getQueryData(adminKeys.requestGroupLimit(7, adminAuthorityScope(owner))), + ).toMatchObject({ group_id: 7, etag: '"limit-1"' }); + }); +}); + function CallStatusHook() { useRequestFeatureStatus(); return null; @@ -207,3 +430,116 @@ it("reads request capabilities through v2", async () => { 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"] }); + }); +}); + +describe("polling while something downloads", () => { + const download = { phase: "downloading", downloads: 1, updated_at: "2026-01-02T03:04:05Z" }; + type RefetchInterval = (query: { state: { data: unknown } }) => number | false; + + function refetchInterval(hook: typeof mocks.useQuery): RefetchInterval { + return (hook.mock.calls.at(-1)![0] as { refetchInterval: RefetchInterval }).refetchInterval; + } + + beforeEach(() => { + mocks.useQuery.mockReset(); + mocks.useInfiniteQuery.mockReset(); + }); + + it("reads the viewer's requests every 30 seconds while one of them downloads", () => { + function CallMine() { + useMyMediaRequests({ limit: 100 }); + return null; + } + render(); + const interval = refetchInterval(mocks.useQuery); + + expect(interval({ state: { data: undefined } })).toBe(false); + expect(interval({ state: { data: [{ id: "a" }, { id: "b", targets: [{ id: 1 }] }] } })).toBe( + false, + ); + expect(interval({ state: { data: [{ id: "a" }, { id: "b", download }] } })).toBe(30_000); + expect(interval({ state: { data: [{ id: "a", targets: [{ id: 1, download }] }] } })).toBe( + 30_000, + ); + }); + + it("leaves the viewer's requests alone on a surface that opts out", () => { + function CallMine() { + useMyMediaRequests({ outcome: "active" }, { pollDownloads: false }); + return null; + } + render(); + const interval = refetchInterval(mocks.useQuery); + + expect(interval({ state: { data: [{ id: "a" }, { id: "b", download }] } })).toBe(false); + expect(interval({ state: { data: [{ id: "a", targets: [{ id: 1, download }] }] } })).toBe( + false, + ); + }); + + it("reads a title's request every 30 seconds while it downloads", () => { + function CallDetail() { + useRequestMediaDetail("movie", 949); + return null; + } + render(); + const interval = refetchInterval(mocks.useQuery); + + expect(interval({ state: { data: undefined } })).toBe(false); + expect(interval({ state: { data: { request: { requestable: false } } } })).toBe(false); + expect(interval({ state: { data: { request: { requestable: false, download } } } })).toBe( + 30_000, + ); + }); + + it("reads the admin queue every 30 seconds while a row on any loaded page downloads", () => { + function CallQueue() { + useAdminRequestQueue({ view: "in_progress" }); + return null; + } + render(); + const interval = refetchInterval(mocks.useInfiniteQuery); + const pages = (...items: unknown[][]) => ({ pages: items.map((rows) => ({ items: rows })) }); + + expect(interval({ state: { data: undefined } })).toBe(false); + expect(interval({ state: { data: pages([{ id: "a", targets: [] }]) } })).toBe(false); + expect( + interval({ + state: { data: pages([{ id: "a" }], [{ id: "b", targets: [{ id: 1, download }] }]) }, + }), + ).toBe(30_000); + }); +}); diff --git a/web/src/hooks/queries/useRequests.ts b/web/src/hooks/queries/useRequests.ts index 828f1813d7..cd600b19c2 100644 --- a/web/src/hooks/queries/useRequests.ts +++ b/web/src/hooks/queries/useRequests.ts @@ -1,30 +1,26 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, + type Query, +} from "@tanstack/react-query"; import { toast } from "sonner"; import { V2ProblemError } from "@/api/v2/request"; -import { - getAdminRequestSettingsV2, - putAdminRequestSettingsV2, - getAdminRequestUserLimitV2, - putAdminRequestUserLimitV2, - listAdminRequestIntegrationsV2, - saveAdminRequestIntegrationV2, - deleteAdminRequestIntegrationV2, - listAdminMediaRequestsV2, - approveAdminRequestV2, - declineAdminRequestV2, - retryAdminRequestV2, - loadAdminRequestIntegrationOptionsV2, -} from "@/api/v2/adminRequests"; import { v2 } from "@/api/v2/request"; import { browseDiscoverV2, + cancelMediaRequestV2, createMediaRequestV2, + followRequestMediaV2, getDiscoverSectionV2, getRequestMediaDetailV2, + unfollowRequestMediaV2, listDiscoverGenresV2, listDiscoverNetworksV2, listDiscoverSectionsV2, listDiscoverStudiosV2, + getMediaRequestV2, listMyMediaRequestsV2, searchRequestMediaV2, } from "@/api/v2/requests"; @@ -32,18 +28,36 @@ import { useCurrentProfile } from "@/hooks/useCurrentProfile"; import type { CreateMediaRequestInput, DiscoverBrowseKind, - LoadRequestIntegrationOptionsRequest, - RequestIntegration, + DiscoverBrowseResponse, + MediaRequest, + RequestDiscoverySection, RequestListParams, + RequestMediaPage, RequestSearchMediaType, RequestMediaType, - RequestUserLimit, } from "@/api/types"; +import { tmdbPageCount } from "@/lib/mediaRequests"; import { adminKeys, requestKeys } from "./keys"; -const REQUESTS_STALE_TIME = 30_000; +export const REQUESTS_STALE_TIME = 30_000; const DISCOVER_BRAND_STALE_TIME = 24 * 60 * 60 * 1000; const BROWSE_STALE_TIME = 60 * 1000; +/** How often a request surface is read again while something on it downloads. */ +export const REQUEST_DOWNLOAD_REFETCH_INTERVAL = 30_000; + +/** + * Polls while a loaded request, or one of its servers, reports download + * progress, and stops once none does. The server refreshes progress about + * once a minute. + */ +export function requestDownloadRefetchInterval( + requests: readonly Pick[] | undefined, +): number | false { + const downloading = requests?.some( + (request) => request.download || request.targets?.some((target) => target.download), + ); + return downloading ? REQUEST_DOWNLOAD_REFETCH_INTERVAL : false; +} function listParamsKey(params: RequestListParams) { return { @@ -54,11 +68,11 @@ function listParamsKey(params: RequestListParams) { }; } -function isValidationFailure(err: unknown): boolean { +export function isValidationFailure(err: unknown): boolean { return err instanceof V2ProblemError && err.problemType === "validation_failed"; } -function invalidateRequestSurfaces(queryClient: ReturnType) { +export function invalidateRequestSurfaces(queryClient: ReturnType) { // requestKeys.all = ["requests"], so invalidating it cascades to nested keys, // including requestKeys.search(...). Policy mutations rely on this to refresh // viewer-scoped search results when request eligibility changes. @@ -74,36 +88,74 @@ export function useRequestDiscovery() { }); } -export function useRequestFeatureStatus() { +export function useRequestFeatureStatus( + options: { enabled?: boolean; refetchOnMount?: boolean } = {}, +) { return useQuery({ queryKey: requestKeys.status(), queryFn: () => v2("GET /api/v2/requests/status"), staleTime: REQUESTS_STALE_TIME, + enabled: options.enabled ?? true, + ...(options.refetchOnMount !== undefined && { refetchOnMount: options.refetchOnMount }), }); } -export function useRequestDiscoverySection(section: string, page = 1) { - return useQuery({ - queryKey: requestKeys.discoverySection(section, page), - queryFn: () => getDiscoverSectionV2(section, page), +/** + * Every title of one Discover row, read page by page as the viewer scrolls. + * See nextDiscoverySectionPage for how the next page is chosen. + */ +export function useRequestDiscoverySection(section: string) { + return useInfiniteQuery({ + queryKey: requestKeys.discoverySection(section), + queryFn: ({ pageParam }) => getDiscoverSectionV2(section, pageParam), + initialPageParam: 1, + getNextPageParam: nextDiscoverySectionPage, enabled: section.trim().length > 0, staleTime: REQUESTS_STALE_TIME, }); } -export function useDiscoverStudios() { +/** + * The page after lastPage in a Discover row. For a rating-restricted profile + * the server may read several TMDB pages to fill one and answers with + * next_page; page + 1 would repeat titles. Once the row has answered with a + * cursor, a page without one is the last. Otherwise pages are numbered, up + * to TMDB's cap. + */ +export function nextDiscoverySectionPage( + lastPage: RequestDiscoverySection, + allPages: RequestDiscoverySection[], + lastPageParam: number, +): number | undefined { + if (lastPage.next_page && lastPage.next_page > lastPageParam) return lastPage.next_page; + if (allPages.some((page) => page.next_page)) return undefined; + return lastPageParam < tmdbPageCount(lastPage.total_pages) ? lastPageParam + 1 : undefined; +} + +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 } : {}), }); } @@ -120,24 +172,45 @@ export interface UseRequestBrowseArgs { slug: string; mediaType?: RequestMediaType; sort: "popularity" | "vote_average" | "release_date"; - page: number; } -export function useRequestBrowse({ kind, slug, mediaType, sort, page }: UseRequestBrowseArgs) { - return useQuery({ - queryKey: requestKeys.discoverBrowse(kind, slug, mediaType, sort, page), - queryFn: () => browseDiscoverV2({ kind, slug, mediaType, sort, page }), +/** A studio, network, or genre, read page by page as the viewer scrolls. */ +export function useRequestBrowse({ kind, slug, mediaType, sort }: UseRequestBrowseArgs) { + return useInfiniteQuery({ + queryKey: requestKeys.discoverBrowse(kind, slug, mediaType, sort), + queryFn: ({ pageParam }) => browseDiscoverV2({ kind, slug, mediaType, sort, page: pageParam }), + initialPageParam: 1, + getNextPageParam: nextBrowsePage, enabled: slug.trim().length > 0 && (kind !== "genre" || Boolean(mediaType)), staleTime: BROWSE_STALE_TIME, }); } -export function useRequestMediaDetail(mediaType: RequestMediaType, tmdbID: number) { +/** + * The page after lastPage of a studio, network, or genre. Browse pages read + * TMDB one page at a time (the server does no rating backfill here), so + * page + 1 is the next page, up to TMDB's cap. + */ +export function nextBrowsePage( + lastPage: DiscoverBrowseResponse, + _allPages: DiscoverBrowseResponse[], + lastPageParam: number, +): number | undefined { + return lastPageParam < tmdbPageCount(lastPage.total_pages) ? lastPageParam + 1 : undefined; +} + +export function useRequestMediaDetail( + mediaType: RequestMediaType, + tmdbID: number, + options: { enabled?: boolean } = {}, +) { return useQuery({ queryKey: requestKeys.detail(mediaType, tmdbID), queryFn: () => getRequestMediaDetailV2(mediaType, tmdbID), - enabled: tmdbID > 0, + enabled: tmdbID > 0 && (options.enabled ?? true), staleTime: REQUESTS_STALE_TIME, + refetchInterval: (query) => + query.state.data?.request.download ? REQUEST_DOWNLOAD_REFETCH_INTERVAL : false, }); } @@ -152,6 +225,11 @@ export interface UseRequestSearchOptions { gcTime?: number; /** Retry policy; interactive search surfaces should not replay expensive failures. */ retry?: boolean | number; + /** + * Keeps showing the previous page's results while another page of the same + * search loads, so a paged grid does not collapse between pages. + */ + keepPreviousPage?: boolean; } export function useRequestSearch( @@ -170,17 +248,31 @@ export function useRequestSearch( const enabledOverride = options.enabled ?? true; const requireProfile = options.requireProfile ?? false; - return useQuery({ - queryKey: requestKeys.search(mediaType, normalizedQuery, page, viewerKey), + const queryKey = requestKeys.search(mediaType, normalizedQuery, page, viewerKey); + return useQuery({ + queryKey, queryFn: ({ signal }) => searchRequestMediaV2(mediaType, normalizedQuery, page, signal), enabled: enabledOverride && normalizedQuery.length > 1 && (!requireProfile || Boolean(profile?.id)), staleTime: options.staleTime ?? REQUESTS_STALE_TIME, ...(options.gcTime !== undefined ? { gcTime: options.gcTime } : {}), ...(options.retry !== undefined ? { retry: options.retry } : {}), + // Only another page of the same search: a new query or type must not show + // the old one's titles. + placeholderData: options.keepPreviousPage + ? (previous: RequestMediaPage | undefined, previousQuery?: Query) => + previousQuery && sameSearchOtherPage(previousQuery.queryKey, queryKey) + ? previous + : undefined + : undefined, }); } +function sameSearchOtherPage(previous: readonly unknown[], next: readonly unknown[]): boolean { + // requestKeys.search: ["requests", "search", viewerKey, mediaType, query, page] + return previous.slice(0, 5).every((part, index) => part === next[index]); +} + export function useCreateMediaRequest() { const queryClient = useQueryClient(); return useMutation({ @@ -196,193 +288,76 @@ export function useCreateMediaRequest() { }); } -export function useMyMediaRequests(params: RequestListParams = {}) { - const key = listParamsKey(params); - return useQuery({ - queryKey: requestKeys.mine(key), - queryFn: () => listMyMediaRequestsV2(params), - staleTime: REQUESTS_STALE_TIME, - }); -} - -export function useAdminMediaRequests(params: RequestListParams = {}) { - const key = listParamsKey(params); - return useQuery({ - queryKey: adminKeys.requests(key), - queryFn: () => listAdminMediaRequestsV2(params), - staleTime: 10_000, - }); -} - -export function useApproveMediaRequest() { - const queryClient = useQueryClient(); - return useMutation({ - retry: false, - mutationFn: (id: string) => approveAdminRequestV2(id), - onSuccess: () => { - toast.success("Request approved"); - invalidateRequestSurfaces(queryClient); - }, - onError: (err) => { - toast.error(err instanceof Error ? err.message : "Failed to approve request"); - }, - }); -} - -export function useDeclineMediaRequest() { - const queryClient = useQueryClient(); - return useMutation({ - retry: false, - mutationFn: ({ id, reason }: { id: string; reason?: string }) => - declineAdminRequestV2(id, reason), - onSuccess: () => { - toast.success("Request declined"); - invalidateRequestSurfaces(queryClient); - }, - onError: (err) => { - toast.error(err instanceof Error ? err.message : "Failed to decline request"); - }, - }); -} - -export function useRetryMediaRequest() { - const queryClient = useQueryClient(); - return useMutation({ - retry: false, - mutationFn: (id: string) => retryAdminRequestV2(id), - onSuccess: () => { - toast.success("Request queued for retry"); - invalidateRequestSurfaces(queryClient); - }, - onError: (err) => { - toast.error(err instanceof Error ? err.message : "Failed to retry request"); - }, - }); -} - -export function useRequestSettings() { - return useQuery({ - queryKey: adminKeys.requestSettings(), - queryFn: getAdminRequestSettingsV2, - staleTime: REQUESTS_STALE_TIME, - }); -} - -export function useUpdateRequestSettings() { - const queryClient = useQueryClient(); - return useMutation({ - retry: false, - mutationFn: putAdminRequestSettingsV2, - onSuccess: () => { - toast.success("Request settings saved"); - queryClient.invalidateQueries({ queryKey: adminKeys.requestSettings() }); - queryClient.invalidateQueries({ queryKey: requestKeys.status() }); - invalidateRequestSurfaces(queryClient); - }, - onError: (err) => { - toast.error(err instanceof Error ? err.message : "Failed to save request settings"); - }, - }); -} - -export function useRequestIntegrations() { - return useQuery({ - queryKey: adminKeys.requestIntegrations(), - queryFn: listAdminRequestIntegrationsV2, - staleTime: REQUESTS_STALE_TIME, - }); -} - -export function useCreateRequestIntegration() { +/** Follows or unfollows a title someone else has already requested. */ +export function useToggleRequestFollow() { const queryClient = useQueryClient(); return useMutation({ retry: false, - mutationFn: (integration: RequestIntegration) => - saveAdminRequestIntegrationV2(integration, true), - onSuccess: () => { - toast.success("Integration created"); - queryClient.invalidateQueries({ queryKey: adminKeys.requestIntegrations() }); + 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) => { - if (isValidationFailure(err)) return; - toast.error(err instanceof Error ? err.message : "Failed to create integration"); + toast.error(err instanceof Error ? err.message : "Failed to update notification"); }, }); } -export function useUpdateRequestIntegration() { +export function useCancelMediaRequest() { const queryClient = useQueryClient(); return useMutation({ retry: false, - mutationFn: (integration: RequestIntegration) => saveAdminRequestIntegrationV2(integration), + mutationFn: (id: string) => cancelMediaRequestV2(id), onSuccess: () => { - toast.success("Integration saved"); - queryClient.invalidateQueries({ queryKey: adminKeys.requestIntegrations() }); - invalidateRequestSurfaces(queryClient); + toast.success("Request cancelled"); }, + // A refused action still refreshes the queue: another admin may have + // acted first, and the row should show what happened. + onSettled: () => invalidateRequestSurfaces(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 cancel request"); }, }); } -export function useDeleteRequestIntegration() { - const queryClient = useQueryClient(); - return useMutation({ - retry: false, - mutationFn: deleteAdminRequestIntegrationV2, - onSuccess: () => { - toast.success("Integration deleted"); - queryClient.invalidateQueries({ queryKey: adminKeys.requestIntegrations() }); - invalidateRequestSurfaces(queryClient); - }, - onError: (err) => { - toast.error(err instanceof Error ? err.message : "Failed to delete integration"); - }, - }); -} - -export function useLoadRequestIntegrationOptions() { - return useMutation({ - retry: false, - mutationFn: ({ id, body }: { id: string; body: LoadRequestIntegrationOptionsRequest }) => - loadAdminRequestIntegrationOptionsV2(id, body), - // Silent background probe: callers surface load failures inline (no toast). - }); -} - -export function useRequestUserLimit(userId?: number) { +/** + * The viewer's own requests. A surface that shows no download progress passes + * pollDownloads: false so a download does not make it read the list again. + */ +/** One request by ID; the viewer's account's own, or any for an admin. */ +export function useMediaRequest(id: string | undefined, options: { enabled?: boolean } = {}) { return useQuery({ - queryKey: adminKeys.requestUserLimit(userId ?? 0), - queryFn: () => getAdminRequestUserLimitV2(userId!), - enabled: Boolean(userId && userId > 0), + queryKey: requestKeys.one(id ?? ""), + queryFn: () => getMediaRequestV2(id as string), + enabled: Boolean(id) && (options.enabled ?? true), staleTime: REQUESTS_STALE_TIME, }); } -export function useUpdateRequestUserLimit() { - const queryClient = useQueryClient(); - return useMutation({ - retry: false, - mutationFn: ({ userId, body }: { userId: number; body: RequestUserLimit }) => - putAdminRequestUserLimitV2(userId, body), - onSuccess: (_data, variables) => { - toast.success("User request limit saved"); - queryClient.invalidateQueries({ queryKey: adminKeys.requestUserLimit(variables.userId) }); - invalidateRequestSurfaces(queryClient); - }, - onError: (err) => { - toast.error(err instanceof Error ? err.message : "Failed to save user limit"); - }, - }); -} - -export function useAdminRequestCapabilities() { +export function useMyMediaRequests( + params: RequestListParams = {}, + options: { enabled?: boolean; pollDownloads?: boolean } = {}, +) { + const key = listParamsKey(params); + const pollDownloads = options.pollDownloads ?? true; return useQuery({ - queryKey: [...adminKeys.requestsRoot(), "capabilities"], - queryFn: () => v2("GET /api/v2/admin/requests/capabilities"), + queryKey: requestKeys.mine(key), + queryFn: () => listMyMediaRequestsV2(params), + enabled: options.enabled ?? true, staleTime: REQUESTS_STALE_TIME, + refetchInterval: (query) => + pollDownloads ? requestDownloadRefetchInterval(query.state.data) : false, }); } diff --git a/web/src/hooks/useCanRequest.test.tsx b/web/src/hooks/useCanRequest.test.tsx index d5ece562a8..d8cb0f2b75 100644 --- a/web/src/hooks/useCanRequest.test.tsx +++ b/web/src/hooks/useCanRequest.test.tsx @@ -9,14 +9,14 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("@/hooks/queries/useRequests", () => ({ - useRequestFeatureStatus: () => mocks.useRequestFeatureStatus(), + useRequestFeatureStatus: (...args: unknown[]) => mocks.useRequestFeatureStatus(...args), })); vi.mock("@/hooks/useCurrentProfile", () => ({ useCurrentProfile: () => mocks.useCurrentProfile(), })); -import { useCanRequest } from "./useCanRequest"; +import { useCanRequest, useMissingSeasonsRequestable } from "./useCanRequest"; function CaptureHook({ onResult }: { onResult: (r: ReturnType) => void }) { const result = useCanRequest(); @@ -119,3 +119,49 @@ describe("useCanRequest", () => { }); }); }); + +describe("useMissingSeasonsRequestable", () => { + function Capture({ enabled, onResult }: { enabled: boolean; onResult: (r: boolean) => void }) { + onResult(useMissingSeasonsRequestable(enabled)); + return null; + } + + function capture(enabled: boolean): boolean | null { + let captured: boolean | null = null; + render( + { + captured = r; + }} + />, + ); + return captured; + } + + it("needs requests on, the viewer allowed, and a library-only setup", () => { + const status = { requests_enabled: true, allowed: true, missing_seasons_requestable: true }; + mocks.useRequestFeatureStatus.mockReturnValue({ data: status }); + expect(capture(true)).toBe(true); + expect(mocks.useRequestFeatureStatus).toHaveBeenLastCalledWith({ + enabled: true, + refetchOnMount: false, + }); + + mocks.useRequestFeatureStatus.mockReturnValue({ + data: { ...status, missing_seasons_requestable: false }, + }); + expect(capture(true)).toBe(false); + mocks.useRequestFeatureStatus.mockReturnValue({ data: { ...status, allowed: false } }); + expect(capture(true)).toBe(false); + }); + + it("stays off, without reading the status, when disabled", () => { + mocks.useRequestFeatureStatus.mockReturnValue({ data: undefined }); + expect(capture(false)).toBe(false); + expect(mocks.useRequestFeatureStatus).toHaveBeenLastCalledWith({ + enabled: false, + refetchOnMount: false, + }); + }); +}); diff --git a/web/src/hooks/useCanRequest.ts b/web/src/hooks/useCanRequest.ts index a26e4ea093..dad25c2bcb 100644 --- a/web/src/hooks/useCanRequest.ts +++ b/web/src/hooks/useCanRequest.ts @@ -12,6 +12,21 @@ export interface CanRequestState { submitDisabledReason: string | null; } +/** + * Whether the viewer can request the seasons a series in the library is + * missing. The server allows it only while no download server takes series, + * since router plugins cannot receive seasons yet. + */ +export function useMissingSeasonsRequestable(enabled: boolean): boolean { + // The shell's sidebar keeps the status fresh; opening a series page reads + // what it has rather than fetching again. + const status = useRequestFeatureStatus({ enabled, refetchOnMount: false }); + const data = status.data; + return ( + enabled && Boolean(data?.requests_enabled && data.allowed && data.missing_seasons_requestable) + ); +} + export function useCanRequest(): CanRequestState { const status = useRequestFeatureStatus(); const { profile } = useCurrentProfile(); diff --git a/web/src/hooks/useSubmitMediaRequest.test.tsx b/web/src/hooks/useSubmitMediaRequest.test.tsx new file mode 100644 index 0000000000..784c1a2584 --- /dev/null +++ b/web/src/hooks/useSubmitMediaRequest.test.tsx @@ -0,0 +1,86 @@ +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { RequestMediaResult } from "@/api/types"; + +const mocks = vi.hoisted(() => ({ + create: vi.fn(), + toastError: vi.fn(), + toastSuccess: vi.fn(), +})); + +vi.mock("@/api/v2/requests", async (importOriginal) => ({ + ...(await importOriginal()), + createMediaRequestV2: (...args: unknown[]) => mocks.create(...args), +})); +vi.mock("sonner", () => ({ + toast: { error: mocks.toastError, success: mocks.toastSuccess }, +})); + +import { useSubmitMediaRequest } from "./useSubmitMediaRequest"; + +function result(tmdbID: number, title: string): RequestMediaResult { + return { + media_type: "movie", + tmdb_id: tmdbID, + title, + availability: "missing", + request: { requestable: true }, + }; +} + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); + return {children}; +} + +describe("useSubmitMediaRequest", () => { + beforeEach(() => { + mocks.create.mockReset(); + mocks.toastError.mockReset(); + mocks.toastSuccess.mockReset(); + }); + + it("clears each card's pending state when overlapping requests both fail", async () => { + const rejects: Array<(error: Error) => void> = []; + mocks.create.mockImplementation(() => new Promise((_resolve, reject) => rejects.push(reject))); + const heat = result(1, "Heat"); + const ronin = result(2, "Ronin"); + const { result: hook } = renderHook(() => useSubmitMediaRequest(), { wrapper }); + + act(() => { + hook.current.submit(heat); + hook.current.submit(ronin); + }); + await waitFor(() => expect(rejects).toHaveLength(2)); + expect(hook.current.isSubmitting(heat)).toBe(true); + expect(hook.current.isSubmitting(ronin)).toBe(true); + + // The first call fails while the second is still in flight. + await act(async () => rejects[0]!(new Error("quota reached"))); + await waitFor(() => expect(hook.current.isSubmitting(heat)).toBe(false)); + expect(hook.current.isSubmitting(ronin)).toBe(true); + + await act(async () => rejects[1]!(new Error("quota reached"))); + await waitFor(() => expect(hook.current.isSubmitting(ronin)).toBe(false)); + + // The shared mutation still reports every failure. + expect(mocks.toastError).toHaveBeenCalledTimes(2); + }); + + it("clears a card once its request succeeds", async () => { + mocks.create.mockResolvedValue({ id: "r1" }); + const heat = result(1, "Heat"); + const { result: hook } = renderHook(() => useSubmitMediaRequest(), { wrapper }); + + act(() => hook.current.submit(heat)); + expect(hook.current.isSubmitting(heat)).toBe(true); + + await waitFor(() => expect(hook.current.isSubmitting(heat)).toBe(false)); + expect(mocks.create).toHaveBeenCalledWith( + expect.objectContaining({ media_type: "movie", tmdb_id: 1, title: "Heat" }), + ); + expect(mocks.toastSuccess).toHaveBeenCalledOnce(); + }); +}); diff --git a/web/src/hooks/useSubmitMediaRequest.ts b/web/src/hooks/useSubmitMediaRequest.ts new file mode 100644 index 0000000000..d53c4454a7 --- /dev/null +++ b/web/src/hooks/useSubmitMediaRequest.ts @@ -0,0 +1,45 @@ +import { useCallback, useState } from "react"; +import type { RequestMediaResult } from "@/api/types"; +import { useCreateMediaRequest } from "@/hooks/queries/useRequests"; +import { requestInputFromMediaResult } from "@/lib/mediaRequests"; + +function resultKey(item: Pick): string { + return `${item.media_type}-${item.tmdb_id}`; +} + +/** + * Requests TMDB results from a list of cards and tracks which cards have a + * request in flight. Each card waits on its own call: a second `mutate()` + * would detach the first call's callbacks, leaving that card on "Sending". + * The shared mutation still shows the success and failure toasts. + */ +export function useSubmitMediaRequest() { + const { mutateAsync } = useCreateMediaRequest(); + const [pendingKeys, setPendingKeys] = useState>(() => new Set()); + + const submit = useCallback( + (item: RequestMediaResult) => { + const key = resultKey(item); + setPendingKeys((prev) => new Set(prev).add(key)); + mutateAsync(requestInputFromMediaResult(item)) + // The mutation's own onError already reported the failure. + .catch(() => {}) + .finally(() => { + setPendingKeys((prev) => { + if (!prev.has(key)) return prev; + const next = new Set(prev); + next.delete(key); + return next; + }); + }); + }, + [mutateAsync], + ); + + const isSubmitting = useCallback( + (item: RequestMediaResult) => pendingKeys.has(resultKey(item)), + [pendingKeys], + ); + + return { submit, isSubmitting }; +} 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..0abd2fac89 100644 --- a/web/src/lib/adminNavigation.ts +++ b/web/src/lib/adminNavigation.ts @@ -116,8 +116,11 @@ 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.", + // Servers, routing, and the request limit itself live on the Requests + // settings page, which the command palette lists with its own keywords. + // A group's or an account's own limit lives with the group or account. + keywords: ["requested media", "approvals", "request queue", "decline"], icon: Send, href: "/admin/requests", }, @@ -168,8 +171,17 @@ export const ADMIN_NAV_SECTIONS: AdminNavGroup[] = [ }, { label: "Access Groups", - description: "Shared access defaults: libraries, downloads, streams, permissions.", - keywords: ["groups", "roles", "permissions", "library access", "downloads", "limits"], + description: + "Shared access defaults: libraries, downloads, streams, requests, permissions.", + keywords: [ + "groups", + "roles", + "permissions", + "library access", + "downloads", + "limits", + "request limits", + ], icon: UsersRound, href: "/admin/access-groups", }, diff --git a/web/src/lib/adminSettingsSearch.ts b/web/src/lib/adminSettingsSearch.ts index 8b89cfb36b..831229ceea 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, @@ -352,6 +353,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 version 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/mediaRequests.test.ts b/web/src/lib/mediaRequests.test.ts new file mode 100644 index 0000000000..b173b40796 --- /dev/null +++ b/web/src/lib/mediaRequests.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from "vitest"; +import type { MediaRequest, RequestMediaResult, RequestMediaSeason } from "@/api/types"; +import { + canCancelOwnRequest, + defaultRequestSeasons, + flattenResultPages, + pendingPageSize, + formatRequestDisplayState, + formatRequestSeasonMeta, + formatSeasonList, + formatSeasonProgress, + parseRequestMediaType, + requestDetailHref, + requestDiscoverSectionHref, + requestDisplayState, + requestSearchHref, + requestSearchTypeForScope, +} from "./mediaRequests"; + +describe("flattenResultPages", () => { + it("keeps page order and drops a title an earlier page already showed", () => { + const title = (tmdbID: number, mediaType: "movie" | "series" = "movie") => + ({ media_type: mediaType, tmdb_id: tmdbID }) as RequestMediaResult; + const pages = [ + { results: [title(1), title(2)] }, + { results: [title(2), title(3), title(1, "series")] }, + ]; + + expect(flattenResultPages(pages).map((item) => `${item.media_type}-${item.tmdb_id}`)).toEqual([ + "movie-1", + "movie-2", + "movie-3", + "series-1", + ]); + expect(flattenResultPages(undefined)).toEqual([]); + }); +}); + +describe("pendingPageSize", () => { + it("expects a page the size of the last one, or TMDB's page size before any", () => { + const results = (count: number) => Array.from({ length: count }) as RequestMediaResult[]; + expect(pendingPageSize([{ results: results(20) }, { results: results(17) }])).toBe(17); + expect(pendingPageSize([{ results: [] }])).toBe(20); + expect(pendingPageSize(undefined)).toBe(20); + }); +}); + +describe("requestSearchHref", () => { + it("opens the app's search page on movies and series", () => { + expect(requestSearchHref(" the bear ")).toBe("/catalog?source=query&q=the+bear&type=video"); + }); + + it("keeps a movie or series type and drops anything else", () => { + expect(requestSearchHref("dune", "movie")).toBe("/catalog?source=query&q=dune&type=movie"); + expect(requestSearchHref("dune", "series")).toBe("/catalog?source=query&q=dune&type=series"); + expect(requestSearchHref("dune", "all")).toBe("/catalog?source=query&q=dune&type=video"); + }); +}); + +describe("requestSearchTypeForScope", () => { + it("searches TMDB for what the catalog scope holds", () => { + expect(requestSearchTypeForScope(undefined)).toBe("all"); + expect(requestSearchTypeForScope("video")).toBe("all"); + expect(requestSearchTypeForScope("movie")).toBe("movie"); + expect(requestSearchTypeForScope("series")).toBe("series"); + expect(requestSearchTypeForScope("episode")).toBe("series"); + }); + + it("skips TMDB for books", () => { + expect(requestSearchTypeForScope("audiobook")).toBeNull(); + expect(requestSearchTypeForScope("ebook")).toBeNull(); + expect(requestSearchTypeForScope("manga")).toBeNull(); + }); +}); + +describe("requestDiscoverSectionHref", () => { + it("builds a Discover row's page", () => { + expect(requestDiscoverSectionHref("trending_movies")).toBe( + "/requests/discover/trending_movies", + ); + }); +}); + +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("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 title detail route", () => { + expect(requestDetailHref("movie", 603)).toBe("/title/movie/603"); + expect(requestDetailHref("series", 1399)).toBe("/title/series/1399"); + }); +}); + +describe("parseRequestMediaType", () => { + it("accepts the two title media types", () => { + expect(parseRequestMediaType("movie")).toBe("movie"); + expect(parseRequestMediaType("series")).toBe("series"); + }); + + it.each([undefined, "", "tv", "Movie", "browse"])("rejects %j", (value) => { + expect(parseRequestMediaType(value)).toBeUndefined(); + }); +}); + +describe("season requests", () => { + const now = new Date(Date.UTC(2026, 4, 24, 12)); + const season = (overrides: Partial): RequestMediaSeason => ({ + season_number: 1, + episode_count: 10, + air_date: "2022-01-01", + availability: "missing", + requested: false, + ...overrides, + }); + + it("names seasons compactly", () => { + expect(formatSeasonList([2])).toBe("Season 2"); + expect(formatSeasonList([5, 1, 2, 3, 3])).toBe("Seasons 1–3, 5"); + }); + + it("picks the aired seasons the library lacks and nobody requested", () => { + const seasons = [ + season({ season_number: 1, availability: "available" }), + season({ season_number: 2, availability: "partial" }), + season({ season_number: 3, requested: true }), + season({ season_number: 4 }), + season({ season_number: 5, air_date: "2026-05-24" }), + season({ season_number: 6, air_date: "2026-09-01" }), + season({ season_number: 7, air_date: undefined, episode_count: 0 }), + ]; + expect(defaultRequestSeasons(seasons, now)).toEqual([2, 4, 5]); + }); + + it("describes a season and a request's progress", () => { + expect(formatRequestSeasonMeta(season({ episode_count: 1 }))).toBe("2022 · 1 episode"); + expect(formatRequestSeasonMeta(season({ air_date: undefined, episode_count: 0 }))).toBe( + "Not announced", + ); + expect( + formatSeasonProgress([ + { season_number: 1, episodes_aired: 9, episodes_available: 9 }, + { season_number: 2, episodes_aired: 10, episodes_available: 4 }, + ]), + ).toBe("13 of 19 episodes in the library"); + expect( + formatSeasonProgress([{ season_number: 1, episodes_aired: 0, episodes_available: 3 }]), + ).toBe(""); + expect(formatRequestDisplayState("partially_available")).toBe("Partially available"); + }); +}); diff --git a/web/src/lib/mediaRequests.ts b/web/src/lib/mediaRequests.ts index 58fe29c111..8c08a2fda2 100644 --- a/web/src/lib/mediaRequests.ts +++ b/web/src/lib/mediaRequests.ts @@ -4,29 +4,14 @@ import type { MediaRequestOutcome, MediaRequestStatus, RequestMediaResult, + RequestMediaSeason, + RequestSeasonProgress, RequestMediaType, + RequestSearchMediaType, + RequestUserState, } from "@/api/types"; import { formatDate } from "@/lib/datetime"; -export const REQUEST_STATUSES: Array = [ - "all", - "pending", - "approved", - "queued", - "downloading", - "completed", -]; - -export const REQUEST_OUTCOMES: Array = [ - "all", - "active", - "declined", - "cancelled", - "failed", -]; - -type BadgeVariant = "default" | "secondary" | "destructive" | "outline"; - export function formatMediaType(mediaType: RequestMediaType): string { return mediaType === "series" ? "Series" : "Movie"; } @@ -48,17 +33,6 @@ export function formatRequestStatus(status?: MediaRequestStatus): string { } } -export function requestStatusBadgeVariant(status?: MediaRequestStatus): BadgeVariant { - switch (status) { - case "completed": - return "default"; - case "pending": - return "outline"; - default: - return "secondary"; - } -} - export function formatRequestOutcome(outcome?: MediaRequestOutcome): string { switch (outcome) { case "active": @@ -74,17 +48,179 @@ export function formatRequestOutcome(outcome?: MediaRequestOutcome): string { } } -export function requestOutcomeBadgeVariant(outcome?: MediaRequestOutcome): BadgeVariant { - switch (outcome) { - case "failed": - case "declined": - case "cancelled": - return "destructive"; - case "active": - return "secondary"; +/** + * The request states the user-facing request pages show. Admin views keep the + * 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 = 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": + 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 "partially_available": + return "Partially available"; + case "available": + return "Available"; + default: + return formatRequestOutcome(state); + } +} + +/** + * Mirrors the server's rule for withdrawing a request, which decline and + * cancel share: 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.) + */ +export function canWithdrawRequest( + request: Pick, +): boolean { + if (request.outcome !== "active") return false; + if (request.status === "pending") return true; + return request.status === "approved" && (request.targets?.length ?? 0) === 0; +} + +/** + * Whether an owner can cancel their request: the withdrawal rule above. + * Callers must already know the viewer owns the request. + */ +export function canCancelOwnRequest( + request: Pick, +): boolean { + return canWithdrawRequest(request); +} + +/** + * The detail page of a TMDB title. It serves titles outside the library; one + * the viewer can open in the library redirects to its item page. + */ +export function requestDetailHref(mediaType: RequestMediaType, tmdbID: number): string { + return `/title/${mediaType}/${tmdbID}`; +} + +/** The media type a title URL names, or undefined for any other segment. */ +export function parseRequestMediaType(value: string | undefined): RequestMediaType | undefined { + return value === "movie" || value === "series" ? value : undefined; +} + +/** + * The app's search page, scoped to what can be requested: movies and series, + * or just one of them. Its "Request to add" section lists the TMDB matches. + */ +export function requestSearchHref(query: string, mediaType?: string | null): string { + const params = new URLSearchParams({ + source: "query", + q: query.trim(), + type: parseRequestMediaType(mediaType ?? undefined) ?? "video", + }); + return `/catalog?${params.toString()}`; +} + +/** + * The TMDB search type for a catalog search scope, or null when the scope + * holds nothing TMDB can supply (audiobooks, ebooks, manga). + */ +export function requestSearchTypeForScope( + scope: string | undefined, +): RequestSearchMediaType | null { + switch (scope) { + case undefined: + case "video": + return "all"; + case "movie": + return "movie"; + case "series": + case "episode": + return "series"; default: - return "outline"; + return null; + } +} + +/** TMDB serves at most 500 pages of any list, whatever total it reports. */ +export const TMDB_MAX_PAGE = 500; + +/** The number of pages a TMDB list can actually be read to. */ +export function tmdbPageCount(totalPages: number | undefined): number { + return Math.min(Math.max(totalPages ?? 0, 0), TMDB_MAX_PAGE); +} + +/** TMDB's page size, the placeholder count before any page has loaded. */ +const TMDB_PAGE_SIZE = 20; + +/** + * How many placeholder cards stand in for the page being loaded: as many as + * the last page held, so the grid barely moves when the titles arrive. + */ +export function pendingPageSize( + pages: readonly { results: RequestMediaResult[] }[] | undefined, +): number { + return pages?.at(-1)?.results.length || TMDB_PAGE_SIZE; +} + +/** + * The titles of a list read page by page, in order. A TMDB list can shift + * between page reads (popularity changes), so a title already shown on an + * earlier page is dropped. + */ +export function flattenResultPages( + pages: readonly { results: RequestMediaResult[] }[] | undefined, +): RequestMediaResult[] { + const seen = new Set(); + const out: RequestMediaResult[] = []; + for (const page of pages ?? []) { + for (const item of page.results) { + const key = `${item.media_type}-${item.tmdb_id}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(item); + } } + return out; +} + +/** The page listing every title of a Discover row, such as Trending Movies. */ +export function requestDiscoverSectionHref(sectionKey: string): string { + return `/requests/discover/${encodeURIComponent(sectionKey)}`; +} + +/** 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 { @@ -104,6 +240,75 @@ export function formatRequestReason(reason?: string): string { } } +/** Names requested seasons compactly: "Season 2", "Seasons 1–3, 5". */ +export function formatSeasonList(seasons: number[]): string { + const sorted = [...new Set(seasons)].sort((a, b) => a - b); + const runs: string[] = []; + for (let i = 0; i < sorted.length; ) { + let j = i; + while (j + 1 < sorted.length && sorted[j + 1] === sorted[j]! + 1) j++; + runs.push(j > i ? `${sorted[i]}–${sorted[j]}` : String(sorted[i])); + i = j + 1; + } + return `${sorted.length === 1 ? "Season" : "Seasons"} ${runs.join(", ")}`; +} + +/** + * A season has started airing, by TMDB's dates, compared with today's UTC + * date as the server compares them. + */ +export function seasonHasAired(season: RequestMediaSeason, now = new Date()): boolean { + return ( + season.episode_count > 0 && + Boolean(season.air_date) && + season.air_date! <= now.toISOString().slice(0, 10) + ); +} + +/** A season a new request can still ask for: not complete in the library, not already asked for. */ +export function seasonRequestable(season: RequestMediaSeason): boolean { + return season.availability !== "available" && !season.requested; +} + +/** + * The seasons a series request asks for unless the viewer changes them: every + * aired season not yet in the library, as the server picks when given none. + */ +export function defaultRequestSeasons(seasons: RequestMediaSeason[], now = new Date()): number[] { + return seasons + .filter((season) => seasonRequestable(season) && seasonHasAired(season, now)) + .map((season) => season.season_number); +} + +/** "2022 · 9 episodes", or "Not announced" before TMDB dates or fills the season. */ +export function formatRequestSeasonMeta(season: RequestMediaSeason): string { + const parts: string[] = []; + if (season.air_date) parts.push(season.air_date.slice(0, 4)); + if (season.episode_count > 0) { + parts.push(`${season.episode_count} ${season.episode_count === 1 ? "episode" : "episodes"}`); + } + return parts.join(" · ") || "Not announced"; +} + +/** + * "14 of 20 episodes in the library", counting the aired episodes of the + * requested seasons; "" when none has aired by the library's dates. + */ +export function formatSeasonProgress(progress: RequestSeasonProgress[]): string { + let aired = 0; + let have = 0; + for (const season of progress) { + aired += season.episodes_aired; + have += Math.min(season.episodes_available, season.episodes_aired); + } + if (aired === 0) return ""; + return `${have} of ${aired} ${aired === 1 ? "episode" : "episodes"} in the library`; +} + +export function formatRequestDate(request: Pick): string { + return formatDate(request.created_at, "medium"); +} + export function tmdbImageURL(path?: string, size = "w342"): string | null { if (!path) return null; return `https://image.tmdb.org/t/p/${size}${path}`; @@ -120,7 +325,3 @@ export function requestInputFromMediaResult(item: RequestMediaResult): CreateMed backdrop_path: item.backdrop_path || undefined, }; } - -export function formatRequestDate(request: Pick): string { - return formatDate(request.created_at, "medium"); -} diff --git a/web/src/lib/requestAccess.test.ts b/web/src/lib/requestAccess.test.ts new file mode 100644 index 0000000000..f335f42fc2 --- /dev/null +++ b/web/src/lib/requestAccess.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from "vitest"; + +import { + clearLegacyRequestBlock, + describeInheritedValue, + describeRequestPolicy, + effectiveRequestPolicy, + formatRequestQuota, + isZeroRequestLimit, + requestGroupLabel, + requestGroupLimitSummary, + requestLimitBody, + requestLimitChanges, + requestLimitDraft, + requestLimitErrors, + resolveRequestTerms, + type RequestAccessInput, + type RequestLimitLayer, +} from "./requestAccess"; + +const SERVER = { + requests_enabled: true, + global_max_requests: 12, + global_window_days: 14, + global_auto_approval_enabled: true, +}; +const INHERIT: RequestLimitLayer = { limit_mode: "inherit", approval_mode: "inherit" }; +const KIDS: RequestLimitLayer = { + limit_mode: "custom", + max_requests: 5, + window_days: 7, + approval_mode: "manual", +}; + +function input(overrides: Partial = {}): RequestAccessInput { + return { + role: "user", + requestsAllowed: true, + requestsAllowedOverride: null, + group: { name: "Kids", limit: KIDS }, + account: INHERIT, + server: SERVER, + ...overrides, + }; +} +const now = (overrides: Partial = {}) => { + const policy = effectiveRequestPolicy(input(overrides)); + return policy && describeRequestPolicy(policy); +}; + +describe("effective request policy", () => { + it("takes the group's approval and limit when the account inherits", () => { + expect(now()).toBe("5 requests per 7 days, an admin approves (from Kids group)"); + }); + + it("lets the account's own setting win over its group's, field by field", () => { + expect(now({ account: { limit_mode: "unlimited", approval_mode: "inherit" } })).toBe( + "no limit (set on this account), an admin approves (from Kids group)", + ); + expect( + now({ + account: { limit_mode: "custom", max_requests: 1, window_days: 1, approval_mode: "auto" }, + }), + ).toBe("1 request per day, approved automatically (set on this account)"); + }); + + it("falls back to the server-wide settings when the group inherits too", () => { + expect(now({ group: { name: "Default Group", limit: INHERIT } })).toBe( + "12 requests per 14 days, approved automatically (server default)", + ); + expect( + now({ group: { name: "Kids", limit: { limit_mode: "inherit", approval_mode: "manual" } } }), + ).toBe("12 requests per 14 days (server default), an admin approves (from Kids group)"); + }); + + it("uses the server's window when a custom limit has none, and 7 days when the server has none", () => { + const terms = resolveRequestTerms( + [[{ kind: "account" }, { limit_mode: "custom", max_requests: 3, approval_mode: "inherit" }]], + { ...SERVER, global_window_days: 0 }, + ); + expect(terms.quota).toEqual({ unlimited: false, max: 3, days: 7 }); + }); + + it("skips the group for an admin account", () => { + expect(now({ role: "admin" })).toBe( + "12 requests per 14 days, approved automatically (server default)", + ); + // An admin needs no group limits to resolve, even while they load. + expect(now({ role: "admin", group: { name: "Kids", limit: undefined } })).toBe( + "12 requests per 14 days, approved automatically (server default)", + ); + }); + + it("waits for the group's limits when the answer depends on them", () => { + expect(effectiveRequestPolicy(input({ group: { name: "Kids", limit: undefined } }))).toBe( + undefined, + ); + expect(now({ group: null })).toBe( + "12 requests per 14 days, approved automatically (server default)", + ); + }); + + it("says why an account can't request, server-wide off first", () => { + expect(now({ server: { ...SERVER, requests_enabled: false }, requestsAllowed: false })).toBe( + "can't request, because requests are turned off server-wide", + ); + expect(now({ requestsAllowed: false, requestsAllowedOverride: false })).toBe( + "can't request, because Media Requests is off for this account", + ); + expect(now({ requestsAllowed: false })).toBe( + "can't request, because Kids group has requests turned off", + ); + expect(now({ account: { limit_mode: "blocked", approval_mode: "auto" } })).toBe( + "can't request, because an old request setting blocks this account", + ); + expect(now({ account: { limit_mode: "inherit", approval_mode: "blocked" } })).toBe( + "can't request, because an old request setting blocks this account", + ); + }); +}); + +describe("request access wording", () => { + it("names groups without doubling the word", () => { + expect(requestGroupLabel("Kids")).toBe("Kids group"); + expect(requestGroupLabel("Default Group")).toBe("Default Group"); + expect(requestGroupLabel("")).toBe("its access group"); + }); + + it("formats quotas", () => { + expect(formatRequestQuota({ unlimited: true })).toBe("no limit"); + expect(formatRequestQuota({ unlimited: false, max: 2, days: 30 })).toBe( + "2 requests per 30 days", + ); + expect(formatRequestQuota({ unlimited: false, max: 0, days: 7 })).toBe( + "no new requests (0 per 7 days)", + ); + }); + + it("reads naturally for a limit of zero", () => { + expect( + now({ + account: { + limit_mode: "custom", + max_requests: 0, + window_days: 1, + approval_mode: "inherit", + }, + }), + ).toBe("no new requests (0 per day, set on this account), an admin approves (from Kids group)"); + expect(now({ group: { name: "Kids", limit: { ...KIDS, max_requests: 0 } } })).toBe( + "no new requests (0 per 7 days), an admin approves (from Kids group)", + ); + }); + + it("says where an inherited value comes from", () => { + const kids = { name: "Kids", limit: INHERIT }; + expect(describeInheritedValue("x", { kind: "group", name: "Kids" }, kids)).toBe( + "Kids group: x", + ); + expect(describeInheritedValue("x", { kind: "server" }, kids)).toBe( + "Kids group uses the server default: x", + ); + expect(describeInheritedValue("x", { kind: "server" }, null)).toBe("Server default: x"); + }); + + it("summarizes a group's own terms for its card", () => { + expect(requestGroupLimitSummary(KIDS)).toBe("Admin approves · 5 per 7 days"); + expect(requestGroupLimitSummary({ limit_mode: "unlimited", approval_mode: "auto" })).toBe( + "Auto-approves · No request limit", + ); + expect(requestGroupLimitSummary(INHERIT)).toBe(""); + }); +}); + +describe("request limit drafts", () => { + it("round-trips a layer and counts edits", () => { + const base = requestLimitDraft(KIDS); + expect(base).toEqual({ + approval: "manual", + limit: "custom", + maxRequests: "5", + windowDays: "7", + }); + expect(requestLimitChanges(base, base)).toBe(0); + expect(requestLimitChanges({ ...base, maxRequests: "6" }, base)).toBe(1); + // The numbers stop counting once the limit is no longer custom. + expect(requestLimitChanges({ ...base, limit: "inherit", maxRequests: "6" }, base)).toBe(1); + expect(requestLimitBody({ ...base, maxRequests: " 6 " })).toEqual({ + limit_mode: "custom", + approval_mode: "manual", + max_requests: 6, + window_days: 7, + }); + expect(requestLimitBody({ ...base, limit: "unlimited" })).toMatchObject({ + max_requests: null, + window_days: null, + }); + }); + + it("never offers blocked, accepts zero requests, and asks for at least one day", () => { + expect(requestLimitDraft({ limit_mode: "blocked", approval_mode: "blocked" })).toMatchObject({ + limit: "inherit", + approval: "inherit", + }); + const draft = requestLimitDraft(KIDS); + expect(requestLimitErrors(draft)).toEqual({}); + // Zero is valid, as it is on the server: it stops new requests. + const zero = { ...draft, maxRequests: "0" }; + expect(requestLimitErrors(zero)).toEqual({}); + expect(isZeroRequestLimit(zero)).toBe(true); + expect(isZeroRequestLimit(draft)).toBe(false); + expect(isZeroRequestLimit({ ...zero, limit: "inherit" })).toBe(false); + expect(requestLimitBody(zero)).toMatchObject({ max_requests: 0, window_days: 7 }); + expect(requestLimitErrors({ ...draft, maxRequests: "-1", windowDays: "0" })).toEqual({ + maxRequests: "Enter a whole number of requests, 0 or more.", + windowDays: "Use at least 1 day.", + }); + expect(requestLimitErrors({ ...draft, maxRequests: "1.5", windowDays: "1.5" })).toEqual({ + maxRequests: "Enter a whole number of requests, 0 or more.", + windowDays: "Use at least 1 day.", + }); + expect(requestLimitErrors({ ...draft, limit: "inherit", maxRequests: "" })).toEqual({}); + }); + + it("clears only the old blocked modes of a legacy row", () => { + expect( + clearLegacyRequestBlock({ limit_mode: "blocked", max_requests: 3, approval_mode: "auto" }), + ).toEqual({ + limit_mode: "inherit", + approval_mode: "auto", + max_requests: null, + window_days: null, + }); + expect( + clearLegacyRequestBlock({ + limit_mode: "custom", + max_requests: 3, + window_days: 5, + approval_mode: "blocked", + }), + ).toEqual({ limit_mode: "custom", approval_mode: "inherit", max_requests: 3, window_days: 5 }); + }); +}); diff --git a/web/src/lib/requestAccess.ts b/web/src/lib/requestAccess.ts new file mode 100644 index 0000000000..cefc0cb0cf --- /dev/null +++ b/web/src/lib/requestAccess.ts @@ -0,0 +1,363 @@ +import type { RequestApprovalMode, RequestLimitMode, RequestSettings } from "@/api/types"; + +// Who may request, and on what terms, resolves in layers, as the server does +// it (internal/requests, EffectivePolicy): the account's own approval and +// limit, then its access group's, then the server-wide request settings. A +// layer set to inherit defers to the next, and admin accounts skip the group. +// Blocking is not a layer value: an account is blocked by the requests switch +// on the account or its group, or by requests being off server-wide. An +// account row can still hold the older "blocked" modes when an API client +// wrote them; the server honors them, and no editor offers them. + +/** An approval an editor offers. */ +export type RequestApprovalChoice = Exclude; +/** A limit an editor offers. */ +export type RequestLimitChoice = Exclude; + +/** One layer's request approval and limit: an account's own, or its access group's. */ +export interface RequestLimitLayer { + limit_mode: RequestLimitMode; + max_requests?: number | null; + window_days?: number | null; + approval_mode: RequestApprovalMode; +} + +export type RequestServerDefaults = Pick< + RequestSettings, + "requests_enabled" | "global_max_requests" | "global_window_days" | "global_auto_approval_enabled" +>; + +/** Where a resolved value comes from. An empty group name means the name is not known. */ +export type RequestPolicySource = + | { kind: "account" } + | { kind: "group"; name: string } + | { kind: "server" }; + +export type RequestQuota = { unlimited: true } | { unlimited: false; max: number; days: number }; + +/** What the layers under an account resolve to, and which layer said so. */ +export interface ResolvedRequestTerms { + quota: RequestQuota; + quotaSource: RequestPolicySource; + autoApprove: boolean; + approvalSource: RequestPolicySource; +} + +export type EffectiveRequestPolicy = + | { kind: "blocked"; reason: "server-off" | "legacy" } + | { kind: "blocked"; reason: "switch"; source: RequestPolicySource } + | ({ kind: "allowed" } & ResolvedRequestTerms); + +/** An account's access group as the resolution sees it. */ +export interface RequestAccessGroup { + name: string; + /** The group's request approval and limit; undefined until loaded. */ + limit: RequestLimitLayer | undefined; +} + +export interface RequestAccessInput { + role: string; + /** The account's resolved requests switch (`effective_policy.requests_allowed`). */ + requestsAllowed: boolean; + /** The account's own requests switch; null follows its group. */ + requestsAllowedOverride: boolean | null; + /** The account's access group, or null when it has none. */ + group: RequestAccessGroup | null; + account: RequestLimitLayer; + server: RequestServerDefaults; +} + +const ACCOUNT: RequestPolicySource = { kind: "account" }; +const SERVER: RequestPolicySource = { kind: "server" }; + +/** The group whose request settings apply to the account: none for an admin. */ +export function requestGroupFor( + role: string, + group: RequestAccessGroup | null, +): RequestAccessGroup | null { + return role === "admin" ? null : group; +} + +/** The account row still holds a "blocked" mode that no editor offers any more. */ +export function isLegacyRequestBlock(layer: RequestLimitLayer): boolean { + return layer.limit_mode === "blocked" || layer.approval_mode === "blocked"; +} + +function serverWindowDays(server: RequestServerDefaults) { + return server.global_window_days > 0 ? server.global_window_days : 7; +} + +/** + * Resolves approval and limit over the given layers, first one first, falling + * back to the server-wide settings. Mirrors the server: a custom limit takes a + * missing count or window from the server-wide one. + */ +export function resolveRequestTerms( + layers: Array<[RequestPolicySource, RequestLimitLayer]>, + server: RequestServerDefaults, +): ResolvedRequestTerms { + let quota: RequestQuota = { + unlimited: false, + max: server.global_max_requests, + days: serverWindowDays(server), + }; + let quotaSource = SERVER; + const limitLayer = layers.find( + ([, layer]) => layer.limit_mode === "custom" || layer.limit_mode === "unlimited", + ); + if (limitLayer) { + const [source, layer] = limitLayer; + quotaSource = source; + quota = + layer.limit_mode === "unlimited" + ? { unlimited: true } + : { + unlimited: false, + max: layer.max_requests ?? server.global_max_requests, + days: + layer.window_days != null && layer.window_days > 0 + ? layer.window_days + : serverWindowDays(server), + }; + } + let autoApprove = server.global_auto_approval_enabled; + let approvalSource = SERVER; + const approvalLayer = layers.find( + ([, layer]) => layer.approval_mode === "manual" || layer.approval_mode === "auto", + ); + if (approvalLayer) { + [approvalSource] = approvalLayer; + autoApprove = approvalLayer[1].approval_mode === "auto"; + } + return { quota, quotaSource, autoApprove, approvalSource }; +} + +/** + * What applies to an account now. Undefined while the account's group limits + * are still loading and the answer depends on them. + */ +export function effectiveRequestPolicy( + input: RequestAccessInput, +): EffectiveRequestPolicy | undefined { + const group = requestGroupFor(input.role, input.group); + if (!input.server.requests_enabled) return { kind: "blocked", reason: "server-off" }; + if (!input.requestsAllowed) { + const source: RequestPolicySource = + input.requestsAllowedOverride !== null + ? ACCOUNT + : group + ? { kind: "group", name: group.name } + : SERVER; + return { kind: "blocked", reason: "switch", source }; + } + if (isLegacyRequestBlock(input.account)) return { kind: "blocked", reason: "legacy" }; + const layers: Array<[RequestPolicySource, RequestLimitLayer]> = [[ACCOUNT, input.account]]; + if (group) { + if (!group.limit) return undefined; + layers.push([{ kind: "group", name: group.name }, group.limit]); + } + return { kind: "allowed", ...resolveRequestTerms(layers, input.server) }; +} + +/** "Kids group", or a name that already says it, as "Default Group". */ +export function requestGroupLabel(name: string): string { + const trimmed = name.trim(); + if (!trimmed) return "its access group"; + return /\bgroup$/i.test(trimmed) ? trimmed : `${trimmed} group`; +} + +function plural(n: number, one: string, many: string) { + return n === 1 ? one : many; +} + +/** + * "5 requests per 7 days", "1 request per day", "no limit", or for a limit of + * zero, which allows nothing new, "no new requests (0 per 7 days)". + */ +export function formatRequestQuota(quota: RequestQuota): string { + return formatQuotaFrom(quota); +} + +/** The quota, with where it comes from in the same parentheses when it has some. */ +function formatQuotaFrom(quota: RequestQuota, source?: string): string { + const from = source ? ` (${source})` : ""; + if (quota.unlimited) return `no limit${from}`; + const window = quota.days === 1 ? "day" : `${quota.days} days`; + if (quota.max === 0) return `no new requests (0 per ${window}${source ? `, ${source}` : ""})`; + return `${quota.max} ${plural(quota.max, "request", "requests")} per ${window}${from}`; +} + +export function formatRequestApproval(autoApprove: boolean): string { + return autoApprove ? "approved automatically" : "an admin approves"; +} + +export function formatRequestSource(source: RequestPolicySource): string { + switch (source.kind) { + case "account": + return "set on this account"; + case "group": + return `from ${requestGroupLabel(source.name)}`; + case "server": + return "server default"; + } +} + +function sourceKey(source: RequestPolicySource) { + return source.kind === "group" ? `group:${source.name}` : source.kind; +} + +/** + * One line on what applies and where it comes from, to follow "Now: ", e.g. + * "5 requests per 7 days, an admin approves (from Kids group)". + */ +export function describeRequestPolicy(policy: EffectiveRequestPolicy): string { + if (policy.kind === "blocked") { + switch (policy.reason) { + case "server-off": + return "can't request, because requests are turned off server-wide"; + case "legacy": + return "can't request, because an old request setting blocks this account"; + case "switch": + return policy.source.kind === "group" + ? `can't request, because ${requestGroupLabel(policy.source.name)} has requests turned off` + : "can't request, because Media Requests is off for this account"; + } + } + const approval = formatRequestApproval(policy.autoApprove); + if (sourceKey(policy.quotaSource) === sourceKey(policy.approvalSource)) { + return `${formatQuotaFrom(policy.quota)}, ${approval} (${formatRequestSource(policy.quotaSource)})`; + } + const quota = formatQuotaFrom(policy.quota, formatRequestSource(policy.quotaSource)); + return `${quota}, ${approval} (${formatRequestSource(policy.approvalSource)})`; +} + +/** + * What "Use the default" means for one field: the value it resolves to and + * which layer supplies it, e.g. "Kids group: an admin approves" or + * "Server default: 12 requests per 14 days". `group` is the account's group + * when it has one, so an inheriting group can say it passes the server's on. + */ +export function describeInheritedValue( + value: string, + source: RequestPolicySource, + group: RequestAccessGroup | null, +): string { + if (source.kind === "group") return `${requestGroupLabel(source.name)}: ${value}`; + if (group) return `${requestGroupLabel(group.name)} uses the server default: ${value}`; + return `Server default: ${value}`; +} + +/** A short line for an access group card, e.g. "Admin approves · 3 per 7 days". */ +export function requestGroupLimitSummary(layer: RequestLimitLayer): string { + const parts: string[] = []; + if (layer.approval_mode === "manual") parts.push("Admin approves"); + if (layer.approval_mode === "auto") parts.push("Auto-approves"); + if (layer.limit_mode === "unlimited") parts.push("No request limit"); + if (layer.limit_mode === "custom" && layer.max_requests != null) { + const days = layer.window_days; + parts.push( + days == null || days <= 0 + ? `${layer.max_requests} ${plural(layer.max_requests, "request", "requests")}` + : `${layer.max_requests} per ${days === 1 ? "day" : `${days} days`}`, + ); + } + return parts.join(" · "); +} + +/** An editor's staged approval and limit; the numbers stay text while typed. */ +export interface RequestLimitDraft { + approval: RequestApprovalChoice; + limit: RequestLimitChoice; + maxRequests: string; + windowDays: string; +} + +export function requestLimitDraft(layer: RequestLimitLayer): RequestLimitDraft { + return { + approval: layer.approval_mode === "blocked" ? "inherit" : layer.approval_mode, + limit: layer.limit_mode === "blocked" ? "inherit" : layer.limit_mode, + maxRequests: layer.max_requests == null ? "" : String(layer.max_requests), + windowDays: layer.window_days == null ? "" : String(layer.window_days), + }; +} + +/** How many staged edits differ from `base`; the numbers count only for a custom limit. */ +export function requestLimitChanges(draft: RequestLimitDraft, base: RequestLimitDraft): number { + let changes = 0; + if (draft.approval !== base.approval) changes++; + if (draft.limit !== base.limit) changes++; + if (draft.limit === "custom") { + if (draft.maxRequests !== base.maxRequests) changes++; + if (draft.windowDays !== base.windowDays) changes++; + } + return changes; +} + +function wholeNumber(value: string, min: number): number | null { + const trimmed = value.trim(); + if (!/^\d+$/.test(trimmed)) return null; + const n = Number(trimmed); + return Number.isSafeInteger(n) && n >= min ? n : null; +} + +export interface RequestLimitErrors { + maxRequests?: string; + windowDays?: string; +} + +/** + * A custom limit needs a whole number of requests, zero included, per at + * least one day. The server accepts zero too: it stops new requests without + * blocking the account (see `isZeroRequestLimit`). + */ +export function requestLimitErrors(draft: RequestLimitDraft): RequestLimitErrors { + if (draft.limit !== "custom") return {}; + const errors: RequestLimitErrors = {}; + if (wholeNumber(draft.maxRequests, 0) === null) { + errors.maxRequests = "Enter a whole number of requests, 0 or more."; + } + if (wholeNumber(draft.windowDays, 1) === null) { + errors.windowDays = "Use at least 1 day."; + } + return errors; +} + +/** The draft is a custom limit of zero: nothing new can be requested, which is not a block. */ +export function isZeroRequestLimit(draft: RequestLimitDraft): boolean { + return draft.limit === "custom" && wholeNumber(draft.maxRequests, 0) === 0; +} + +export function hasRequestLimitErrors(errors: RequestLimitErrors): boolean { + return Boolean(errors.maxRequests || errors.windowDays); +} + +/** What an account or group limit save sends. */ +export interface RequestLimitBody { + limit_mode: RequestLimitChoice; + approval_mode: RequestApprovalChoice; + max_requests: number | null; + window_days: number | null; +} + +/** The body a save sends; call it only for a draft without errors. */ +export function requestLimitBody(draft: RequestLimitDraft): RequestLimitBody { + const custom = draft.limit === "custom"; + return { + limit_mode: draft.limit, + approval_mode: draft.approval, + max_requests: custom ? Number(draft.maxRequests.trim()) : null, + window_days: custom ? Number(draft.windowDays.trim()) : null, + }; +} + +/** The account row with its old "blocked" modes set back to the default, the rest kept. */ +export function clearLegacyRequestBlock(layer: RequestLimitLayer): RequestLimitBody { + const limitMode = layer.limit_mode === "blocked" ? "inherit" : layer.limit_mode; + const custom = limitMode === "custom"; + return { + limit_mode: limitMode, + approval_mode: layer.approval_mode === "blocked" ? "inherit" : layer.approval_mode, + max_requests: custom ? (layer.max_requests ?? null) : null, + window_days: custom ? (layer.window_days ?? null) : null, + }; +} diff --git a/web/src/lib/requestDownload.test.ts b/web/src/lib/requestDownload.test.ts new file mode 100644 index 0000000000..1328dae22b --- /dev/null +++ b/web/src/lib/requestDownload.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import type { RequestDownload } from "@/api/types"; +import { formatRequestDownload, requestDownloadPercent } from "./requestDownload"; + +describe("request download progress", () => { + const now = new Date("2026-01-02T03:04:05Z"); + const download = (overrides: Partial = {}): RequestDownload => ({ + phase: "downloading", + percent: 43, + bytes_total: 4294967296, + bytes_left: 2448131358, + estimated_completion_at: "2026-01-02T03:16:05Z", + downloads: 1, + updated_at: "2026-01-02T03:03:05Z", + ...overrides, + }); + + it("names the phase, the percentage and the time left while downloading", () => { + expect(formatRequestDownload(download(), { now })).toBe( + "Downloading · 43% · about 12 min left", + ); + expect( + formatRequestDownload(download({ estimated_completion_at: "2026-01-02T05:34:05Z" }), { + now, + }), + ).toBe("Downloading · 43% · about 2 hr 30 min left"); + }); + + it("leaves out what the server does not know yet", () => { + expect( + formatRequestDownload( + download({ + percent: undefined, + bytes_total: undefined, + bytes_left: undefined, + estimated_completion_at: undefined, + }), + { now }, + ), + ).toBe("Downloading"); + }); + + it("drops an estimate that has passed or comes from figures over ten minutes old", () => { + expect( + formatRequestDownload(download({ estimated_completion_at: "2026-01-02T03:04:00Z" }), { + now, + }), + ).toBe("Downloading · 43%"); + expect(formatRequestDownload(download({ updated_at: "2026-01-02T02:54:04Z" }), { now })).toBe( + "Downloading · 43%", + ); + // Exactly ten minutes old is still recent enough. + expect(formatRequestDownload(download({ updated_at: "2026-01-02T02:54:05Z" }), { now })).toBe( + "Downloading · 43% · about 12 min left", + ); + }); + + it.each([ + ["queued", "Waiting to download"], + ["paused", "Download paused"], + ["stalled", "Download stalled"], + ["importing", "Importing"], + ["import_blocked", "Waiting for import"], + ])("labels %s as %s", (phase, label) => { + expect(formatRequestDownload(download({ phase }), { now })).toBe(label); + }); + + it("tells an admin that an import is blocked", () => { + expect(formatRequestDownload(download({ phase: "import_blocked" }), { now, admin: true })).toBe( + "Import blocked", + ); + expect(formatRequestDownload(download(), { now, admin: true })).toBe( + "Downloading · 43% · about 12 min left", + ); + }); + + it("reads a phase it does not know as downloading, without figures", () => { + const unknown = download({ phase: "seeding" }); + expect(formatRequestDownload(unknown, { now })).toBe("Downloading"); + expect(requestDownloadPercent(unknown)).toBeUndefined(); + }); + + it("gives a bar percentage once the size is known, clamped to 0–100", () => { + expect(requestDownloadPercent(download())).toBe(43); + expect(requestDownloadPercent(download({ phase: "paused" }))).toBe(43); + expect(requestDownloadPercent(download({ percent: undefined }))).toBeUndefined(); + expect(requestDownloadPercent(download({ percent: 140 }))).toBe(100); + expect(requestDownloadPercent(download({ percent: -3 }))).toBe(0); + }); +}); diff --git a/web/src/lib/requestDownload.ts b/web/src/lib/requestDownload.ts new file mode 100644 index 0000000000..477c672c8f --- /dev/null +++ b/web/src/lib/requestDownload.ts @@ -0,0 +1,72 @@ +import type { RequestDownload } from "@/api/types"; +import { formatHoursMinutes } from "@/lib/audiobooks/duration"; + +// Download progress labels live apart from mediaRequests: that module is on +// the launch path (search and status badges import it), and only the lazily +// loaded request pages show progress. + +const DOWNLOAD_PHASES = new Set([ + "queued", + "downloading", + "paused", + "stalled", + "importing", + "import_blocked", +]); + +/** Figures the server has not refreshed for this long no longer support an estimate. */ +const DOWNLOAD_ESTIMATE_MAX_AGE_MS = 10 * 60_000; + +/** + * How much has downloaded, for a progress bar; undefined while the size is + * unknown, and for a phase this client does not know. + */ +export function requestDownloadPercent(download: RequestDownload): number | undefined { + if (download.percent == null || !DOWNLOAD_PHASES.has(download.phase)) return undefined; + return Math.min(100, Math.max(0, download.percent)); +} + +/** + * "Downloading · 43% · about 12 min left", or the phase alone. The estimate + * is left out once it has passed, or when the server last heard from the + * download server more than ten minutes ago. A phase this client does not + * know reads as Downloading. Admin views call a blocked import what it is; + * a requester only needs to know it is waiting. + */ +export function formatRequestDownload( + download: RequestDownload, + { now = new Date(), admin = false }: { now?: Date; admin?: boolean } = {}, +): string { + switch (download.phase) { + case "queued": + return "Waiting to download"; + case "paused": + return "Download paused"; + case "stalled": + return "Download stalled"; + case "importing": + return "Importing"; + case "import_blocked": + return admin ? "Import blocked" : "Waiting for import"; + case "downloading": + break; + default: + return "Downloading"; + } + const parts = ["Downloading"]; + const percent = requestDownloadPercent(download); + if (percent !== undefined) parts.push(`${percent}%`); + const left = downloadTimeLeft(download, now); + if (left) parts.push(`about ${left} left`); + return parts.join(" · "); +} + +function downloadTimeLeft(download: RequestDownload, now: Date): string | null { + if (!download.estimated_completion_at) return null; + const eta = Date.parse(download.estimated_completion_at); + const heard = Date.parse(download.updated_at); + const at = now.getTime(); + if (!Number.isFinite(eta) || eta <= at) return null; + if (!Number.isFinite(heard) || at - heard > DOWNLOAD_ESTIMATE_MAX_AGE_MS) return null; + return formatHoursMinutes((eta - at) / 1000); +} 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/AdminAccessGroups.test.tsx b/web/src/pages/AdminAccessGroups.test.tsx index 94c37c2c9b..aa671ad10c 100644 --- a/web/src/pages/AdminAccessGroups.test.tsx +++ b/web/src/pages/AdminAccessGroups.test.tsx @@ -97,9 +97,29 @@ function renderPage(initialPath = "/admin/access-groups") { return router; } +const REQUEST_SETTINGS = { + requests_enabled: true, + global_max_requests: 12, + global_window_days: 14, + global_auto_approval_enabled: true, + force_dual_quality: false, +}; +const INHERIT_LIMIT = { + group_id: "1", + limit_mode: "inherit", + max_requests: null, + window_days: null, + approval_mode: "inherit", +}; + describe("AdminAccessGroups", () => { let putBody: unknown; let group: typeof GROUP; + // The group's request limit as stored, and every write to it, in order. + let groupLimit: Record; + let groupLimitTag: string; + let writes: Array<{ url: string; ifMatch: string | null; body: Record }>; + let refuseLimitWrites: number; beforeEach(() => { installPolicyStorageMocks(); @@ -108,11 +128,45 @@ describe("AdminAccessGroups", () => { setProfileToken(null); putBody = undefined; group = GROUP; + groupLimit = INHERIT_LIMIT; + groupLimitTag = '"limit-initial"'; + writes = []; + refuseLimitWrites = 0; vi.stubGlobal( "fetch", vi.fn(async (input, init) => { const url = String(input); const method = init?.method ?? "GET"; + if (method === "PUT") { + writes.push({ + url, + ifMatch: new Headers(init?.headers).get("If-Match"), + body: JSON.parse(String(init?.body)), + }); + } + if (url === "/api/v2/admin/request-settings") return jsonResponse(REQUEST_SETTINGS); + if (url === "/api/v2/admin/request-groups/1/limit" && method === "GET") { + return jsonResponse(groupLimit, 200, groupLimitTag); + } + if (url === "/api/v2/admin/request-groups/1/limit" && method === "PUT") { + if (refuseLimitWrites > 0) { + refuseLimitWrites--; + groupLimitTag = '"limit-newer"'; + return jsonResponse( + { + type: "https://silo.example/problems/precondition_failed", + title: "Changed", + status: 412, + detail: "The access group's request limit changed; reload before saving.", + }, + 412, + groupLimitTag, + ); + } + groupLimit = { group_id: "1", ...JSON.parse(String(init?.body)) }; + groupLimitTag = '"limit-saved"'; + return jsonResponse(groupLimit, 200, groupLimitTag); + } if (url === "/api/v2/admin/users/capabilities") return jsonResponse({ access_groups: true }); if (url === "/api/v2/admin/access-groups?limit=200" && method === "GET") { @@ -175,6 +229,120 @@ describe("AdminAccessGroups", () => { is_default: true, }); }); + // The request limit was not edited, so nothing wrote it. + expect(writes.map((write) => write.url)).toEqual(["/api/v2/admin/access-groups/1"]); + }); + + it("summarizes the group's own request approval and limit on its card", async () => { + group = { ...GROUP, requests_allowed: true }; + groupLimit = { + ...INHERIT_LIMIT, + limit_mode: "custom", + max_requests: 3, + window_days: 7, + approval_mode: "manual", + }; + renderPage(); + expect(await screen.findByText("Admin approves · 3 per 7 days")).toBeInTheDocument(); + expect(screen.getByText("Requests on")).toBeInTheDocument(); + }); + + it("saves only the request approval and limit when no group field changed", async () => { + toastSuccess.mockClear(); + const user = userEvent.setup(); + const router = renderPage("/admin/access-groups/1"); + const requests = await screen.findByRole("region", { name: "Requests" }); + // Inheriting fields say what the server-wide default is. + expect( + await within(requests).findByText("Server default: approved automatically"), + ).toBeInTheDocument(); + expect( + within(requests).getByText("Server default: 12 requests per 14 days"), + ).toBeInTheDocument(); + + await pickOption(user, "Approval", "An admin approves"); + await user.click(screen.getByRole("combobox", { name: "Limit" })); + expect((await screen.findAllByRole("option")).map((option) => option.textContent)).toEqual([ + "Use server default", + "Custom limit", + "No limit", + ]); + await user.click(screen.getByRole("option", { name: "Custom limit" })); + const max = screen.getByRole("spinbutton", { name: "Requests allowed" }); + expect(max).toHaveValue(12); + await user.clear(max); + await user.type(max, "3"); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + + expect(await screen.findByRole("heading", { name: "Access Groups" })).toBeInTheDocument(); + expect(router.state.location.pathname).toBe("/admin/access-groups"); + expect(toastSuccess).toHaveBeenCalledWith("Group saved"); + // The group itself was untouched, so its revision is left alone. + expect(writes.map((write) => write.url)).toEqual(["/api/v2/admin/request-groups/1/limit"]); + expect(writes[0]).toMatchObject({ + ifMatch: '"limit-initial"', + body: { limit_mode: "custom", max_requests: 3, window_days: 14, approval_mode: "manual" }, + }); + }); + + it("saves a group's limit of zero and says it only stops new requests", async () => { + groupLimit = { ...INHERIT_LIMIT, limit_mode: "custom", max_requests: 0, window_days: 7 }; + const user = userEvent.setup(); + renderPage("/admin/access-groups/1"); + const max = await screen.findByRole("spinbutton", { name: "Requests allowed" }); + expect(max).toHaveValue(0); + expect( + screen.getByText( + "0 stops new requests; to block this group, turn off Media requests instead.", + ), + ).toBeInTheDocument(); + await pickOption(user, "Approval", "Approve automatically"); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + expect(await screen.findByRole("heading", { name: "Access Groups" })).toBeInTheDocument(); + expect(writes).toEqual([ + expect.objectContaining({ + url: "/api/v2/admin/request-groups/1/limit", + body: { limit_mode: "custom", max_requests: 0, window_days: 7, approval_mode: "auto" }, + }), + ]); + }); + + it("stays on the group when its request limit could not be saved, and retries only the limit", async () => { + toastSuccess.mockClear(); + refuseLimitWrites = 1; + const user = userEvent.setup(); + const router = renderPage("/admin/access-groups/1"); + await screen.findByRole("region", { name: "Requests" }); + fireEvent.click(screen.getByRole("switch", { name: "Allow downloads" })); + await pickOption(user, "Limit", "No limit"); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + + expect( + await screen.findByText(/The group was saved, but its request approval and limit were not/), + ).toBeInTheDocument(); + expect(screen.getByText(/changed by another administrator/)).toBeInTheDocument(); + expect(router.state.location.pathname).toBe("/admin/access-groups/1"); + expect(toastSuccess).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled(); + + await user.click(screen.getByRole("button", { name: "Reload latest version" })); + await waitFor(() => + expect(screen.getByRole("combobox", { name: "Limit" })).toHaveTextContent( + "Use server default", + ), + ); + await pickOption(user, "Limit", "No limit"); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + expect(await screen.findByRole("heading", { name: "Access Groups" })).toBeInTheDocument(); + // The group was written once; the retry wrote only the limit. + expect(writes.map((write) => write.url)).toEqual([ + "/api/v2/admin/access-groups/1", + "/api/v2/admin/request-groups/1/limit", + "/api/v2/admin/request-groups/1/limit", + ]); + const limitWrites = writes.filter((write) => write.url.includes("request-groups")); + expect(limitWrites.map((write) => write.ifMatch)).toEqual(['"limit-initial"', '"limit-newer"']); + expect(limitWrites[1]?.body).toMatchObject({ limit_mode: "unlimited", max_requests: null }); }); it("lists the group's members with links to their user pages", async () => { diff --git a/web/src/pages/AdminAccessGroups.tsx b/web/src/pages/AdminAccessGroups.tsx index 5d06551979..ed41dea71d 100644 --- a/web/src/pages/AdminAccessGroups.tsx +++ b/web/src/pages/AdminAccessGroups.tsx @@ -10,8 +10,11 @@ import { getAccessGroup, type AccessGroupEditor as GroupEditor, } from "@/api/v2/accessGroups"; +import { isRequestEditorConflict } from "@/api/v2/adminRequests"; import { V2ProblemError } from "@/api/v2/request"; import type { AccessGroup, AccessGroupInput } from "@/api/types"; +import { EditorConflict } from "@/components/admin/EditorConflict"; +import { RequestLimitFields } from "@/components/admin/RequestLimitFields"; import { LibraryAccessSelector } from "@/components/LibraryAccessSelector"; import { StreamBitrateLimitInput } from "@/components/StreamBitrateLimitInput"; import { @@ -44,8 +47,26 @@ import { } from "@/hooks/queries/admin/accessGroups"; import { useAdminLibraries } from "@/hooks/queries/admin/libraries"; import { useAdminUsers } from "@/hooks/queries/admin/users"; +import { + useRequestGroupLimit, + useRequestSettings, + useUpdateRequestGroupLimit, +} from "@/hooks/queries/admin/requests"; import { useDocumentTitle } from "@/hooks/useDocumentTitle"; import { PERMISSION_MARKER_EDIT, PERMISSION_METADATA_CURATION } from "@/lib/permissions"; +import { + describeInheritedValue, + formatRequestApproval, + formatRequestQuota, + hasRequestLimitErrors, + requestGroupLimitSummary, + requestLimitBody, + requestLimitChanges, + requestLimitDraft, + requestLimitErrors, + resolveRequestTerms, +} from "@/lib/requestAccess"; +import { useStagedDraft } from "@/pages/admin-settings/useStagedDraft"; import { PLAYBACK_QUALITY_OPTIONS, playbackQualityPresetFromValue, @@ -72,6 +93,39 @@ function limitLabel(value: number) { return value > 0 ? String(value) : "Unlimited"; } +/** + * The body a save would send for the group as stored, built with the same + * normalization as the editor's (quality preset, clamped transcoded + * downloads), so an untouched draft compares equal to it. + */ +function storedGroupBody(group: AccessGroup): AccessGroupInput { + return { + name: group.name.trim(), + description: group.description.trim(), + library_ids: group.library_ids, + max_playback_quality: playbackQualityValueFromPreset( + playbackQualityPresetFromValue(group.max_playback_quality), + ), + download_allowed: group.download_allowed, + download_transcode_allowed: group.download_allowed && group.download_transcode_allowed, + transcode_allowed: group.transcode_allowed, + audio_transcode_allowed: group.audio_transcode_allowed, + max_streams: group.max_streams, + max_transcodes: group.max_transcodes, + max_remote_stream_bitrate_kbps: group.max_remote_stream_bitrate_kbps, + max_local_stream_bitrate_kbps: group.max_local_stream_bitrate_kbps, + allowed_permissions: group.allowed_permissions, + requests_allowed: group.requests_allowed, + is_default: group.is_default, + }; +} + +function sameGroupBody(a: AccessGroupInput, b: AccessGroupInput): boolean { + return (Object.keys(b) as Array).every( + (key) => JSON.stringify(a[key]) === JSON.stringify(b[key]), + ); +} + export default function AdminAccessGroups() { useAuth(); return ; @@ -284,6 +338,11 @@ function AccessGroupsPage() { } function AccessGroupCard({ group, onClick }: { group: AccessGroup; onClick: () => void }) { + const requestLimit = useRequestGroupLimit(Number(group.id)); + // The group's own request approval and limit, when it sets any; a group + // that only follows the server-wide defaults says nothing more. + const requestTerms = + group.requests_allowed && requestLimit.data ? requestGroupLimitSummary(requestLimit.data) : ""; const facts = [ group.library_ids === null ? "All libraries" @@ -291,6 +350,7 @@ function AccessGroupCard({ group, onClick }: { group: AccessGroup; onClick: () = group.download_allowed ? "Downloads on" : "No downloads", `${limitLabel(group.max_streams)} stream${group.max_streams === 1 ? "" : "s"}`, group.requests_allowed ? "Requests on" : "No requests", + ...(requestTerms ? [requestTerms] : []), ]; return (
-

Downloads & requests

+

Downloads

+
+ +
+
+

+ Requests +

+

+ A member's own approval and limit win over the group's, and admin accounts + don't use a group. Server defaults are in{" "} + + Settings › Requests + + . +

+
+ {groupLimit.isError ? ( +

+ Couldn't load the group's request approval and limit.{" "} + +

+ ) : requestLimit.draft ? ( + requestLimit.update(() => next)} + subject="group" + inheritLabel="Use server default" + inherited={ + serverTerms + ? { + approval: describeInheritedValue( + formatRequestApproval(serverTerms.autoApprove), + serverTerms.approvalSource, + null, + ), + limit: describeInheritedValue( + formatRequestQuota(serverTerms.quota), + serverTerms.quotaSource, + null, + ), + } + : undefined + } + customSeed={ + serverTerms && !serverTerms.quota.unlimited + ? { + maxRequests: String(serverTerms.quota.max), + windowDays: String(serverTerms.quota.days), + } + : { maxRequests: "", windowDays: "" } + } + errors={limitErrors} + disabled={updateGroupLimit.isPending} + /> + ) : ( +

Loading request approval and limit...

+ )} + {limitError && ( +

+ {limitError} +

+ )} + {limitConflict && }
@@ -673,13 +856,15 @@ function AccessGroupEditor({ initialEditor, onSaved, onDeleted }: AccessGroupEdi onClick={save} disabled={ updateGroup.isPending || + updateGroupLimit.isPending || deleteGroup.isPending || conflict || reloading || - !bitrateLimitsValid + !bitrateLimitsValid || + limitBlocksSave } > - {updateGroup.isPending ? "Saving..." : "Save changes"} + {updateGroup.isPending || updateGroupLimit.isPending ? "Saving..." : "Save changes"}
diff --git a/web/src/pages/AdminRequests.tsx b/web/src/pages/AdminRequests.tsx index bd260aa102..31c6d1a254 100644 --- a/web/src/pages/AdminRequests.tsx +++ b/web/src/pages/AdminRequests.tsx @@ -1,137 +1,52 @@ -import { - getAdminRequestIntegrationV2, - isRequestEditorConflict, - requestValidationErrors, -} from "@/api/v2/adminRequests"; -import { useEffect, useMemo, useRef, useState } from "react"; -import type { ReactNode } from "react"; -import { Link, 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 { Badge } from "@/components/ui/badge"; +import { useEffect } from "react"; +import { Link, Navigate, useSearchParams } from "react-router"; +import { Settings2 } from "lucide-react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - 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 { Skeleton } from "@/components/ui/skeleton"; -import { Switch } from "@/components/ui/switch"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { - Table, - TableBody, - TableCell, - TableHead, - 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 { - formatMediaType, - formatRequestDate, - formatRequestOutcome, - formatRequestStatus, - requestOutcomeBadgeVariant, - requestStatusBadgeVariant, - REQUEST_OUTCOMES, - REQUEST_STATUSES, -} from "@/lib/mediaRequests"; -import { applyExclusivity } from "./requestExclusivity"; -import { supportedMediaTypesForConfig } from "./requestIntegrationMediaTypes"; +import { useAdminRequestCapabilities } from "@/hooks/queries/admin/requests"; +import { RequestQueue } from "@/pages/admin-requests/RequestQueue"; +import { EmptyPanel, RowsSkeleton } from "@/pages/admin-requests/queueParts"; + +/** Where request settings, servers, and routing live now. */ +export const REQUEST_SETTINGS_HREF = "/admin/settings/requests"; + +// Tabs this page used to have. Settings and servers moved to Settings → +// Requests; per-account overrides moved to each account's page, next to its +// access group's, so the old link lands on the accounts list. +const MOVED_TABS: Record = { + settings: REQUEST_SETTINGS_HREF, + integrations: REQUEST_SETTINGS_HREF, + overrides: "/admin/users", +}; -type StatusFilter = MediaRequestStatus | "all"; -type OutcomeFilter = MediaRequestOutcome | "all"; +const OVERRIDES_MOVED_TOAST = "request-overrides-moved"; -const ADMIN_REQUEST_TABS = ["queue", "settings", "integrations", "overrides"] as const; -type AdminRequestTab = (typeof ADMIN_REQUEST_TABS)[number]; +export default function AdminRequests() { + const [searchParams] = useSearchParams(); + const requestedTab = searchParams.get("tab"); + const movedTo = requestedTab === null ? undefined : MOVED_TABS[requestedTab]; + if (movedTo) return ; + return ; +} -function normalizeAdminRequestTab(value: string | null): AdminRequestTab { - return ADMIN_REQUEST_TABS.includes(value as AdminRequestTab) - ? (value as AdminRequestTab) - : "queue"; +/** Sends a link to a retired tab where its content lives now. */ +function MovedTab({ tab, to }: { tab: string; to: string }) { + useEffect(() => { + if (tab !== "overrides") return; + // The fixed id keeps a double-run effect from stacking two toasts. + toast.info("Request limits moved to each account", { + id: OVERRIDES_MOVED_TOAST, + description: + "Open an account to set its request approval and limit. Access groups set them for everyone in the group.", + }); + }, [tab]); + return ; } -export default function AdminRequests() { - const [searchParams, setSearchParams] = useSearchParams(); - const activeTab = normalizeAdminRequestTab(searchParams.get("tab")); +function RequestQueuePage() { const capabilities = useAdminRequestCapabilities(); - - function setActiveTab(value: string) { - const nextTab = normalizeAdminRequestTab(value); - const next = new URLSearchParams(searchParams); - - if (nextTab === "queue") { - next.delete("tab"); - } else { - next.set("tab", nextTab); - } - - setSearchParams(next, { replace: true }); - } - if (capabilities.isLoading) return ; - if ( - !capabilities.data?.available || - (activeTab !== "queue" && !capabilities.data.guarded_configuration) - ) { + if (!capabilities.data?.available) { 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. + Approve, decline, and follow media requests.

-
-
- - -
- - Queue - Settings - Integrations - User Overrides - -
- - - - - - - - - - - - -
-
- ); -} - -function RequestQueueTab() { - const [status, setStatus] = useState("all"); - const [outcome, setOutcome] = useState("all"); - const requests = useAdminMediaRequests({ status, outcome, limit: 100 }); - const users = useAdminUsers(); - const approve = useApproveMediaRequest(); - const decline = useDeclineMediaRequest(); - const retry = useRetryMediaRequest(); - const [declineTarget, setDeclineTarget] = useState(null); - const [declineReason, setDeclineReason] = useState(""); - const usernamesByID = useMemo(() => { - return new Map((users.data ?? []).map((user) => [user.id, user.username])); - }, [users.data]); - - function handleDecline(request: MediaRequest) { - setDeclineTarget(request); - setDeclineReason(""); - } - - function confirmDecline() { - if (!declineTarget) return; - decline.mutate({ id: declineTarget.id, reason: declineReason }); - setDeclineTarget(null); - setDeclineReason(""); - } - - return ( -
-
- - - -
- - {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} - - - -