Skip to content

feat(requests): show download progress while a request downloads - #1649

Merged
Quick104 merged 5 commits into
feat/request-revampfrom
feat/request-download-progress
Sep 29, 2026
Merged

Quick104 merged 5 commits into
feat/request-revampfrom
feat/request-download-progress

Conversation

@Quick104

@Quick104 Quick104 commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Related issue: #1643
Validation tasks: reaches #1202 C3, #1203 C1, #1204 C2 (none run yet). Availability, the request lifecycle and notifications are unchanged; this adds display and a status-only refresh.

While Sonarr or Radarr downloads a requested title, Silo shows "Processing" and nothing else. Requesters can't tell whether a download is nearly done or stuck, and admins only see a raw status string such as downloading/downloading. The Sonarr/Radarr plugin already reads each request's download queue, but it passes on only a queued or downloading state, and Silo asks for it every five minutes, which is too slow for a progress bar.

This change stores download progress reported by request-router plugins and shows it to requesters, on the title page and in the admin queue. The labels read "Downloading · 43% · about 12 min left", or the phase: "Waiting to download", "Download paused", "Download stalled", "Importing", or "Waiting for import" ("Import blocked" for admins).

Approach

  • Contract. Plugins report progress through the new TargetStatus.progress and declare it with request_router.reports_download_progress (feat(requestrouter): let request routers report download progress silo-plugin-sdk#27). Silo ignores progress from plugins that don't declare it.
  • Storage. Progress lives in new nullable download_* columns on media_request_targets. Writing it never touches the target's updated_at (the stalled-target backstop reads it), the request's status or its history. It clears when a target completes or fails, when the plugin stops reporting it, or 15 minutes after the server stops answering. When the phase moves but the status doesn't, the target's raw external_status is updated the same way, so the two agree.
  • Refresh. The reconcile pass records a download's first progress. A new hidden refresh_request_downloads task then refreshes every minute, but only downloading targets that have progress, from declaring plugins. Queued targets and targets without progress stay on the five-minute pass: an unreleased movie can sit queued for months, and Seerr keeps media at Processing with nothing in its queue. The task skips when nothing is downloading.
  • Locking. Reconcile keeps its one-server lock. Both passes also share a new target-write lock: reconcile takes it on the same database session as its own lock, so the pass keeps the rest of the pool, and waits up to two minutes for a running refresh instead of skipping. The refresh skips while reconcile runs, and each refresh has a 90-second budget, so the wait always ends.
  • API v2 (additive). A RequestDownload object (phase, percent, bytes_total, bytes_left, estimated_completion_at, downloads, updated_at) appears on request targets, on the request (all its targets combined), and on the title detail's request state. GET /api/v2/requests/status advertises download_progress_supported. /api/v1 is unchanged.
  • Web. The Requests page, the title page's request bar and the admin queue (per server) show a bar and the label. Queries poll every 30 seconds while something they show is downloading. The label code loads with those pages, not with the launch bundle.
  • TMDB. Title details are cached for two minutes, so title pages that poll a download share one TMDB fetch.
  • docs/architecture/media-requests.md and docs/architecture/api-contract.md describe the behavior.

The SDK is pinned to the SDK PR's commit (v0.18.1-0.20260928194839-1abd582d0304) until v0.19.0 is tagged. The Sonarr/Radarr (Silo-Community/silo-plugins-requests-arr#17) and Seerr (Silo-Community/silo-plugins-requests-seerr#7) plugins report progress. The Apple (Silo-Server/silo-apple#537) and Android (Silo-Server/silo-android#409) changes are ready on branches and follow once this merges.

Validation

  • The full CONTRIBUTING gate passes:
    • Go: build, vet, gofmt, make lint-changed (base origin/feat/request-revamp) and make test-go.
    • Contracts: every verify-* target and the committed-artifact test.
    • Web: lint, format, build, bundle budget and make test-web.
  • DB-backed tests for requests, the lock helper and the tasks pass against a migrated database, including a reconcile pass on a two-connection pool. make test-db-pins on a fresh database: 23 of 23 pins pass.
  • End to end, I ran a disposable sandbox with both plugins built from their PR branches and stub Radarr, Sonarr and Seerr servers:
    • A request moved from queued to downloading and showed its percentage and estimate. The percentage advanced every minute without a reload, and the progress cleared on completion.
    • A season pack, and a held-back pack listed per episode, each counted once.
    • Stalled and import-blocked downloads showed their phases.
    • A server that stopped answering kept its last progress for 15 minutes, then it cleared.
    • Seerr 4K progress came from the 4K download list, and an idle Seerr request wasn't polled every minute.
    • The web, Android phone and TV, iOS and tvOS all showed the same labels.
  • Not exercised in the sandbox:
    • The refresh task going idle, which the running test downloads prevented; a unit test covers it.
    • The final import-to-available step, which is unchanged.
    • A plugin without the flag.
    • Several servers in one cluster; the lock behavior is covered by DB-backed tests.

Evidence: https://evidence.siloserver.org/r/silo-server/request-download-progress/

Risks

  • Migration: additive nullable columns, with CHECK constraints on the byte counts.
  • Load: the one-minute task adds one plugin call per downloading request with progress, capped at 200 requests and 90 seconds per pass. An idle server doesn't run it.
  • Lock waits: a reconcile pass that overlaps a refresh now waits for it, up to two minutes, instead of skipping.
  • Base branch: this PR targets feat/request-revamp, which feat(requests): route requests by rules, request seasons, and rework request pages and admin #1632 merges into main. Before merge, bump the SDK pin to v0.19.0.

Checklist

  • I read and can explain the complete diff.
  • This pull request addresses one concern.

AI Disclosure

  • Harness: T3 Code (Claude Code agent harness)

  • Tool(s): Claude Code, including workflow subagents; GitHub CLI; Playwright MCP

  • Model(s): claude-opus-5-5

  • Involvement: Fully AI-generated at the maintainer's request. The agent wrote the change and ran the checks and the sandbox validation.

  • Adversarial review: Independent Claude subagents reviewed the backend and the web/API halves over two rounds, each in a fresh context. They covered the progress invariants, fast-pass selection, locking, SQL, the API mapping and web polling. They found these problems, all fixed:

    • Targets that never report progress took the front of every refresh batch.
    • Reconcile could skip while a refresh held the lock.
    • A refresh had no time limit.
    • Stale estimates stayed on screen.
    • Title pages polled TMDB on every refresh.
    • Unanswered progress never expired.

    A sandbox validation pass and a completeness critic then found four display defects, also fixed: bar width, a stale raw status, and two Apple issues.

🤖 Generated with Claude Code

Note

Show request download progress in API responses and web UI

  • Adds a hidden refresh_request_downloads task that polls plugins for progress on active downloading targets every minute, bounded to 200 requests and a 90-second budget, and stores per-target progress in new media_request_targets columns (migration)
  • Reconciliation and the refresh pass now coordinate through a request-target write advisory lock plus the cluster reconcile lock, so a reconcile pass waits (up to 2 minutes) for an in-progress refresh instead of running concurrently
  • API v2 responses include a new RequestDownload object on requests, targets, and media detail, with phase, percentage, byte counts, ETA, and download count; the mediaRequestOf mapper now exposes download-server identities, raw statuses, and errors only to admins
  • The web UI renders a new RequestDownloadProgress component in the Requests list, admin queue, details dialog, and title detail, and refetches request queries every 30 seconds while any loaded request or target has download data
  • TMDB GetMediaDetail now caches successful details for 2 minutes and shares concurrent in-flight fetches for the same title
  • Behavioral Change: request reconciliation now fails when the request-target write lock stays unavailable past the 2-minute wait, and refresh passes skip while the lock is held; go.mod pins silo-plugin-sdk to a commit-based version

Macroscope summarized 8a50d47.

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) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: bbd2f50f-44c8-400f-80f2-06dffafe7341

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-29T01:04:48.852456Z 8a50d47 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8d2ee249e8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/taskmanager/tasks/reconcile_requests.go Outdated
Comment thread internal/requests/types.go
…ests 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) <noreply@anthropic.com>
@macroscopeapp

macroscopeapp Bot commented Sep 28, 2026

Copy link
Copy Markdown

The Validation tasks: line omits the native Requests C2 tasks: Silo-Server/silo-apple#328, Silo-Server/silo-apple#329, Silo-Server/silo-android#338, and Silo-Server/silo-android#339. The new one-minute refresh can expose downloading → completed sooner on a fresh request read; these clients consume raw request status, even though they ignore the new optional download fields. All four task results are currently Not run, so there is no recorded result to invalidate or unblock.

Suggested replacement:

Validation tasks: reaches #1202 C3, #1203 C1, #1204 C2, Silo-Server/silo-apple#328 C2, Silo-Server/silo-apple#329 C2, Silo-Server/silo-android#338 C2, Silo-Server/silo-android#339 C2 (none run yet)

Automated check: Macroscope check run agent (gpt-6-luna). No validation was performed.

Posted via Macroscope — v1 validation impact

…equest-download-progress

# Conflicts:
#	contracts/api/v2/fixtures/get_system_info_ok.json

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6f3831f556

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/requests/repository.go
Quick104 and others added 2 commits September 28, 2026 21:23
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) <noreply@anthropic.com>
…ts (#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) <noreply@anthropic.com>
@Quick104
Quick104 merged commit 900ecfe into feat/request-revamp Sep 29, 2026
3 of 7 checks passed
@Quick104
Quick104 deleted the feat/request-download-progress branch September 29, 2026 00:59

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8a50d47ea5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +116 to +118
reports, err := s.targetReportsProgress(ctx, fc, req.MediaType, t)
if err != nil {
return reconcileUnchanged, false, err

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rotate candidates after capability read errors

When reading reports_download_progress fails for a candidate, this returns before either applyTargetStatuses or MarkTargetDownloadChecked advances its download_checked_at. Because ListDownloadingRequests always selects the oldest 200 candidates, a persistent capability-metadata error affecting the first batch makes those same requests fill every pass and prevents healthy downloads behind them from ever refreshing. Preserve the error, but mark the affected progress-bearing targets as checked so failures still take their turn in the rotation.

AGENTS.md reference: AGENTS.md:L73-L75

Useful? React with 👍 / 👎.

Quick104 added a commit that referenced this pull request Sep 29, 2026
…request pages and admin (#1632)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* test(animeids): parse verbatim entries from the published anime list

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* test(web): give the group-limit save test a complete request body

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* test(requests): name the split-service test's Radarr so goconst stays quiet

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* test(web): cover the request queue's running-action spinner

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* refactor(requests): name the route tier fields once

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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 answer…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant