Compare commits

...
Author SHA1 Message Date
QuickandClaude Fable 5 712969d351 fix(naming): let trailing bare IMDb ids anchor group identity
hasStructuredIDAnchor only consulted ParseStructuredFolderIDs, so folders
tagged with an unbracketed trailing id ("Eggs Run (2021) tt8049994") still
went ambiguous on title conflicts. ParseFolderIDs now returns only explicit
evidence (structured tags plus unambiguous tt-prefixed ids), so use it for
the anchor check. Covers the last 3 ambiguous-with-IDs groups on dev.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 16:58:00 -04:00
QuickandClaude Fable 5 9ada640fcc fix(naming): merge trailing bare IMDb id with structured folder tags
ParseFolderIDs returned early on any structured tag, so a folder like
"Show [tvdbid-81189] tt1375666" lost the trailing IMDb id. Parse both and
merge, with an explicit structured imdb tag still taking precedence over a
trailing bare id. Matches Jellyfin, which resolves each provider key
independently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 16:35:19 -04:00
Quick 296d366af9 Merge remote-tracking branch 'origin/main' into worktree-scanner-matching-fixes 2026-06-09 15:51:28 -04:00
QuickandClaude Fable 5 46807e079c fix(naming,metadata): anchor identity on provider IDs, drop bare-numeric IDs, back off match queue retries
Three scanner/matching fixes validated against dev data:

- Group identity: explicit structured provider tags ({tmdb-...},
  [tvdbid-...]) now anchor a group's identity, so folder/file title
  conflicts (renamed releases in Radarr-tagged folders) no longer mark
  groups ambiguous and silently exclude them from matching. 3,049 of
  3,121 ambiguous groups on dev carried explicit tags.

- ParseFolderIDs: remove bare trailing numeric ID parsing entirely,
  mirroring Jellyfin's path-attribute model (bracketed key tags plus
  unambiguous tt-prefixed IMDb ids only). Titles ending in numbers
  ("District 9", "Beverly Hills 90210", "Season 01") were misparsed as
  trusted IDs, which suppresses title search and silently mismatches.
  The folderType parameter existed only to type bare numerics, so it
  is gone too.

- Match queues: replace the constant 15s/30s retry delay with shared
  exponential backoff capped at 24h. Terminal failures ("no metadata
  found from any provider") had rows at 15k+ attempts hot-looping
  every 15s on dev.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 15:51:28 -04:00
QuickandClaude Fable 5 2fe2c918f4 feat(search): add Media/Audiobooks/All scope with remembered per-user default
Search previously mixed audiobooks into movie/series results with no way to
separate them beyond single-type filters.

Backend: accept a new "video" group media scope (movies + series) anywhere a
media_scope is valid, expanded centrally via MediaScopeItemTypes into the
search item-type list, browse comma-list Type filter, and a type = ANY(...)
condition in the query executor. Register a user-scoped search.media_scope
setting (all|video|audiobook, default video).

Frontend: Media / Audiobooks / All chips on the search results page that
filter results and persist the choice as the user's default; the global
search typeahead follows the same preference. An explicit URL ?type= always
wins (with type=all as an unscoped sentinel), and the filter-bar dropdown
gains a Movies & Series option.

The API surface is additive, so Android/Apple clients are unaffected until
they adopt the new scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 15:46:11 -04:00
QuickandClaude Fable 5 adbdd86ac5 fix(catalog): restore search broken by unaliased COALESCE in scored CTE
The audiobook NULL-poster work wrapped nullable string columns in bare
COALESCE(...) expressions inside itemColumns/qualifiedItemColumns. Postgres
names an unaliased COALESCE output "coalesce", so the search query's scored
CTE stopped exposing poster_path etc. and every /catalog?source=query
request failed with SQLSTATE 42703, surfacing as a 500.

Alias each coalesced column back to its own name, deduplicate the three
copies of the column list into a shared itemColumnNames slice, and give the
search CTE's GROUP BY its own alias-free reference list (output aliases are
invalid in GROUP BY). Regression tests pin both invariants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 15:45:58 -04:00
QuickandClaude Fable 5 b9a9613fd0 feat(web): redesign library create/edit dialog with sectioned navigation
Replace the single scrolling library form dialog with a Plex-style
editor: a left-hand navigation rail with General, Folders, Metadata,
and Advanced panes, inline validation that jumps to the offending
section, a visual icon-card library type picker, and a sticky footer
with Cancel/Save actions.

Form state, provider-chain logic, and save mutations move into a
shared useLibraryForm hook with reusable field-group components, so
the setup wizard keeps its inline layout without duplicating logic.
LibraryPosterSection moves out of AdminLibraries.tsx into its own
component, and the Advanced toggle cards now use semantic theme
tokens instead of hardcoded white overlays that broke light themes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 14:51:38 -04:00
QuickandClaude Fable 5 cb6272ef25 fix(ratelimit): keep rate limit settings usable when limiter is disabled
The /admin/rate-limits/config routes were only mounted when the limiter
was running, and the limiter is only constructed at boot when
ratelimit.enabled is true. Disabling rate limiting from the UI and
restarting therefore 404'd the settings page permanently, with no way
to re-enable it without editing the database.

Mount the routes whenever the settings store exists and make the
handler tolerate a nil limiter: saves always persist, hot-reload is
skipped when nothing is running, and the PUT response reports
restart_required (enabling with no limiter built, or switching backend
on a running one). GET now exposes active/active_backend so the UI can
tell saved config apart from what the process is enforcing.

The settings page shows a persistent restart banner driven by that
server state, with the same restart flow as other settings pages via a
RestartServerButton extracted from SaveBar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 14:23:28 -04:00
QuickandClaude Fable 5 89f005dfa8 feat(web): regroup admin navigation and settings into intuitive sections
Reorganize the admin sidebar into Overview / Content / Automation /
Users / System so media pipeline tools no longer hide under Users and
Settings is not buried in a seven-item Server grab-bag. Group the flat
13-tab admin settings page into Server / Media / Connections / Data
sections, and split the Integrations tab into dedicated Subtitles
(search providers + AI translation) and Watch Providers (Trakt/Simkl
OAuth) pages, leaving Integrations for one-off keys like MDBList. All
routes and ?tab= ids are unchanged so deep links keep working.

Extract the shared grouped-rail markup used by the admin sidebar, admin
settings nav, and user settings nav into a SideNav component, and
remove the orphaned admin-settings PluginsSettings page (superseded by
/admin/plugins).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 14:23:10 -04:00
2c714e4ef2 feat(requests): pluginize request fulfillment behind request_router.v1 (#104)
* docs: design spec for pluginizing requests fulfillment

Pluginize the requests fulfillment backend behind an agnostic
request_router.v1 capability (high seam: whole-request fulfiller).
Host keeps lifecycle/quota/policy/quality-governance and a generic
two-tier connection registry; plugins own routing+submission+status.
First plugin extracts multi-instance Sonarr/Radarr; Seerr follows in
a separate spec. Preserves autoscan reuse of arr connection rows.

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

* docs: implementation plan for requests pluginization

Three-phase plan: (1) request_router.v1 SDK capability, (2) new
silo-plugin-requests-arr plugin extracting multi-instance Sonarr/Radarr,
(3) host refactor routing fulfillment through the plugin while keeping
quality governance, target records, and autoscan connection reuse host-side.

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

* feat(db): generalize request_integrations into a two-tier connection registry

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

* feat(requests): add generic connection fields to Integration + repo mapping

* feat(pluginhost): typed RequestRouter capability client + resolver

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

* feat(requests): plugin-backed RequestRouterProvider seam

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

* feat(requests): route fulfillment through RequestRouterProvider; host keeps quality governance

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

* fix(requests): base auto-approve gate on router connection model

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

* feat(api): wire plugin-backed request router at both service sites

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

* refactor(requests): remove in-host Sonarr/Radarr fulfillment code

* test(autoscan): lock request-integration reuse after connection generalization

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

* feat(web): plugin-driven request integration config form

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

* feat(api): echo router connection fields in integration response

* fix(requests): retry dropped qualities, contain to one router installation, dedupe targets

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

* fix(requests): harden plugin trust boundary (validate targets, contain bad connections, media-type routing)

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

* fix(requests): tighten auto-approve gate, restore default/4k validation, propagate config-encode error, drop itoa wrapper, test status/options translation

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

* perf(requests): resolve integrations/settings/secrets once per reconcile cycle

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

* fix(web): dedupe config helpers, preserve zero profile id, stabilize installation default, drop redundant options write

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

* docs: design spec for schema-driven plugin config form

Extends AdminFormDescriptor into a full form-description language (dynamic
options, multi-select, conditional visibility, sections, validation) + a
plugin Validate RPC, rendered by one reusable SchemaForm engine. Retires the
bespoke arr connection form and integrationOptionsFromRouter so any
request_router backend renders its config UI from manifest data with zero
host changes. Addresses code-review finding #9.

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

* docs: implementation plan for schema-driven plugin config form

Six phases: SDK AdminFormDescriptor extensions + Validate RPC; reusable
SchemaForm renderer (refactor PluginConfigForm onto it); host Validate
plumbing + generic options + legacy-column derivation + retire
integrationOptionsFromRouter; requests admin page swap to SchemaForm with
per-plugin grouping; arr manifest enrichment + Validate impl; verification.

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

* feat(web): extend plugin admin-form TS types (sections, conditions, validation, multi-select)

* feat(web): schema-form pure utils (show_when, validation, value coercion)

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

* feat(web): SchemaForm renderer (controls, sections, show_when, dynamic options, errors)

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

* refactor(web): render PluginConfigForm via the shared SchemaForm engine

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

* feat(requests): RequestRouter Validate client + provider seam

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

* feat(requests): plugin Validate on save, generic options, derive legacy columns from plugin_config

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

* feat(api): generic options response + 400 field_errors on plugin validation failure

* feat(web): generic request-integration options type + surface validation field_errors

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

* feat(web): render request connections via SchemaForm; per-plugin grouping; retire bespoke arr form

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

* fix(web): silent connection-options probe with inline failure status (no toast spam)

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

* fix(api): serialize admin_form sections/show_when/dynamic_options/validation to the client

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

* fix(web): drop show_when-hidden fields from buildSchemaValues payload

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

* feat(requests): pass requester user id as int64 (no truncation)

* refactor(requests): drop legacy arr columns; plugin_config is sole source of truth

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

* fix(requests): backfill api key in plugin validate; centralize validation 400; drop duplicate host cross-field check; guard admin-form serializer

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

* fix(requests): refuse stored api key reuse when base_url changes (security hardening)

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

* feat(web): SchemaForm regex-guard, default_value, type-driven coercion, validity callback

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

* fix(web): connection-options latest-wins + narrowed deps + clear stale errors; auto-select; type-driven persist; reuse types

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

* docs: design spec for silo-plugin-requests-seerr (request_router.v1 backend)

* docs: implementation plan for silo-plugin-requests-seerr

* docs(spec): FindExistingRequest uses /api/v1/request (carries request id)

* docs(spec): seerr hardening — id-recovery, 404 terminal, media-status, sort pin, single missing-tmdb message

* docs: design spec for shared plugin-platform SDK helpers (code-review #10)

* docs: plan for plugin-platform SDK helpers (#10) + spec fix (inline broker wiring, no import cycle)

* docs: design spec for typed 4K quality-tier signal (code-review #9)

* docs: implementation plan for typed 4K quality-tier signal (#9)

* feat(requests): stamp is4k per quality (host owns the 4K-tier fact)

* fix(requests): store capability sub-id, not the type, in request_integrations

request_integrations.capability_id carried the capability TYPE
("request_router.v1") instead of the capability sub-id ("arr"/"seerr").
The host resolves a router plugin via
requireCapability("request_router.v1", id), which keys on (type, id), so
storing the type resolved to no capability: every save/options/fulfill
500'd ("Request operation failed" / "no fulfillment backend configured")
in ~1ms, before the arr/Seerr API was ever contacted. The path was
internally split-brained (the fulfillment filter matched the type while
the dispatcher needed the sub-id), so it never worked end-to-end; the
unit tests hid it behind a fake provider that skips requireCapability.

Align capability_id with the scan_source/metadata convention (sub-id):
- validateInstance: require a non-empty sub-id; drop the default-to-type
  and the "!= request_router.v1" reject.
- resolveRouterConnections / integrationConfigured / unbound-guidance:
  match on a non-empty capability, not type equality.
- repository: persist capability_id verbatim (never default to the type).
- web AdminRequests: send the selected plugin's capability.id in both the
  options probe and the save payload (was a hardcoded type constant).
- migration 20260608131649: backfill capability_id from each bound
  installation's request_router.v1 capability and drop the column's
  misleading default. Unbound legacy rows are left for admin re-save.

Tests: validateInstance now requires the sub-id, and the selected sub-id
must reach the plugin Validate RPC (fakeRouterProvider records it).

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

* feat(web): polish request connection cards (grouped toggles + option loading states)

The schema-driven connection cards rendered each boolean as its own
bordered, double-labeled box and showed dynamic SELECTs (root folder,
quality profile, tags) as empty controls with a single "Loading options…"
line while the host probed the service.

- Toggles render as a cohesive settings list: consecutive switches collapse
  into one bordered, divided container; each row is toggle-first with the
  label + description hugging beside it (no stranded whitespace between a
  short label and its switch). Honors show_when, so conditional toggles
  still group correctly.
- Dynamic SELECT/MULTI_SELECT fields show a per-field spinner + shimmer
  skeleton while options load, and only when there's nothing to show yet —
  a background re-probe never flashes over the operator's current value.
- Sections get a softer surface and clearer titles; the card's enable
  switch is labeled Enabled/Disabled; the options-load failure is a proper
  inline alert with retry guidance.

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

* fix(requests): treat "Any"/no-cap playback ceiling as 4K-allowed

allowedQualities decided whether to also request 2160p with
`CompareQuality(ceiling, PlaybackQuality4K) >= 0`. But an "Any" max
playback quality resolves to an empty ceiling ("no cap"), and in
qualityRank "" is the LOWEST rank (0) — so CompareQuality("", "2160p")
returns -1 and 4K was dropped. A requester with unlimited playback quality
only got a 1080p request, never the 4K one.

Use access.QualityAllowed(PlaybackQuality4K, ceiling), which already
encodes "empty ceiling == no cap == allows everything". Now:
- "" / "Any"  -> 1080p + 2160p
- "2160p"     -> 1080p + 2160p
- "1080p"     -> 1080p only
- resolver error still fails safe to the HD ceiling.

Tests: add an "any/no-cap ceiling adds 2160p" case; the unknown-quality,
status-coercion, dedup, and per-quality-idempotency submit tests now pin
an explicit HD ceiling (they relied on the old empty-default == HD-only
behavior and were not about 4K entitlement).

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

* docs: design spec for collapsible Library + anime gate/nesting (request card UI, Spec A)

Spec A of two for the request connection card UX: Library section becomes
collapsible/collapsed (auto-expanding on validation errors) and the anime
override fields move into a single gated section below Library instead of
popping out as a detached sibling card. Single-default enforcement is Spec B.

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

* docs: implementation plan for collapsible Library + anime gate/nesting (Spec A)

Task-by-task TDD plan: SchemaForm auto-expand-on-error + nested-field
affordance (silo-server), arr manifest regroup (collapsible Library, anime
gate section), then build/deploy/reinstall + manual verify.

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

* feat(web): auto-expand collapsible schema sections that have validation errors

SchemaFormSection now accepts a forceOpen prop; when any field in the section
has a mergedError (client validation or server error), the section expands
automatically so required-field setup can never be hidden behind a collapsed
accordion. The operator's manual toggle is preserved via a nullable userOpen
state that only takes effect when forceOpen is false.

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

* feat(web): indent show_when-revealed schema fields to read as nested

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: design spec for schema-driven single-default exclusivity enforcement (Spec B)

At most one connection per service_kind may be the HD default (is_default) or
4K default (is_default_4k). Generic exclusivity: a new AdminFormField
exclusive_group_field declares the rule, the plugin Validate enforces it
against host-supplied siblings (config only, no creds), and the admin UI
auto-clears conflicts as you toggle. Host stays plugin-agnostic. Forward-only;
no migration.

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

* docs: implementation plan for single-default exclusivity enforcement (Spec B)

Five TDD tasks across 3 repos: SDK proto (siblings + exclusive_group_field)
+ buf regen; arr Validate cross-sibling + manifest; host gathers siblings
(config-only) into Validate; frontend generic mutual-exclusion helper; then
re-vendor/rebuild/redeploy + plugininstall.

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

* feat(requests): pass sibling connections to plugin Validate for cross-connection rules

Adds siblings []ResolvedRouterConnection to RequestRouterProvider.Validate so
the plugin can enforce cross-connection invariants (e.g. one default per
service_kind) without the host resolving sibling credentials. The new
siblingConnections helper gathers other connections on the same installation,
carrying only ID + PluginConfig. Vendor updated to the Task 1 SDK version that
carries ValidateRequest.Siblings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(web): auto-clear mutually-exclusive defaults across request connection cards

Adds generic applyExclusivity helper and wires it into updateCardConfig so
turning on a field with exclusive_group_field proactively clears the same
field on sibling cards sharing the same group value, matching server-side
enforcement with a proactive UX.

* docs: design spec for single-flighting plugin client launch (cold-start herd fix)

Concurrent ensureClient calls for a cold installation each spawn a redundant
plugin process (Host.Start releases its lock during launch). Wrap ensureClient
in a per-installation singleflight.Group so concurrent first-use collapses to
one launch. Host-only fix; surfaced while testing the request-router feature.

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

* docs: implementation plan for single-flighting plugin client launch

TDD: concurrency tests (herd collapses to one launch, warm-cache reuse,
distinct installations stay parallel, failed launch propagates) + the
singleflight wrapper around ensureClient; then rebuild/redeploy + verify.

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

* fix(plugins): single-flight ensureClient to prevent cold-start launch herd

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(requests): harden capability containment + dedupe eligibility; UI/migration cleanups

Addresses /code-review high findings on the previously-unreviewed commits:
- resolveRouterConnections contains fulfillment to the first chosen
  (installation, capability) and locks only after a connection's key resolves,
  so a plugin exposing >1 request_router capability never mixes connections and
  a skipped bad-key connection never pins the capability (+ test).
- extract eligibleRouterConnection, shared by resolveRouterConnections and
  integrationConfigured so the auto-approval gate and fulfillment filter can't
  drift.
- SchemaForm: shared FieldDescription helper (field/switch/section); key switch
  groups by position so a show_when reveal doesn't remount the group (focus loss).
- migration backfill uses a deterministic correlated subquery instead of a join
  cross-product when an installation exposes multiple request_router capabilities.

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

* docs: design spec for opt-in Seerr per-user requester mapping

Per-connection requester_mode (admin default | mapped). In mapped mode the host
pushes the requester email/username into the Fulfill descriptor and the seerr
plugin resolves/creates the matching Seerr user by email with operator-chosen
default permissions, attributing the request (and gating Seerr-side approval via
the auto-approve permission). Spans SDK (descriptor fields), host (extend
UserIdentityLookup with email + a requester resolver), and the seerr plugin
(Seerr user API + mapping). Fallback to admin on any failure.

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

* docs: implementation plan for Seerr per-user requester mapping

Five TDD tasks across 3 repos: SDK descriptor fields (requester_email/username);
host resolves identity (UserIdentityLookup+email, RequesterIdentityResolver,
populate descriptor at both Fulfill sites); seerr config+user API (find/create
by email, exported PermissionBits); seerr Fulfill mapping + admin_form; then
re-vendor/rebuild/redeploy + plugininstall (installation 6).

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

* docs: make Seerr unmapped-requester behavior a toggle (admin fallback | fail request)

Per user feedback: require_mapped_user switch (default off = admin fallback,
on = fail the request). Updates spec + plan Tasks 3/4 (config field, Fulfill
honoring the toggle via a mapFailed signal, a new test, and the manifest switch).

* feat(requests): resolve requester email/username into the Fulfill descriptor

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: design spec for simplified Seerr mapped-user permissions

Reduce the 5 permission toggles to two (request_4k_all + auto_approve);
1080p always granted; remove manage_requests; 4K eligibility per-user from the
request's qualities (host-decided, same as arr) with a blanket override toggle.
Seerr-plugin-only; permission-only override (host still gates 4K requests).

* docs: implementation plan for simplified Seerr mapped-user permissions

Two tasks (seerr-plugin-only): replace the 5 perm toggles with request_4k_all +
auto_approve (1080p always; 4K from request qualities via userPermissions;
remove PermManageRequests/PermissionBits; manifest + json_schema), then rebuild
+ reinstall (installation 6). No host/SDK change.

* docs: design spec for host rebase onto main + #95 credential-model adoption

Per-commit rebase of our 68 request-router commits onto the force-pushed
origin/main (drops 188 patch-equivalent). At the credential-path conflicts, adopt
#95's inline secret.Cipher model: keep our plugin columns + #95's encrypt/decrypt
in repository.go; drop our SecretResolver and read in.APIKeyRef directly in
service.go; wire NewRepository(pool, dataCipher). #39-area conflicts take ours
(our pluginization supersedes it). Security review + SECRET_KEY deploy note.

* docs: implementation plan for host rebase + #95 credential adoption

Four tasks: (1) guided per-commit rebase onto origin/main, take-ours on
credential files so it builds; (2) TDD integration commit adopting #95's
secret.Cipher (encrypt/decrypt in repository.go, drop SecretResolver, read
APIKeyRef directly, wire NewRepository(pool, cipher)); (3) security review;
(4) pin published SDK v0.6.0, push fork, open host PR with SECRET_KEY deploy note.

* chore(rebase): restore scan-source service methods + temp requests-repo arity

Post-rebase conflict fixups: take-ours on internal/plugins/service.go dropped
origin's ScanSourceClientByPluginID (independent upstream capability) — restored.
mediarequests.NewRepository temporarily 1-arg to match our pre-#95 repo; Task 2
restores the cipher arg when adopting #95's at-rest credential model.

* feat(requests): adopt at-rest credential cipher (#95) for plugin api keys; drop SecretResolver

* build: pin published silo-plugin-sdk v0.6.0 (drop local replace)

* test(requests): guard at-rest cipher round-trip + empty-key auto-approval (code-review)

Max-effort code review of the #95 credential integration. Fixes the actionable
findings:
- TestEncryptAPIKeyRoundTripAndAAD: pins encryptAPIKey<->DecryptIfEncrypted
  inversion, the id-bound apiKeyAAD == secret.RowAAD(...) match (so #95's backfill
  rows decrypt), the blank-key "" sentinel, and row-bound AAD — the security-
  critical invariants had no automated guard (no DB harness for scanIntegration).
- TestCreateRequestAutoApprovalEmptyKeyTreatedAsUnconfigured: pins that a keyless
  connection reads as unconfigured (request stays pending, never submitted), so
  integrationConfigured and resolveRouterConnections can't drift.
- Fix stale fulfillContext comment (referenced a resolved-API-key cache removed
  with SecretResolver).

Assessed-not-changed (documented): decrypt-error-fails-closed and failed-backfill
behaviors are origin/main #95 design we adopt; nil-cipher is unreachable in prod
and matches the codebase-wide no-guard pattern.

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

* build: drop stale machine-local SDK replace comment from go.mod

The replace directive was already removed when v0.6.0 was pinned (3410df7);
this leftover comment falsely claimed a local replace still existed.

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

* style(web): prettier-format schema-form utils to 100-col width

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

* docs: drop internal superpowers specs/plans from PR

These design specs and implementation plans are internal development
artifacts; keep them out of the upstream PR diff.

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

* fix(metadata): exclude providers from content levels they don't declare

ResolveChain falls back to every enabled metadata provider when a library
+ content-level has no enabled chain entry. That fallback was media-type
blind: a provider declaring default_priority only for an unrelated level
(e.g. an audiobook provider declaring {"audiobook": N}) was kept in the
list (merely sorted last) and invoked for video content levels.

In production this made silo.audiobook-metadata hammer external audiobook
APIs with anime/movie/series titles every scheduled enrichment pass
(MatchWorker, 30s) for the season/episode levels that had no enabled chain
entry. Disabling the chain entries did not help because the fallback never
consults them; only disabling the installation removed it from the global
set.

Treat a non-empty default_priority map as the provider enumerating the
content levels it supports: in resolveEnabledProvidersByPriority, exclude
providers whose declared map omits the requested level instead of ranking
them last. Providers that declare no default_priority make no claim and
stay eligible everywhere (legacy behavior).

Fixes #105

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

* fix(plugins): isolate singleflight launch from leader ctx cancellation

The deduped ensureClient launch ran doEnsureClient under the leader caller's
ctx, so if that caller's request was canceled/timed out mid-launch the shared
plugin start was torn down and the error propagated to every waiter. Run the
launch under context.WithoutCancel so a single caller cannot cancel work the
other waiters depend on (values preserved for tracing/auth). (CodeRabbit)

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

* fix(api): nil-guard request-router wiring

RequestRouterClient dereferenced a.Svc unconditionally and AttachRequestRouter
called SetRouterProvider even with nil deps, so a build without the plugin
service would panic instead of degrading. Guard both: the adapter returns a
controlled error and AttachRequestRouter no-ops, leaving fulfillment to fail
with the existing "no backend configured" path. (CodeRabbit)

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

* fix(web): correct value coercion + track capability sub-id in request form

- schemaForm: Boolean("false") was true; parse string booleans explicitly.
  array:num now coerces decimals ("1.5"), array:int stays integer-only.
- AdminRequests: track capability_id alongside installation_id (composite
  <Select> value) so a multi-capability installation resolves the exact
  backend; reset pluginConfig when the selected plugin changes so plugin A's
  keys never reach plugin B's options probe/save. (CodeRabbit)

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

* fix(requests): address request-router review findings

* fix(requests): handle router review edge cases

* fix(web): resolve schema form build casing

* fix(requests): skip unconfigured 4k fulfillment targets

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-09 13:00:48 -04:00
66a4146fc2 fix(metadata): exclude providers from content levels they don't declare (#106)
ResolveChain falls back to every enabled metadata provider when a library
+ content-level has no enabled chain entry. That fallback was media-type
blind: a provider declaring default_priority only for an unrelated level
(e.g. an audiobook provider declaring {"audiobook": N}) was kept in the
list (merely sorted last) and invoked for video content levels.

In production this made silo.audiobook-metadata hammer external audiobook
APIs with anime/movie/series titles every scheduled enrichment pass
(MatchWorker, 30s) for the season/episode levels that had no enabled chain
entry. Disabling the chain entries did not help because the fallback never
consults them; only disabling the installation removed it from the global
set.

Treat a non-empty default_priority map as the provider enumerating the
content levels it supports: in resolveEnabledProvidersByPriority, exclude
providers whose declared map omits the requested level instead of ranking
them last. Providers that declare no default_priority make no claim and
stay eligible everywhere (legacy behavior).

Fixes #105

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:31:52 -04:00
Quick b7439884ec fix(tasks): skip idle audiobook maintenance 2026-06-08 22:46:44 -04:00
QuickandGitHub 7c64c06e3d Redirect Jellyfin image routes to upstream URLs (#101)
* Redirect Jellyfin image routes to upstream URLs

* fix(jellycompat): proxy images for incompatible clients
2026-06-08 22:36:22 -04:00
QuickandGitHub 2fa30379ad fix(jellycompat): include Wholphin display preference dimensions (#102) 2026-06-08 22:36:07 -04:00
5855cebe75 fix(audiobooks): complete playback and catalog parity (#96)
* fix(audiobooks): complete playback and catalog parity

* fix(web): allow podcast continue targets

* fix(audiobooks): use folder sidecar covers during scan

* fix(audiobooks): read nullable poster paths during cover scan

* feat(home): split continue listening sections

* fix(catalog): coalesce nullable media artwork fields

* feat(playback): surface audiobook sessions in admin activity

* fix(audiobooks): address playback review feedback

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-08 21:55:35 -04:00
cf4e080bf4 fix(jellycompat): Wholphin (jellyfin-sdk-kotlin) playback & genre compatibility (#100)
* fix(jellycompat): match MediaSourceId across UUID formats (compact vs dashed)

* fix(jellycompat): honor ImageTypes=Backdrop as a filter on /Items

Wholphin genre cards request /Items?imageTypes=Backdrop&limit=1&sortBy=Random
and assume every returned item has a backdrop. Silo ignored ImageTypes, so a
random pick could lack a backdrop (BackdropImageTags: null), crashing Wholphin.

Push the filter down to the catalog browse SQL
(NULLIF(BTRIM(backdrop_path),'') IS NOT NULL) so random/limited selections only
ever consider backdrop-having items; empty genres correctly return [].

* fix(jellycompat): case-insensitive PlaySessionId + api_key in stream auth

Wholphin's jellyfin-sdk-kotlin builds its own direct-play URL
(/Videos/{id}/stream?static=true&playSessionId=...&mediaSourceId=...) with a
lowercase 'playSessionId', no api_key, and no auth header (ExoPlayer's data
source drops it). PlaybackSessionAuth read 'PlaySessionId'/'PlaySessionID'
case-sensitively, so the fallback never matched -> 401 on every direct-play
stream -> forced (often failing) transcode fallback. Resolve PlaySessionId via
newCaseInsensitiveQuery, and likewise accept case-variant api_key in
ExtractToken.

* fix(jellycompat): support Wholphin season item queries

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-08 21:05:33 -04:00
339ed20074 fix(audiobooks): improve poster enrichment throughput (#94)
* fix(audiobooks): improve poster enrichment throughput

* fix(audiobooks): enable ABS compat by default

* chore(audiobooks): remove stale feature flag setting

* fix(audiobooks): validate scoped scan roots

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
2026-06-08 18:17:08 -04:00
216 changed files with 13049 additions and 5309 deletions
+2 -3
View File
@@ -72,8 +72,6 @@ import (
"github.com/Silo-Server/silo-server/internal/ratelimit"
"github.com/Silo-Server/silo-server/internal/recommendations"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
"github.com/Silo-Server/silo-server/internal/requests/radarr"
"github.com/Silo-Server/silo-server/internal/requests/sonarr"
"github.com/Silo-Server/silo-server/internal/s3client"
"github.com/Silo-Server/silo-server/internal/scanner"
"github.com/Silo-Server/silo-server/internal/scanqueue"
@@ -1541,7 +1539,8 @@ func main() {
catalog.NewProviderIDRepository(deps.DB),
),
)
requestReconcileSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil))
requestReconcileSvc.SetRequesterIdentityResolver(plugins.RequesterIdentityFromLookup(plugins.NewPgUserIdentityLookup(deps.DB)))
api.AttachRequestRouter(requestReconcileSvc, pluginService)
if userStoreProvider != nil {
reconcileResolver := access.NewResolver(
auth.NewUserRepository(deps.DB),
+1 -1
View File
@@ -76,7 +76,7 @@ require (
)
require (
github.com/Silo-Server/silo-plugin-sdk v0.5.0
github.com/Silo-Server/silo-plugin-sdk v0.6.0
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
+2 -2
View File
@@ -2,8 +2,8 @@ entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ=
entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
github.com/Silo-Server/silo-plugin-sdk v0.5.0 h1:0yJpD0RP1AetGsWVCXoPih2ggLjN8hVp8blS+2RkIWU=
github.com/Silo-Server/silo-plugin-sdk v0.5.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
github.com/Silo-Server/silo-plugin-sdk v0.6.0 h1:Gi9TdH9kt7b8X4xRXH493/nSYb9n0GO4VCWmlll0hKI=
github.com/Silo-Server/silo-plugin-sdk v0.6.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
+33 -1
View File
@@ -1,6 +1,10 @@
package api
import "testing"
import (
"testing"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
)
// TestCheckRequestIntegrationUsable verifies the Fix 4 gate: a reused Requests
// connection is rejected when the integration is disabled or has no base_url, so
@@ -22,3 +26,31 @@ func TestCheckRequestIntegrationUsable(t *testing.T) {
}
})
}
// TestCheckRequestIntegrationUsableGatesReuse is the Task 17 regression test. It
// locks the autoscan→requests credential-reuse behavior after the
// request_integrations table was generalized into the two-tier connection
// registry (Task 9/10): autoscan must keep reusing the Sonarr/Radarr connection
// rows via the same enabled + base_url gate, regardless of the added
// capability/installation columns.
func TestCheckRequestIntegrationUsableGatesReuse(t *testing.T) {
// Enabled + non-blank base_url -> usable (autoscan can reuse the connection).
if err := checkRequestIntegrationUsable("int-1", true, "http://sonarr:8989"); err != nil {
t.Fatalf("enabled integration with base_url should be usable, got: %v", err)
}
// Disabled -> error (autoscan must not poll a disabled connection).
if err := checkRequestIntegrationUsable("int-1", false, "http://sonarr:8989"); err == nil {
t.Fatal("disabled integration must be rejected")
}
// Blank base_url -> error.
if err := checkRequestIntegrationUsable("int-1", true, " "); err == nil {
t.Fatal("blank base_url must be rejected")
}
}
// Compile-time guard: autoscan's RequestIntegrationLookup reads these fields off
// mediarequests.Integration. If any is renamed/removed, autoscan reuse breaks —
// fail the build here rather than silently at runtime.
var _ = func(in mediarequests.Integration) (string, string, bool) {
return in.BaseURL, in.APIKeyRef, in.Enabled
}
+83
View File
@@ -225,6 +225,9 @@ type sortMetricsResponse struct {
ProgressRatio *float64 `json:"progress_ratio,omitempty"`
ViewedAt string `json:"viewed_at,omitempty"`
PlayCount *int `json:"play_count,omitempty"`
Author string `json:"author,omitempty"`
Narrator string `json:"narrator,omitempty"`
SeriesName string `json:"series_name,omitempty"`
}
// browseResponse is the paginated response for the /items endpoint.
@@ -891,10 +894,90 @@ func (h *ItemsHandler) listSortMetrics(
}
case "progress", "date_viewed", "plays":
h.listUserSortMetrics(ctx, items, sortField, store, profileID, metrics)
case "author", "narrator", "series":
h.listAudiobookSortMetrics(ctx, items, sortField, metrics)
}
return metrics
}
func (h *ItemsHandler) listAudiobookSortMetrics(
ctx context.Context,
items []*models.MediaItem,
sortField string,
metrics map[string]*sortMetricsResponse,
) {
if h == nil || h.browseRepo == nil || h.browseRepo.Pool() == nil || len(items) == 0 {
return
}
contentIDs := uniqueItemContentIDs(items)
if len(contentIDs) == 0 {
return
}
var query string
switch sortField {
case "author":
query = audiobookPersonSortMetricQuery(int(models.PersonKindAuthor))
case "narrator":
query = audiobookPersonSortMetricQuery(int(models.PersonKindNarrator))
case "series":
query = `
SELECT target.content_id, BTRIM(s.series_name) AS value
FROM unnest($1::text[]) WITH ORDINALITY AS target(content_id, ord)
JOIN audiobook_series s ON s.content_id = target.content_id
WHERE BTRIM(s.series_name) <> ''`
default:
return
}
rows, err := h.browseRepo.Pool().Query(ctx, query, contentIDs)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var contentID, value string
if err := rows.Scan(&contentID, &value); err != nil {
return
}
value = strings.TrimSpace(value)
if contentID == "" || value == "" {
continue
}
resp := metrics[contentID]
if resp == nil {
resp = &sortMetricsResponse{}
metrics[contentID] = resp
}
switch sortField {
case "author":
resp.Author = value
case "narrator":
resp.Narrator = value
case "series":
resp.SeriesName = value
}
}
}
func audiobookPersonSortMetricQuery(kind int) string {
return fmt.Sprintf(`
SELECT target.content_id, person.name AS value
FROM unnest($1::text[]) WITH ORDINALITY AS target(content_id, ord)
JOIN LATERAL (
SELECT BTRIM(p.name) AS name
FROM item_people ip
JOIN people p ON p.id = ip.person_id
WHERE ip.content_id = target.content_id
AND ip.kind = %d
AND p.name IS NOT NULL
AND BTRIM(p.name) <> ''
ORDER BY p.name ASC
LIMIT 1
) person ON TRUE`, kind)
}
func (h *ItemsHandler) listUserSortMetrics(
ctx context.Context,
items []*models.MediaItem,
+6 -1
View File
@@ -574,6 +574,11 @@ func (h *LibraryHandler) HandleCreateLibrary(w http.ResponseWriter, r *http.Requ
if seedErr := h.SectionRepo.SeedDefaults(r.Context(), "library", &folder.ID, sections.DefaultLibrarySectionsForType(&folder.ID, folder.Type)); seedErr != nil {
slog.Warn("seed default sections for new library", "library_id", folder.ID, "error", seedErr)
}
if sections.IsAudiobookLibraryType(folder.Type) {
if _, seedErr := h.SectionRepo.EnsureHomeContinueListeningSection(r.Context()); seedErr != nil {
slog.Warn("ensure home continue listening section", "library_id", folder.ID, "error", seedErr)
}
}
if _, seedErr := h.SectionRepo.CreateGeneratedHomeLibraryRecentSections(r.Context(), folder.ID, folder.Name, folder.Type); seedErr != nil {
slog.Warn("seed generated home sections for new library", "library_id", folder.ID, "error", seedErr)
}
@@ -2055,7 +2060,7 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin
var candidates []candidate
for _, c := range caps {
defaultPriority := metadata.LookupDefaultPriority(ctx, h.ChainRepo.Pool(), c.PluginInstallationID, level)
defaultPriority := metadata.LookupDefaultPriority(ctx, h.ChainRepo.Pool(), c.PluginInstallationID, c.CapabilityID, level)
if defaultPriority > 0 {
candidates = append(candidates, candidate{
installationID: c.PluginInstallationID,
+16 -3
View File
@@ -49,6 +49,8 @@ type SessionManagerInterface interface {
SetEffectiveMediaFileID(sessionID string, fileID int) error
SetTranscodeNodeURL(sessionID, url string) error
SetWebSocket(sessionID string, connected bool) error
SetRealtimeConnection(sessionID string, connected bool) error
SetProgressPersistenceDisabled(sessionID string, disabled bool) error
StopSession(sessionID string) error
GetSession(sessionID string) (*playback.Session, error)
}
@@ -257,6 +259,7 @@ type startPlaybackRequest struct {
StartPosition *float64 `json:"start_position,omitempty"`
AudioTrackIndex *int `json:"audio_track_index,omitempty"`
PreserveDirectAudioSelection bool `json:"preserve_direct_audio_selection,omitempty"`
DisableProgressPersistence bool `json:"disable_progress_persistence,omitempty"`
CodecsVideo []string `json:"codecs_video"`
CodecsAudio []string `json:"codecs_audio"`
Containers []string `json:"containers"`
@@ -761,6 +764,9 @@ func (h *PlaybackHandler) persistProgress(ctx context.Context, session *playback
if h.StoreProvider == nil || h.fileResolver == nil {
return
}
if session == nil || session.DisableProgressPersistence {
return
}
file, err := h.loadFileByPreferredID(ctx, requestedMediaFileID(session), session.MediaFileID)
targetID := playbackProgressTarget(file)
@@ -799,7 +805,7 @@ func (h *PlaybackHandler) persistStopAndHistory(ctx context.Context, session *pl
if h.StoreProvider == nil || h.fileResolver == nil {
return watchstate.PlaybackStopResult{}
}
if session == nil || session.Position <= 0 {
if session == nil || session.DisableProgressPersistence || session.Position <= 0 {
return watchstate.PlaybackStopResult{}
}
@@ -1281,6 +1287,13 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
return
}
setPlaybackSessionLogContext(r, session.ID)
if req.DisableProgressPersistence {
if err := h.sessionMgr.SetProgressPersistenceDisabled(session.ID, true); err != nil {
slog.Error("failed to disable progress persistence", "session", session.ID, "error", err)
} else {
session.DisableProgressPersistence = true
}
}
if err := h.sessionMgr.UpdateAudioTrack(session.ID, audioTrackIndex, session.PlayMethod); err != nil {
slog.Error("failed to set audio track", "session", session.ID, "error", err)
@@ -1320,7 +1333,7 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
} else {
h.restoreSessionProgress(r.Context(), session, file)
}
if h.WatchScrobbler != nil && effectiveFile != nil {
if !session.DisableProgressPersistence && h.WatchScrobbler != nil && effectiveFile != nil {
targetID := playbackProgressTarget(effectiveFile)
if targetID != "" {
event := h.scrobbleEventForSession(r.Context(), session, targetID, float64(effectiveFile.Duration), session.Position)
@@ -1549,7 +1562,7 @@ func (h *PlaybackHandler) HandleUpdateProgress(w http.ResponseWriter, r *http.Re
// Persist progress to UserStore (best-effort).
if sess, getErr := h.sessionMgr.GetSession(sessionID); getErr == nil {
h.persistProgress(r.Context(), sess)
if h.WatchScrobbler != nil && wasPaused != sess.IsPaused {
if !sess.DisableProgressPersistence && h.WatchScrobbler != nil && wasPaused != sess.IsPaused {
if file, loadErr := h.loadFileByPreferredID(r.Context(), requestedMediaFileID(sess), sess.MediaFileID); loadErr == nil && file != nil {
targetID := playbackProgressTarget(file)
if targetID != "" {
+62
View File
@@ -129,6 +129,10 @@ func (failingSessionManager) SetTranscodeNodeURL(string, string) error { return
func (failingSessionManager) SetWebSocket(string, bool) error { return nil }
func (failingSessionManager) SetRealtimeConnection(string, bool) error { return nil }
func (failingSessionManager) SetProgressPersistenceDisabled(string, bool) error { return nil }
func (failingSessionManager) StopSession(string) error { return nil }
func (failingSessionManager) GetSession(string) (*playback.Session, error) { return nil, nil }
@@ -283,6 +287,64 @@ func TestHandleStartPlayback_UsesExplicitZeroStartPositionInsteadOfStoredResume(
}
}
func TestPlaybackSessionProgressPersistenceCanBeDisabled(t *testing.T) {
store := newPlaybackTestStore(t)
if err := store.SetProgress(context.Background(), "profile-1", "book-1", 500, 1200, userstore.ProgressThresholds{}); err != nil {
t.Fatalf("seed progress: %v", err)
}
file := &models.MediaFile{
ID: 42,
ContentID: "book-1",
FilePath: writePlaybackTestMediaFile(t, "book.m4b"),
Duration: 600,
}
sessionMgr := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file})
handler.StoreProvider = testUserStoreProvider{store: store}
handler.ItemAccess = allowAllPlaybackItemAccess{}
req := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","play_method":"direct","start_position":120,"disable_progress_persistence":true}`))
req = req.WithContext(newAuthorizedPlaybackContext())
rr := httptest.NewRecorder()
handler.HandleStartPlayback(rr, req)
if rr.Code != 201 {
t.Fatalf("start status = %d, body = %s", rr.Code, rr.Body.String())
}
var resp playbackSessionResponse
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
progressReq := httptest.NewRequest("POST", "/api/v1/playback/"+resp.SessionID+"/progress", strings.NewReader(`{"position":240,"is_paused":false}`))
progressReq = progressReq.WithContext(newAuthorizedPlaybackContext())
progressReq = withPlaybackRouteParam(progressReq, "session_id", resp.SessionID)
progressRR := httptest.NewRecorder()
handler.HandleUpdateProgress(progressRR, progressReq)
if progressRR.Code != 204 {
t.Fatalf("progress status = %d, body = %s", progressRR.Code, progressRR.Body.String())
}
stopReq := httptest.NewRequest("DELETE", "/api/v1/playback/"+resp.SessionID, nil)
stopReq = stopReq.WithContext(newAuthorizedPlaybackContext())
stopReq = withPlaybackRouteParam(stopReq, "session_id", resp.SessionID)
stopRR := httptest.NewRecorder()
handler.HandleStopPlayback(stopRR, stopReq)
if stopRR.Code != 204 {
t.Fatalf("stop status = %d, body = %s", stopRR.Code, stopRR.Body.String())
}
progress, err := store.GetProgress(context.Background(), "profile-1", "book-1")
if err != nil {
t.Fatalf("get progress: %v", err)
}
if progress == nil || progress.PositionSeconds != 500 || progress.DurationSeconds != 1200 {
t.Fatalf("progress after disabled session = %+v, want 500/1200", progress)
}
}
func TestBuildTranscodeStartResponse_UnifiedSeekAnywhere(t *testing.T) {
file := &models.MediaFile{Duration: 3600}
+107 -25
View File
@@ -169,22 +169,27 @@ type pluginConfigSchemaJSON struct {
}
type pluginAdminFormJSON struct {
Fields []pluginAdminFormFieldJSON `json:"fields"`
SubmitLabel string `json:"submit_label,omitempty"`
Fields []pluginAdminFormFieldJSON `json:"fields"`
SubmitLabel string `json:"submit_label,omitempty"`
Sections []pluginAdminFormSectionJSON `json:"sections,omitempty"`
}
type pluginAdminFormFieldJSON struct {
Key string `json:"key"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
Control string `json:"control"`
Placeholder string `json:"placeholder,omitempty"`
Required bool `json:"required"`
Secret bool `json:"secret"`
Multiline bool `json:"multiline"`
DefaultValue any `json:"default_value,omitempty"`
Options []pluginAdminFormOptionJSON `json:"options,omitempty"`
Rows int32 `json:"rows,omitempty"`
Key string `json:"key"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
Control string `json:"control"`
Placeholder string `json:"placeholder,omitempty"`
Required bool `json:"required"`
Secret bool `json:"secret"`
Multiline bool `json:"multiline"`
DefaultValue any `json:"default_value,omitempty"`
Options []pluginAdminFormOptionJSON `json:"options,omitempty"`
Rows int32 `json:"rows,omitempty"`
DynamicOptions bool `json:"dynamic_options,omitempty"`
ShowWhen []pluginAdminFormConditionJSON `json:"show_when,omitempty"`
Validation *pluginAdminFormValidationJSON `json:"validation,omitempty"`
ExclusiveGroupField string `json:"exclusive_group_field,omitempty"`
}
type pluginAdminFormOptionJSON struct {
@@ -193,6 +198,31 @@ type pluginAdminFormOptionJSON struct {
Description string `json:"description,omitempty"`
}
type pluginAdminFormConditionJSON struct {
Field string `json:"field"`
Equals []string `json:"equals"`
}
type pluginAdminFormValidationJSON struct {
HasMin bool `json:"has_min,omitempty"`
Min float64 `json:"min,omitempty"`
HasMax bool `json:"has_max,omitempty"`
Max float64 `json:"max,omitempty"`
Pattern string `json:"pattern,omitempty"`
MinLength int32 `json:"min_length,omitempty"`
MaxLength int32 `json:"max_length,omitempty"`
}
type pluginAdminFormSectionJSON struct {
Key string `json:"key"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Collapsible bool `json:"collapsible"`
CollapsedDefault bool `json:"collapsed_default"`
FieldKeys []string `json:"field_keys"`
ShowWhen []pluginAdminFormConditionJSON `json:"show_when,omitempty"`
}
type pluginCapabilityJSON struct {
Type string `json:"type"`
ID string `json:"id"`
@@ -660,7 +690,7 @@ func (h *PluginHandler) syncMetadataProviders(ctx context.Context, installation
continue
}
if err := h.chainRepo.AppendProviderToAllChains(ctx, installation.ID, cap.ID, func(level string) int {
return metadata.LookupDefaultPriority(ctx, h.chainRepo.Pool(), installation.ID, level)
return metadata.LookupDefaultPriority(ctx, h.chainRepo.Pool(), installation.ID, cap.ID, level)
}); err != nil {
slog.Warn("failed to append provider to library chains",
"installation_id", installation.ID,
@@ -1342,26 +1372,78 @@ func adminFormToJSON(form *pluginv1.AdminFormDescriptor) *pluginAdminFormJSON {
if field.GetDefaultValue() != nil {
defaultValue = field.GetDefaultValue().AsInterface()
}
var validation *pluginAdminFormValidationJSON
if v := field.GetValidation(); v != nil {
validation = &pluginAdminFormValidationJSON{
HasMin: v.GetHasMin(),
Min: v.GetMin(),
HasMax: v.GetHasMax(),
Max: v.GetMax(),
Pattern: v.GetPattern(),
MinLength: v.GetMinLength(),
MaxLength: v.GetMaxLength(),
}
}
fields = append(fields, pluginAdminFormFieldJSON{
Key: field.GetKey(),
Label: field.GetLabel(),
Description: field.GetDescription(),
Control: strings.TrimPrefix(field.GetControl().String(), "ADMIN_FORM_CONTROL_"),
Placeholder: field.GetPlaceholder(),
Required: field.GetRequired(),
Secret: field.GetSecret(),
Multiline: field.GetMultiline(),
DefaultValue: defaultValue,
Options: options,
Rows: field.GetRows(),
Key: field.GetKey(),
Label: field.GetLabel(),
Description: field.GetDescription(),
Control: strings.TrimPrefix(field.GetControl().String(), "ADMIN_FORM_CONTROL_"),
Placeholder: field.GetPlaceholder(),
Required: field.GetRequired(),
Secret: field.GetSecret(),
Multiline: field.GetMultiline(),
DefaultValue: defaultValue,
Options: options,
Rows: field.GetRows(),
DynamicOptions: field.GetDynamicOptions(),
ShowWhen: adminFormConditionsToJSON(field.GetShowWhen()),
Validation: validation,
ExclusiveGroupField: field.GetExclusiveGroupField(),
})
}
sections := make([]pluginAdminFormSectionJSON, 0, len(form.GetSections()))
for _, section := range form.GetSections() {
if section == nil {
continue
}
sections = append(sections, pluginAdminFormSectionJSON{
Key: section.GetKey(),
Title: section.GetTitle(),
Description: section.GetDescription(),
Collapsible: section.GetCollapsible(),
CollapsedDefault: section.GetCollapsedDefault(),
FieldKeys: append([]string(nil), section.GetFieldKeys()...),
ShowWhen: adminFormConditionsToJSON(section.GetShowWhen()),
})
}
return &pluginAdminFormJSON{
Fields: fields,
SubmitLabel: form.GetSubmitLabel(),
Sections: sections,
}
}
func adminFormConditionsToJSON(conditions []*pluginv1.AdminFormCondition) []pluginAdminFormConditionJSON {
if len(conditions) == 0 {
return nil
}
out := make([]pluginAdminFormConditionJSON, 0, len(conditions))
for _, condition := range conditions {
if condition == nil {
continue
}
out = append(out, pluginAdminFormConditionJSON{
Field: condition.GetField(),
Equals: append([]string(nil), condition.GetEquals()...),
})
}
if len(out) == 0 {
return nil
}
return out
}
func capabilitiesToJSON(descriptors []*pluginv1.CapabilityDescriptor) []pluginCapabilityJSON {
response := make([]pluginCapabilityJSON, 0, len(descriptors))
for _, descriptor := range descriptors {
@@ -0,0 +1,216 @@
package handlers
import (
"encoding/json"
"reflect"
"strings"
"testing"
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
)
// TestAdminFormToJSONSchemaFields locks the bridge between the SDK proto
// AdminForm descriptor and the JSON the frontend SchemaForm consumes. Without
// this, the schema-driven request-connection form silently loses its
// dynamic dropdowns (dynamic_options), conditional visibility (show_when),
// validation, sections, and the MULTI_SELECT control mapping, which makes the
// new form non-functional (required fields can never be set, save stays
// disabled).
func TestAdminFormToJSONSchemaFields(t *testing.T) {
descriptor := &pluginv1.AdminFormDescriptor{
SubmitLabel: "Connect",
Fields: []*pluginv1.AdminFormField{
{
Key: "quality_profile_ids",
Label: "Quality Profiles",
Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_MULTI_SELECT,
DynamicOptions: true,
ExclusiveGroupField: "service_kind",
ShowWhen: []*pluginv1.AdminFormCondition{
{Field: "service_type", Equals: []string{"radarr", "sonarr"}},
},
Validation: &pluginv1.AdminFormValidation{
HasMin: true,
Min: 1,
HasMax: true,
Max: 10,
Pattern: "^[0-9]+$",
MinLength: 1,
MaxLength: 64,
},
},
},
Sections: []*pluginv1.AdminFormSection{
{
Key: "advanced",
Title: "Advanced",
Description: "Optional tuning",
Collapsible: true,
CollapsedDefault: true,
FieldKeys: []string{"quality_profile_ids"},
ShowWhen: []*pluginv1.AdminFormCondition{
{Field: "service_type", Equals: []string{"radarr"}},
},
},
},
}
form := adminFormToJSON(descriptor)
if form == nil {
t.Fatal("adminFormToJSON returned nil")
}
raw, err := json.Marshal(form)
if err != nil {
t.Fatalf("marshal admin form: %v", err)
}
var decoded struct {
Fields []struct {
Control string `json:"control"`
DynamicOptions bool `json:"dynamic_options"`
ShowWhen []struct {
Field string `json:"field"`
Equals []string `json:"equals"`
} `json:"show_when"`
Validation *struct {
HasMin bool `json:"has_min"`
Min float64 `json:"min"`
HasMax bool `json:"has_max"`
Max float64 `json:"max"`
Pattern string `json:"pattern"`
MinLength int32 `json:"min_length"`
MaxLength int32 `json:"max_length"`
} `json:"validation"`
ExclusiveGroupField string `json:"exclusive_group_field"`
} `json:"fields"`
Sections []struct {
Key string `json:"key"`
Title string `json:"title"`
Description string `json:"description"`
Collapsible bool `json:"collapsible"`
CollapsedDefault bool `json:"collapsed_default"`
FieldKeys []string `json:"field_keys"`
ShowWhen []struct {
Field string `json:"field"`
Equals []string `json:"equals"`
} `json:"show_when"`
} `json:"sections"`
}
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("unmarshal admin form: %v\njson: %s", err, raw)
}
if len(decoded.Fields) != 1 {
t.Fatalf("expected 1 field, got %d", len(decoded.Fields))
}
field := decoded.Fields[0]
// Control must serialize to the SHORT form the frontend switches on.
if field.Control != "MULTI_SELECT" {
t.Errorf("control = %q, want %q", field.Control, "MULTI_SELECT")
}
if !field.DynamicOptions {
t.Error("dynamic_options = false, want true")
}
if field.ExclusiveGroupField != "service_kind" {
t.Errorf("exclusive_group_field = %q, want %q", field.ExclusiveGroupField, "service_kind")
}
if len(field.ShowWhen) != 1 || field.ShowWhen[0].Field != "service_type" ||
len(field.ShowWhen[0].Equals) != 2 {
t.Errorf("field show_when not serialized correctly: %+v", field.ShowWhen)
}
if field.Validation == nil {
t.Fatal("validation not serialized")
}
if !field.Validation.HasMin || field.Validation.Min != 1 ||
!field.Validation.HasMax || field.Validation.Max != 10 ||
field.Validation.Pattern != "^[0-9]+$" ||
field.Validation.MinLength != 1 || field.Validation.MaxLength != 64 {
t.Errorf("validation not serialized correctly: %+v", field.Validation)
}
if len(decoded.Sections) != 1 {
t.Fatalf("expected 1 section, got %d", len(decoded.Sections))
}
section := decoded.Sections[0]
if section.Key != "advanced" || section.Title != "Advanced" ||
section.Description != "Optional tuning" || !section.Collapsible ||
!section.CollapsedDefault {
t.Errorf("section scalar fields not serialized correctly: %+v", section)
}
if len(section.FieldKeys) != 1 || section.FieldKeys[0] != "quality_profile_ids" {
t.Errorf("section field_keys not serialized correctly: %+v", section.FieldKeys)
}
if len(section.ShowWhen) != 1 || section.ShowWhen[0].Field != "service_type" ||
len(section.ShowWhen[0].Equals) != 1 {
t.Errorf("section show_when not serialized correctly: %+v", section.ShowWhen)
}
}
// adminFormDTOMappingRenames records intentional proto-field -> DTO-field name
// renames. The DTO field names currently mirror the proto field names 1:1, so
// this is empty; add an entry only when a DTO field is deliberately named
// differently from its proto source (the test then accepts the renamed target).
var adminFormDTOMappingRenames = map[string]string{}
// protoInternalFields are the protobuf-runtime bookkeeping fields every
// generated message struct carries. They are never serialized to the client
// DTO, so the completeness guard skips them.
var protoInternalFields = map[string]bool{
"state": true,
"sizeCache": true,
"unknownFields": true,
}
// TestAdminFormDTOCompleteness is a guard against the C1-class bug: the
// hand-written adminFormToJSON allowlist (pluginAdminFormFieldJSON /
// pluginAdminFormSectionJSON / pluginAdminFormJSON) silently dropping a NEW
// proto field added to AdminFormField / AdminFormDescriptor / AdminFormSection
// later. It reflects over each proto message's exported fields and asserts the
// corresponding JSON DTO struct has a field mapping to it (by identical Go name,
// or via an explicit rename in adminFormDTOMappingRenames). When the next proto
// field lands, this FAILS naming the un-mapped field, forcing the serializer +
// DTO to be updated in lockstep. (We deliberately do not switch the serializer
// to protojson — the short-form control-string MULTI_SELECT mapping makes that
// risky; this guard is the proportionate fix.)
func TestAdminFormDTOCompleteness(t *testing.T) {
cases := []struct {
name string
proto reflect.Type
dto reflect.Type
}{
{"AdminFormField", reflect.TypeOf(pluginv1.AdminFormField{}), reflect.TypeOf(pluginAdminFormFieldJSON{})},
{"AdminFormDescriptor", reflect.TypeOf(pluginv1.AdminFormDescriptor{}), reflect.TypeOf(pluginAdminFormJSON{})},
{"AdminFormSection", reflect.TypeOf(pluginv1.AdminFormSection{}), reflect.TypeOf(pluginAdminFormSectionJSON{})},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dtoFields := map[string]bool{}
for i := 0; i < tc.dto.NumField(); i++ {
dtoFields[tc.dto.Field(i).Name] = true
}
for i := 0; i < tc.proto.NumField(); i++ {
pf := tc.proto.Field(i)
if !pf.IsExported() {
continue // unexported protobuf-internal state
}
if protoInternalFields[pf.Name] || strings.HasPrefix(pf.Name, "XXX_") {
continue
}
want := pf.Name
if mapped, ok := adminFormDTOMappingRenames[pf.Name]; ok {
want = mapped
}
if !dtoFields[want] {
t.Errorf("proto field %s.%s has no corresponding field %q in the JSON DTO %s; "+
"adminFormToJSON would silently drop it. Add the field to the DTO + serializer "+
"(or record an intentional rename in adminFormDTOMappingRenames).",
tc.name, pf.Name, want, tc.dto.Name())
}
}
})
}
}
+27 -5
View File
@@ -29,6 +29,13 @@ type rateLimitConfigResponse struct {
IPReqPerMinute float64 `json:"ip_requests_per_minute"`
IPBurst int `json:"ip_burst"`
AuthEndpoints map[string]authEndpointConfigResponse `json:"auth_endpoints"`
// Active reports whether a limiter is running in this process. The
// limiter is constructed at startup, so config saved while it is absent
// (or a backend change while it is running) needs a restart to apply.
Active bool `json:"active"`
// ActiveBackend is the backend the running limiter actually uses, which
// can differ from Backend until the server restarts.
ActiveBackend string `json:"active_backend,omitempty"`
}
type tierConfigResponse struct {
@@ -64,6 +71,10 @@ func (h *RateLimitHandler) HandleGetConfig(w http.ResponseWriter, r *http.Reques
IPReqPerMinute: cfg.IPReqPerMinute,
IPBurst: cfg.IPBurst,
AuthEndpoints: make(map[string]authEndpointConfigResponse),
Active: h.mw != nil,
}
if h.mw != nil {
resp.ActiveBackend = h.mw.ActiveBackend()
}
for name, tier := range cfg.Tiers {
resp.Tiers[name] = tierConfigResponse{
@@ -151,10 +162,21 @@ func (h *RateLimitHandler) HandleUpdateConfig(w http.ResponseWriter, r *http.Req
}
}
// Hot-reload: apply new config immediately on this instance
if err := h.mw.Reload(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Config saved but reload failed")
return
// The limiter is constructed at startup, so enabling while it is absent
// or switching backend while it runs only takes effect after a restart.
// Everything else hot-reloads below.
restartRequired := false
if h.mw == nil {
restartRequired = req.Enabled
} else {
if (req.Backend == "memory" || req.Backend == "redis") && req.Backend != h.mw.ActiveBackend() {
restartRequired = true
}
// Hot-reload: apply new config immediately on this instance
if err := h.mw.Reload(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Config saved but reload failed")
return
}
}
// Publish for multi-instance reload (if EventBus is available/backed by Redis)
@@ -165,5 +187,5 @@ func (h *RateLimitHandler) HandleUpdateConfig(w http.ResponseWriter, r *http.Req
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
json.NewEncoder(w).Encode(map[string]any{"status": "ok", "restart_required": restartRequired})
}
+140
View File
@@ -0,0 +1,140 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/ratelimit"
)
type fakeRateLimitStore struct {
values map[string]string
}
func newFakeRateLimitStore() *fakeRateLimitStore {
return &fakeRateLimitStore{values: make(map[string]string)}
}
func (s *fakeRateLimitStore) Get(_ context.Context, key string) (string, error) {
return s.values[key], nil
}
func (s *fakeRateLimitStore) Set(_ context.Context, key, value string) error {
s.values[key] = value
return nil
}
func (s *fakeRateLimitStore) GetAll(_ context.Context) (map[string]string, error) {
out := make(map[string]string, len(s.values))
for k, v := range s.values {
out[k] = v
}
return out, nil
}
func newRunningRateLimitMiddleware(t *testing.T, store ratelimit.SettingsStore) *ratelimit.Middleware {
t.Helper()
perKey := ratelimit.NewMemoryLimiter()
global := ratelimit.NewMemoryLimiter()
t.Cleanup(func() {
perKey.Close()
global.Close()
})
mw := ratelimit.NewMiddleware(perKey, global, store, true)
if err := mw.Init(context.Background()); err != nil {
t.Fatalf("init middleware: %v", err)
}
return mw
}
func putRateLimitConfig(t *testing.T, h *RateLimitHandler, body string) map[string]any {
t.Helper()
req := httptest.NewRequest(http.MethodPut, "/admin/rate-limits/config", strings.NewReader(body))
rec := httptest.NewRecorder()
h.HandleUpdateConfig(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PUT status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode PUT response: %v", err)
}
return resp
}
func getRateLimitConfig(t *testing.T, h *RateLimitHandler) rateLimitConfigResponse {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/admin/rate-limits/config", nil)
rec := httptest.NewRecorder()
h.HandleGetConfig(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp rateLimitConfigResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode GET response: %v", err)
}
return resp
}
func TestRateLimitHandlerWithoutRunningLimiter(t *testing.T) {
// The limiter is nil when rate limiting was disabled at boot. The config
// endpoints must still work so an admin can re-enable it from the UI.
store := newFakeRateLimitStore()
store.values["ratelimit.enabled"] = "false"
h := NewRateLimitHandler(store, nil, nil)
got := getRateLimitConfig(t, h)
if got.Enabled {
t.Error("GET enabled = true, want false")
}
if got.Active {
t.Error("GET active = true, want false when no limiter is running")
}
resp := putRateLimitConfig(t, h, `{"enabled":true,"backend":"redis"}`)
if resp["restart_required"] != true {
t.Errorf("enabling without a running limiter: restart_required = %v, want true", resp["restart_required"])
}
if store.values["ratelimit.enabled"] != "true" {
t.Errorf("ratelimit.enabled = %q, want \"true\"", store.values["ratelimit.enabled"])
}
if store.values["ratelimit.backend"] != "redis" {
t.Errorf("ratelimit.backend = %q, want \"redis\"", store.values["ratelimit.backend"])
}
resp = putRateLimitConfig(t, h, `{"enabled":false,"backend":"redis"}`)
if resp["restart_required"] != false {
t.Errorf("saving disabled config without a running limiter: restart_required = %v, want false", resp["restart_required"])
}
}
func TestRateLimitHandlerWithRunningLimiter(t *testing.T) {
store := newFakeRateLimitStore()
mw := newRunningRateLimitMiddleware(t, store)
h := NewRateLimitHandler(store, mw, nil)
got := getRateLimitConfig(t, h)
if !got.Active {
t.Error("GET active = false, want true with a running limiter")
}
if got.ActiveBackend != "memory" {
t.Errorf("GET active_backend = %q, want \"memory\"", got.ActiveBackend)
}
// Toggling enabled hot-reloads; no restart needed when the backend matches.
resp := putRateLimitConfig(t, h, `{"enabled":false,"backend":"memory"}`)
if resp["restart_required"] != false {
t.Errorf("toggle on running limiter: restart_required = %v, want false", resp["restart_required"])
}
// Switching backend only takes effect at boot.
resp = putRateLimitConfig(t, h, `{"enabled":true,"backend":"redis"}`)
if resp["restart_required"] != true {
t.Errorf("backend change: restart_required = %v, want true", resp["restart_required"])
}
}
+39 -53
View File
@@ -38,7 +38,7 @@ type RequestService interface {
CreateIntegration(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error)
UpdateIntegration(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.Integration, error)
DeleteIntegration(ctx context.Context, viewer mediarequests.Viewer, id string) error
LoadIntegrationOptions(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (*mediarequests.IntegrationOptions, error)
LoadIntegrationOptions(ctx context.Context, viewer mediarequests.Viewer, integration mediarequests.Integration) (map[string][]mediarequests.RouterOption, error)
ListStudios(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error)
ListNetworks(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error)
@@ -588,62 +588,36 @@ func parsePositivePathInt(w http.ResponseWriter, r *http.Request, key string) (i
}
type requestIntegrationResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
Enabled bool `json:"enabled"`
Is4K bool `json:"is_4k"`
IsDefault bool `json:"is_default"`
IsDefault4K bool `json:"is_default_4k"`
AnimeEnabled bool `json:"anime_enabled"`
AnimeQualityProfileID *int `json:"anime_quality_profile_id,omitempty"`
AnimeRootFolder string `json:"anime_root_folder,omitempty"`
AnimeTags []int `json:"anime_tags"`
BaseURL string `json:"base_url"`
HasAPIKey bool `json:"has_api_key"`
RootFolder string `json:"root_folder"`
QualityProfileID *int `json:"quality_profile_id,omitempty"`
Tags []int `json:"tags"`
Options map[string]any `json:"options"`
LastCheckAt *time.Time `json:"last_check_at,omitempty"`
LastCheckStatus string `json:"last_check_status,omitempty"`
LastCheckError string `json:"last_check_error,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
Name string `json:"name"`
CapabilityID string `json:"capability_id"`
InstallationID *int `json:"installation_id,omitempty"`
SupportedMediaTypes []string `json:"supported_media_types"`
PluginConfig map[string]any `json:"plugin_config"`
Enabled bool `json:"enabled"`
BaseURL string `json:"base_url"`
HasAPIKey bool `json:"has_api_key"`
LastCheckAt *time.Time `json:"last_check_at,omitempty"`
LastCheckStatus string `json:"last_check_status,omitempty"`
LastCheckError string `json:"last_check_error,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
func requestIntegrationResponseFrom(integration mediarequests.Integration) requestIntegrationResponse {
// Normalize nil slices so they serialize as [] (not null); the frontend
// types them as number[] and indexes/maps over them.
tags := integration.Tags
if tags == nil {
tags = []int{}
}
animeTags := integration.AnimeTags
if animeTags == nil {
animeTags = []int{}
}
return requestIntegrationResponse{
ID: integration.ID,
Name: integration.Name,
Kind: integration.Kind,
Enabled: integration.Enabled,
Is4K: integration.Is4K,
IsDefault: integration.IsDefault,
IsDefault4K: integration.IsDefault4K,
AnimeEnabled: integration.AnimeEnabled,
AnimeQualityProfileID: integration.AnimeQualityProfileID,
AnimeRootFolder: integration.AnimeRootFolder,
AnimeTags: animeTags,
BaseURL: integration.BaseURL,
HasAPIKey: strings.TrimSpace(integration.APIKeyRef) != "",
RootFolder: integration.RootFolder,
QualityProfileID: integration.QualityProfileID,
Tags: tags,
Options: integration.Options,
LastCheckAt: integration.LastCheckAt,
LastCheckStatus: integration.LastCheckStatus,
LastCheckError: integration.LastCheckError,
UpdatedAt: integration.UpdatedAt,
ID: integration.ID,
Name: integration.Name,
CapabilityID: integration.CapabilityID,
InstallationID: integration.InstallationID,
SupportedMediaTypes: integration.SupportedMediaTypes,
PluginConfig: integration.PluginConfig,
Enabled: integration.Enabled,
BaseURL: integration.BaseURL,
HasAPIKey: strings.TrimSpace(integration.APIKeyRef) != "",
LastCheckAt: integration.LastCheckAt,
LastCheckStatus: integration.LastCheckStatus,
LastCheckError: integration.LastCheckError,
UpdatedAt: integration.UpdatedAt,
}
}
@@ -656,6 +630,18 @@ func toIntegrationResponses(integrations []mediarequests.Integration) []requestI
}
func writeRequestServiceError(w http.ResponseWriter, err error) {
// Plugin/instance validation failures carry inline field/form errors; surface
// them as a structured 400 so any handler routing through here renders them
// inline. Checked first because *ValidationError does not wrap a sentinel.
var verr *mediarequests.ValidationError
if errors.As(err, &verr) {
writeJSON(w, http.StatusBadRequest, map[string]any{
"error": "validation_failed",
"field_errors": verr.FieldErrors,
"form_error": verr.FormError,
})
return
}
var quota mediarequests.QuotaError
switch {
case errors.As(err, &quota):
+1 -1
View File
@@ -139,7 +139,7 @@ func (f *fakeRequestService) DeleteIntegration(context.Context, mediarequests.Vi
return nil
}
func (f *fakeRequestService) LoadIntegrationOptions(context.Context, mediarequests.Viewer, mediarequests.Integration) (*mediarequests.IntegrationOptions, error) {
func (f *fakeRequestService) LoadIntegrationOptions(context.Context, mediarequests.Viewer, mediarequests.Integration) (map[string][]mediarequests.RouterOption, error) {
return nil, nil
}
+22 -5
View File
@@ -157,8 +157,11 @@ func validateSectionConfig(sectionType sections.SectionType, config json.RawMess
}
cfg := sections.ParseConfigFilters(config)
if cfg.FilterType != "" && cfg.FilterType != "movie" && cfg.FilterType != "series" {
return "filter_type must be 'movie' or 'series'", false
if cfg.FilterType != "" && cfg.FilterType != "movie" && cfg.FilterType != "series" && cfg.FilterType != "audiobook" {
return "filter_type must be 'movie', 'series', or 'audiobook'", false
}
if _, err := sections.ParseContinueType(config); err != nil {
return err.Error(), false
}
var rawLibraryConfig struct {
@@ -1408,8 +1411,8 @@ func (h *SectionHandler) maybeInjectNextUp(ctx context.Context, resolved []secti
return resolved
}
// injectNextUpSection inserts a synthetic SectionNextUp entry immediately after
// SectionContinueWatching in the resolved sections list.
// injectNextUpSection inserts a synthetic SectionNextUp entry after the
// contiguous continue rows that start with the video Continue Watching row.
func injectNextUpSection(resolved []sections.ResolvedSection) []sections.ResolvedSection {
nextUp := sections.ResolvedSection{
ID: "system-next-up",
@@ -1418,6 +1421,20 @@ func injectNextUpSection(resolved []sections.ResolvedSection) []sections.Resolve
ItemLimit: 20,
}
for i, s := range resolved {
if s.SectionType == sections.SectionContinueWatching && sections.ContinueTypeFromConfig(s.Config) == sections.ContinueTypeWatching {
insertAt := i + 1
for insertAt < len(resolved) && resolved[insertAt].SectionType == sections.SectionContinueWatching {
insertAt++
}
result := make([]sections.ResolvedSection, 0, len(resolved)+1)
result = append(result, resolved[:insertAt]...)
result = append(result, nextUp)
result = append(result, resolved[insertAt:]...)
return result
}
}
for i, s := range resolved {
if s.SectionType == sections.SectionContinueWatching {
result := make([]sections.ResolvedSection, 0, len(resolved)+1)
@@ -1428,7 +1445,7 @@ func injectNextUpSection(resolved []sections.ResolvedSection) []sections.Resolve
}
}
// If no continue_watching found, prepend
// If no resume row is found, prepend.
return append([]sections.ResolvedSection{nextUp}, resolved...)
}
+60
View File
@@ -282,6 +282,66 @@ func TestBuildSectionsResponseBatchResolvesImageURLs(t *testing.T) {
}
}
func TestValidateSectionConfigAcceptsContinueTypes(t *testing.T) {
tests := []string{
`{"continue_type":"watching"}`,
`{"continue_type":"listening"}`,
`{"continue_type":"reading"}`,
`{"filter_type":"audiobook"}`,
}
for _, config := range tests {
t.Run(config, func(t *testing.T) {
if msg, ok := validateSectionConfig(sections.SectionContinueWatching, []byte(config)); !ok {
t.Fatalf("validateSectionConfig(%s) rejected config: %s", config, msg)
}
})
}
}
func TestValidateSectionConfigRejectsUnknownContinueType(t *testing.T) {
msg, ok := validateSectionConfig(sections.SectionContinueWatching, []byte(`{"continue_type":"scrolling"}`))
if ok {
t.Fatal("validateSectionConfig accepted unknown continue_type")
}
if msg != "continue_type must be 'watching', 'listening', or 'reading'" {
t.Fatalf("message = %q", msg)
}
}
func TestInjectNextUpAfterContiguousContinueRows(t *testing.T) {
in := []sections.ResolvedSection{
{
ID: "cw",
SectionType: sections.SectionContinueWatching,
Title: "Continue Watching",
Config: sections.ContinueTypeConfig(sections.ContinueTypeWatching),
},
{
ID: "cl",
SectionType: sections.SectionContinueWatching,
Title: "Continue Listening",
Config: sections.ContinueTypeConfig(sections.ContinueTypeListening),
},
{ID: "recent", SectionType: sections.SectionRecentlyAdded, Title: "Recently Added"},
}
got := injectNextUpSection(in)
gotIDs := make([]string, 0, len(got))
for _, section := range got {
gotIDs = append(gotIDs, section.ID)
}
wantIDs := []string{"cw", "cl", "system-next-up", "recent"}
if len(gotIDs) != len(wantIDs) {
t.Fatalf("section ids = %v, want %v", gotIDs, wantIDs)
}
for i := range wantIDs {
if gotIDs[i] != wantIDs[i] {
t.Fatalf("section ids = %v, want %v", gotIDs, wantIDs)
}
}
}
func TestDropEmptySeasonalSectionsRemovesOnlyEmptySeasonal(t *testing.T) {
in := []sections.SectionWithItems{
// empty seasonal — drop
+9
View File
@@ -20,6 +20,7 @@ const subtitleAppearanceSettingKey = "subtitle_appearance"
const (
libraryPageStateSettingKey = "ui.library_page_state"
rememberLibraryPageStateSettingKey = "ui.remember_library_page_state"
searchMediaScopeSettingKey = "search.media_scope"
)
const (
@@ -171,6 +172,14 @@ var settingsRegistry = map[string]settingSpec{
DefaultValue: "true",
Validate: validateBoolSetting(rememberLibraryPageStateSettingKey),
},
// Preferred default scope for global/catalog search. "video" keeps
// results to movies and series; "all" mixes audiobooks in.
searchMediaScopeSettingKey: {
Scope: scopeUser,
DefaultValue: "video",
Validate: validateEnumSetting(searchMediaScopeSettingKey,
"all", "video", "audiobook"),
},
"player.hdr_enabled": {
Scope: scopeDevice,
DefaultValue: "true",
+36
View File
@@ -0,0 +1,36 @@
package api
import (
"context"
"errors"
"github.com/Silo-Server/silo-server/internal/plugins"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
)
// PluginRequestRouterAdapter adapts plugins.Service to
// mediarequests.RouterClientResolver. The concrete *pluginhost.RequestRouterClient
// satisfies mediarequests.RouterClient, so the adapter names the interface as its
// return type (Go has no return-type covariance).
type PluginRequestRouterAdapter struct {
Svc *plugins.Service
}
func (a PluginRequestRouterAdapter) RequestRouterClient(ctx context.Context, installationID int, capabilityID string) (mediarequests.RouterClient, error) {
if a.Svc == nil {
return nil, errors.New("request router plugin service is not configured")
}
return a.Svc.RequestRouterClient(ctx, installationID, capabilityID)
}
// 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
// plugin service) it leaves the requests service router-less, so fulfillment
// degrades to the existing "no backend configured" failure instead of panicking.
func AttachRequestRouter(svc *mediarequests.Service, pluginService *plugins.Service) {
if svc == nil || pluginService == nil {
return
}
svc.SetRouterProvider(mediarequests.NewPluginRouterProvider(PluginRequestRouterAdapter{pluginService}))
}
+22
View File
@@ -0,0 +1,22 @@
package api
import (
"context"
"testing"
)
// AttachRequestRouter must tolerate absent dependencies (e.g. a build with the
// plugin service disabled) without panicking — fulfillment then degrades to the
// existing "no backend configured" failure rather than crashing the wiring.
func TestAttachRequestRouterNilDependenciesDoesNotPanic(t *testing.T) {
AttachRequestRouter(nil, nil)
}
// A router adapter installed without a plugin service must return a controlled
// error rather than panic on the request path.
func TestPluginRequestRouterAdapterNilServiceReturnsError(t *testing.T) {
adapter := PluginRequestRouterAdapter{}
if _, err := adapter.RequestRouterClient(context.Background(), 1, "request_router.v1"); err == nil {
t.Fatal("RequestRouterClient with nil Svc = nil error, want a controlled error")
}
}
+7 -5
View File
@@ -48,8 +48,6 @@ import (
"github.com/Silo-Server/silo-server/internal/ratelimit"
"github.com/Silo-Server/silo-server/internal/recommendations"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
"github.com/Silo-Server/silo-server/internal/requests/radarr"
"github.com/Silo-Server/silo-server/internal/requests/sonarr"
"github.com/Silo-Server/silo-server/internal/s3client"
"github.com/Silo-Server/silo-server/internal/scanner"
"github.com/Silo-Server/silo-server/internal/scanqueue"
@@ -441,7 +439,8 @@ func NewRouter(deps Dependencies) chi.Router {
tmdb.NewClient(tmdbAPIKey, 40),
mediarequests.NewCatalogPresence(itemRepo, providerIDRepo),
)
requestSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil))
AttachRequestRouter(requestSvc, deps.PluginService)
requestSvc.SetRequesterIdentityResolver(plugins.RequesterIdentityFromLookup(plugins.NewPgUserIdentityLookup(deps.DB)))
if viewerResolver != nil {
requestSvc.SetEntitlementResolver(mediarequests.NewAccessEntitlements(viewerResolver))
}
@@ -2157,8 +2156,11 @@ func NewRouter(deps Dependencies) chi.Router {
})
}
// Rate limit admin routes
if deps.RateLimitMW != nil && settingsRepo != nil {
// Rate limit admin routes. Mounted even when the limiter is not
// running (deps.RateLimitMW == nil) so admins can always reach the
// config — otherwise disabling rate limiting and restarting would
// permanently lock the settings page out of re-enabling it.
if settingsRepo != nil {
rateLimitHandler := handlers.NewRateLimitHandler(settingsRepo, deps.RateLimitMW, deps.EventBus)
r.Route("/rate-limits", func(r chi.Router) {
r.Get("/config", rateLimitHandler.HandleGetConfig)
+46 -8
View File
@@ -44,7 +44,8 @@ const (
// defaultEnrichBatchSize is the maximum number of audiobook items processed
// per sweep invocation. Keeps latency bounded for large libraries.
defaultEnrichBatchSize = 50
// Override with SILO_AUDIOBOOK_ENRICH_BATCH_SIZE.
defaultEnrichBatchSize = 250
// defaultEnrichWorkers is the default fan-out used by Enricher.Run.
// Network-bound: each worker holds one provider HTTP call at a time, so
// 4 is enough to mask single-request latency without hammering plugins.
@@ -52,18 +53,28 @@ const (
defaultEnrichWorkers = 4
)
// audiobookEnrichBatchSize returns the configured maximum sweep size.
func audiobookEnrichBatchSize() int {
if v := os.Getenv("SILO_AUDIOBOOK_ENRICH_BATCH_SIZE"); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
return parsed
}
}
return defaultEnrichBatchSize
}
// audiobookEnrichWorkers returns the configured number of parallel enrichment
// workers, capped to defaultEnrichBatchSize so workers never outnumber the
// batch they drain.
func audiobookEnrichWorkers() int {
// workers, capped to the active batch size so workers never outnumber the batch
// they drain.
func audiobookEnrichWorkers(batchSize int) int {
n := defaultEnrichWorkers
if v := os.Getenv("SILO_AUDIOBOOK_ENRICH_WORKERS"); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
n = parsed
}
}
if n > defaultEnrichBatchSize {
n = defaultEnrichBatchSize
if batchSize > 0 && n > batchSize {
n = batchSize
}
return n
}
@@ -105,6 +116,7 @@ func NewEnricher(
personRepo *catalog.PersonRepository,
providerIDs *catalog.ProviderIDRepository,
) *Enricher {
batchSize := audiobookEnrichBatchSize()
return &Enricher{
pool: pool,
chainRepo: chainRepo,
@@ -112,8 +124,8 @@ func NewEnricher(
itemRepo: itemRepo,
personRepo: personRepo,
providerIDs: providerIDs,
batchSize: defaultEnrichBatchSize,
workers: audiobookEnrichWorkers(),
batchSize: batchSize,
workers: audiobookEnrichWorkers(batchSize),
}
}
@@ -166,6 +178,32 @@ func (e *Enricher) Run(ctx context.Context) (int, error) {
return enriched, nil
}
// HasPendingItems reports whether a scheduled sweep has any audiobook rows to
// process. It mirrors claimBatch's eligibility predicate without loading rows
// or provider IDs, so the scheduler can skip no-op executions without adding
// task-history noise.
func (e *Enricher) HasPendingItems(ctx context.Context) (bool, error) {
if e == nil || e.pool == nil || e.chainRepo == nil {
return false, nil
}
var exists bool
err := e.pool.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1
FROM media_items mi
WHERE mi.type = 'audiobook'
AND (mi.poster_path IS NULL OR mi.poster_path = '')
AND mi.last_refreshed IS NULL
LIMIT 1
)
`).Scan(&exists)
if err != nil {
return false, fmt.Errorf("checking pending audiobook enrichment: %w", err)
}
return exists, nil
}
// runBatch processes items in parallel using e.workers goroutines. The
// enrichFn parameter exists for testing: production calls pass e.enrichItem.
// Returns the count of items where enrichFn returned nil.
+44
View File
@@ -63,6 +63,50 @@ func TestEnricherRunFansOut(t *testing.T) {
}
}
func TestNewEnricherUsesConfiguredBatchSize(t *testing.T) {
t.Setenv("SILO_AUDIOBOOK_ENRICH_BATCH_SIZE", "123")
t.Setenv("SILO_AUDIOBOOK_ENRICH_WORKERS", "8")
e := NewEnricher(nil, nil, nil, nil, nil, nil)
if e.batchSize != 123 {
t.Fatalf("batchSize = %d, want 123", e.batchSize)
}
if e.workers != 8 {
t.Fatalf("workers = %d, want 8", e.workers)
}
}
func TestNewEnricherCapsWorkersToConfiguredBatchSize(t *testing.T) {
t.Setenv("SILO_AUDIOBOOK_ENRICH_BATCH_SIZE", "50")
t.Setenv("SILO_AUDIOBOOK_ENRICH_WORKERS", "100")
e := NewEnricher(nil, nil, nil, nil, nil, nil)
if e.batchSize != 50 {
t.Fatalf("batchSize = %d, want 50", e.batchSize)
}
if e.workers != e.batchSize {
t.Fatalf("workers = %d, want capped batchSize %d", e.workers, e.batchSize)
}
}
func TestAudiobookEnrichWorkersCapsAtBatchSize(t *testing.T) {
t.Setenv("SILO_AUDIOBOOK_ENRICH_WORKERS", "8")
if got := audiobookEnrichWorkers(3); got != 3 {
t.Fatalf("audiobookEnrichWorkers(3) = %d, want 3", got)
}
}
func TestAudiobookEnrichBatchSizeIgnoresInvalidEnv(t *testing.T) {
t.Setenv("SILO_AUDIOBOOK_ENRICH_BATCH_SIZE", "nope")
if got := audiobookEnrichBatchSize(); got != defaultEnrichBatchSize {
t.Fatalf("audiobookEnrichBatchSize() = %d, want %d", got, defaultEnrichBatchSize)
}
}
func TestCacheRemotePosterCachesProviderURL(t *testing.T) {
cacher := &fakeAudiobookImageCacher{}
e := &Enricher{imageCacher: cacher}
+22 -4
View File
@@ -106,6 +106,23 @@ func (r *Refresher) WithHTTPClient(hc *http.Client) *Refresher {
return r
}
// HasDue reports whether RefreshDue would attempt at least one feed. The task
// scheduler uses this to avoid recording empty maintenance runs when no podcast
// feeds exist or every feed is still inside its refresh window.
func (r *Refresher) HasDue(ctx context.Context, s Store) (bool, error) {
feeds, err := s.ListPodcastFeeds(ctx)
if err != nil {
return false, fmt.Errorf("list podcast feeds: %w", err)
}
now := time.Now()
for _, f := range feeds {
if shouldRefreshFeed(f, now) {
return true, nil
}
}
return false, nil
}
// RefreshDue walks every podcast_feeds row and refreshes those whose
// last_refreshed_at + refresh_interval has elapsed. Per-feed failures are
// logged at Warn and do not abort the walk. Returns the count of feeds
@@ -118,10 +135,7 @@ func (r *Refresher) RefreshDue(ctx context.Context, s Store) (int, error) {
now := time.Now()
attempted := 0
for _, f := range feeds {
if f.FeedURL == "" {
continue
}
if !isDue(f, now) {
if !shouldRefreshFeed(f, now) {
continue
}
attempted++
@@ -136,6 +150,10 @@ func (r *Refresher) RefreshDue(ctx context.Context, s Store) (int, error) {
return attempted, nil
}
func shouldRefreshFeed(f PodcastFeed, now time.Time) bool {
return f.FeedURL != "" && isDue(f, now)
}
// RefreshOne refreshes a single podcast feed. Public so the admin
// force-refresh endpoint can call it directly. Records success or failure
// in podcast_feeds.last_refresh_error regardless of outcome.
+1 -1
View File
@@ -162,7 +162,7 @@ func (s *Service) BuildABSHandler(deps ABSHandlerDeps) *abs.Handler {
if deps.Detail == nil {
return ""
}
return deps.Detail.PresignURL(ctx, path, variant)
return deps.Detail.PresignImageURL(ctx, path, "poster", variant)
},
})
s.ABSHandler = h
+54
View File
@@ -3,7 +3,14 @@ package audiobooks
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"unsafe"
"github.com/Silo-Server/silo-server/internal/audiobooks/abs"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/scanner"
)
type fakeSettingsReader struct {
@@ -74,3 +81,50 @@ func TestServiceABSCompatEnabledNilSettingsReturnsFalse(t *testing.T) {
t.Fatal("ABSCompatEnabled = true, want false")
}
}
func TestBuildABSHandlerCoverResolverUsesPosterVariant(t *testing.T) {
resolver := &recordingImageResolver{}
detail := &catalog.DetailService{}
detail.SetImageResolver(resolver)
handler := New(nil).BuildABSHandler(ABSHandlerDeps{
Items: &catalog.ItemRepository{},
Files: &scanner.FileRepository{},
Detail: detail,
})
coverResolver := absCoverResolverForTest(t, handler)
got := coverResolver(context.Background(), "local/audiobooks/book-1/poster/original.webp", "card")
if !strings.Contains(got, "/w500.webp") {
t.Fatalf("resolved URL = %q, want w500 poster variant", got)
}
if resolver.variant != "featured" {
t.Fatalf("resolver variant = %q, want featured", resolver.variant)
}
}
func absCoverResolverForTest(t *testing.T, handler *abs.Handler) func(context.Context, string, string) string {
t.Helper()
field := reflect.ValueOf(handler).Elem().FieldByName("deps").FieldByName("CoverResolver")
return reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Interface().(func(context.Context, string, string) string)
}
type recordingImageResolver struct {
path string
variant string
}
func (r *recordingImageResolver) ResolveImageURL(_ context.Context, path string, variant string) string {
r.path = path
r.variant = variant
return "resolved://" + path
}
func (r *recordingImageResolver) ResolveImageURLs(_ context.Context, paths []string, variant string) map[string]string {
out := make(map[string]string, len(paths))
for _, path := range paths {
out[path] = r.ResolveImageURL(context.Background(), path, variant)
}
return out
}
+8
View File
@@ -36,6 +36,7 @@ type BrowseFilters struct {
MaxLimit int // optional caller-specific cap; zero keeps the default browse cap
Offset int
SnapshotAt *time.Time // pagination fence: exclude items created after this timestamp
RequireBackdrop bool // only return items with a non-empty backdrop_path (Jellyfin ImageTypes=Backdrop filter)
}
// BrowseResult contains the paginated result of a browse query.
@@ -295,6 +296,13 @@ func (r *BrowseRepository) buildBrowsePlan(filters BrowseFilters) (browseQueryPl
argIdx++
}
// Backdrop presence filter (Jellyfin ImageTypes=Backdrop). Pushed down so
// random/limited selections only ever pick items that actually have a
// backdrop — filtering after the LIMIT would wrongly return empty pages.
if filters.RequireBackdrop {
conditions = append(conditions, "NULLIF(BTRIM(mi.backdrop_path), '') IS NOT NULL")
}
// Library access control: restrict to user's accessible libraries.
needsLibJoin := filters.LibraryID > 0 || filters.LibraryIDs != nil || len(filters.DisabledLibraryIDs) > 0 || filters.Sort == "recently_added"
needsPersonJoin := filters.PersonID > 0
+31
View File
@@ -0,0 +1,31 @@
package catalog
import (
"strings"
"testing"
)
// TestBuildBrowsePlan_RequireBackdrop asserts the ImageTypes=Backdrop filter
// (BrowseFilters.RequireBackdrop) renders the backdrop-presence predicate into
// the WHERE clause, and is absent otherwise. Guards against a future refactor
// of buildBrowsePlan silently dropping the condition.
func TestBuildBrowsePlan_RequireBackdrop(t *testing.T) {
const predicate = "NULLIF(BTRIM(mi.backdrop_path), '') IS NOT NULL"
repo := &BrowseRepository{}
plan, earlyEmpty, err := repo.buildBrowsePlan(BrowseFilters{Type: "movie", RequireBackdrop: true})
if err != nil || earlyEmpty {
t.Fatalf("buildBrowsePlan(RequireBackdrop) err=%v earlyEmpty=%v", err, earlyEmpty)
}
if !strings.Contains(plan.whereClause, predicate) {
t.Fatalf("RequireBackdrop=true: whereClause missing predicate.\ngot: %s", plan.whereClause)
}
plan, _, err = repo.buildBrowsePlan(BrowseFilters{Type: "movie"})
if err != nil {
t.Fatalf("buildBrowsePlan err=%v", err)
}
if strings.Contains(plan.whereClause, predicate) {
t.Fatalf("RequireBackdrop unset: predicate should be absent.\ngot: %s", plan.whereClause)
}
}
+4 -5
View File
@@ -389,12 +389,11 @@ func normalizeCatalogMatch(raw string) string {
}
func parseCatalogMediaScope(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "movie", "series", "episode", "audiobook":
return strings.ToLower(strings.TrimSpace(raw))
default:
return ""
scope := strings.ToLower(strings.TrimSpace(raw))
if scope != "" && IsValidMediaScope(scope) {
return scope
}
return ""
}
func defaultCatalogQuerySort(searchQuery string) QuerySort {
+7 -14
View File
@@ -1086,12 +1086,8 @@ func validateCatalogExactCollectionRequest(req CatalogRequest) error {
}
func validateCatalogOverlayQuery(searchQuery string, def QueryDefinition, ruleFields, sortFields map[string]bool, allowRelevance bool) error {
if def.MediaScope != "" &&
def.MediaScope != "movie" &&
def.MediaScope != "series" &&
def.MediaScope != "episode" &&
def.MediaScope != "audiobook" {
return fmt.Errorf("%w: media_scope must be 'movie', 'series', 'episode', or 'audiobook'", ErrInvalidCatalogRequest)
if !IsValidMediaScope(def.MediaScope) {
return fmt.Errorf("%w: media_scope must be 'movie', 'series', 'episode', 'audiobook', or 'video'", ErrInvalidCatalogRequest)
}
if def.Match != "" && def.Match != "all" && def.Match != "any" {
return fmt.Errorf("%w: match must be 'all' or 'any'", ErrInvalidCatalogRequest)
@@ -1607,12 +1603,7 @@ func catalogSearchAccess(req CatalogRequest, access AccessFilter) (AccessFilter,
MaxContentRating: access.MaxContentRating,
}
var itemTypes []string
if req.Query.MediaScope != "" {
itemTypes = []string{req.Query.MediaScope}
}
return searchAccess, itemTypes, false
return searchAccess, MediaScopeItemTypes(req.Query.MediaScope), false
}
func catalogBrowseFilters(req CatalogRequest, access AccessFilter) (BrowseFilters, bool, error) {
@@ -1622,7 +1613,9 @@ func catalogBrowseFilters(req CatalogRequest, access AccessFilter) (BrowseFilter
}
filters := BrowseFilters{
Type: req.Query.MediaScope,
// BrowseFilters.Type accepts a comma-separated type list, so group
// scopes like "video" expand here rather than leaking downstream.
Type: strings.Join(MediaScopeItemTypes(req.Query.MediaScope), ","),
NamePrefix: req.NamePrefix,
DisabledLibraryIDs: effectiveCatalogDisabledLibraryIDs(req.Query.LibraryIDs, access.DisabledLibraryIDs),
MaxContentRating: access.MaxContentRating,
@@ -1763,7 +1756,7 @@ func filterCatalogItems(items []*models.MediaItem, def QueryDefinition) []*model
if item == nil {
continue
}
if def.MediaScope != "" && !strings.EqualFold(strings.TrimSpace(item.Type), def.MediaScope) {
if !MediaScopeMatchesItemType(def.MediaScope, item.Type) {
continue
}
if catalogDefinitionMatchesItem(item, def) {
+7 -3
View File
@@ -1061,6 +1061,10 @@ func firstNonEmptyString(values []string) string {
return ""
}
func (s *DetailService) presignAudiobookPosterURL(ctx context.Context, posterPath string) string {
return s.PresignImageURL(ctx, posterPath, "poster", "")
}
func appendAudiobookItemAccessConditions(
alias string,
filter AccessFilter,
@@ -1146,7 +1150,7 @@ func (s *DetailService) fetchAudiobookAlsoByAuthor(ctx context.Context, contentI
continue
}
seen[item.ContentID] = struct{}{}
item.PosterURL = s.PresignURL(ctx, posterPath, "featured")
item.PosterURL = s.presignAudiobookPosterURL(ctx, posterPath)
out = append(out, item)
}
return out
@@ -1205,7 +1209,7 @@ func (s *DetailService) fetchAudiobookSimilarByGenres(ctx context.Context, conte
if err := rows.Scan(&item.ContentID, &item.Title, &item.Year, &posterPath); err != nil {
return []AudiobookRelatedItem{}
}
item.PosterURL = s.PresignURL(ctx, posterPath, "featured")
item.PosterURL = s.presignAudiobookPosterURL(ctx, posterPath)
out = append(out, item)
}
return out
@@ -1266,7 +1270,7 @@ func (s *DetailService) fetchAudiobookSeries(ctx context.Context, contentID stri
item.SeriesIndex = &n
}
}
item.PosterURL = s.PresignURL(ctx, poster, "featured")
item.PosterURL = s.presignAudiobookPosterURL(ctx, poster)
entries = append(entries, item)
}
if len(entries) < 2 {
+36
View File
@@ -1,6 +1,8 @@
package catalog
import (
"context"
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
@@ -40,3 +42,37 @@ func TestSortAudiobookMediaFilesFallsBackToPath(t *testing.T) {
}
}
}
func TestPresignAudiobookPosterURLUsesPosterVariant(t *testing.T) {
resolver := &recordingCatalogImageResolver{}
detail := &DetailService{}
detail.SetImageResolver(resolver)
got := detail.presignAudiobookPosterURL(context.Background(), "local/audiobooks/book/poster/original.webp")
if !strings.Contains(got, "/w500.webp") {
t.Fatalf("resolved URL = %q, want w500 poster variant", got)
}
if resolver.variant != "featured" {
t.Fatalf("resolver variant = %q, want featured", resolver.variant)
}
}
type recordingCatalogImageResolver struct {
path string
variant string
}
func (r *recordingCatalogImageResolver) ResolveImageURL(_ context.Context, path string, variant string) string {
r.path = path
r.variant = variant
return "resolved://" + path
}
func (r *recordingCatalogImageResolver) ResolveImageURLs(_ context.Context, paths []string, variant string) map[string]string {
out := make(map[string]string, len(paths))
for _, path := range paths {
out[path] = r.ResolveImageURL(context.Background(), path, variant)
}
return out
}
+96 -45
View File
@@ -35,6 +35,26 @@ func NewItemRepository(pool *pgxpool.Pool) *ItemRepository {
return &ItemRepository{pool: pool}
}
// GetPosterPath returns the current poster path for a media item. Missing or
// NULL poster values are returned as an empty string.
func (r *ItemRepository) GetPosterPath(ctx context.Context, contentID string) (string, error) {
if r == nil || r.pool == nil {
return "", ErrItemNotFound
}
var posterPath string
if err := r.pool.QueryRow(ctx, `
SELECT COALESCE(poster_path, '')
FROM media_items
WHERE content_id = $1
`, contentID).Scan(&posterPath); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrItemNotFound
}
return "", fmt.Errorf("getting media item poster path: %w", err)
}
return posterPath, nil
}
// overviewMatchFloor is the minimum ts_rank_cd score an overview-only match
// must achieve to be returned when no title FTS match exists in the candidate
// set. The overview tsvector is built without setweight(), so PostgreSQL's
@@ -45,60 +65,87 @@ func NewItemRepository(pool *pgxpool.Pool) *ItemRepository {
// incidental one-mention hits that flooded results before.
const overviewMatchFloor = 0.15
// itemColumnNames lists, in scan order, every column selected by media_items
// item queries. Shared by itemColumns, qualifiedItemColumns,
// qualifiedListItemColumns, and qualifiedItemColumnRefs so the select lists
// can never drift from each other or from scanItem.
var itemColumnNames = []string{
"content_id", "type", "title", "sort_title", "default_metadata_language", "original_title", "year", "genres",
"content_rating", "runtime", "overview", "tagline",
"rating_imdb", "rating_tmdb", "rating_rt_critic", "rating_rt_audience",
"imdb_id", "tmdb_id", "tvdb_id",
"poster_path", "poster_thumbhash", "backdrop_path", "backdrop_thumbhash", "logo_path",
"metadata_s3_path", "metadata_etag", "season_count",
"studios", "networks", "countries", "keywords", "original_language", "release_date::text", "first_air_date", "last_air_date", "air_time", "air_timezone",
"show_status",
"matched_at", "last_refreshed", "refresh_failures",
"episode_metadata_incomplete", "episode_metadata_last_checked_at", "locked_fields", "status", "created_at", "updated_at",
}
// nullableStringItemColumns are media_items columns that may hold NULL but
// scan into plain (non-pointer) string fields on models.MediaItem, so select
// lists coalesce them to ”.
var nullableStringItemColumns = map[string]bool{
"poster_path": true,
"poster_thumbhash": true,
"backdrop_path": true,
"backdrop_thumbhash": true,
"logo_path": true,
"metadata_s3_path": true,
"metadata_etag": true,
}
// itemColumnExpr renders one select-list entry for col, qualified with alias
// when non-empty. Nullable string columns are coalesced to ” and aliased
// back to their own name so queries that wrap the select list in a CTE or
// subquery can still reference the column by name.
func itemColumnExpr(alias, col string) string {
qualified := col
if alias != "" {
qualified = alias + "." + col
}
if nullableStringItemColumns[col] {
return fmt.Sprintf("COALESCE(%s, '') AS %s", qualified, col)
}
return qualified
}
func joinItemColumns(alias string) string {
exprs := make([]string, len(itemColumnNames))
for i, col := range itemColumnNames {
exprs[i] = itemColumnExpr(alias, col)
}
return strings.Join(exprs, ", ")
}
// itemColumns is the list of columns returned by all SELECT queries on media_items.
const itemColumns = `content_id, type, title, sort_title, default_metadata_language, original_title, year, genres,
content_rating, runtime, overview, tagline,
rating_imdb, rating_tmdb, rating_rt_critic, rating_rt_audience,
imdb_id, tmdb_id, tvdb_id,
poster_path, poster_thumbhash, backdrop_path, backdrop_thumbhash, logo_path,
metadata_s3_path, metadata_etag, season_count,
studios, networks, countries, keywords, original_language, release_date::text, first_air_date, last_air_date, air_time, air_timezone,
show_status,
matched_at, last_refreshed, refresh_failures,
episode_metadata_incomplete, episode_metadata_last_checked_at, locked_fields, status, created_at, updated_at`
var itemColumns = joinItemColumns("")
func qualifiedItemColumns(alias string) string {
cols := []string{
"content_id", "type", "title", "sort_title", "default_metadata_language", "original_title", "year", "genres",
"content_rating", "runtime", "overview", "tagline",
"rating_imdb", "rating_tmdb", "rating_rt_critic", "rating_rt_audience",
"imdb_id", "tmdb_id", "tvdb_id",
"poster_path", "poster_thumbhash", "backdrop_path", "backdrop_thumbhash", "logo_path",
"metadata_s3_path", "metadata_etag", "season_count",
"studios", "networks", "countries", "keywords", "original_language", "release_date::text", "first_air_date", "last_air_date", "air_time", "air_timezone",
"show_status",
"matched_at", "last_refreshed", "refresh_failures",
"episode_metadata_incomplete", "episode_metadata_last_checked_at", "locked_fields", "status", "created_at", "updated_at",
return joinItemColumns(alias)
}
// qualifiedItemColumnRefs renders plain alias-qualified column references
// without COALESCE or AS aliases, for contexts like GROUP BY where output
// aliases are invalid.
func qualifiedItemColumnRefs(alias string) string {
refs := make([]string, len(itemColumnNames))
for i, col := range itemColumnNames {
refs[i] = alias + "." + col
}
prefixed := make([]string, len(cols))
for i, col := range cols {
prefixed[i] = alias + "." + col
}
return strings.Join(prefixed, ", ")
return strings.Join(refs, ", ")
}
func qualifiedListItemColumns(alias string) string {
cols := []string{
"content_id", "type", "title", "sort_title", "default_metadata_language", "original_title", "year", "genres",
"content_rating", "runtime", "overview", "tagline",
"rating_imdb", "rating_tmdb", "rating_rt_critic", "rating_rt_audience",
"imdb_id", "tmdb_id", "tvdb_id",
"poster_path", "poster_thumbhash", "backdrop_path", "backdrop_thumbhash", "logo_path",
"metadata_s3_path", "metadata_etag", "season_count",
"studios", "networks", "countries", "keywords", "original_language", "release_date::text", "first_air_date", "last_air_date", "air_time", "air_timezone",
"show_status",
"matched_at", "last_refreshed", "refresh_failures",
"episode_metadata_incomplete", "episode_metadata_last_checked_at", "locked_fields", "status", "created_at", "updated_at",
}
prefixed := make([]string, len(cols))
for i, col := range cols {
exprs := make([]string, len(itemColumnNames))
for i, col := range itemColumnNames {
if col == "last_air_date" {
prefixed[i] = effectiveLastAirDateExpr(alias)
exprs[i] = effectiveLastAirDateExpr(alias) + " AS last_air_date"
continue
}
prefixed[i] = alias + "." + col
exprs[i] = itemColumnExpr(alias, col)
}
return strings.Join(prefixed, ", ")
return strings.Join(exprs, ", ")
}
// scanItem scans a single row into a *models.MediaItem.
@@ -956,8 +1003,12 @@ func (r *ItemRepository) buildSearchSQL(query string, itemTypes []string, limit,
)
// Use qualified column names inside the CTE to avoid ambiguity when
// the FROM clause includes a JOIN to media_item_libraries.
// the FROM clause includes a JOIN to media_item_libraries. The select
// list aliases coalesced columns back to their own names (poster_path
// etc.) so the outer query can re-reference them; GROUP BY needs the
// raw references because output aliases are invalid there.
qualifiedCols := qualifiedItemColumns("mi")
groupByCols := qualifiedItemColumnRefs("mi")
scoredCTE := fmt.Sprintf(`
WITH scored AS (
SELECT
@@ -984,7 +1035,7 @@ func (r *ItemRepository) buildSearchSQL(query string, itemTypes []string, limit,
%s
GROUP BY %s
)
`, qualifiedCols, exactTitleMatch, contiguousTitleMatch, yearIdx, yearIdx, titleVector, titleQuery, overviewVector, phraseIdx, titleVector, phraseIdx, fromClause, whereClause, qualifiedCols)
`, qualifiedCols, exactTitleMatch, contiguousTitleMatch, yearIdx, yearIdx, titleVector, titleQuery, overviewVector, phraseIdx, titleVector, phraseIdx, fromClause, whereClause, groupByCols)
// COUNT(*) OVER () runs after the GROUP BY in the scored CTE collapses
// duplicates from the library JOIN, so the window count preserves the
+57
View File
@@ -2,6 +2,7 @@ package catalog
import (
"fmt"
"sort"
"strings"
"testing"
)
@@ -367,3 +368,59 @@ func TestItemRepo_Search_NormalizesTsqueryInput(t *testing.T) {
t.Fatalf("overview arm should NOT wrap $1 in normalize_search_text; got:\n%s", sql)
}
}
// TestItemRepo_Search_ScoredCTEExposesItemColumnNames guards the contract
// between the scored CTE and the outer SELECT in buildSearchSQL. The CTE
// projects qualifiedItemColumns("mi") and the outer query re-selects those
// columns by name via itemColumns, so every entry must expose its own column
// name as the output name. Postgres names an unaliased expression like
// COALESCE(mi.poster_path, ”) "coalesce", which breaks the outer reference
// with SQLSTATE 42703 (column "poster_path" does not exist) — exactly how
// search returned 500s when the nullable-string COALESCE wrappers first
// landed without AS aliases.
func TestItemRepo_Search_ScoredCTEExposesItemColumnNames(t *testing.T) {
exposed := map[string]bool{}
for _, part := range splitTopLevelSQLCommas(qualifiedItemColumns("mi")) {
exposed[sqlOutputColumnName(part)] = true
}
var missing []string
for _, col := range itemColumnNames {
name := trailingSQLIdent(col)
if !exposed[name] {
missing = append(missing, name)
}
}
if len(missing) > 0 {
sort.Strings(missing)
t.Fatalf("qualifiedItemColumns(\"mi\") does not expose output columns %v; "+
"alias each expression back to its column name (e.g. COALESCE(mi.x, '') AS x) "+
"or the search CTE's outer SELECT fails with SQLSTATE 42703", missing)
}
}
// TestItemRepo_Search_GroupByHasNoOutputAliases asserts the scored CTE's
// GROUP BY uses raw column references: AS aliases are valid in a select list
// but are a syntax error inside GROUP BY, so the CTE must not reuse the
// aliased projection there.
func TestItemRepo_Search_GroupByHasNoOutputAliases(t *testing.T) {
repo := &ItemRepository{}
dataSQL, countSQL, _ := repo.buildSearchSQL("avatar", []string{"movie"}, 20, 0, AccessFilter{})
for _, sql := range []string{dataSQL, countSQL} {
idx := strings.Index(sql, "GROUP BY")
if idx < 0 {
t.Fatalf("expected GROUP BY in scored CTE; got:\n%s", sql)
}
clause := sql[idx:]
if end := strings.Index(clause, ")"); end >= 0 {
clause = clause[:end]
}
if strings.Contains(clause, " AS ") {
t.Fatalf("GROUP BY must not contain output aliases; got:\n%s", clause)
}
if strings.Contains(clause, "COALESCE") {
t.Fatalf("GROUP BY should group by raw columns, not COALESCE expressions; got:\n%s", clause)
}
}
}
+117
View File
@@ -0,0 +1,117 @@
package catalog
import (
"net/url"
"reflect"
"strings"
"testing"
)
// TestMediaScopeItemTypes pins the expansion of group scopes: "video" covers
// the video-side media_items types so search/browse can offer a
// "Movies & Series vs Audiobooks" split without enumerating types per caller.
func TestMediaScopeItemTypes(t *testing.T) {
cases := []struct {
scope string
want []string
}{
{"", nil},
{"movie", []string{"movie"}},
{"audiobook", []string{"audiobook"}},
{"video", []string{"movie", "series"}},
{" Video ", []string{"movie", "series"}},
}
for _, tc := range cases {
if got := MediaScopeItemTypes(tc.scope); !reflect.DeepEqual(got, tc.want) {
t.Errorf("MediaScopeItemTypes(%q) = %v, want %v", tc.scope, got, tc.want)
}
}
}
func TestMediaScopeMatchesItemType(t *testing.T) {
cases := []struct {
scope string
itemType string
want bool
}{
{"", "audiobook", true},
{"video", "movie", true},
{"video", "series", true},
{"video", "audiobook", false},
{"audiobook", "audiobook", true},
{"movie", "series", false},
}
for _, tc := range cases {
if got := MediaScopeMatchesItemType(tc.scope, tc.itemType); got != tc.want {
t.Errorf("MediaScopeMatchesItemType(%q, %q) = %v, want %v", tc.scope, tc.itemType, got, tc.want)
}
}
}
// TestParseCatalogRequest_VideoMediaScope asserts ?type=video parses into the
// query definition's media scope, and that catalogSearchAccess expands it to
// the video item types for the direct search path.
func TestParseCatalogRequest_VideoMediaScope(t *testing.T) {
req, err := ParseCatalogRequest(url.Values{
"source": {"query"},
"q": {"the rookie"},
"type": {"video"},
})
if err != nil {
t.Fatalf("ParseCatalogRequest: %v", err)
}
if req.Query.MediaScope != "video" {
t.Fatalf("expected media scope video, got %q", req.Query.MediaScope)
}
_, itemTypes, earlyEmpty := catalogSearchAccess(req, AccessFilter{})
if earlyEmpty {
t.Fatal("unexpected early empty")
}
if !reflect.DeepEqual(itemTypes, []string{"movie", "series"}) {
t.Fatalf("expected video scope to expand to movie+series, got %v", itemTypes)
}
}
// TestPreviewPage_VideoScopeUsesTypeAny asserts the preview/query-executor
// path renders a multi-type condition for the video group scope.
func TestPreviewPage_VideoScopeUsesTypeAny(t *testing.T) {
sql, args, err := (&QueryExecutor{}).buildPreviewPageSQL(
QueryDefinition{
MediaScope: "video",
Sort: QuerySort{Field: "title", Order: "asc"},
},
AccessFilter{},
20,
0,
true,
)
if err != nil {
t.Fatalf("buildPreviewPageSQL error: %v", err)
}
if !strings.Contains(sql, "mi.type = ANY(") {
t.Fatalf("expected mi.type = ANY(...) for video scope, got %s", sql)
}
found := false
for _, arg := range args {
if types, ok := arg.([]string); ok && reflect.DeepEqual(types, []string{"movie", "series"}) {
found = true
}
}
if !found {
t.Fatalf("expected movie+series type arg, got %v", args)
}
}
// TestQueryDefinitionValidate_VideoScope asserts "video" passes definition
// validation alongside the single-type scopes.
func TestQueryDefinitionValidate_VideoScope(t *testing.T) {
def := QueryDefinition{MediaScope: "video"}
if err := def.Validate(); err != nil {
t.Fatalf("expected video media scope to validate, got %v", err)
}
bad := QueryDefinition{MediaScope: "podcast"}
if err := bad.Validate(); err == nil {
t.Fatal("expected invalid media scope to fail validation")
}
}
+49 -6
View File
@@ -104,6 +104,53 @@ type QueryDefinition struct {
Limit *int `json:"limit,omitempty"`
}
// MediaScopeVideo is the group scope covering all video-side item types. It
// is accepted anywhere a single-type media scope is and expands to the
// underlying media_items.type values via MediaScopeItemTypes.
const MediaScopeVideo = "video"
// IsValidMediaScope reports whether scope (already normalized to lowercase)
// is an accepted media_scope value. Empty means unscoped and is valid.
func IsValidMediaScope(scope string) bool {
switch scope {
case "", "movie", "series", "episode", "audiobook", MediaScopeVideo:
return true
default:
return false
}
}
// MediaScopeItemTypes expands a media scope into the media_items.type values
// it covers. Single-type scopes map to themselves; the empty scope returns
// nil (unscoped).
func MediaScopeItemTypes(scope string) []string {
scope = strings.ToLower(strings.TrimSpace(scope))
switch scope {
case "":
return nil
case MediaScopeVideo:
return []string{"movie", "series"}
default:
return []string{scope}
}
}
// MediaScopeMatchesItemType reports whether an item type falls inside scope.
// An empty scope matches every type.
func MediaScopeMatchesItemType(scope, itemType string) bool {
types := MediaScopeItemTypes(scope)
if len(types) == 0 {
return true
}
itemType = strings.ToLower(strings.TrimSpace(itemType))
for _, t := range types {
if t == itemType {
return true
}
}
return false
}
const (
DefaultSmartCollectionItemLimit = 100
MaxSmartCollectionItemLimit = 500
@@ -182,12 +229,8 @@ func (q QueryDefinition) ValidateWithOptions(allowPersonalizedSorts, allowPerson
}
}
if normalized.MediaScope != "" &&
normalized.MediaScope != "movie" &&
normalized.MediaScope != "series" &&
normalized.MediaScope != "episode" &&
normalized.MediaScope != "audiobook" {
return fmt.Errorf("media_scope must be 'movie', 'series', 'episode', or 'audiobook'")
if !IsValidMediaScope(normalized.MediaScope) {
return fmt.Errorf("media_scope must be 'movie', 'series', 'episode', 'audiobook', or 'video'")
}
if normalized.Match != "all" && normalized.Match != "any" {
+8 -2
View File
@@ -282,8 +282,14 @@ func (e *QueryExecutor) buildPreviewPagePlan(
conditions = append(conditions, filterWhere)
}
if def.MediaScope != "" && !isEpisodeCatalogScope(def.MediaScope) {
conditions = append(conditions, fmt.Sprintf("mi.type = $%d", argIdx))
args = append(args, def.MediaScope)
scopeTypes := MediaScopeItemTypes(def.MediaScope)
if len(scopeTypes) == 1 {
conditions = append(conditions, fmt.Sprintf("mi.type = $%d", argIdx))
args = append(args, scopeTypes[0])
} else {
conditions = append(conditions, fmt.Sprintf("mi.type = ANY($%d)", argIdx))
args = append(args, scopeTypes)
}
argIdx++
}
libScopeWhere, libScopeArgs, hasLibraryScope := "", []any(nil), false
+3 -3
View File
@@ -355,9 +355,9 @@ func LoadFromDB(m map[string]string) (*Config, error) {
}
cfg.Auth.RefreshTokenExpiry = refreshTokenExpiry
// AudiobookshelfCompat — dedicated listener for ABS client apps. Keep the
// listener off unless ABS compatibility is explicitly enabled.
absCompatEnabled, err := boolOr(m, "audiobookshelf_compat.enabled", false)
// AudiobookshelfCompat — dedicated listener for ABS client apps. Docker
// deployments publish :13378, so listen by default unless disabled.
absCompatEnabled, err := boolOr(m, "audiobookshelf_compat.enabled", true)
if err != nil {
return nil, err
}
+9 -1
View File
@@ -35,7 +35,15 @@ func TestLoadFromDBMetadataPresignExpiryRejectsInvalidDuration(t *testing.T) {
}
func TestLoadFromDBAudiobookshelfCompatFlagGatesCompatListener(t *testing.T) {
cfg, err := LoadFromDB(map[string]string{"audiobookshelf_compat.enabled": "false"})
cfg, err := LoadFromDB(map[string]string{})
if err != nil {
t.Fatalf("LoadFromDB() returned error: %v", err)
}
if cfg.AudiobookshelfCompat.Listen != ":13378" {
t.Fatalf("default audiobooks listener = %q, want default :13378", cfg.AudiobookshelfCompat.Listen)
}
cfg, err = LoadFromDB(map[string]string{"audiobookshelf_compat.enabled": "false"})
if err != nil {
t.Fatalf("LoadFromDB() returned error: %v", err)
}
+8 -2
View File
@@ -62,7 +62,8 @@ func ExtractToken(r *http.Request) (string, bool) {
if token := strings.TrimSpace(r.Header.Get("X-Mediabrowser-Token")); token != "" {
return token, true
}
if token := strings.TrimSpace(r.URL.Query().Get("api_key")); token != "" {
// Case-insensitive: Jellyfin clients vary the casing (api_key / Api_Key / API_KEY).
if token := strings.TrimSpace(newCaseInsensitiveQuery(r.URL.Query()).Get("api_key")); token != "" {
return token, true
}
@@ -185,7 +186,12 @@ func PlaybackSessionAuth(sessions *SessionStore, playbackStore *PlaybackSessionS
// no auth header or api_key. Resolve the negotiated session's
// CompatToken — which for an API-key stream is itself the sa_ key,
// so it must go through the same session-or-API-key resolution.
if playSessionID := firstNonEmpty(r.URL.Query().Get("PlaySessionId"), r.URL.Query().Get("PlaySessionID")); playSessionID != "" {
//
// The lookup must be case-insensitive: Wholphin's jellyfin-sdk-kotlin
// builds its own direct-play URL with a lowercase "playSessionId"
// (and no api_key / auth header), so a case-sensitive match would
// miss it and 401 the stream — forcing a needless transcode fallback.
if playSessionID := newCaseInsensitiveQuery(r.URL.Query()).Get("PlaySessionId"); playSessionID != "" {
if playSession, found := playbackStore.Get(playSessionID); found {
if session, ok := resolveCompatToken(r.Context(), sessions, keyAuth, playSession.CompatToken); ok {
serveWithSession(next, w, r, session)
+54
View File
@@ -119,6 +119,60 @@ func TestRequireSession_NoAuthService_PassesThroughExpiredStreamAppToken(t *test
}
}
func TestPlaybackSessionAuth_CaseInsensitivePlaySessionId(t *testing.T) {
now := fixedNow()
clock := func() time.Time { return now }
sessions := NewSessionStore(30*24*time.Hour, clock)
_ = sessions.Put(Session{Token: "compat-tok", StreamAppUserID: 1})
playbackStore := NewPlaybackSessionStore(time.Hour, clock)
playbackStore.Put(PlaybackSession{ID: "ps-abc", CompatToken: "compat-tok"})
mw := PlaybackSessionAuth(sessions, playbackStore, nil)
cases := []struct {
name string
rawQuery string
wantCode int
}{
// Wholphin's jellyfin-sdk-kotlin direct-play URL: lowercase playSessionId,
// no api_key and no auth header. Previously 401'd (case-sensitive lookup).
{"lowercase playSessionId (Wholphin)", "static=true&mediaSourceId=x&playSessionId=ps-abc", http.StatusOK},
{"canonical PlaySessionId", "PlaySessionId=ps-abc", http.StatusOK},
{"legacy PlaySessionID", "PlaySessionID=ps-abc", http.StatusOK},
{"unknown play session", "playSessionId=does-not-exist", http.StatusUnauthorized},
{"no auth at all", "static=true", http.StatusUnauthorized},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/Videos/itm/stream?"+tc.rawQuery, nil)
rec := httptest.NewRecorder()
gotSession := false
mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if SessionFromContext(r.Context()) != nil {
gotSession = true
}
w.WriteHeader(http.StatusOK)
})).ServeHTTP(rec, req)
if rec.Code != tc.wantCode {
t.Fatalf("status = %d, want %d (body: %s)", rec.Code, tc.wantCode, rec.Body.String())
}
if tc.wantCode == http.StatusOK && !gotSession {
t.Fatal("expected authenticated session in context")
}
})
}
}
func TestExtractToken_CaseInsensitiveAPIKey(t *testing.T) {
for _, key := range []string{"api_key", "Api_Key", "API_KEY"} {
req := httptest.NewRequest("GET", "/Videos/itm/stream?"+key+"=tok123", nil)
if got, ok := ExtractToken(req); !ok || got != "tok123" {
t.Fatalf("%s: ExtractToken = (%q, %v), want (tok123, true)", key, got, ok)
}
}
}
func TestRequireAdminAPIKey_AcceptsAdminKey(t *testing.T) {
authn := newAdminAPIKeyAuthForTest(
&fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}},
+1
View File
@@ -226,6 +226,7 @@ func (s *directContentService) BrowseItems(ctx context.Context, session *Session
Limit: fetchLimit,
MaxLimit: compatBrowseMaxLimit,
Offset: requestedOffset,
RequireBackdrop: parseBool(params.Get("require_backdrop"), false),
}
var collected []upstreamListItem
+2
View File
@@ -48,6 +48,7 @@ type baseItemDTO struct {
ProviderIDs map[string]string `json:"ProviderIds,omitempty"`
ProductionLocations []string `json:"ProductionLocations,omitempty"`
ImageTags map[string]string `json:"ImageTags"`
PrimaryImageItemID string `json:"PrimaryImageItemId,omitempty"`
BackdropImageTags []string `json:"BackdropImageTags,omitempty"`
PrimaryImageAspectRatio *float64 `json:"PrimaryImageAspectRatio,omitempty"`
ImageBlurHashes map[string]map[string]string `json:"ImageBlurHashes,omitempty"`
@@ -73,6 +74,7 @@ type baseItemDTO struct {
SpecialFeatureCount int `json:"SpecialFeatureCount,omitempty"`
MovieCount int `json:"MovieCount,omitempty"`
SeriesCount int `json:"SeriesCount,omitempty"`
SeasonCount int `json:"SeasonCount,omitempty"`
EpisodeCount int `json:"EpisodeCount,omitempty"`
LockedFields []string `json:"LockedFields,omitempty"`
LockData bool `json:"LockData,omitempty"`
+12 -10
View File
@@ -14,16 +14,18 @@ import (
// displayPreferencesDTO mirrors Jellyfin's DisplayPreferences response.
type displayPreferencesDTO struct {
ID string `json:"Id"`
SortBy string `json:"SortBy"`
SortOrder string `json:"SortOrder"`
RememberIndexing bool `json:"RememberIndexing"`
RememberSorting bool `json:"RememberSorting"`
ScrollDirection string `json:"ScrollDirection"`
ShowBackdrop bool `json:"ShowBackdrop"`
ShowSidebar bool `json:"ShowSidebar"`
Client string `json:"Client"`
CustomPrefs map[string]string `json:"CustomPrefs"`
ID string `json:"Id"`
SortBy string `json:"SortBy"`
SortOrder string `json:"SortOrder"`
RememberIndexing bool `json:"RememberIndexing"`
RememberSorting bool `json:"RememberSorting"`
ScrollDirection string `json:"ScrollDirection"`
ShowBackdrop bool `json:"ShowBackdrop"`
ShowSidebar bool `json:"ShowSidebar"`
PrimaryImageHeight int `json:"PrimaryImageHeight"`
PrimaryImageWidth int `json:"PrimaryImageWidth"`
Client string `json:"Client"`
CustomPrefs map[string]string `json:"CustomPrefs"`
}
// DisplayPreferencesHandler serves Jellyfin display preferences endpoints,
@@ -0,0 +1,27 @@
package jellycompat
import (
"encoding/json"
"testing"
)
func TestDefaultDisplayPreferencesIncludesRequiredImageDimensions(t *testing.T) {
dto := defaultDisplayPreferences("default", "Wholphin")
body, err := json.Marshal(dto)
if err != nil {
t.Fatalf("marshal default display preferences: %v", err)
}
var raw map[string]any
if err := json.Unmarshal(body, &raw); err != nil {
t.Fatalf("unmarshal default display preferences: %v", err)
}
if _, ok := raw["PrimaryImageHeight"]; !ok {
t.Fatal("PrimaryImageHeight missing from display preferences JSON")
}
if _, ok := raw["PrimaryImageWidth"]; !ok {
t.Fatal("PrimaryImageWidth missing from display preferences JSON")
}
}
+56 -15
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
@@ -16,7 +17,6 @@ import (
type ImagesHandler struct {
content ContentService
codec *ResourceIDCodec
httpClient *http.Client
sessions *SessionStore
images *ImageCache
personRepo *catalog.PersonRepository
@@ -29,6 +29,7 @@ type ImagesHandler struct {
posterSigner LibraryPosterPresigner
presignTTL time.Duration
imageTags *imageTagSigner
httpClient *http.Client
}
type imageItemRepository interface {
@@ -48,15 +49,14 @@ type imageFolderRepository interface {
GetByID(ctx context.Context, id int) (*models.MediaFolder, error)
}
// NewImagesHandler creates an image proxy handler.
func NewImagesHandler(content ContentService, codec *ResourceIDCodec, httpClient *http.Client, sessions *SessionStore, images *ImageCache, personRepo *catalog.PersonRepository, detailSvc *catalog.DetailService, itemRepo *catalog.ItemRepository, folderRepo *catalog.FolderRepository, seasonRepo *catalog.SeasonRepository, episodeRepo *catalog.EpisodeRepository, accessFilter AccessFilterResolver, posterSigner LibraryPosterPresigner, presignTTL time.Duration, imageTagSecret string) *ImagesHandler {
// NewImagesHandler creates a Jellyfin-compatible image route handler.
func NewImagesHandler(content ContentService, codec *ResourceIDCodec, sessions *SessionStore, images *ImageCache, personRepo *catalog.PersonRepository, detailSvc *catalog.DetailService, itemRepo *catalog.ItemRepository, folderRepo *catalog.FolderRepository, seasonRepo *catalog.SeasonRepository, episodeRepo *catalog.EpisodeRepository, accessFilter AccessFilterResolver, posterSigner LibraryPosterPresigner, presignTTL time.Duration, imageTagSecret string, httpClient *http.Client) *ImagesHandler {
if httpClient == nil {
httpClient = http.DefaultClient
}
return &ImagesHandler{
content: content,
codec: codec,
httpClient: httpClient,
sessions: sessions,
images: images,
personRepo: personRepo,
@@ -69,6 +69,7 @@ func NewImagesHandler(content ContentService, codec *ResourceIDCodec, httpClient
posterSigner: posterSigner,
presignTTL: presignTTL,
imageTags: newImageTagSigner(imageTagSecret),
httpClient: httpClient,
}
}
@@ -80,6 +81,10 @@ func (h *ImagesHandler) HandleItemImage(w http.ResponseWriter, r *http.Request)
imageType := chiURLParam(r, "imageType")
imageSize := compatRequestImageSize(r, imageType)
tag := strings.TrimSpace(r.URL.Query().Get("tag"))
if canonicalRouteID, ok := canonicalCompatImageRouteID(h.codec, routeID); ok {
routeID = canonicalRouteID
r = withCompatImageProxyRouteRequest(r)
}
if tag != "" {
imageURL, ok, err := h.resolveItemImageURLFromTag(r.Context(), routeID, imageType, imageSize, tag)
if err != nil {
@@ -88,15 +93,15 @@ func (h *ImagesHandler) HandleItemImage(w http.ResponseWriter, r *http.Request)
}
if ok {
h.images.RememberSizedUntil(routeID, imageType, imageURL.URL, imageSize, imageURL.ExpiresAt)
h.proxyImageURL(w, r, imageURL.URL)
h.serveImageURL(w, r, imageURL.URL)
return
}
if imageURL, ok := h.images.LookupTag(tag); ok {
h.proxyImageURL(w, r, imageURL)
h.serveImageURL(w, r, imageURL)
return
}
} else if imageURL, ok := h.images.LookupSized(routeID, imageType, "", imageSize); ok {
h.proxyImageURL(w, r, imageURL)
h.serveImageURL(w, r, imageURL)
return
}
@@ -133,7 +138,7 @@ func (h *ImagesHandler) HandleItemImage(w http.ResponseWriter, r *http.Request)
return
}
h.images.RememberSizedUntil(routeID, imageType, imageURL, imageSize, resolvedImage.ExpiresAt)
h.proxyImageURL(w, r, imageURL)
h.serveImageURL(w, r, imageURL)
}
// handlePersonImage serves person photo images.
@@ -159,7 +164,7 @@ func (h *ImagesHandler) handlePersonImage(w http.ResponseWriter, r *http.Request
return
}
h.images.RememberSizedUntil(routeID, imageType, imageURL, imageSize, resolvedImage.ExpiresAt)
h.proxyImageURL(w, r, imageURL)
h.serveImageURL(w, r, imageURL)
}
func (h *ImagesHandler) resolveItemImageURL(ctx context.Context, session *Session, contentID, imageType string, r *http.Request) (catalog.ResolvedImageURL, error) {
@@ -414,18 +419,45 @@ func firstResolvedImageURL(values ...catalog.ResolvedImageURL) catalog.ResolvedI
return catalog.ResolvedImageURL{}
}
func (h *ImagesHandler) serveImageURL(w http.ResponseWriter, r *http.Request, imageURL string) {
if shouldProxyCompatImageRequest(r) {
h.proxyImageURL(w, r, imageURL)
return
}
h.redirectImageURL(w, r, imageURL)
}
func (h *ImagesHandler) redirectImageURL(w http.ResponseWriter, r *http.Request, imageURL string) {
if _, err := parseRemoteImageURL(imageURL); err != nil {
writeError(w, http.StatusBadGateway, "UpstreamError", "Failed to load image")
return
}
// Do not let clients cache the temporary redirect itself. The object-store
// response can still carry its own cache headers after the client follows it.
setCompatImageRouteNoStore(w.Header())
http.Redirect(w, r, imageURL, http.StatusFound)
}
func (h *ImagesHandler) proxyImageURL(w http.ResponseWriter, r *http.Request, imageURL string) {
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, imageURL, nil)
target, err := parseRemoteImageURL(imageURL)
if err != nil {
writeError(w, http.StatusBadGateway, "UpstreamError", "Failed to load image")
return
}
for _, header := range []string{"If-None-Match", "If-Modified-Since"} {
if value := r.Header.Get(header); value != "" {
req.Header.Set(header, value)
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil)
if err != nil {
writeError(w, http.StatusBadGateway, "UpstreamError", "Failed to load image")
return
}
resp, err := h.httpClient.Do(req)
copyConditionalImageRequestHeaders(req.Header, r.Header)
client := h.httpClient
if client == nil {
client = http.DefaultClient
}
resp, err := client.Do(req)
if err != nil {
writeError(w, http.StatusBadGateway, "UpstreamError", "Failed to load image")
return
@@ -433,6 +465,15 @@ func (h *ImagesHandler) proxyImageURL(w http.ResponseWriter, r *http.Request, im
proxyImage(w, resp)
}
func parseRemoteImageURL(imageURL string) (*url.URL, error) {
target, err := url.Parse(imageURL)
if err != nil || target.Scheme == "" || target.Host == "" ||
(target.Scheme != "http" && target.Scheme != "https") {
return nil, errors.New("invalid remote image URL")
}
return target, nil
}
// HandleUserImage returns a deterministic placeholder avatar.
func (h *ImagesHandler) HandleUserImage(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
+49 -2
View File
@@ -140,6 +140,8 @@ func (h *ItemsHandler) HandleItems(w http.ResponseWriter, r *http.Request) {
h.handleSearchItems(w, r, session, query)
case query.isFavorite:
h.handleFavoriteItems(w, r, session, query)
case isSeasonChildItemsQuery(query):
h.handleSeasonChildItems(w, r, session, query)
case query.parentLibraryID == 0 && len(query.itemTypes) == 0:
// No ParentId and no type filter: return top-level library views.
// Jellyfin clients (e.g. Findroid "My Media") call GET /Items?userId=...
@@ -150,6 +152,18 @@ func (h *ItemsHandler) HandleItems(w http.ResponseWriter, r *http.Request) {
}
}
func isSeasonChildItemsQuery(query itemsQuery) bool {
if query.parentItemID == "" {
return false
}
for _, itemType := range query.itemTypes {
if itemType == "season" {
return true
}
}
return false
}
// HandleItem serves GET /Items/{id}.
func (h *ItemsHandler) HandleItem(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
@@ -209,6 +223,7 @@ func (h *ItemsHandler) HandleItem(w http.ResponseWriter, r *http.Request) {
browsableSeasons := filterBrowsableSeasons(seasons)
dto.ChildCount = len(browsableSeasons)
dto.RecursiveItemCount = len(browsableSeasons)
dto.SeasonCount = len(browsableSeasons)
}
}
if strings.EqualFold(detail.Type, "episode") && detail.SeriesID != "" {
@@ -919,9 +934,35 @@ func (h *ItemsHandler) HandleSeasons(w http.ResponseWriter, r *http.Request) {
writeCompatUpstreamError(w, err)
return
}
h.writeSeasonItemsResponse(w, r, session, seriesID, seasons, query, false)
}
func (h *ItemsHandler) handleSeasonChildItems(w http.ResponseWriter, r *http.Request, session *Session, query itemsQuery) {
seasons, err := h.content.ListSeasons(r.Context(), session, query.parentItemID, nil)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
h.writeSeasonItemsResponse(w, r, session, query.parentItemID, seasons, query, true)
}
func (h *ItemsHandler) writeSeasonItemsResponse(w http.ResponseWriter, r *http.Request, session *Session, seriesID string, seasons []upstreamSeason, query itemsQuery, page bool) {
seasons = filterBrowsableSeasons(seasons)
h.rememberSeasonImages(seasons, seriesID)
total := len(seasons)
if page {
start := query.startIndex
if start > total {
start = total
}
end := total
if query.limit > 0 && start+query.limit < end {
end = start + query.limit
}
seasons = seasons[start:end]
}
favorites, err := resolveFavoritesForContentIDs(r.Context(), session, h.userData, seasonContentIDs(seasons))
if err != nil {
writeCompatUpstreamError(w, err)
@@ -944,10 +985,16 @@ func (h *ItemsHandler) HandleSeasons(w http.ResponseWriter, r *http.Request) {
}
items = append(items, h.mapper.seasonFromUpstream(season, seriesID, favorites[season.ContentID]))
}
applyImageTypeLimit(items, query.imageTypeLimit)
startIndex := 0
if page {
startIndex = query.startIndex
}
writeJSON(w, http.StatusOK, queryResultDTO{
Items: items,
TotalRecordCount: len(items),
StartIndex: 0,
TotalRecordCount: total,
StartIndex: startIndex,
})
}
+72 -2
View File
@@ -2,6 +2,7 @@ package jellycompat
import (
"context"
"encoding/json"
"net/http/httptest"
"net/url"
"strings"
@@ -19,7 +20,10 @@ import (
type countingContentService struct {
episodeDetail *upstreamItemDetail
seriesDetail *upstreamItemDetail
seasons []upstreamSeason
getItemDetailCalls int
listSeasonsCalls int
listSeasonsSeries string
}
func (s *countingContentService) GetItemDetail(_ context.Context, _ *Session, contentID string, _ *int) (*upstreamItemDetail, error) {
@@ -50,8 +54,15 @@ func (s *countingContentService) SearchItems(context.Context, *Session, string,
panic("unused")
}
func (s *countingContentService) ListSeasons(context.Context, *Session, string, *int) ([]upstreamSeason, error) {
panic("unused")
func (s *countingContentService) ListSeasons(_ context.Context, _ *Session, seriesID string, _ *int) ([]upstreamSeason, error) {
s.listSeasonsCalls++
s.listSeasonsSeries = seriesID
if s.seasons == nil {
panic("unused")
}
out := make([]upstreamSeason, len(s.seasons))
copy(out, s.seasons)
return out, nil
}
func (s *countingContentService) GetSeason(context.Context, *Session, string, int, *int) (*upstreamSeason, error) {
@@ -70,6 +81,65 @@ func (s *countingContentService) ListItemFilters(context.Context, *Session, url.
panic("unused")
}
func TestHandleItems_SeriesParentSeasonFilterReturnsPagedSeasons(t *testing.T) {
codec := NewResourceIDCodec()
seriesContentID := "series-1"
encodedSeriesID := codec.EncodeStringID(EncodedIDItem, seriesContentID)
contentSvc := &countingContentService{
seasons: []upstreamSeason{
{ContentID: "season-1", SeasonNumber: 1, Title: "Season 1", EpisodeCount: 10},
{ContentID: "season-2", SeasonNumber: 2, Title: "Season 2", EpisodeCount: 8},
},
}
h := &ItemsHandler{
content: contentSvc,
userData: &mockUserDataService{},
codec: codec,
mapper: newMapper(codec, &config.Config{}),
images: NewImageCache(time.Hour, time.Now),
}
req := httptest.NewRequest("GET", "/Users/test/Items?ParentId="+encodedSeriesID+
"&IncludeItemTypes=Season&Recursive=false&SortBy=IndexNumber&SortOrder=Ascending"+
"&Fields=PrimaryImageAspectRatio,CanDelete&StartIndex=1&Limit=1", nil)
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, &Session{
StreamAppUserID: 1,
ProfileID: "profile-1",
}))
rec := httptest.NewRecorder()
h.HandleItems(rec, req)
if rec.Code != 200 {
t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String())
}
if contentSvc.listSeasonsCalls != 1 || contentSvc.listSeasonsSeries != seriesContentID {
t.Fatalf("ListSeasons calls = %d for %q, want 1 for %q",
contentSvc.listSeasonsCalls, contentSvc.listSeasonsSeries, seriesContentID)
}
var result queryResultDTO
if err := json.NewDecoder(rec.Body).Decode(&result); err != nil {
t.Fatalf("decode response: %v", err)
}
if result.TotalRecordCount != 2 || result.StartIndex != 1 {
t.Fatalf("TotalRecordCount/StartIndex = %d/%d, want 2/1",
result.TotalRecordCount, result.StartIndex)
}
if len(result.Items) != 1 {
t.Fatalf("len(Items) = %d, want 1", len(result.Items))
}
item := result.Items[0]
if item.Type != "Season" || item.Name != "Season 2" {
t.Fatalf("item = {%q %q}, want Season/Season 2", item.Type, item.Name)
}
if item.ParentID != encodedSeriesID || item.SeriesID != encodedSeriesID {
t.Fatalf("ParentID/SeriesID = %q/%q, want %q/%q",
item.ParentID, item.SeriesID, encodedSeriesID, encodedSeriesID)
}
}
// TestHandleItem_Episode_FetchesSeriesDetailForStableParentImageTags verifies
// that episode detail responses fetch parent series image metadata even when
// image URLs are already cached. Cached URLs are not enough to build stable
+1 -1
View File
@@ -439,7 +439,7 @@ func (h *PlaybackHandler) HandlePlaybackInfo(w http.ResponseWriter, r *http.Requ
allow4KTranscode := h.allow4KVideoTranscode(r.Context())
for _, version := range detail.Versions {
source := h.buildPlaybackSource(routeItemID, playSessionID, version, profile, req, allow4KTranscode)
if req.MediaSourceID != "" && source.ID != req.MediaSourceID {
if req.MediaSourceID != "" && !mediaSourceIDsEqual(source.ID, req.MediaSourceID) {
continue
}
sources = append(sources, source)
+16
View File
@@ -21,6 +21,7 @@ const (
EncodedIDGenre EncodedIDType = 6
EncodedIDStudio EncodedIDType = 7
EncodedIDPerson EncodedIDType = 8
EncodedIDImageProxy EncodedIDType = 9
)
var (
@@ -141,6 +142,21 @@ func (c *ResourceIDCodec) LookupMediaSourceOwner(fileID int64) (string, bool) {
return contentID, ok
}
// mediaSourceIDsEqual reports whether two media-source IDs refer to the same
// source, tolerating UUID format differences. Silo exposes the canonical
// dashed compat UUID (e.g. "03000000-0000-0000-0000-00000019e8c2"), but some
// Jellyfin clients (e.g. Wholphin) echo it back in the compact 32-char hex
// form ("0300000000000000000000000019e8c2"). Both parse to the same UUID, so
// matching must compare the parsed values rather than the raw strings.
func mediaSourceIDsEqual(a, b string) bool {
if a == b {
return true
}
ua, errA := uuid.Parse(a)
ub, errB := uuid.Parse(b)
return errA == nil && errB == nil && ua == ub
}
// DecodeID unpacks a compat UUID into its original numeric value.
func DecodeID(raw string) (DecodedID, error) {
parsed, err := uuid.Parse(raw)
@@ -0,0 +1,63 @@
package jellycompat
import "testing"
func TestMediaSourceIDsEqual(t *testing.T) {
cases := []struct {
name string
a string
b string
want bool
}{
{
name: "identical dashed",
a: "03000000-0000-0000-0000-00000019e8c2",
b: "03000000-0000-0000-0000-00000019e8c2",
want: true,
},
{
name: "dashed vs compact (Wholphin)",
a: "03000000-0000-0000-0000-00000019e8c2",
b: "0300000000000000000000000019e8c2",
want: true,
},
{
name: "compact vs dashed",
a: "0300000000000000000000000019e8c2",
b: "03000000-0000-0000-0000-00000019e8c2",
want: true,
},
{
name: "case insensitive hex",
a: "03000000-0000-0000-0000-00000019E8C2",
b: "0300000000000000000000000019e8c2",
want: true,
},
{
name: "different uuids",
a: "03000000-0000-0000-0000-00000019e8c2",
b: "0300000000000000000000000019e8c3",
want: false,
},
{
name: "non-uuid falls back to exact match",
a: "abc",
b: "abc",
want: true,
},
{
name: "non-uuid mismatch",
a: "abc",
b: "def",
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := mediaSourceIDsEqual(tc.a, tc.b); got != tc.want {
t.Fatalf("mediaSourceIDsEqual(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want)
}
})
}
}
+2 -2
View File
@@ -93,7 +93,7 @@ func (c *ImageCache) LookupSized(routeID, imageType, tag, size string) (string,
return "", false
}
if tag = strings.TrimSpace(tag); tag != "" {
if tag = canonicalCompatImageTag(tag); tag != "" {
return c.LookupTag(tag)
}
@@ -108,7 +108,7 @@ func (c *ImageCache) LookupTag(tag string) (string, bool) {
if c == nil {
return "", false
}
return c.lookupTag(strings.TrimSpace(tag))
return c.lookupTag(canonicalCompatImageTag(tag))
}
// lookupTag resolves a tag without size partitioning. Tags are sha1 of the
+233
View File
@@ -0,0 +1,233 @@
package jellycompat
import (
"bytes"
"encoding/json"
"net/http"
"strings"
)
func compatImageProxyTagVariantMiddleware(codec *ResourceIDCodec) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || !isCompatImageProxyClientRequest(r) || isWebSocketUpgrade(r) {
next.ServeHTTP(w, r)
return
}
rw := &compatImageProxyTagResponseWriter{ResponseWriter: w, codec: codec}
next.ServeHTTP(rw, r)
rw.finish()
})
}
}
type compatImageProxyTagResponseWriter struct {
http.ResponseWriter
status int
body bytes.Buffer
passthrough bool
codec *ResourceIDCodec
}
func (w *compatImageProxyTagResponseWriter) WriteHeader(status int) {
if w.passthrough {
w.ResponseWriter.WriteHeader(status)
return
}
w.status = status
}
func (w *compatImageProxyTagResponseWriter) Write(p []byte) (int, error) {
if w.passthrough {
return w.ResponseWriter.Write(p)
}
if w.status == 0 {
w.status = http.StatusOK
}
if !isJSONResponse(w.Header().Get("Content-Type")) {
w.passthrough = true
w.ResponseWriter.WriteHeader(w.status)
return w.ResponseWriter.Write(p)
}
return w.body.Write(p)
}
func (w *compatImageProxyTagResponseWriter) finish() {
if w.passthrough {
return
}
if w.status == 0 && w.body.Len() == 0 {
return
}
status := w.status
if status == 0 {
status = http.StatusOK
}
body := w.body.Bytes()
if isJSONResponse(w.Header().Get("Content-Type")) && len(body) > 0 {
body = rewriteCompatImageProxyTags(w.codec, body)
}
w.ResponseWriter.WriteHeader(status)
if len(body) > 0 {
_, _ = w.ResponseWriter.Write(body)
}
}
func isJSONResponse(contentType string) bool {
contentType = strings.ToLower(strings.TrimSpace(contentType))
return contentType == "application/json" || strings.HasPrefix(contentType, "application/json;")
}
func isWebSocketUpgrade(r *http.Request) bool {
return strings.EqualFold(r.Header.Get("Upgrade"), "websocket")
}
func rewriteCompatImageProxyTags(codec *ResourceIDCodec, body []byte) []byte {
var value any
if err := json.Unmarshal(body, &value); err != nil {
return body
}
if !appendCompatImageProxyTags(codec, value) {
return body
}
rewritten, err := json.Marshal(value)
if err != nil {
return body
}
return append(rewritten, '\n')
}
func appendCompatImageProxyTags(codec *ResourceIDCodec, value any) bool {
switch typed := value.(type) {
case map[string]any:
changed := false
hasPrimaryImageTag := compatMapHasPrimaryImageTag(typed)
for key, child := range typed {
switch key {
case "ImageTags":
changed = appendCompatImageProxyTagsInMap(child) || changed
case "ImageBlurHashes":
changed = appendCompatImageProxyTagsInBlurHashes(child) || changed
case "BackdropImageTags", "ParentBackdropImageTags":
changed = appendCompatImageProxyTagsInSlice(child) || changed
case "PrimaryImageTag", "SeriesPrimaryImageTag", "ParentThumbImageTag", "BackdropImageTag", "ImageTag":
if tag, ok := child.(string); ok && tag != "" {
typed[key] = compatImageProxyTag(tag)
changed = true
}
default:
changed = appendCompatImageProxyTags(codec, child) || changed
}
}
if hasPrimaryImageTag {
changed = appendCompatPrimaryImageItemID(codec, typed) || changed
}
return changed
case []any:
changed := false
for _, child := range typed {
changed = appendCompatImageProxyTags(codec, child) || changed
}
return changed
default:
return false
}
}
func compatMapHasPrimaryImageTag(value map[string]any) bool {
if tags, ok := value["ImageTags"].(map[string]any); ok {
if tag, ok := tags["Primary"].(string); ok && strings.TrimSpace(tag) != "" {
return true
}
}
for _, key := range []string{"PrimaryImageTag", "ImageTag"} {
if tag, ok := value[key].(string); ok && strings.TrimSpace(tag) != "" {
return true
}
}
return false
}
func appendCompatPrimaryImageItemID(codec *ResourceIDCodec, value map[string]any) bool {
if codec == nil {
return false
}
routeID := compatStringMapValue(value, "PrimaryImageItemId")
if routeID == "" {
routeID = compatStringMapValue(value, "Id")
}
if routeID == "" {
routeID = compatStringMapValue(value, "ItemId")
}
if routeID == "" {
return false
}
proxyRouteID := compatImageProxyRouteID(codec, routeID)
if proxyRouteID == "" || proxyRouteID == routeID {
return false
}
if compatStringMapValue(value, "PrimaryImageItemId") == proxyRouteID {
return false
}
value["PrimaryImageItemId"] = proxyRouteID
return true
}
func compatStringMapValue(value map[string]any, key string) string {
if raw, ok := value[key].(string); ok {
return strings.TrimSpace(raw)
}
return ""
}
func appendCompatImageProxyTagsInMap(value any) bool {
tags, ok := value.(map[string]any)
if !ok {
return false
}
changed := false
for key, raw := range tags {
if tag, ok := raw.(string); ok && tag != "" {
tags[key] = compatImageProxyTag(tag)
changed = true
}
}
return changed
}
func appendCompatImageProxyTagsInSlice(value any) bool {
tags, ok := value.([]any)
if !ok {
return false
}
changed := false
for idx, raw := range tags {
if tag, ok := raw.(string); ok && tag != "" {
tags[idx] = compatImageProxyTag(tag)
changed = true
}
}
return changed
}
func appendCompatImageProxyTagsInBlurHashes(value any) bool {
byImageType, ok := value.(map[string]any)
if !ok {
return false
}
changed := false
for imageType, rawTags := range byImageType {
tags, ok := rawTags.(map[string]any)
if !ok {
continue
}
next := make(map[string]any, len(tags))
for tag, hash := range tags {
next[compatImageProxyTag(tag)] = hash
changed = true
}
byImageType[imageType] = next
}
return changed
}
@@ -0,0 +1,111 @@
package jellycompat
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestCompatImageProxyTagVariantMiddlewareSuffixesInfuseImageTags(t *testing.T) {
codec := NewResourceIDCodec()
handler := compatImageProxyTagVariantMiddleware(codec)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, queryResultDTO{
Items: []baseItemDTO{{
ID: "item-1",
Name: "Movie",
ImageTags: map[string]string{"Primary": "primary-tag"},
BackdropImageTags: []string{"backdrop-tag"},
SeriesPrimaryImageTag: "series-tag",
ParentBackdropImageTags: []string{"parent-backdrop-tag"},
ParentThumbImageTag: "parent-thumb-tag",
ImageBlurHashes: map[string]map[string]string{
"Primary": {"primary-tag": "thumbhash"},
},
}},
TotalRecordCount: 1,
})
}))
req := httptest.NewRequest(http.MethodGet, "/Items", nil)
req.Header.Set("User-Agent", "Infuse-Direct/8.4.6")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String())
}
var got queryResultDTO
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("decode response: %v", err)
}
item := got.Items[0]
if item.ImageTags["Primary"] != "primary-tag-p" {
t.Fatalf("primary tag = %q, want primary-tag-p", item.ImageTags["Primary"])
}
if item.PrimaryImageItemID != compatImageProxyRouteID(codec, "item-1") {
t.Fatalf("PrimaryImageItemId = %q, want proxy route id", item.PrimaryImageItemID)
}
if canonical, ok := canonicalCompatImageRouteID(codec, item.PrimaryImageItemID); !ok || canonical != "item-1" {
t.Fatalf("canonical proxy route = %q, %v; want item-1, true", canonical, ok)
}
if item.BackdropImageTags[0] != "backdrop-tag-p" {
t.Fatalf("backdrop tag = %q, want backdrop-tag-p", item.BackdropImageTags[0])
}
if item.SeriesPrimaryImageTag != "series-tag-p" {
t.Fatalf("series tag = %q, want series-tag-p", item.SeriesPrimaryImageTag)
}
if item.ParentBackdropImageTags[0] != "parent-backdrop-tag-p" {
t.Fatalf("parent backdrop tag = %q, want parent-backdrop-tag-p", item.ParentBackdropImageTags[0])
}
if item.ParentThumbImageTag != "parent-thumb-tag-p" {
t.Fatalf("parent thumb tag = %q, want parent-thumb-tag-p", item.ParentThumbImageTag)
}
if _, ok := item.ImageBlurHashes["Primary"]["primary-tag-p"]; !ok {
t.Fatalf("blurhash keys = %#v, want suffixed primary tag", item.ImageBlurHashes["Primary"])
}
}
func TestCompatImageProxyTagVariantMiddlewareLeavesOtherClientsUnchanged(t *testing.T) {
codec := NewResourceIDCodec()
handler := compatImageProxyTagVariantMiddleware(codec)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, queryResultDTO{
Items: []baseItemDTO{{
ID: "item-1",
Name: "Movie",
ImageTags: map[string]string{"Primary": "primary-tag"},
}},
TotalRecordCount: 1,
})
}))
req := httptest.NewRequest(http.MethodGet, "/Items", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String())
}
var got queryResultDTO
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("decode response: %v", err)
}
if got.Items[0].ImageTags["Primary"] != "primary-tag" {
t.Fatalf("primary tag = %q, want primary-tag", got.Items[0].ImageTags["Primary"])
}
if got.Items[0].PrimaryImageItemID != "" {
t.Fatalf("PrimaryImageItemId = %q, want empty", got.Items[0].PrimaryImageItemID)
}
}
func TestCompatImageProxyRouteIDCanonicalizesNumericRouteWithoutRegistration(t *testing.T) {
routeID := EncodeNumericID(EncodedIDItem, 12345).String()
proxyRouteID := compatImageProxyRouteID(NewResourceIDCodec(), routeID)
canonical, ok := canonicalCompatImageRouteID(NewResourceIDCodec(), proxyRouteID)
if !ok || canonical != routeID {
t.Fatalf("canonical proxy route = %q, %v; want %q, true", canonical, ok, routeID)
}
}
+1 -1
View File
@@ -40,7 +40,7 @@ func (s *imageTagSigner) Equal(seed, fallbackURL, actual string) bool {
if s == nil {
return false
}
actual = strings.TrimSpace(actual)
actual = canonicalCompatImageTag(actual)
expected := s.Tag(seed, fallbackURL)
if expected == "" || actual == "" || len(expected) != len(actual) {
return false
+196 -18
View File
@@ -2,48 +2,226 @@ package jellycompat
import (
"bytes"
"context"
"crypto/sha1"
"encoding/binary"
"image"
"image/color"
"image/png"
"io"
"net/http"
"strings"
"github.com/google/uuid"
)
const defaultImageProxyCacheControl = "public, max-age=300, must-revalidate"
var compatImageProxyUserAgentSubstrings = []string{
"infuse",
}
const compatImageRouteCacheControl = "private, no-store, no-cache, max-age=0, s-maxage=0, must-revalidate"
const compatImageProxyTagSuffix = "-p"
const compatImageProxyRoutePrefix = "__jellycompat_image_proxy__:"
type compatImageProxyRouteContextKey struct{}
var compatImageProxyHeaders = []string{
"Accept-Ranges",
"Content-Length",
"Content-Type",
"ETag",
"Last-Modified",
}
func shouldProxyCompatImageRequest(r *http.Request) bool {
return isCompatImageProxyClientRequest(r) ||
isCompatImageProxyRouteRequest(r) ||
isCompatImageProxyTag(r.URL.Query().Get("tag"))
}
func isCompatImageProxyClientRequest(r *http.Request) bool {
if r == nil {
return false
}
clientText := strings.ToLower(strings.Join([]string{
r.UserAgent(),
r.Header.Get("X-Emby-Authorization"),
}, " "))
for _, substring := range compatImageProxyUserAgentSubstrings {
if strings.Contains(clientText, substring) {
return true
}
}
return false
}
func compatImageProxyTag(tag string) string {
tag = strings.TrimSpace(tag)
if tag == "" || isCompatImageProxyTag(tag) {
return tag
}
return tag + compatImageProxyTagSuffix
}
func isCompatImageProxyTag(tag string) bool {
return strings.HasSuffix(strings.TrimSpace(tag), compatImageProxyTagSuffix)
}
func canonicalCompatImageTag(tag string) string {
tag = strings.TrimSpace(tag)
return strings.TrimSuffix(tag, compatImageProxyTagSuffix)
}
func compatImageProxyRouteID(codec *ResourceIDCodec, routeID string) string {
routeID = strings.TrimSpace(routeID)
if codec == nil || routeID == "" {
return routeID
}
if canonical, ok := canonicalCompatImageRouteID(codec, routeID); ok {
return compatImageProxyRouteID(codec, canonical)
}
if decoded, ok := decodePackedCompatRouteID(routeID); ok {
return encodeNumericCompatImageProxyRouteID(decoded)
}
return codec.EncodeStringID(EncodedIDItem, compatImageProxyRoutePrefix+routeID)
}
func canonicalCompatImageRouteID(codec *ResourceIDCodec, routeID string) (string, bool) {
if canonical, ok := decodeNumericCompatImageProxyRouteID(routeID); ok {
return canonical, true
}
if codec == nil {
return routeID, false
}
value, err := codec.DecodeStringID(EncodedIDItem, strings.TrimSpace(routeID))
if err != nil || !strings.HasPrefix(value, compatImageProxyRoutePrefix) {
return routeID, false
}
canonical := strings.TrimSpace(strings.TrimPrefix(value, compatImageProxyRoutePrefix))
if canonical == "" {
return routeID, false
}
return canonical, true
}
func encodeNumericCompatImageProxyRouteID(decoded DecodedID) string {
var raw [16]byte
raw[0] = byte(EncodedIDImageProxy)
raw[1] = byte(decoded.Type)
binary.BigEndian.PutUint64(raw[8:], decoded.Value)
return uuid.UUID(raw).String()
}
func decodeNumericCompatImageProxyRouteID(routeID string) (string, bool) {
parsed, err := uuid.Parse(strings.TrimSpace(routeID))
if err != nil || EncodedIDType(parsed[0]) != EncodedIDImageProxy {
return "", false
}
kind := EncodedIDType(parsed[1])
if !isPackedCompatRouteIDType(kind) || !zeroUUIDBytes(parsed[2:8]) {
return "", false
}
return EncodeNumericID(kind, binary.BigEndian.Uint64(parsed[8:])).String(), true
}
func decodePackedCompatRouteID(routeID string) (DecodedID, bool) {
parsed, err := uuid.Parse(strings.TrimSpace(routeID))
if err != nil {
return DecodedID{}, false
}
kind := EncodedIDType(parsed[0])
if !isPackedCompatRouteIDType(kind) || !zeroUUIDBytes(parsed[1:8]) {
return DecodedID{}, false
}
return DecodedID{Type: kind, Value: binary.BigEndian.Uint64(parsed[8:])}, true
}
func isPackedCompatRouteIDType(kind EncodedIDType) bool {
switch kind {
case EncodedIDLibrary,
EncodedIDItem,
EncodedIDMediaSource,
EncodedIDSeason,
EncodedIDPlaySession,
EncodedIDGenre,
EncodedIDStudio,
EncodedIDPerson:
return true
default:
return false
}
}
func zeroUUIDBytes(values []byte) bool {
for _, value := range values {
if value != 0 {
return false
}
}
return true
}
func withCompatImageProxyRouteRequest(r *http.Request) *http.Request {
if r == nil {
return r
}
return r.WithContext(context.WithValue(r.Context(), compatImageProxyRouteContextKey{}, true))
}
func isCompatImageProxyRouteRequest(r *http.Request) bool {
if r == nil {
return false
}
forceProxy, _ := r.Context().Value(compatImageProxyRouteContextKey{}).(bool)
return forceProxy
}
func copyConditionalImageRequestHeaders(dst, src http.Header) {
for _, key := range []string{"If-Match", "If-None-Match", "If-Modified-Since", "If-Unmodified-Since"} {
if value := src.Values(key); len(value) > 0 {
dst[key] = append([]string(nil), value...)
}
}
}
func proxyImage(w http.ResponseWriter, resp *http.Response) {
defer resp.Body.Close()
copyImageProxyHeaders(w.Header(), resp.Header)
if resp.StatusCode == http.StatusNotModified {
copyImageProxyHeaders(w.Header(), resp.Header)
setCompatImageRouteNoStore(w.Header())
w.WriteHeader(http.StatusNotModified)
return
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
setCompatImageRouteNoStore(w.Header())
writeError(w, http.StatusBadGateway, "UpstreamError", "Failed to load image")
return
}
w.WriteHeader(http.StatusOK)
copyImageProxyHeaders(w.Header(), resp.Header)
setCompatImageRouteNoStore(w.Header())
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}
func copyImageProxyHeaders(dst, src http.Header) {
for _, name := range []string{"Content-Type", "Cache-Control", "ETag", "Last-Modified", "Expires"} {
values := src.Values(name)
if len(values) == 0 {
values = src[name]
}
if len(values) == 0 {
continue
}
dst.Del(name)
for _, value := range values {
dst.Add(name, value)
for _, key := range compatImageProxyHeaders {
if value := src.Values(key); len(value) > 0 {
dst[key] = append([]string(nil), value...)
}
}
if dst.Get("Cache-Control") == "" {
dst.Set("Cache-Control", defaultImageProxyCacheControl)
}
}
func setCompatImageRouteNoStore(header http.Header) {
header.Set("Cache-Control", compatImageRouteCacheControl)
header.Set("CDN-Cache-Control", "private, no-store, no-cache, max-age=0")
header.Set("Expires", "Thu, 01 Jan 1970 00:00:00 GMT")
header.Set("Pragma", "no-cache")
header.Set("Surrogate-Control", "no-store")
header.Set("X-Accel-Expires", "0")
header.Add("Vary", "User-Agent")
}
func placeholderAvatarPNG(seed string) ([]byte, error) {
+192 -114
View File
@@ -2,10 +2,8 @@ package jellycompat
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -16,88 +14,66 @@ import (
"github.com/Silo-Server/silo-server/internal/models"
)
func TestProxyImageDefaultsToRevalidatingCachePolicy(t *testing.T) {
rec := httptest.NewRecorder()
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{
"Content-Type": []string{"image/jpeg"},
"ETag": []string{`"abc"`},
"Last-Modified": []string{"Tue, 12 May 2026 12:00:00 GMT"},
"Expires": []string{"Tue, 12 May 2026 12:05:00 GMT"},
},
Body: io.NopCloser(strings.NewReader("image-bytes")),
}
proxyImage(rec, resp)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Cache-Control"); got != defaultImageProxyCacheControl {
t.Fatalf("Cache-Control = %q, want %q", got, defaultImageProxyCacheControl)
}
if got := rec.Header().Get("ETag"); got != `"abc"` {
t.Fatalf("ETag = %q", got)
}
if strings.Contains(rec.Header().Get("Cache-Control"), "immutable") {
t.Fatalf("Cache-Control unexpectedly immutable: %q", rec.Header().Get("Cache-Control"))
}
}
func TestProxyImageRelaysNotModified(t *testing.T) {
rec := httptest.NewRecorder()
resp := &http.Response{
StatusCode: http.StatusNotModified,
Header: http.Header{
"ETag": []string{`"abc"`},
"Cache-Control": []string{"public, max-age=60"},
},
Body: io.NopCloser(strings.NewReader("")),
}
proxyImage(rec, resp)
if rec.Code != http.StatusNotModified {
t.Fatalf("status = %d, want 304", rec.Code)
}
if got := rec.Header().Get("ETag"); got != `"abc"` {
t.Fatalf("ETag = %q", got)
}
if rec.Body.Len() != 0 {
t.Fatalf("304 body length = %d, want 0", rec.Body.Len())
}
}
func TestProxyImageURLForwardsConditionalHeaders(t *testing.T) {
func TestHandleItemImageAcceptsSignedTagWithoutSessionOrCache(t *testing.T) {
upstreamCalled := false
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("If-None-Match"); got != `"abc"` {
t.Fatalf("If-None-Match = %q", got)
}
if got := r.Header.Get("If-Modified-Since"); got != "Tue, 12 May 2026 12:00:00 GMT" {
t.Fatalf("If-Modified-Since = %q", got)
}
w.Header().Set("ETag", `"abc"`)
w.WriteHeader(http.StatusNotModified)
upstreamCalled = true
w.WriteHeader(http.StatusTeapot)
}))
defer upstream.Close()
h := &ImagesHandler{httpClient: upstream.Client()}
req := httptest.NewRequest(http.MethodGet, "/Items/1/Images/Primary", nil)
req.Header.Set("If-None-Match", `"abc"`)
req.Header.Set("If-Modified-Since", "Tue, 12 May 2026 12:00:00 GMT")
codec := NewResourceIDCodec()
contentID := "movie-1"
routeID := codec.EncodeStringID(EncodedIDItem, contentID)
updatedAt := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
item := &models.MediaItem{
ContentID: contentID,
PosterPath: upstream.URL,
PosterThumbhash: "poster-thumbhash",
UpdatedAt: updatedAt,
}
cfg := &config.Config{Auth: config.AuthConfig{JWTSecret: "image-secret"}}
tag := newMapper(codec, cfg).itemFromList(upstreamListItem{
ContentID: contentID,
Type: "movie",
Title: "Movie",
PosterURL: item.PosterPath,
PosterPath: item.PosterPath,
PosterThumbhash: item.PosterThumbhash,
UpdatedAt: item.UpdatedAt,
}, false, nil, nil).ImageTags["Primary"]
h := &ImagesHandler{
codec: codec,
images: NewImageCache(time.Hour, func() time.Time { return updatedAt }),
itemRepo: fakeImageItemRepo{item: item},
imageTags: newImageTagSigner(cfg.Auth.JWTSecret),
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?fillHeight=267&fillWidth=474&quality=96&tag="+tag, nil)
req = withImageRouteParams(req, routeID, "Primary")
rec := httptest.NewRecorder()
h.proxyImageURL(rec, req, upstream.URL)
h.HandleItemImage(rec, req)
if rec.Code != http.StatusNotModified {
t.Fatalf("status = %d, want 304", rec.Code)
assertImageRedirect(t, rec, upstream.URL)
if upstreamCalled {
t.Fatal("compat image route proxied the upstream image instead of redirecting")
}
if cached, ok := h.images.LookupSized(routeID, "Primary", "", compatRequestImageSize(req, "Primary")); !ok || cached == "" {
t.Fatal("signed-tag image URL was not cached after resolution")
}
}
func TestHandleItemImageAcceptsSignedTagWithoutSessionOrCache(t *testing.T) {
func TestHandleItemImageProxiesInfuseSignedTagWithoutSessionOrCache(t *testing.T) {
upstreamCalled := false
var gotIfNoneMatch string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamCalled = true
gotIfNoneMatch = r.Header.Get("If-None-Match")
w.Header().Set("Cache-Control", "public, max-age=14400")
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("ETag", `"poster-v1"`)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("image-bytes"))
}))
defer upstream.Close()
@@ -124,13 +100,15 @@ func TestHandleItemImageAcceptsSignedTagWithoutSessionOrCache(t *testing.T) {
}, false, nil, nil).ImageTags["Primary"]
h := &ImagesHandler{
codec: codec,
httpClient: upstream.Client(),
images: NewImageCache(time.Hour, func() time.Time { return updatedAt }),
itemRepo: fakeImageItemRepo{item: item},
imageTags: newImageTagSigner(cfg.Auth.JWTSecret),
httpClient: upstream.Client(),
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?fillHeight=267&fillWidth=474&quality=96&tag="+tag, nil)
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?fillHeight=267&fillWidth=474&quality=96&tag="+compatImageProxyTag(tag), nil)
req.Header.Set("If-None-Match", `"poster-v1"`)
req.Header.Set("User-Agent", "Infuse-Direct/8.4.6")
req = withImageRouteParams(req, routeID, "Primary")
rec := httptest.NewRecorder()
@@ -140,10 +118,90 @@ func TestHandleItemImageAcceptsSignedTagWithoutSessionOrCache(t *testing.T) {
t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String())
}
if got := rec.Body.String(); got != "image-bytes" {
t.Fatalf("body = %q, want image bytes", got)
t.Fatalf("body = %q, want image-bytes", got)
}
if got := rec.Header().Get("Location"); got != "" {
t.Fatalf("Location = %q, want empty", got)
}
if got := rec.Header().Get("Content-Type"); got != "image/jpeg" {
t.Fatalf("Content-Type = %q, want image/jpeg", got)
}
if got := rec.Header().Get("Cache-Control"); got != compatImageRouteCacheControl {
t.Fatalf("Cache-Control = %q, want %q", got, compatImageRouteCacheControl)
}
if got := rec.Header().Get("CDN-Cache-Control"); got != "private, no-store, no-cache, max-age=0" {
t.Fatalf("CDN-Cache-Control = %q, want private, no-store, no-cache, max-age=0", got)
}
if got := rec.Header().Get("X-Accel-Expires"); got != "0" {
t.Fatalf("X-Accel-Expires = %q, want 0", got)
}
if got := gotIfNoneMatch; got != `"poster-v1"` {
t.Fatalf("forwarded If-None-Match = %q, want poster-v1", got)
}
if !upstreamCalled {
t.Fatal("Infuse compat image route did not proxy the upstream image")
}
}
func TestHandleItemImageProxyRouteIDUsesCanonicalItemAndProxy(t *testing.T) {
upstreamCalled := false
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamCalled = true
w.Header().Set("Content-Type", "image/webp")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("proxy-route-image"))
}))
defer upstream.Close()
codec := NewResourceIDCodec()
contentID := "movie-1"
routeID := codec.EncodeStringID(EncodedIDItem, contentID)
proxyRouteID := compatImageProxyRouteID(codec, routeID)
updatedAt := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
item := &models.MediaItem{
ContentID: contentID,
PosterPath: upstream.URL,
PosterThumbhash: "poster-thumbhash",
UpdatedAt: updatedAt,
}
cfg := &config.Config{Auth: config.AuthConfig{JWTSecret: "image-secret"}}
tag := newMapper(codec, cfg).itemFromList(upstreamListItem{
ContentID: contentID,
Type: "movie",
Title: "Movie",
PosterURL: item.PosterPath,
PosterPath: item.PosterPath,
PosterThumbhash: item.PosterThumbhash,
UpdatedAt: item.UpdatedAt,
}, false, nil, nil).ImageTags["Primary"]
h := &ImagesHandler{
codec: codec,
images: NewImageCache(time.Hour, func() time.Time { return updatedAt }),
itemRepo: fakeImageItemRepo{item: item},
imageTags: newImageTagSigner(cfg.Auth.JWTSecret),
httpClient: upstream.Client(),
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+proxyRouteID+"/Images/Primary?fillHeight=267&fillWidth=474&quality=96&tag="+compatImageProxyTag(tag), nil)
req = withImageRouteParams(req, proxyRouteID, "Primary")
rec := httptest.NewRecorder()
h.HandleItemImage(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String())
}
if got := rec.Body.String(); got != "proxy-route-image" {
t.Fatalf("body = %q, want proxy-route-image", got)
}
if got := rec.Header().Get("Location"); got != "" {
t.Fatalf("Location = %q, want empty", got)
}
if cached, ok := h.images.LookupSized(routeID, "Primary", "", compatRequestImageSize(req, "Primary")); !ok || cached == "" {
t.Fatal("signed-tag image URL was not cached after resolution")
t.Fatal("proxy route image URL was not cached under the canonical route ID")
}
if !upstreamCalled {
t.Fatal("proxy route did not fetch the upstream image")
}
}
@@ -168,10 +226,9 @@ func TestHandleItemImageRejectsUnsignedTagWhenSecretBlank(t *testing.T) {
UpdatedAt: item.UpdatedAt,
}, false, nil, nil).ImageTags["Primary"]
h := &ImagesHandler{
codec: codec,
httpClient: http.DefaultClient,
itemRepo: fakeImageItemRepo{item: item},
imageTags: newImageTagSigner(""),
codec: codec,
itemRepo: fakeImageItemRepo{item: item},
imageTags: newImageTagSigner(""),
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tag, nil)
@@ -186,9 +243,10 @@ func TestHandleItemImageRejectsUnsignedTagWhenSecretBlank(t *testing.T) {
}
func TestHandleItemImageAcceptsSignedCanonicalBackdropTagWithoutSessionOrCache(t *testing.T) {
upstreamCalled := false
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write([]byte("backdrop-bytes"))
upstreamCalled = true
w.WriteHeader(http.StatusTeapot)
}))
defer upstream.Close()
@@ -201,9 +259,8 @@ func TestHandleItemImageAcceptsSignedCanonicalBackdropTagWithoutSessionOrCache(t
upstream.URL,
)
h := &ImagesHandler{
codec: codec,
httpClient: upstream.Client(),
images: NewImageCache(time.Hour, time.Now),
codec: codec,
images: NewImageCache(time.Hour, time.Now),
itemRepo: fakeImageItemRepo{item: &models.MediaItem{
ContentID: contentID,
BackdropPath: upstream.URL,
@@ -217,18 +274,17 @@ func TestHandleItemImageAcceptsSignedCanonicalBackdropTagWithoutSessionOrCache(t
h.HandleItemImage(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String())
}
if got := rec.Body.String(); got != "backdrop-bytes" {
t.Fatalf("body = %q, want backdrop bytes", got)
assertImageRedirect(t, rec, upstream.URL)
if upstreamCalled {
t.Fatal("compat image route proxied the upstream image instead of redirecting")
}
}
func TestHandleItemImageAcceptsLibraryPosterTagWithoutSessionOrCache(t *testing.T) {
upstreamCalled := false
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write([]byte("library-poster"))
upstreamCalled = true
w.WriteHeader(http.StatusTeapot)
}))
defer upstream.Close()
@@ -243,7 +299,6 @@ func TestHandleItemImageAcceptsLibraryPosterTagWithoutSessionOrCache(t *testing.
)
h := &ImagesHandler{
codec: codec,
httpClient: upstream.Client(),
images: NewImageCache(time.Hour, time.Now),
folderRepo: fakeImageFolderRepo{folder: &models.MediaFolder{ID: libraryID, PosterPath: posterPath}},
posterSigner: fakeLibraryPosterPresigner{url: upstream.URL},
@@ -256,18 +311,17 @@ func TestHandleItemImageAcceptsLibraryPosterTagWithoutSessionOrCache(t *testing.
h.HandleItemImage(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String())
}
if got := rec.Body.String(); got != "library-poster" {
t.Fatalf("body = %q, want library poster", got)
assertImageRedirect(t, rec, upstream.URL)
if upstreamCalled {
t.Fatal("compat image route proxied the upstream image instead of redirecting")
}
}
func TestHandleItemImageAcceptsLegacyCachedURLTagWithoutRouteFallback(t *testing.T) {
upstreamCalled := false
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write([]byte("cached-image"))
upstreamCalled = true
w.WriteHeader(http.StatusTeapot)
}))
defer upstream.Close()
@@ -276,10 +330,9 @@ func TestHandleItemImageAcceptsLegacyCachedURLTagWithoutRouteFallback(t *testing
cache := NewImageCache(time.Hour, time.Now)
cache.RememberSized(routeID, "Primary", upstream.URL, compatCardImageSize)
h := &ImagesHandler{
codec: codec,
httpClient: upstream.Client(),
images: cache,
imageTags: newImageTagSigner("image-secret"),
codec: codec,
images: cache,
imageTags: newImageTagSigner("image-secret"),
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tagValue(upstream.URL), nil)
@@ -288,11 +341,9 @@ func TestHandleItemImageAcceptsLegacyCachedURLTagWithoutRouteFallback(t *testing
h.HandleItemImage(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String())
}
if got := rec.Body.String(); got != "cached-image" {
t.Fatalf("body = %q, want cached image", got)
assertImageRedirect(t, rec, upstream.URL)
if upstreamCalled {
t.Fatal("compat image route proxied the upstream image instead of redirecting")
}
}
@@ -328,11 +379,10 @@ func TestHandleItemImageRevalidatesTagBeforeRouteCacheHit(t *testing.T) {
UpdatedAt: item.UpdatedAt,
}, false, nil, nil).ImageTags["Primary"]
h := &ImagesHandler{
codec: codec,
httpClient: upstream.Client(),
images: cache,
itemRepo: fakeImageItemRepo{item: item},
imageTags: newImageTagSigner("new-secret"),
codec: codec,
images: cache,
itemRepo: fakeImageItemRepo{item: item},
imageTags: newImageTagSigner("new-secret"),
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tag, nil)
@@ -349,6 +399,34 @@ func TestHandleItemImageRevalidatesTagBeforeRouteCacheHit(t *testing.T) {
}
}
func TestRedirectImageURLRejectsNonHTTPURL(t *testing.T) {
h := &ImagesHandler{}
req := httptest.NewRequest(http.MethodGet, "/Items/1/Images/Primary", nil)
rec := httptest.NewRecorder()
h.redirectImageURL(rec, req, "catalog/poster.jpg")
if rec.Code != http.StatusBadGateway {
t.Fatalf("status = %d, body = %s; want 502", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Location"); got != "" {
t.Fatalf("Location = %q, want empty", got)
}
}
func assertImageRedirect(t *testing.T, rec *httptest.ResponseRecorder, wantLocation string) {
t.Helper()
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, body = %s; want 302", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Location"); got != wantLocation {
t.Fatalf("Location = %q, want %q", got, wantLocation)
}
if got := rec.Header().Get("Cache-Control"); got != compatImageRouteCacheControl {
t.Fatalf("Cache-Control = %q, want %q", got, compatImageRouteCacheControl)
}
}
type fakeImageItemRepo struct {
item *models.MediaItem
}
+4 -2
View File
@@ -106,8 +106,10 @@ func (m *mapper) itemFromList(item upstreamListItem, isFavorite bool, progress *
dto.RecursiveItemCount = *item.EpisodeCount
}
if item.SeasonCount != nil {
dto.ChildCount = *item.SeasonCount
dto.RecursiveItemCount = *item.SeasonCount
seasonCount := *item.SeasonCount
dto.ChildCount = seasonCount
dto.RecursiveItemCount = seasonCount
dto.SeasonCount = seasonCount
}
primaryPath, primaryThumbhash := listItemPrimaryImageSeedParts(item)
if tags := imageTagsWithSeed(m.imageTagSigner,
+36
View File
@@ -35,3 +35,39 @@ func TestItemListSortNamePrefersSortTitle(t *testing.T) {
t.Fatalf("SortName = %q, want %q", dto.SortName, "Matrix, The")
}
}
func TestSeriesListIncludesSeasonCount(t *testing.T) {
m := newMapper(NewResourceIDCodec(), &config.Config{})
seasonCount := 4
dto := m.itemFromList(upstreamListItem{
ContentID: "series-1",
Type: "series",
Title: "Snowpiercer",
SeasonCount: &seasonCount,
}, false, nil, nil)
if dto.SeasonCount != 4 {
t.Fatalf("SeasonCount = %d, want 4", dto.SeasonCount)
}
if dto.ChildCount != 4 || dto.RecursiveItemCount != 4 {
t.Fatalf("ChildCount/RecursiveItemCount = %d/%d, want 4/4", dto.ChildCount, dto.RecursiveItemCount)
}
}
func TestSeriesDetailIncludesSeasonCount(t *testing.T) {
m := newMapper(NewResourceIDCodec(), &config.Config{})
seasonCount := 4
dto := m.itemFromDetail(upstreamItemDetail{
ContentID: "series-1",
Type: "series",
Title: "Snowpiercer",
SeasonCount: &seasonCount,
}, false, nil)
if dto.SeasonCount != 4 {
t.Fatalf("SeasonCount = %d, want 4", dto.SeasonCount)
}
if dto.ChildCount != 4 || dto.RecursiveItemCount != 4 {
t.Fatalf("ChildCount/RecursiveItemCount = %d/%d, want 4/4", dto.ChildCount, dto.RecursiveItemCount)
}
}
+1 -1
View File
@@ -140,7 +140,7 @@ func (s *PlaybackSessionStore) FindByRoute(compatToken, routeID string) (*Playba
return &cp, nil, true
}
for _, source := range session.MediaSources {
if source.ID == routeID {
if mediaSourceIDsEqual(source.ID, routeID) {
cp := session
sourceCopy := source
return &cp, &sourceCopy, true
+18
View File
@@ -19,6 +19,7 @@ type itemsQuery struct {
namePrefix string
maxOfficialRating string
parentLibraryID int
parentItemID string
specificIDs []string
itemTypes []string
genreName string
@@ -32,6 +33,7 @@ type itemsQuery struct {
personID int64
isPlayed *bool // nil = not specified
imageTypeLimit *int // nil = not specified
requireBackdrop bool // true when ImageTypes includes Backdrop (filter, not just a hint)
mediaTypes []string
mediaTypesSet map[string]bool
mediaTypesExplicit bool
@@ -55,6 +57,8 @@ func parseItemsQuery(r *http.Request, codec *ResourceIDCodec) itemsQuery {
if parentID := strings.TrimSpace(q.Get("ParentId")); parentID != "" {
if libraryID, err := codec.DecodeIntID(EncodedIDLibrary, parentID); err == nil {
result.parentLibraryID = int(libraryID)
} else if contentID, itemErr := decodeItemID(codec, parentID); itemErr == nil && contentID != "" {
result.parentItemID = contentID
}
}
@@ -111,6 +115,17 @@ func parseItemsQuery(r *http.Request, codec *ResourceIDCodec) itemsQuery {
}
}
// ImageTypes acts as a filter: clients (e.g. Wholphin genre cards) request
// ImageTypes=Backdrop and assume every returned item has a backdrop. Only
// Backdrop is enforced — the catalog browse path can filter on backdrop_path.
for _, raw := range q.Values("ImageTypes") {
for part := range strings.SplitSeq(raw, ",") {
if strings.EqualFold(strings.TrimSpace(part), "Backdrop") {
result.requireBackdrop = true
}
}
}
mediaTypesRaw := q.Values("MediaTypes")
result.mediaTypes = parseMediaTypes(mediaTypesRaw)
result.mediaTypesExplicit = len(mediaTypesRaw) > 0 && strings.TrimSpace(strings.Join(mediaTypesRaw, "")) != ""
@@ -211,6 +226,9 @@ func buildBrowseParams(query itemsQuery) url.Values {
params.Set("is_played", "false")
}
}
if query.requireBackdrop {
params.Set("require_backdrop", "true")
}
return params
}
@@ -0,0 +1,37 @@
package jellycompat
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestParseItemsQuery_ImageTypesBackdropFilter(t *testing.T) {
codec := NewResourceIDCodec()
cases := []struct {
name string
rawQuery string
wantRequire bool
}{
{"backdrop requested", "ImageTypes=Backdrop", true},
{"lowercase param and value", "imagetypes=backdrop", true},
{"backdrop among several", "ImageTypes=Primary,Backdrop,Logo", true},
{"bracket array variant", "ImageTypes[]=Backdrop", true},
{"primary only does not filter", "ImageTypes=Primary", false},
{"absent does not filter", "Limit=1", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/Items?"+tc.rawQuery, nil)
query := parseItemsQuery(req, codec)
if query.requireBackdrop != tc.wantRequire {
t.Fatalf("requireBackdrop = %v, want %v", query.requireBackdrop, tc.wantRequire)
}
params := buildBrowseParams(query)
gotParam := params.Get("require_backdrop") == "true"
if gotParam != tc.wantRequire {
t.Fatalf("require_backdrop param = %q, want emitted=%v", params.Get("require_backdrop"), tc.wantRequire)
}
})
}
}
+5 -4
View File
@@ -56,6 +56,7 @@ func NewRouter(deps Dependencies) chi.Router {
}
slog.Info("jellycompat debug logging enabled", logAttrs...)
}
r.Use(compatImageProxyTagVariantMiddleware(deps.IDCodec))
r.Use(requestLoggerMiddleware)
r.Use(middleware.Recoverer)
@@ -99,7 +100,7 @@ func NewRouter(deps Dependencies) chi.Router {
playbackHandler.S3Client = deps.S3Client
playbackHandler.S3Bucket = deps.S3Bucket
}
imagesHandler := NewImagesHandler(deps.ContentService, deps.IDCodec, deps.HTTPClient, deps.SessionStore, deps.ImageCache, deps.PersonRepo, deps.DetailSvc, deps.ItemRepo, deps.FolderRepo, deps.SeasonRepo, deps.EpisodeRepo, deps.AccessFilterFn, deps.PosterPresigner, deps.PresignTTL, deps.JWTSecret)
imagesHandler := NewImagesHandler(deps.ContentService, deps.IDCodec, deps.SessionStore, deps.ImageCache, deps.PersonRepo, deps.DetailSvc, deps.ItemRepo, deps.FolderRepo, deps.SeasonRepo, deps.EpisodeRepo, deps.AccessFilterFn, deps.PosterPresigner, deps.PresignTTL, deps.JWTSecret, deps.HTTPClient)
displayPrefsHandler := NewDisplayPreferencesHandler(deps.UserStoreProvider)
recsHandler := NewRecommendationsHandler(deps.Recommender, deps.ItemRepo, deps.ContentService, deps.UserDataService, deps.IDCodec, deps.Config, deps.AccessFilterFn)
@@ -261,15 +262,15 @@ func withDefaults(deps Dependencies) Dependencies {
if deps.Config != nil && deps.Config.JellyfinCompat.PlaybackSessionTTL > 0 {
playbackTTL = deps.Config.JellyfinCompat.PlaybackSessionTTL
}
if deps.HTTPClient == nil {
deps.HTTPClient = &http.Client{Timeout: 30 * time.Second}
}
if deps.DeviceProfiles == nil {
deps.DeviceProfiles = NewDeviceProfileStore(playbackTTL, deps.Now)
}
if deps.PlaybackStore == nil {
deps.PlaybackStore = NewPlaybackSessionStore(playbackTTL, deps.Now)
}
if deps.HTTPClient == nil {
deps.HTTPClient = &http.Client{Timeout: 30 * time.Second}
}
// Build ContentService from repos if not provided
if deps.ContentService == nil && deps.BrowseRepo != nil && deps.ItemRepo != nil && deps.DetailSvc != nil {
+1 -2
View File
@@ -27,7 +27,6 @@ type Dependencies struct {
DB *pgxpool.Pool
SecretCipher *secret.Cipher // at-rest credential cipher (required when DB is set)
ClientIPResolver *clientip.Resolver
HTTPClient *http.Client
Now func() time.Time
TokenGenerator func() string
SessionStore *SessionStore
@@ -38,6 +37,7 @@ type Dependencies struct {
LoginResolver loginResolver
Authenticator *Authenticator
WebFS fs.FS
HTTPClient *http.Client
// Direct service dependencies (replaces Client)
ContentService ContentService
@@ -145,6 +145,5 @@ func NewDependencies(cfg *config.Config) Dependencies {
Config: cfg,
Now: time.Now,
TokenGenerator: uuid.NewString,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
}
}
+5 -5
View File
@@ -123,7 +123,7 @@ func (h *PlaybackHandler) HandleMasterManifest(w http.ResponseWriter, r *http.Re
return
}
playSessionID := firstNonEmpty(r.URL.Query().Get("PlaySessionId"), r.URL.Query().Get("PlaySessionID"))
playSessionID := newCaseInsensitiveQuery(r.URL.Query()).Get("PlaySessionId")
if playSessionID == "" {
writeError(w, http.StatusBadRequest, "BadRequest", "PlaySessionId is required")
return
@@ -906,7 +906,7 @@ func audioSelectionChanged(session *PlaybackSession, mediaSourceID string, incom
return true
}
for _, source := range session.MediaSources {
if mediaSourceID != "" && source.ID != mediaSourceID {
if mediaSourceID != "" && !mediaSourceIDsEqual(source.ID, mediaSourceID) {
continue
}
if source.SelectedAudioStreamIndex == nil {
@@ -925,7 +925,7 @@ func (h *PlaybackHandler) setSelectedAudioStream(playSessionID, mediaSourceID st
if mediaSourceID != "" {
sourceIndex = -1
for index := range current.MediaSources {
if current.MediaSources[index].ID == mediaSourceID {
if mediaSourceIDsEqual(current.MediaSources[index].ID, mediaSourceID) {
sourceIndex = index
break
}
@@ -1061,7 +1061,7 @@ func (h *PlaybackHandler) createStaticPlaySession(ctx context.Context, session *
}
func (h *PlaybackHandler) resolvePlaybackRoute(r *http.Request, compatSession *Session, routeID, mediaSourceID string) (*PlaybackSession, *PlaybackMediaSource, error) {
if playSessionID := firstNonEmpty(r.URL.Query().Get("PlaySessionId"), r.URL.Query().Get("PlaySessionID")); playSessionID != "" {
if playSessionID := newCaseInsensitiveQuery(r.URL.Query()).Get("PlaySessionId"); playSessionID != "" {
playSession, ok := h.playbackStore.Get(playSessionID)
if !ok || playSession.CompatToken != compatSession.Token {
return nil, nil, ErrSessionNotFound
@@ -1099,7 +1099,7 @@ func findMediaSource(session *PlaybackSession, mediaSourceID string) *PlaybackMe
return nil
}
for _, source := range session.MediaSources {
if source.ID == mediaSourceID {
if mediaSourceIDsEqual(source.ID, mediaSourceID) {
copy := source
return &copy
}
+66 -21
View File
@@ -303,9 +303,21 @@ func resolveEnabledProvidersByPriority(
priority int
}
items := make([]ranked, len(caps))
for i, c := range caps {
items[i] = ranked{cap: c, priority: LookupDefaultPriority(ctx, pool, c.PluginInstallationID, contentLevel)}
// Only providers that support contentLevel participate in the chain-less
// fallback. A provider declaring a non-empty default_priority map that omits
// this level is excluded outright (not merely ranked last), so a
// single-purpose provider is never invoked for content it does not handle.
items := make([]ranked, 0, len(caps))
for _, c := range caps {
metadataJSON := lookupCapabilityMetadata(ctx, pool, c.PluginInstallationID, c.CapabilityID)
if !providerSupportsLevel(metadataJSON, contentLevel) {
slog.Debug("skipping metadata provider: does not declare support for content level",
"installation_id", c.PluginInstallationID,
"capability_id", c.CapabilityID,
"content_level", contentLevel)
continue
}
items = append(items, ranked{cap: c, priority: extractDefaultPriority(metadataJSON, contentLevel)})
}
sort.SliceStable(items, func(i, j int) bool {
@@ -326,32 +338,68 @@ func resolveEnabledProvidersByPriority(
return buildProviders(ctx, sorted, resolver, pool), nil
}
// LookupDefaultPriority queries plugin_capabilities for a provider's declared
// default_priority at the given content level. Returns 0 if not found.
func LookupDefaultPriority(ctx context.Context, pool *pgxpool.Pool, pluginInstallationID int, contentLevel string) int {
// providerSupportsLevel reports whether a metadata provider should participate
// in the chain-less global fallback for contentLevel. A provider that declares
// a non-empty default_priority map is treated as enumerating the content levels
// it supports: it is eligible only for levels present in that map with a
// positive priority. A provider that declares no default_priority makes no
// claim and stays eligible for every level (legacy behavior), ranked last.
//
// This is what keeps a single-purpose provider (e.g. an audiobook metadata
// provider declaring only {"audiobook": N}) from being pulled into video
// content levels when a library has no enabled chain entry for that level.
func providerSupportsLevel(metadataJSON []byte, contentLevel string) bool {
levels, declared := declaredPriorityLevels(metadataJSON)
if !declared {
return true
}
return levels[contentLevel] > 0
}
// lookupCapabilityMetadata returns the raw plugin_capabilities.metadata JSON for
// a provider's exact metadata_provider.v1 capability, or nil if absent.
func lookupCapabilityMetadata(ctx context.Context, pool *pgxpool.Pool, pluginInstallationID int, capabilityID string) []byte {
var metadataJSON []byte
err := pool.QueryRow(ctx,
`SELECT metadata FROM plugin_capabilities
WHERE plugin_installation_id = $1 AND capability_type = 'metadata_provider.v1'
LIMIT 1`,
WHERE plugin_installation_id = $1
AND capability_id = $2
AND capability_type = 'metadata_provider.v1'`,
pluginInstallationID,
capabilityID,
).Scan(&metadataJSON)
if err != nil {
return 0
return nil
}
return metadataJSON
}
return extractDefaultPriority(metadataJSON, contentLevel)
// LookupDefaultPriority queries plugin_capabilities for a provider's declared
// default_priority at the given content level. Returns 0 if not found.
func LookupDefaultPriority(ctx context.Context, pool *pgxpool.Pool, pluginInstallationID int, capabilityID, contentLevel string) int {
return extractDefaultPriority(lookupCapabilityMetadata(ctx, pool, pluginInstallationID, capabilityID), contentLevel)
}
// extractDefaultPriority parses the default_priority for a content level from
// capability metadata JSON.
func extractDefaultPriority(metadataJSON []byte, contentLevel string) int {
levels, _ := declaredPriorityLevels(metadataJSON)
if v, ok := levels[contentLevel]; ok && v > 0 {
return int(v)
}
return 0
}
// declaredPriorityLevels parses a capability's default_priority map. The map may
// sit at the top level or inside a "metadata" envelope (plugin capability
// metadata wraps plugin-declared fields in a "metadata" sub-object). The second
// return value is true only when a non-empty map was found, i.e. the provider
// explicitly enumerates the content levels it supports.
func declaredPriorityLevels(metadataJSON []byte) (map[string]float64, bool) {
var meta map[string]json.RawMessage
if err := json.Unmarshal(metadataJSON, &meta); err != nil {
return 0
return nil, false
}
// default_priority may be at the top level or nested inside a "metadata" sub-object
// (plugin capability metadata wraps plugin-declared fields in a "metadata" envelope).
dpRaw, ok := meta["default_priority"]
if !ok {
if innerRaw, innerOK := meta["metadata"]; innerOK {
@@ -360,18 +408,15 @@ func extractDefaultPriority(metadataJSON []byte, contentLevel string) int {
dpRaw, ok = inner["default_priority"]
}
}
if !ok {
return 0
}
}
if !ok {
return nil, false
}
var dpMap map[string]float64
if err := json.Unmarshal(dpRaw, &dpMap); err != nil {
return 0
return nil, false
}
if v, ok := dpMap[contentLevel]; ok && v > 0 {
return int(v)
}
return 0
return dpMap, len(dpMap) > 0
}
// ListEnabledMetadataCapabilities returns all metadata_provider.v1 capabilities
+59
View File
@@ -0,0 +1,59 @@
package metadata
import "testing"
// Capability metadata as stored in plugin_capabilities.metadata: plugin-declared
// fields are wrapped in a "metadata" envelope.
const (
audiobookCapMetadata = `{"metadata":{"default_priority":{"audiobook":2}},"display_name":"Audiobook Metadata"}`
tmdbCapMetadata = `{"metadata":{"default_priority":{"movie":2,"season":3,"series":3,"episode":3}},"display_name":"TMDB"}`
tvdbCapMetadata = `{"metadata":{"default_priority":{"season":2,"series":2,"episode":2}},"display_name":"TVDB"}`
)
func TestProviderSupportsLevel(t *testing.T) {
cases := []struct {
name string
metadataJSON string
contentLevel string
want bool
}{
// An audiobook-only provider must NOT be pulled into video content
// levels by the chain-less global fallback. This is the regression
// that let silo.audiobook-metadata hammer audiobook APIs with
// anime/movie/series titles.
{"audiobook provider excluded from episode", audiobookCapMetadata, "episode", false},
{"audiobook provider excluded from series", audiobookCapMetadata, "series", false},
{"audiobook provider excluded from season", audiobookCapMetadata, "season", false},
{"audiobook provider excluded from movie", audiobookCapMetadata, "movie", false},
{"audiobook provider included for audiobook", audiobookCapMetadata, "audiobook", true},
// Providers that declare a level stay eligible for it.
{"tmdb included for movie", tmdbCapMetadata, "movie", true},
{"tmdb included for season", tmdbCapMetadata, "season", true},
{"tmdb included for episode", tmdbCapMetadata, "episode", true},
// tvdb declares no movie level -> excluded from the movie fallback.
{"tvdb excluded from movie", tvdbCapMetadata, "movie", false},
{"tvdb included for series", tvdbCapMetadata, "series", true},
// A provider that declares no default_priority makes no claim and stays
// eligible everywhere (legacy behavior), ranked last by priority 0.
{"no metadata is eligible", ``, "episode", true},
{"empty object is eligible", `{}`, "episode", true},
{"empty priority map is eligible", `{"metadata":{"default_priority":{}}}`, "episode", true},
{"malformed json is eligible (fail-open)", `{not json`, "episode", true},
// A declared level with non-positive priority is not supported.
{"zero priority level not supported", `{"metadata":{"default_priority":{"episode":0}}}`, "episode", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := providerSupportsLevel([]byte(tc.metadataJSON), tc.contentLevel)
if got != tc.want {
t.Fatalf("providerSupportsLevel(%q, %q) = %v, want %v",
tc.metadataJSON, tc.contentLevel, got, tc.want)
}
})
}
}
+26 -2
View File
@@ -18,8 +18,32 @@ const (
movieQueueRetryDelay = 15 * time.Second
seriesRootQueueQuietWindow = 10 * time.Second
seriesRootQueueRetryDelay = 30 * time.Second
// matchQueueRetryMaxDelay caps the exponential retry backoff shared by both
// match queues. Terminal outcomes like "no metadata found from any
// provider" stay in the queue but decay to at most one attempt per day
// instead of hot-looping at the base delay forever. A row self-heals by
// being deleted on the first successful attempt.
matchQueueRetryMaxDelay = 24 * time.Hour
// matchQueueBackoffMaxExponent clamps the 2^attempt_count factor so the
// float math stays finite for rows that accumulated very large attempt
// counts before backoff existed.
matchQueueBackoffMaxExponent = 16
)
// matchQueueBackoffExpr returns the SQL expression both match queues use to
// schedule the next retry after a failure: base_delay * 2^attempt_count,
// capped at matchQueueRetryMaxDelay. attempt_count is incremented when a row
// is claimed, so the first failure backs off to twice the base delay.
// basePlaceholder and maxPlaceholder name the interval bind parameters (e.g.
// "$3", "$4") in the caller's statement.
func matchQueueBackoffExpr(basePlaceholder, maxPlaceholder string) string {
return fmt.Sprintf(
"NOW() + LEAST(%s::interval * power(2::float8, LEAST(attempt_count, %d)), %s::interval)",
basePlaceholder, matchQueueBackoffMaxExponent, maxPlaceholder,
)
}
type MovieMatchQueueRepository struct {
pool *pgxpool.Pool
fileRepo *scannerrepo.FileRepository
@@ -477,10 +501,10 @@ func (r *MovieMatchQueueRepository) UpdateError(ctx context.Context, mediaFileID
if _, err := r.pool.Exec(ctx, `
UPDATE movie_match_queue
SET last_error = $2,
available_at = NOW() + $3::interval,
available_at = `+matchQueueBackoffExpr("$3", "$4")+`,
updated_at = NOW()
WHERE media_file_id = $1
`, mediaFileID, errText, intervalLiteral(movieQueueRetryDelay)); err != nil {
`, mediaFileID, errText, intervalLiteral(movieQueueRetryDelay), intervalLiteral(matchQueueRetryMaxDelay)); err != nil {
return fmt.Errorf("updating movie queue error: %w", err)
}
return nil
@@ -550,10 +550,10 @@ func (r *SeriesRootMatchQueueRepository) UpdateError(ctx context.Context, folder
_, err := r.pool.Exec(ctx, `
UPDATE series_root_match_queue
SET last_error = $3,
available_at = NOW() + $4::interval,
available_at = `+matchQueueBackoffExpr("$4", "$5")+`,
updated_at = NOW()
WHERE media_folder_id = $1 AND observed_root_path = $2
`, folderID, filepath.Clean(observedRootPath), errText, intervalLiteral(seriesRootQueueRetryDelay))
`, folderID, filepath.Clean(observedRootPath), errText, intervalLiteral(seriesRootQueueRetryDelay), intervalLiteral(matchQueueRetryMaxDelay))
if err != nil {
return fmt.Errorf("updating series root queue error: %w", err)
}
+2 -6
View File
@@ -3856,10 +3856,6 @@ func (s *MetadataService) createOrFindSkeleton(ctx context.Context, file *models
if res.Type == "" {
res.Type = "movie"
}
folderType := res.Type
if folderType == "" {
folderType = "movie"
}
// Explicit structured IDs from the file or folder are treated as trusted.
// When present, they override scanner ambiguity and become the authoritative
@@ -3874,9 +3870,9 @@ func (s *MetadataService) createOrFindSkeleton(ctx context.Context, file *models
// Parse observed location names for external IDs before falling back to the
// legacy canonical root path. This preserves existing heuristic behavior for
// roots without explicit structured tags.
folderIDs := naming.ParseFolderIDs(filepath.Base(observedRootPath), folderType)
folderIDs := naming.ParseFolderIDs(filepath.Base(observedRootPath))
if folderIDs == nil && contentRootPath != "" && contentRootPath != observedRootPath {
folderIDs = naming.ParseFolderIDs(filepath.Base(contentRootPath), folderType)
folderIDs = naming.ParseFolderIDs(filepath.Base(contentRootPath))
}
effectiveExternalIDs := folderIDs
+20 -46
View File
@@ -3,7 +3,6 @@ package naming
import (
"regexp"
"strings"
"unicode"
)
// folderIDPattern matches patterns like [tmdbid-27205], {tmdb-27205},
@@ -11,7 +10,6 @@ import (
// The regex captures the provider prefix and the ID value.
var folderIDPattern = regexp.MustCompile(`[{\[](tmdb|tmdbid|imdb|imdbid|tvdb|tvdbid)-([\w]+)[}\]]`)
var trailingImdbIDPattern = regexp.MustCompile(`(?i)(?:^|\s)(tt\d{7,8})$`)
var trailingNumericIDPattern = regexp.MustCompile(`(?:^|\s)(\d+)$`)
// bracketedBareImdbPattern matches a bare IMDb id wrapped in brackets without a
// provider prefix, e.g. [tt10011226] or {tt0095016} (Plex/Kodi-style tags). A
@@ -48,54 +46,30 @@ func ParseStructuredFolderIDs(name string) *FolderIDHints {
return hints
}
// ParseFolderIDs extracts external provider IDs from a folder name.
// It supports bracket styles [] and {} with provider prefixes tmdb/tmdbid,
// imdb/imdbid, tvdb/tvdbid, plus bare trailing IDs. Bare numeric IDs are
// interpreted using folderType: series -> TVDB, everything else -> TMDB.
func ParseFolderIDs(folderName string, folderType string) *FolderIDHints {
if hints := ParseStructuredFolderIDs(folderName); hints != nil {
return hints
}
// ParseFolderIDs extracts external provider IDs from a folder name. Mirroring
// Jellyfin's path-attribute model, only explicit evidence is honored: bracket
// tags with provider prefixes ({tmdb-27205}, [tvdbid-81189], [imdbid-tt1375666]),
// bracketed bare IMDb ids ([tt1375666]), and a trailing bare IMDb id — the
// "tt" prefix makes IMDb ids unambiguous without brackets. Bare trailing
// numbers are never treated as IDs: titles legitimately end in numbers
// ("District 9", "Beverly Hills 90210"), no mainstream tool emits bare-number
// tags, and a misparsed ID becomes a trusted match hint downstream where it
// silently produces a wrong match or blocks matching entirely.
func ParseFolderIDs(folderName string) *FolderIDHints {
hints := ParseStructuredFolderIDs(folderName)
// A trailing bare IMDb id complements structured tags for other providers
// ("Show [tvdbid-81189] tt1375666"); an explicit structured imdb tag still
// wins when both are present.
trimmed := strings.TrimSpace(folderName)
if m := trailingImdbIDPattern.FindStringSubmatch(trimmed); m != nil {
return &FolderIDHints{ImdbID: strings.ToLower(m[1])}
}
m := trailingNumericIDPattern.FindStringSubmatch(trimmed)
if m == nil {
return nil
}
id := m[1]
if looksLikeYear(id) {
return nil
}
// A bare trailing number is only an ID when appended to a real title. If the
// name has no letters (e.g. "86", "22 7"), it's a numeric title, not an ID.
// Trade-off: a folder named ONLY a bare provider id (e.g. "81189") with no
// title text now returns nil rather than that id — acceptable because
// Sonarr/Radarr never produce bare-number folders.
if !containsLetter(trimmed) {
return nil
}
if strings.EqualFold(strings.TrimSpace(folderType), "series") {
return &FolderIDHints{TvdbID: id}
}
return &FolderIDHints{TmdbID: id}
}
func containsLetter(s string) bool {
for _, r := range s {
if unicode.IsLetter(r) {
return true
if hints == nil {
return &FolderIDHints{ImdbID: strings.ToLower(m[1])}
}
if hints.ImdbID == "" {
hints.ImdbID = strings.ToLower(m[1])
}
}
return false
}
func looksLikeYear(value string) bool {
return len(value) == 4 && value >= "1800" && value <= "2100"
return hints
}
+58 -31
View File
@@ -2,36 +2,63 @@ package naming
import "testing"
func TestParseFolderIDs_NumericTitleIsNotAnID(t *testing.T) {
// Numeric-only anime titles must NOT be parsed as a bare trailing tvdb/tmdb id.
if got := ParseFolderIDs("86", "series"); got != nil {
t.Errorf(`ParseFolderIDs("86","series") = %+v, want nil`, got)
}
if got := ParseFolderIDs("22 7", "series"); got != nil {
t.Errorf(`ParseFolderIDs("22 7","series") = %+v, want nil`, got)
}
// A real bare trailing id with title text must still be parsed.
got := ParseFolderIDs("Some Show 81189", "series")
if got == nil || got.TvdbID != "81189" {
t.Errorf(`ParseFolderIDs("Some Show 81189","series") = %+v, want TvdbID="81189"`, got)
}
// Structured tags must still win regardless of letters.
got = ParseFolderIDs("{tmdb-27205}", "movies")
if got == nil || got.TmdbID != "27205" {
t.Errorf(`ParseFolderIDs("{tmdb-27205}","movies") = %+v, want TmdbID="27205"`, got)
}
// Non-Latin (CJK) title with a trailing real id must still parse —
// unicode.IsLetter covers kana/kanji, so this is treated as title + id.
got = ParseFolderIDs("進撃の巨人 73743", "series")
if got == nil || got.TvdbID != "73743" {
t.Errorf(`ParseFolderIDs("進撃の巨人 73743","series") = %+v, want TvdbID="73743"`, got)
}
// Numeric-only title for a movies library is also not an id.
if got := ParseFolderIDs("86", "movies"); got != nil {
t.Errorf(`ParseFolderIDs("86","movies") = %+v, want nil`, got)
// Bare numbers in folder names are never provider IDs. This mirrors
// Jellyfin's path-attribute model: only explicit bracket tags (and
// unambiguous tt-prefixed IMDb ids) carry identity. Titles legitimately end
// in numbers, and a misparsed bare ID becomes a trusted match hint that
// silently mismatches or blocks matching.
func TestParseFolderIDs_BareNumbersAreNeverIDs(t *testing.T) {
for _, c := range []string{
// Numeric-only titles (anime and otherwise).
"86",
"22 7",
// Titles ending in a short number.
"District 9",
"Apollo 13",
"Ocean's 11",
"THX 1138",
"Stranger Things 4",
// Season-style directory names.
"Season 01",
// Titles ending in a long number that looks like a plausible ID.
"Beverly Hills 90210",
// Bare trailing numbers that previously parsed as IDs.
"Some Show 81189",
"進撃の巨人 73743",
} {
if got := ParseFolderIDs(c); got != nil {
t.Errorf(`ParseFolderIDs(%q) = %+v, want nil`, c, got)
}
}
}
func TestParseFolderIDs_StructuredTagsStillParse(t *testing.T) {
got := ParseFolderIDs("{tmdb-27205}")
if got == nil || got.TmdbID != "27205" {
t.Errorf(`ParseFolderIDs("{tmdb-27205}") = %+v, want TmdbID="27205"`, got)
}
got = ParseFolderIDs("Some Show (2010) [tvdbid-81189]")
if got == nil || got.TvdbID != "81189" {
t.Errorf(`ParseFolderIDs("Some Show (2010) [tvdbid-81189]") = %+v, want TvdbID="81189"`, got)
}
// Trailing bare IMDb ids stay supported — the tt prefix is unambiguous.
got = ParseFolderIDs("Some Movie (2010) tt1375666")
if got == nil || got.ImdbID != "tt1375666" {
t.Errorf(`ParseFolderIDs("Some Movie (2010) tt1375666") = %+v, want ImdbID="tt1375666"`, got)
}
// A structured tag for one provider must not swallow a trailing bare IMDb
// id for another — both merge.
got = ParseFolderIDs("Some Show (2010) [tvdbid-81189] tt1375666")
if got == nil || got.TvdbID != "81189" || got.ImdbID != "tt1375666" {
t.Errorf(`ParseFolderIDs("Some Show (2010) [tvdbid-81189] tt1375666") = %+v, want TvdbID="81189" and ImdbID="tt1375666"`, got)
}
// An explicit structured imdb tag still wins over a trailing bare id.
got = ParseFolderIDs("Some Movie [imdb-tt1111111] tt2222222")
if got == nil || got.ImdbID != "tt1111111" {
t.Errorf(`ParseFolderIDs("Some Movie [imdb-tt1111111] tt2222222") = %+v, want ImdbID="tt1111111"`, got)
}
}
+4 -4
View File
@@ -11,19 +11,19 @@ func TestParseFolderIDs_BracketedBareImdb(t *testing.T) {
{"8-digit tt", "Movie (2024) [tt12345678]", "tt12345678"},
}
for _, c := range cases {
got := ParseFolderIDs(c.folder, "movie")
got := ParseFolderIDs(c.folder)
if got == nil || got.ImdbID != c.wantImdb {
t.Errorf("%s: ParseFolderIDs(%q) = %+v, want ImdbID=%q", c.name, c.folder, got, c.wantImdb)
}
}
// must NOT change existing behavior:
if got := ParseFolderIDs("Movie [imdb-tt1375666]", "movie"); got == nil || got.ImdbID != "tt1375666" {
if got := ParseFolderIDs("Movie [imdb-tt1375666]"); got == nil || got.ImdbID != "tt1375666" {
t.Errorf("structured imdb regressed: %+v", got)
}
if got := ParseFolderIDs("Some Movie (2020) [BB]", "movie"); got != nil {
if got := ParseFolderIDs("Some Movie (2020) [BB]"); got != nil {
t.Errorf("non-tt bracket tag must not parse as id, got %+v", got)
}
if got := ParseFolderIDs("17 Blocks (2021)", "movie"); got != nil {
if got := ParseFolderIDs("17 Blocks (2021)"); got != nil {
t.Errorf("no-id folder must return nil, got %+v", got)
}
}
+45 -13
View File
@@ -47,13 +47,20 @@ func InferGroupIdentity(filePath string, libraryType string, assignment RootAssi
}
}
// Explicit structured provider IDs (e.g. {tmdb-694938}) anchor the group's
// identity regardless of how folder and file titles relate, so title
// conflicts must not mark the group ambiguous. Renamed releases inside a
// tagged folder ("Override (2021) {tmdb-694938}" containing "R.I.A.
// (2021).mkv") are the common case.
idAnchored := hasStructuredIDAnchor(cleanFilePath, group.ObservedRootPath)
if group.BaseType == "series" {
populateSeriesGroupIdentity(cleanFilePath, libraryType, assignment, &group)
populateSeriesGroupIdentity(cleanFilePath, libraryType, assignment, idAnchored, &group)
} else {
populateMovieGroupIdentity(cleanFilePath, assignment, &group)
populateMovieGroupIdentity(cleanFilePath, assignment, idAnchored, &group)
}
if ids := ParseFolderIDs(filepath.Base(group.ObservedRootPath), group.BaseType); ids != nil {
if ids := ParseFolderIDs(filepath.Base(group.ObservedRootPath)); ids != nil {
group.TmdbID = ids.TmdbID
group.ImdbID = ids.ImdbID
group.TvdbID = ids.TvdbID
@@ -79,7 +86,7 @@ func InferGroupIdentity(filePath string, libraryType string, assignment RootAssi
return group
}
func populateMovieGroupIdentity(filePath string, assignment RootAssignment, group *GroupIdentity) {
func populateMovieGroupIdentity(filePath string, assignment RootAssignment, idAnchored bool, group *GroupIdentity) {
parentDir := filepath.Dir(filePath)
parentTitle, parentYear, parentTrusted := parseInferFolderTitleYear(filepath.Base(group.ObservedRootPath))
parentTitle = StripComparisonSafeEditionSuffix(parentTitle)
@@ -107,8 +114,12 @@ func populateMovieGroupIdentity(filePath string, assignment RootAssignment, grou
case parentTrusted && relation == titleRelationSoft:
reasons = append(reasons, "variant_suffix")
case parentTrusted && relation == titleRelationHard:
state = "ambiguous"
reasons = append(reasons, "unrelated_title")
if idAnchored {
reasons = append(reasons, "unrelated_title_resolved_by_provider_ids")
} else {
state = "ambiguous"
reasons = append(reasons, "unrelated_title")
}
default:
if baseTitle == "" {
baseTitle = StripComparisonSafeEditionSuffix(stem.Title)
@@ -130,7 +141,7 @@ func populateMovieGroupIdentity(filePath string, assignment RootAssignment, grou
baseYear = parentYear
}
if assignment.HasEpisodePattern && !assignment.HasSeasonStructure && !parentTrusted {
if assignment.HasEpisodePattern && !assignment.HasSeasonStructure && !parentTrusted && !idAnchored {
state = "ambiguous"
reasons = append(reasons, "episode_pattern")
}
@@ -154,7 +165,7 @@ func populateMovieGroupIdentity(filePath string, assignment RootAssignment, grou
})
}
func populateSeriesGroupIdentity(filePath string, libraryType string, assignment RootAssignment, group *GroupIdentity) {
func populateSeriesGroupIdentity(filePath string, libraryType string, assignment RootAssignment, idAnchored bool, group *GroupIdentity) {
ctx := ResolvePathContext(filePath, libraryType)
title := assignment.Title
year := assignment.Year
@@ -179,8 +190,12 @@ func populateSeriesGroupIdentity(filePath string, libraryType string, assignment
case titleRelationSoft:
reasons = append(reasons, "series_alias")
case titleRelationHard:
state = "ambiguous"
reasons = append(reasons, "series_title_conflict")
if idAnchored {
reasons = append(reasons, "series_title_conflict_resolved_by_provider_ids")
} else {
state = "ambiguous"
reasons = append(reasons, "series_title_conflict")
}
}
if year == 0 {
year = observedYear
@@ -191,8 +206,12 @@ func populateSeriesGroupIdentity(filePath string, libraryType string, assignment
case titleRelationSoft:
reasons = append(reasons, "series_assignment_soft_conflict")
case titleRelationHard:
state = "ambiguous"
reasons = append(reasons, "series_assignment_conflict")
if idAnchored {
reasons = append(reasons, "series_assignment_conflict_resolved_by_provider_ids")
} else {
state = "ambiguous"
reasons = append(reasons, "series_assignment_conflict")
}
}
}
@@ -206,7 +225,7 @@ func populateSeriesGroupIdentity(filePath string, libraryType string, assignment
if assignment.HasEpisodePattern && (assignment.HasSeasonStructure || (ctx != nil && ctx.HasSeasonStructure)) && state != "ambiguous" {
confidence = "high"
}
if title == "" && assignment.HasEpisodePattern {
if title == "" && assignment.HasEpisodePattern && !idAnchored {
state = "ambiguous"
reasons = append(reasons, "series_identity_missing")
}
@@ -229,6 +248,19 @@ func populateSeriesGroupIdentity(filePath string, libraryType string, assignment
})
}
// hasStructuredIDAnchor reports whether the observed root folder or the file
// name carries explicit provider IDs — structured tags (e.g. {tmdb-694938},
// [tvdbid-81189]) or an unambiguous tt-prefixed IMDb id ("Eggs Run (2021)
// tt8049994"). ParseFolderIDs returns only such explicit evidence, which is
// strong enough to anchor identity over a title conflict.
func hasStructuredIDAnchor(filePath, observedRootPath string) bool {
if ParseFolderIDs(filepath.Base(observedRootPath)) != nil {
return true
}
baseNoExt := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
return ParseFolderIDs(baseNoExt) != nil
}
func deriveObservedRootPath(filePath, candidateRoot string) string {
cleanFilePath := filepath.Clean(filePath)
cleanRoot := filepath.Clean(candidateRoot)
+108
View File
@@ -0,0 +1,108 @@
package naming
import "testing"
// A renamed release inside a provider-tagged folder must stay resolved: the
// structured IDs anchor the identity even though folder and file titles are
// unrelated (Radarr keeps the folder name from grab time while the release
// inside carries the retitled name).
func TestInferGroupIdentity_StructuredIDsResolveMovieTitleConflict(t *testing.T) {
group := InferGroupIdentity(
"/movies/20s/On Fire {imdb-tt28078628} {tmdb-1196791}/Soul on Fire (2025) [WEBDL-1080p 8-bit h264 EAC3 5.1]-FLUX.mkv",
"movies",
RootAssignment{
RootPath: "/movies/20s/On Fire {imdb-tt28078628} {tmdb-1196791}",
InferredType: "movie",
},
)
if got, want := group.State, "resolved"; got != want {
t.Fatalf("State = %q, want %q", got, want)
}
if got, want := group.BaseTitle, "On Fire"; got != want {
t.Fatalf("BaseTitle = %q, want %q", got, want)
}
if got, want := group.TmdbID, "1196791"; got != want {
t.Fatalf("TmdbID = %q, want %q", got, want)
}
if got, want := group.ImdbID, "tt28078628"; got != want {
t.Fatalf("ImdbID = %q, want %q", got, want)
}
}
// An unbracketed trailing IMDb id anchors identity the same way a structured
// tag does — the tt prefix is unambiguous. Mirrors the dev-library case
// "Eggs Run (2021) tt8049994" whose file carries a different release title.
func TestInferGroupIdentity_TrailingImdbIDResolvesMovieTitleConflict(t *testing.T) {
group := InferGroupIdentity(
"/movies-int/es/Eggs Run (2021) tt8049994/Huevitos Congelados (2022) [WEBDL-1080p]-GRP.mkv",
"movies",
RootAssignment{
RootPath: "/movies-int/es/Eggs Run (2021) tt8049994",
InferredType: "movie",
},
)
if got, want := group.State, "resolved"; got != want {
t.Fatalf("State = %q, want %q", got, want)
}
if got, want := group.ImdbID, "tt8049994"; got != want {
t.Fatalf("ImdbID = %q, want %q", got, want)
}
}
// Without provider IDs the same folder/file title conflict must still be
// flagged ambiguous — there is nothing to anchor the identity.
func TestInferGroupIdentity_TitleConflictWithoutIDsStaysAmbiguous(t *testing.T) {
group := InferGroupIdentity(
"/movies/20s/On Fire (2024)/Soul on Fire (2025) [WEBDL-1080p 8-bit h264 EAC3 5.1]-FLUX.mkv",
"movies",
RootAssignment{
RootPath: "/movies/20s/On Fire (2024)",
InferredType: "movie",
},
)
if got, want := group.State, "ambiguous"; got != want {
t.Fatalf("State = %q, want %q", got, want)
}
}
func TestInferGroupIdentity_StructuredIDsResolveSeriesTitleConflict(t *testing.T) {
group := InferGroupIdentity(
"/tv/Border Control - Sweden (2023) {tvdb-442777}/Season 01/Gränsbevakarna Sverige S01E01.mkv",
"series",
RootAssignment{
RootPath: "/tv/Border Control - Sweden (2023) {tvdb-442777}",
InferredType: "series",
Title: "Gränsbevakarna Sverige",
HasSeasonStructure: true,
HasEpisodePattern: true,
},
)
if got, want := group.State, "resolved"; got != want {
t.Fatalf("State = %q, want %q", got, want)
}
if got, want := group.TvdbID, "442777"; got != want {
t.Fatalf("TvdbID = %q, want %q", got, want)
}
}
func TestInferGroupIdentity_SeriesTitleConflictWithoutIDsStaysAmbiguous(t *testing.T) {
group := InferGroupIdentity(
"/tv/Border Control - Sweden (2023)/Season 01/Gränsbevakarna Sverige S01E01.mkv",
"series",
RootAssignment{
RootPath: "/tv/Border Control - Sweden (2023)",
InferredType: "series",
Title: "Gränsbevakarna Sverige",
HasSeasonStructure: true,
HasEpisodePattern: true,
},
)
if got, want := group.State, "ambiguous"; got != want {
t.Fatalf("State = %q, want %q", got, want)
}
}
+1 -1
View File
@@ -157,7 +157,7 @@ func parseInferFolderTitleYear(name string) (string, int, bool) {
year, _ := strconv.Atoi(match[2])
return strings.TrimSpace(match[1]), year, true
}
if ids := ParseFolderIDs(name, "movie"); ids != nil || ParseFolderIDs(name, "series") != nil {
if ParseFolderIDs(name) != nil {
return strings.TrimSpace(surface), 0, true
}
return strings.TrimSpace(surface), 0, false
+4 -4
View File
@@ -89,7 +89,7 @@ func InferRootAssignments(
Year: assignment.Year,
},
}
if ids := ParseFolderIDs(filepath.Base(assignment.RootPath), assignment.InferredType); ids != nil {
if ids := ParseFolderIDs(filepath.Base(assignment.RootPath)); ids != nil {
agg.hasFolderIDs = true
agg.root.TmdbID = ids.TmdbID
agg.root.ImdbID = ids.ImdbID
@@ -254,7 +254,7 @@ func inferFileRootAssignment(
}
}
if ids := ParseFolderIDs(filepath.Base(assignment.RootPath), assignment.InferredType); ids != nil {
if ids := ParseFolderIDs(filepath.Base(assignment.RootPath)); ids != nil {
assignment.HasFolderIDs = true
}
@@ -369,7 +369,7 @@ func detectInferMovieFolderEvidence(parentBase string, nameNoExt string, hasSeas
if hasSeasonStructure {
return false
}
if ParseFolderIDs(parentBase, "movie") != nil || ParseFolderIDs(parentBase, "series") != nil {
if ParseFolderIDs(parentBase) != nil {
return true
}
parentTitle, parentYear, trusted := parseInferFolderTitleYear(parentBase)
@@ -457,7 +457,7 @@ func promoteCandidateRoot(filePath, currentRoot, title string, year int) (string
parentBase := filepath.Base(parent)
currentBase := filepath.Base(cleanRoot)
parentIDs := ParseFolderIDs(parentBase, "movie") != nil || ParseFolderIDs(parentBase, "series") != nil
parentIDs := ParseFolderIDs(parentBase) != nil
childTitle, childYear := parseInferTitleYear(currentBase)
parentTitle, parentYear := parseInferTitleYear(parentBase)
+26 -8
View File
@@ -31,14 +31,15 @@ type Session struct {
TargetBitrateKbps int // requested output bitrate cap for transcodes
TranscodeHWAccel string // effective hardware acceleration mode for transcodes
Position float64
IsPaused bool
HasWebSocket bool
HasRealtimeConnection bool
StartedAt time.Time
UpdatedAt time.Time
LastActivityAt time.Time
activeTransportCount int
Position float64
IsPaused bool
HasWebSocket bool
HasRealtimeConnection bool
DisableProgressPersistence bool
StartedAt time.Time
UpdatedAt time.Time
LastActivityAt time.Time
activeTransportCount int
}
// SessionStreamState stores the mutable stream-specific details that can
@@ -371,6 +372,23 @@ func (m *SessionManager) SetRealtimeConnection(sessionID string, connected bool)
return nil
}
// SetProgressPersistenceDisabled controls whether session progress updates and
// stop events should write resume/history state. This is useful for players
// whose resume timeline is not the same as the session's file-local timeline.
func (m *SessionManager) SetProgressPersistenceDisabled(sessionID string, disabled bool) error {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[sessionID]
if !ok {
return ErrSessionNotFound
}
s.DisableProgressPersistence = disabled
m.touchSessionLocked(s)
return nil
}
// TouchActivity refreshes the session's activity timestamp without changing
// any other playback state.
func (m *SessionManager) TouchActivity(sessionID string) error {
+45
View File
@@ -54,6 +54,11 @@ type ScanSourceClient struct {
timeout time.Duration
}
type RequestRouterClient struct {
client pluginv1.RequestRouterClient
timeout time.Duration
}
type EventConsumerClient struct {
client pluginv1.EventConsumerClient
timeout time.Duration
@@ -148,6 +153,16 @@ func (c *Client) ScanSource(capabilityID string) (*ScanSourceClient, error) {
}, nil
}
func (c *Client) RequestRouter(capabilityID string) (*RequestRouterClient, error) {
if err := c.requireCapability("request_router.v1", capabilityID); err != nil {
return nil, err
}
return &RequestRouterClient{
client: c.rpc.RequestRouter(),
timeout: DefaultRequestRouterTimeout,
}, nil
}
func (c *Client) EventConsumer(capabilityID string) (*EventConsumerClient, error) {
if err := c.requireCapability("event_consumer.v1", capabilityID); err != nil {
return nil, err
@@ -283,6 +298,36 @@ func (c *ScanSourceClient) PollChanges(ctx context.Context, req *pluginv1.PollCh
return c.client.PollChanges(callCtx, req)
}
func (c *RequestRouterClient) Fulfill(ctx context.Context, req *pluginv1.FulfillRequest) (*pluginv1.FulfillResponse, error) {
callCtx, cancel := ensureDeadline(ctx, c.timeout)
defer cancel()
return c.client.Fulfill(callCtx, req)
}
func (c *RequestRouterClient) CheckStatus(ctx context.Context, req *pluginv1.CheckStatusRequest) (*pluginv1.CheckStatusResponse, error) {
callCtx, cancel := ensureDeadline(ctx, c.timeout)
defer cancel()
return c.client.CheckStatus(callCtx, req)
}
func (c *RequestRouterClient) ListConfigOptions(ctx context.Context, req *pluginv1.ListConfigOptionsRequest) (*pluginv1.ListConfigOptionsResponse, error) {
callCtx, cancel := ensureDeadline(ctx, c.timeout)
defer cancel()
return c.client.ListConfigOptions(callCtx, req)
}
func (c *RequestRouterClient) TestConnection(ctx context.Context, req *pluginv1.TestConnectionRequest) (*pluginv1.TestConnectionResponse, error) {
callCtx, cancel := ensureDeadline(ctx, c.timeout)
defer cancel()
return c.client.TestConnection(callCtx, req)
}
func (c *RequestRouterClient) Validate(ctx context.Context, req *pluginv1.ValidateRequest) (*pluginv1.ValidateResponse, error) {
callCtx, cancel := ensureDeadline(ctx, c.timeout)
defer cancel()
return c.client.Validate(callCtx, req)
}
func (c *EventConsumerClient) HandleEvent(ctx context.Context, req *pluginv1.HandleEventRequest) (*pluginv1.HandleEventResponse, error) {
callCtx, cancel := ensureDeadline(ctx, c.timeout)
defer cancel()
+3
View File
@@ -23,6 +23,9 @@ const (
DefaultEventTimeout = 10 * time.Second
DefaultAuthTimeout = 10 * time.Second
DefaultRouteTimeout = 10 * time.Second
// DefaultRequestRouterTimeout bounds a single request_router RPC.
// Fulfillment hits remote arr instances, so allow generous headroom.
DefaultRequestRouterTimeout = 60 * time.Second
)
func HandshakeConfig() plugin.HandshakeConfig {
+34
View File
@@ -10,9 +10,11 @@ import (
"os"
"path/filepath"
"runtime/debug"
"strconv"
"sync"
"sync/atomic"
"golang.org/x/sync/singleflight"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/structpb"
@@ -27,6 +29,7 @@ type pluginClient interface {
MediaAnalyzer(capabilityID string) (*pluginhost.MediaAnalyzerClient, error)
ScheduledTask(capabilityID string) (*pluginhost.ScheduledTaskClient, error)
ScanSource(capabilityID string) (*pluginhost.ScanSourceClient, error)
RequestRouter(capabilityID string) (*pluginhost.RequestRouterClient, error)
EventConsumer(capabilityID string) (*pluginhost.EventConsumerClient, error)
AuthProvider(capabilityID string) (*pluginhost.AuthProviderClient, error)
HTTPRoutes(capabilityID string) (*pluginhost.HTTPRoutesClient, error)
@@ -66,6 +69,7 @@ type Service struct {
dispatcher *EventDispatcher
lifecycleMu sync.RWMutex
lifecycleHooks []func(context.Context)
launchGroup singleflight.Group
}
// SetEventDispatcher wires the EventDispatcher into the Service. The
@@ -482,6 +486,18 @@ func (s *Service) ScanSourceClient(
return client.ScanSource(capabilityID)
}
func (s *Service) RequestRouterClient(
ctx context.Context,
installationID int,
capabilityID string,
) (*pluginhost.RequestRouterClient, error) {
client, err := s.ensureClient(ctx, installationID)
if err != nil {
return nil, err
}
return client.RequestRouter(capabilityID)
}
func (s *Service) ScanSourceClientByPluginID(
ctx context.Context,
pluginID string,
@@ -605,7 +621,25 @@ func (s *Service) ManifestForInstallation(
return s.manifestForInstallation(ctx, installationID, false)
}
// ensureClient returns a running client for the installation, collapsing
// concurrent first-use of a cold installation into a single launch so a burst of
// callers does not spawn redundant plugin processes (Host.Start releases its lock
// during the slow launch and cannot dedupe). After the flight completes the key
// is freed, so subsequent callers re-run and hit the now-warm cache.
func (s *Service) ensureClient(ctx context.Context, installationID int) (pluginClient, error) {
v, err, _ := s.launchGroup.Do(strconv.Itoa(installationID), func() (any, error) {
// Isolate the shared launch from the leader caller's cancellation: other
// waiters depend on this in-flight launch, so a single caller's canceled
// request must not tear it down. Values (tracing, auth) are preserved.
return s.doEnsureClient(context.WithoutCancel(ctx), installationID)
})
if err != nil {
return nil, err
}
return v.(pluginClient), nil
}
func (s *Service) doEnsureClient(ctx context.Context, installationID int) (pluginClient, error) {
installation, err := s.loadInstallation(ctx, installationID, true)
if err != nil {
return nil, err
+73
View File
@@ -45,6 +45,16 @@ var runPluginConnectionCheck = func(
if err != nil {
return err
}
capability := metadataProviderConnectionCheckCapability(manifest, capabilityID)
if !metadataProviderSupportsConnectionProbe(capability, "movie") {
slog.Debug(
"skipping metadata provider connection check for unsupported probe type",
"plugin_id", manifest.GetPluginId(),
"capability_id", capabilityID,
"item_type", "movie",
)
return nil
}
metadataClient, err := client.MetadataProvider(capabilityID)
if err != nil {
@@ -224,3 +234,66 @@ func metadataProviderConnectionCheckCapabilityID(manifest *pluginv1.PluginManife
Cause: ErrConnectionTestUnsupported,
}
}
func metadataProviderConnectionCheckCapability(
manifest *pluginv1.PluginManifest,
capabilityID string,
) *pluginv1.CapabilityDescriptor {
for _, capability := range manifest.GetCapabilities() {
if capability.GetType() == "metadata_provider.v1" && capability.GetId() == capabilityID {
return capability
}
}
return nil
}
func metadataProviderSupportsConnectionProbe(
capability *pluginv1.CapabilityDescriptor,
contentType string,
) bool {
priorities, ok := metadataProviderDefaultPriorities(capability)
if !ok {
return true
}
return priorities[contentType] > 0
}
func metadataProviderDefaultPriorities(
capability *pluginv1.CapabilityDescriptor,
) (map[string]float64, bool) {
if capability == nil || capability.GetMetadata() == nil {
return nil, false
}
metadataMap := capability.GetMetadata().AsMap()
raw, ok := metadataMap["default_priority"]
if !ok {
nested, nestedOK := metadataMap["metadata"].(map[string]any)
if !nestedOK {
return nil, false
}
raw, ok = nested["default_priority"]
if !ok {
return nil, false
}
}
rawMap, ok := raw.(map[string]any)
if !ok {
return nil, false
}
priorities := make(map[string]float64, len(rawMap))
for key, value := range rawMap {
switch v := value.(type) {
case float64:
priorities[key] = v
case int:
priorities[key] = float64(v)
case int32:
priorities[key] = float64(v)
case int64:
priorities[key] = float64(v)
}
}
return priorities, len(priorities) > 0
}
@@ -7,6 +7,7 @@ import (
"testing"
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
"google.golang.org/protobuf/types/known/structpb"
)
type fakeServiceConfigStore struct {
@@ -149,6 +150,35 @@ func TestServiceTestGlobalConfigUsesMergedDraftAndStopsTemporaryInstance(t *test
}
}
func TestRunPluginConnectionCheckSkipsMovieProbeForAudiobookOnlyProvider(t *testing.T) {
metadata, err := structpb.NewStruct(map[string]any{
"default_priority": map[string]any{
"audiobook": 2,
},
})
if err != nil {
t.Fatalf("NewStruct() error = %v", err)
}
manifest := connectionTestManifest(t, "silo.audiobook-metadata", "0.1.2")
manifest.Capabilities = []*pluginv1.CapabilityDescriptor{
{
Type: "metadata_provider.v1",
Id: "audiobook-metadata",
DisplayName: "Audiobook Metadata",
Metadata: metadata,
},
}
client := &fakePluginClient{manifest: manifest}
if err := runPluginConnectionCheck(context.Background(), client, manifest); err != nil {
t.Fatalf("runPluginConnectionCheck() error = %v", err)
}
if client.metadataProviderCalls != 0 {
t.Fatalf("metadata provider calls = %d, want 0", client.metadataProviderCalls)
}
}
func TestServiceTestGlobalConfigReturnsUnsupportedWithoutStartingPlugin(t *testing.T) {
manifest := connectionTestManifest(t, "silo.simple", "0.0.1")
manifest.Capabilities = []*pluginv1.CapabilityDescriptor{
+7 -1
View File
@@ -188,7 +188,8 @@ func (f *fakeServiceHost) Shutdown(context.Context) error {
}
type fakePluginClient struct {
manifest *pluginv1.PluginManifest
manifest *pluginv1.PluginManifest
metadataProviderCalls int
}
func (f *fakePluginClient) Manifest() *pluginv1.PluginManifest {
@@ -196,6 +197,7 @@ func (f *fakePluginClient) Manifest() *pluginv1.PluginManifest {
}
func (f *fakePluginClient) MetadataProvider(string) (*pluginhost.MetadataProviderClient, error) {
f.metadataProviderCalls++
return nil, nil
}
@@ -215,6 +217,10 @@ func (f *fakePluginClient) ScanSource(string) (*pluginhost.ScanSourceClient, err
return nil, nil
}
func (f *fakePluginClient) RequestRouter(string) (*pluginhost.RequestRouterClient, error) {
return nil, nil
}
func (f *fakePluginClient) EventConsumer(string) (*pluginhost.EventConsumerClient, error) {
return nil, nil
}
@@ -0,0 +1,274 @@
package plugins
import (
"context"
"sync"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/pluginhost"
)
// countingHerdHost simulates the real Host: Start records the launched client so
// a later Client() hits the warm cache; it counts Start calls and can delay each
// launch to force concurrent overlap.
type countingHerdHost struct {
mu sync.Mutex
current map[int]pluginClient
starts int
startResult pluginClient
startErr error
startDelay time.Duration
}
func (h *countingHerdHost) Client(id int) (pluginClient, error) {
h.mu.Lock()
defer h.mu.Unlock()
if c, ok := h.current[id]; ok {
return c, nil
}
return nil, pluginhost.ErrClientNotFound
}
func (h *countingHerdHost) Start(_ context.Context, req pluginhost.StartRequest) (pluginClient, error) {
if h.startDelay > 0 {
time.Sleep(h.startDelay)
}
h.mu.Lock()
defer h.mu.Unlock()
h.starts++
if h.startErr != nil {
return nil, h.startErr
}
if h.current == nil {
h.current = map[int]pluginClient{}
}
h.current[req.InstallationID] = h.startResult
return h.startResult, nil
}
func (h *countingHerdHost) Stop(id int) error {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.current, id)
return nil
}
func (h *countingHerdHost) Shutdown(context.Context) error { return nil }
func (h *countingHerdHost) startCount() int {
h.mu.Lock()
defer h.mu.Unlock()
return h.starts
}
func TestEnsureClientSingleFlightsConcurrentLaunch(t *testing.T) {
manifest := testPluginManifest(t, "silo.metadb", "0.0.36")
installPath := writeInstalledPluginManifest(t, manifest)
store := newFakeServiceInstallationStore(&Installation{
ID: 7, PluginID: manifest.GetPluginId(), Version: manifest.GetVersion(),
InstallPath: installPath, Enabled: true,
})
host := &countingHerdHost{
startResult: &fakePluginClient{manifest: manifest},
startDelay: 25 * time.Millisecond,
}
service := &Service{installations: store, host: host}
const n = 20
var wg sync.WaitGroup
release := make(chan struct{})
clients := make([]pluginClient, n)
errs := make([]error, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-release
clients[i], errs[i] = service.ensureClient(context.Background(), 7)
}(i)
}
close(release)
wg.Wait()
if got := host.startCount(); got != 1 {
t.Fatalf("host.Start calls = %d, want 1 (singleflighted)", got)
}
for i := 0; i < n; i++ {
if errs[i] != nil {
t.Fatalf("caller %d err = %v", i, errs[i])
}
if clients[i] != host.startResult {
t.Fatalf("caller %d got %#v, want shared launched client", i, clients[i])
}
}
if _, err := service.ensureClient(context.Background(), 7); err != nil {
t.Fatalf("post-warm ensureClient err = %v", err)
}
if got := host.startCount(); got != 1 {
t.Fatalf("post-warm host.Start calls = %d, want still 1", got)
}
}
func TestEnsureClientDistinctInstallationsLaunchIndependently(t *testing.T) {
mA := testPluginManifest(t, "silo.metadb", "0.0.36")
mB := testPluginManifest(t, "silo.other", "1.0.0")
pathA := writeInstalledPluginManifest(t, mA)
pathB := writeInstalledPluginManifest(t, mB)
store := newFakeServiceInstallationStore(
&Installation{ID: 7, PluginID: mA.GetPluginId(), Version: mA.GetVersion(), InstallPath: pathA, Enabled: true},
&Installation{ID: 8, PluginID: mB.GetPluginId(), Version: mB.GetVersion(), InstallPath: pathB, Enabled: true},
)
host := &countingHerdHost{startResult: &fakePluginClient{manifest: mA}, startDelay: 10 * time.Millisecond}
service := &Service{installations: store, host: host}
var wg sync.WaitGroup
for _, id := range []int{7, 8, 7, 8} {
wg.Add(1)
go func(id int) { defer wg.Done(); _, _ = service.ensureClient(context.Background(), id) }(id)
}
wg.Wait()
if got := host.startCount(); got != 2 {
t.Fatalf("host.Start calls = %d, want 2 (one per installation)", got)
}
}
// ctxCaptureHost blocks inside Start until released, and records whether the
// launch context was canceled. It lets a test cancel the singleflight leader's
// ctx while a launch is in flight.
type ctxCaptureHost struct {
mu sync.Mutex
current map[int]pluginClient
startResult pluginClient
entered chan struct{}
proceed chan struct{}
sawCancel bool
}
func (h *ctxCaptureHost) Client(id int) (pluginClient, error) {
h.mu.Lock()
defer h.mu.Unlock()
if c, ok := h.current[id]; ok {
return c, nil
}
return nil, pluginhost.ErrClientNotFound
}
func (h *ctxCaptureHost) Start(ctx context.Context, req pluginhost.StartRequest) (pluginClient, error) {
close(h.entered)
select {
case <-ctx.Done():
h.mu.Lock()
h.sawCancel = true
h.mu.Unlock()
return nil, ctx.Err()
case <-h.proceed:
}
h.mu.Lock()
defer h.mu.Unlock()
if h.current == nil {
h.current = map[int]pluginClient{}
}
h.current[req.InstallationID] = h.startResult
return h.startResult, nil
}
func (h *ctxCaptureHost) Stop(id int) error {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.current, id)
return nil
}
func (h *ctxCaptureHost) Shutdown(context.Context) error { return nil }
func (h *ctxCaptureHost) canceledDuringLaunch() bool {
h.mu.Lock()
defer h.mu.Unlock()
return h.sawCancel
}
// TestEnsureClientLaunchIsolatedFromLeaderCancellation pins that a deduped
// launch is not coupled to the cancellation of whichever caller happened to be
// the singleflight leader: if that caller's request is canceled mid-launch, the
// shared launch (which other waiters depend on) must continue.
func TestEnsureClientLaunchIsolatedFromLeaderCancellation(t *testing.T) {
manifest := testPluginManifest(t, "silo.metadb", "0.0.36")
installPath := writeInstalledPluginManifest(t, manifest)
store := newFakeServiceInstallationStore(&Installation{
ID: 7, PluginID: manifest.GetPluginId(), Version: manifest.GetVersion(),
InstallPath: installPath, Enabled: true,
})
host := &ctxCaptureHost{
startResult: &fakePluginClient{manifest: manifest},
entered: make(chan struct{}),
proceed: make(chan struct{}),
}
service := &Service{installations: store, host: host}
ctx, cancel := context.WithCancel(context.Background())
type result struct {
c pluginClient
err error
}
done := make(chan result, 1)
go func() {
c, err := service.ensureClient(ctx, 7)
done <- result{c, err}
}()
<-host.entered // launch is in flight inside Start
cancel() // cancel the leader's ctx mid-launch
select {
case r := <-done:
t.Fatalf("ensureClient returned early after leader cancel (err=%v); launch was coupled to leader ctx", r.err)
case <-time.After(100 * time.Millisecond):
// still launching — isolated from the leader's cancellation, as required
}
close(host.proceed)
r := <-done
if r.err != nil {
t.Fatalf("ensureClient err = %v, want nil", r.err)
}
if r.c != host.startResult {
t.Fatalf("got %#v, want launched client", r.c)
}
if host.canceledDuringLaunch() {
t.Fatalf("launch ctx was canceled by the leader; expected isolation")
}
}
func TestEnsureClientFailedLaunchPropagatesToAllCallers(t *testing.T) {
manifest := testPluginManifest(t, "silo.metadb", "0.0.36")
installPath := writeInstalledPluginManifest(t, manifest)
store := newFakeServiceInstallationStore(&Installation{
ID: 7, PluginID: manifest.GetPluginId(), Version: manifest.GetVersion(),
InstallPath: installPath, Enabled: true,
})
host := &countingHerdHost{startErr: context.DeadlineExceeded, startDelay: 15 * time.Millisecond}
service := &Service{installations: store, host: host}
const n = 10
var wg sync.WaitGroup
release := make(chan struct{})
errs := make([]error, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) { defer wg.Done(); <-release; _, errs[i] = service.ensureClient(context.Background(), 7) }(i)
}
close(release)
wg.Wait()
for i := 0; i < n; i++ {
if errs[i] == nil {
t.Fatalf("caller %d err = nil, want the launch error", i)
}
}
if got := host.startCount(); got != 1 {
t.Fatalf("failed launch host.Start calls = %d, want 1 (still single-flighted)", got)
}
}
+22 -2
View File
@@ -14,6 +14,7 @@ import (
// UIs without reaching back into the browser's localStorage.
type UserIdentity struct {
Username string
Email string
ProfileName string
ProfileIsPrimary bool
}
@@ -44,8 +45,8 @@ func (l *PgUserIdentityLookup) LookupIdentity(ctx context.Context, userID int, p
}
err := l.pool.QueryRow(ctx,
"SELECT username FROM users WHERE id = $1", userID,
).Scan(&out.Username)
"SELECT username, COALESCE(email, '') FROM users WHERE id = $1", userID,
).Scan(&out.Username, &out.Email)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return out, err
}
@@ -65,3 +66,22 @@ func (l *PgUserIdentityLookup) LookupIdentity(ctx context.Context, userID int, p
}
return out, nil
}
// RequesterIdentityFromLookup adapts a UserIdentityLookup into the requests
// service's requester-identity resolver (email + username by user id).
type requesterIdentityAdapter struct{ lookup UserIdentityLookup }
func RequesterIdentityFromLookup(l UserIdentityLookup) *requesterIdentityAdapter {
return &requesterIdentityAdapter{lookup: l}
}
func (a *requesterIdentityAdapter) ResolveRequester(ctx context.Context, userID int) (string, string, error) {
if a == nil || a.lookup == nil || userID <= 0 {
return "", "", nil
}
id, err := a.lookup.LookupIdentity(ctx, userID, "")
if err != nil {
return "", "", err
}
return id.Email, id.Username, nil
}
+10
View File
@@ -35,6 +35,16 @@ func NewMiddleware(perKey RateLimiter, global RateLimiter, store SettingsStore,
}
}
// ActiveBackend reports which limiter backend this process is actually
// running, as opposed to the configured backend, which only takes effect
// after a restart.
func (mw *Middleware) ActiveBackend() string {
if mw.isMemory {
return "memory"
}
return "redis"
}
// Init loads config and seeds defaults. Call once at startup.
func (mw *Middleware) Init(ctx context.Context) error {
if err := SeedDefaults(ctx, mw.store); err != nil {
+31
View File
@@ -90,6 +90,37 @@ func TestApplyMediaTypeFloorAddsAvailableSupplementalType(t *testing.T) {
}
}
func TestApplyMediaTypeFloorAddsAvailableAudiobooks(t *testing.T) {
items := []ScoredItem{
{MediaItemID: "m1", Score: 1.00},
{MediaItemID: "m2", Score: 0.99},
{MediaItemID: "m3", Score: 0.98},
{MediaItemID: "m4", Score: 0.97},
{MediaItemID: "m5", Score: 0.96},
{MediaItemID: "m6", Score: 0.95},
{MediaItemID: "m7", Score: 0.94},
{MediaItemID: "m8", Score: 0.93},
{MediaItemID: "m9", Score: 0.92},
{MediaItemID: "m10", Score: 0.91},
}
candidates := append([]ScoredItem(nil), items...)
candidates = append(candidates,
ScoredItem{MediaItemID: "a1", Score: 0.90},
ScoredItem{MediaItemID: "a2", Score: 0.89},
)
mediaTypes := map[string]string{
"m1": "movie", "m2": "movie", "m3": "movie", "m4": "movie", "m5": "movie",
"m6": "movie", "m7": "movie", "m8": "movie", "m9": "movie", "m10": "movie",
"a1": "audiobook", "a2": "audiobook",
}
mixed := applyMediaTypeFloor(items, candidates, mediaTypes)
if got := countMediaType(mixed, mediaTypes, "audiobook"); got != 2 {
t.Fatalf("expected 2 audiobooks from supplemental candidates, got %d in %#v", got, mixed)
}
}
func TestApplyMediaTypeFloorNoopsWithoutSupplementalType(t *testing.T) {
items := []ScoredItem{
{MediaItemID: "s1", Score: 1.00},
+1 -1
View File
@@ -11,7 +11,7 @@ import (
const aggregateMediaTypeFloorDivisor = 5
var aggregateSupplementMediaTypes = []string{"movie", "series"}
var aggregateSupplementMediaTypes = []string{"movie", "series", "audiobook"}
// ForYou returns personalised recommendations grouped by taste clusters.
// For cold-start users, non-personalized rows are returned.
+10 -8
View File
@@ -52,8 +52,8 @@ const tasteSeedCandidateQuery = `
SELECT mi.content_id
FROM media_items mi
LEFT JOIN watched_counts wc ON wc.item_id = mi.content_id
WHERE mi.status = 'matched'
AND mi.type IN ('movie', 'series')
WHERE (mi.status = 'matched' OR mi.type = 'audiobook')
AND mi.type IN ('movie', 'series', 'audiobook')
AND mi.poster_path IS NOT NULL
AND mi.poster_path <> ''
ORDER BY COALESCE(wc.watch_count, 0) DESC,
@@ -279,7 +279,7 @@ func (r *Repo) findTasteProfileCandidates(
}
conditions := []string{
"mi.status = 'matched'",
"(mi.status = 'matched' OR mi.type = 'audiobook')",
"e.media_item_id != ALL($2)",
}
args := []any{pgvector.NewVector(embedding), excludeIDs}
@@ -1223,7 +1223,7 @@ func (r *Repo) GetItemWatchers(ctx context.Context, minWatchers int, maxPerUser
// --- Cold Start Queries ---
// GetPopularItems returns the most-watched series/movies over the given number of days.
// GetPopularItems returns the most-played media items over the given number of days.
// Episodes are resolved to their parent series.
func (r *Repo) GetPopularItems(ctx context.Context, days, limit int) ([]ScoredItem, error) {
rows, err := r.pool.Query(ctx, `
@@ -1270,11 +1270,13 @@ func (r *Repo) GetPopularItems(ctx context.Context, days, limit int) ([]ScoredIt
}
// GetRecentlyAddedItems returns items added within the given number of days.
// Audiobooks bypass the matched-status gate because their scan-derived metadata
// is authoritative before any external-provider match exists.
func (r *Repo) GetRecentlyAddedItems(ctx context.Context, days, limit int) ([]ScoredItem, error) {
rows, err := r.pool.Query(ctx, `
SELECT content_id, created_at
FROM media_items
WHERE status = 'matched'
WHERE (status = 'matched' OR type = 'audiobook')
AND created_at > NOW() - ($1 || ' days')::interval
ORDER BY created_at DESC
LIMIT $2`,
@@ -1325,7 +1327,7 @@ func (r *Repo) GetTopRatedItems(ctx context.Context, minRatings, limit int) ([]S
return items, rows.Err()
}
// GetTasteSeedCandidates returns movie/series content IDs ordered for the
// GetTasteSeedCandidates returns movie/series/audiobook content IDs ordered for the
// taste-seeding picker: server engagement first (most-watched in the last
// 180 days), then rating reliability and rating score, then recency. This keeps
// fresh servers from front-loading single-vote TMDB 10.0 obscurities while
@@ -1393,7 +1395,7 @@ func (r *Repo) GetTopGenres(ctx context.Context, limit int) ([]string, error) {
return genres, rows.Err()
}
// GetGenreSamplerItems returns the most-watched series/movies in a specific genre.
// GetGenreSamplerItems returns the most-played media items in a specific genre.
func (r *Repo) GetGenreSamplerItems(ctx context.Context, genre string, limit int) ([]ScoredItem, error) {
rows, err := r.pool.Query(ctx, `
WITH watched_items AS (
@@ -1614,7 +1616,7 @@ func (r *Repo) GetAllUsersWithTasteProfiles(ctx context.Context) ([]StaleProfile
return profiles, rows.Err()
}
// GetWatchedItemIDs returns content IDs of series/movies the user has watched
// GetWatchedItemIDs returns content IDs of media items the user has watched
// (>= 50% progress or completed). Episodes are resolved to their parent series.
func (r *Repo) GetWatchedItemIDs(ctx context.Context, userID int, profileID string) ([]string, error) {
rows, err := r.pool.Query(ctx, `
+9 -29
View File
@@ -24,51 +24,31 @@ type TagResource struct {
Label string `json:"label"`
}
func ListRootFolders(ctx context.Context, client *Client) ([]mediarequests.IntegrationRootFolder, error) {
// ListRootFolders returns the arr root folders. It yields the local
// RootFolderResource shape directly; the schema-driven config form consumes
// generic RouterOptions from the plugin instead of arr-specific option types.
func ListRootFolders(ctx context.Context, client *Client) ([]RootFolderResource, error) {
var resources []RootFolderResource
if err := client.GetJSON(ctx, "/api/v3/rootfolder", &resources); err != nil {
return nil, err
}
out := make([]mediarequests.IntegrationRootFolder, 0, len(resources))
for _, resource := range resources {
out = append(out, mediarequests.IntegrationRootFolder{
Path: resource.Path,
FreeSpace: resource.FreeSpace,
TotalSpace: resource.TotalSpace,
Accessible: resource.Accessible,
})
}
return out, nil
return resources, nil
}
func ListQualityProfiles(ctx context.Context, client *Client) ([]mediarequests.IntegrationQualityProfile, error) {
func ListQualityProfiles(ctx context.Context, client *Client) ([]QualityProfileResource, error) {
var resources []QualityProfileResource
if err := client.GetJSON(ctx, "/api/v3/qualityprofile", &resources); err != nil {
return nil, err
}
out := make([]mediarequests.IntegrationQualityProfile, 0, len(resources))
for _, resource := range resources {
out = append(out, mediarequests.IntegrationQualityProfile{
ID: resource.ID,
Name: resource.Name,
})
}
return out, nil
return resources, nil
}
func ListTags(ctx context.Context, client *Client) ([]mediarequests.IntegrationTag, error) {
func ListTags(ctx context.Context, client *Client) ([]TagResource, error) {
var resources []TagResource
if err := client.GetJSON(ctx, "/api/v3/tag", &resources); err != nil {
return nil, err
}
out := make([]mediarequests.IntegrationTag, 0, len(resources))
for _, resource := range resources {
out = append(out, mediarequests.IntegrationTag{
ID: resource.ID,
Label: resource.Label,
})
}
return out, nil
return resources, nil
}
// AcceptedWithoutResponse returns a FulfillmentResult marking the submission
+13
View File
@@ -28,3 +28,16 @@ func (e QuotaError) Error() string {
func (e QuotaError) Unwrap() error {
return ErrQuotaExceeded
}
// ValidationError carries plugin Validate results back to the API layer.
type ValidationError struct {
FieldErrors map[string]string
FormError string
}
func (e *ValidationError) Error() string {
if e.FormError != "" {
return e.FormError
}
return "validation failed"
}
+249
View File
@@ -0,0 +1,249 @@
package requests
import (
"context"
"fmt"
"strconv"
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
"google.golang.org/protobuf/types/known/structpb"
)
// RequestRouterProvider fulfills a whole request and checks target status via a
// request_router.v1 plugin. The host owns governance (which qualities) and the
// target records; the provider is the plugin boundary.
type RequestRouterProvider interface {
Fulfill(ctx context.Context, installationID int, capabilityID string, req Request, qualities []Quality, conns []ResolvedRouterConnection) ([]RouterTarget, string, error)
CheckStatus(ctx context.Context, installationID int, capabilityID string, req Request, targets []RouterTargetRef, conns []ResolvedRouterConnection) ([]RouterTargetStatus, error)
ListConfigOptions(ctx context.Context, installationID int, capabilityID string, conn ResolvedRouterConnection) (map[string][]RouterOption, error)
TestConnection(ctx context.Context, installationID int, capabilityID string, conn ResolvedRouterConnection) (bool, string, error)
Validate(ctx context.Context, installationID int, capabilityID string, conn ResolvedRouterConnection, siblings []ResolvedRouterConnection) (fieldErrors map[string]string, formError string, err error)
}
// ResolvedRouterConnection is a connection with plaintext credentials + parsed config.
type ResolvedRouterConnection struct {
ID string
BaseURL string
APIKey string
Config map[string]any
}
type RouterTarget struct {
Quality Quality
ConnectionID string
ExternalID string
ExternalStatus string
Status Status
Message string
}
type RouterTargetRef struct {
Quality Quality
ConnectionID string
ExternalID string
}
type RouterTargetStatus struct {
Quality Quality
ConnectionID string
Status Status
ExternalStatus string
Message string
}
type RouterOption struct {
Value string `json:"value"`
Label string `json:"label"`
}
// RouterClientResolver yields a per-(installation, capability) router client.
type RouterClientResolver interface {
RequestRouterClient(ctx context.Context, installationID int, capabilityID string) (RouterClient, error)
}
// RouterClient mirrors *pluginhost.RequestRouterClient (exported so the api
// package can adapt the concrete type across packages without an import cycle).
type RouterClient interface {
Fulfill(ctx context.Context, req *pluginv1.FulfillRequest) (*pluginv1.FulfillResponse, error)
CheckStatus(ctx context.Context, req *pluginv1.CheckStatusRequest) (*pluginv1.CheckStatusResponse, error)
ListConfigOptions(ctx context.Context, req *pluginv1.ListConfigOptionsRequest) (*pluginv1.ListConfigOptionsResponse, error)
TestConnection(ctx context.Context, req *pluginv1.TestConnectionRequest) (*pluginv1.TestConnectionResponse, error)
Validate(ctx context.Context, req *pluginv1.ValidateRequest) (*pluginv1.ValidateResponse, error)
}
type pluginRouterProvider struct{ resolver RouterClientResolver }
func NewPluginRouterProvider(r RouterClientResolver) RequestRouterProvider {
return &pluginRouterProvider{resolver: r}
}
func routerDescriptor(req Request) *pluginv1.RequestDescriptor {
ids := map[string]string{}
if req.TMDBID != 0 {
ids["tmdb"] = strconv.Itoa(req.TMDBID)
}
if req.TVDBID != nil {
ids["tvdb"] = strconv.Itoa(*req.TVDBID)
}
if req.IMDbID != "" {
ids["imdb"] = req.IMDbID
}
year := 0
if req.Year != nil {
year = *req.Year
}
return &pluginv1.RequestDescriptor{
MediaType: string(req.MediaType),
Title: req.Title,
Year: int32(year),
ExternalIds: ids,
IsAnime: req.IsAnime,
RequesterUserId: int64(req.RequestedByUserID),
RequesterProfileId: req.RequestedByProfileID,
RequesterEmail: req.RequesterEmail,
RequesterUsername: req.RequesterUsername,
}
}
func routerProtoConn(c ResolvedRouterConnection) (*pluginv1.RouterConnection, error) {
cfg, err := structpb.NewStruct(c.Config)
if err != nil {
return nil, fmt.Errorf("router: encode connection %s config: %w", c.ID, err)
}
return &pluginv1.RouterConnection{Id: c.ID, BaseUrl: c.BaseURL, ApiKey: c.APIKey, Config: cfg}, nil
}
func routerProtoConns(conns []ResolvedRouterConnection) ([]*pluginv1.RouterConnection, error) {
out := make([]*pluginv1.RouterConnection, 0, len(conns))
for _, c := range conns {
pc, err := routerProtoConn(c)
if err != nil {
return nil, err
}
out = append(out, pc)
}
return out, nil
}
func (p *pluginRouterProvider) Fulfill(ctx context.Context, installationID int, capabilityID string, req Request, qualities []Quality, conns []ResolvedRouterConnection) ([]RouterTarget, string, error) {
client, err := p.resolver.RequestRouterClient(ctx, installationID, capabilityID)
if err != nil {
return nil, "", err
}
qs := make([]*pluginv1.RequestedQuality, 0, len(qualities))
for _, q := range qualities {
qs = append(qs, &pluginv1.RequestedQuality{Id: string(q), Is4K: q == Quality2160p})
}
pconns, err := routerProtoConns(conns)
if err != nil {
return nil, "", err
}
resp, err := client.Fulfill(ctx, &pluginv1.FulfillRequest{
CapabilityId: capabilityID, Request: routerDescriptor(req), Qualities: qs, Connections: pconns,
})
if err != nil {
return nil, "", err
}
targets := make([]RouterTarget, 0, len(resp.GetTargets()))
for _, t := range resp.GetTargets() {
targets = append(targets, RouterTarget{
Quality: Quality(t.GetQuality()), ConnectionID: t.GetConnectionId(),
ExternalID: t.GetExternalId(), ExternalStatus: t.GetExternalStatus(),
Status: Status(t.GetStatus()), Message: t.GetMessage(),
})
}
return targets, resp.GetMessage(), nil
}
func (p *pluginRouterProvider) CheckStatus(ctx context.Context, installationID int, capabilityID string, req Request, targets []RouterTargetRef, conns []ResolvedRouterConnection) ([]RouterTargetStatus, error) {
client, err := p.resolver.RequestRouterClient(ctx, installationID, capabilityID)
if err != nil {
return nil, err
}
refs := make([]*pluginv1.TargetRef, 0, len(targets))
for _, t := range targets {
refs = append(refs, &pluginv1.TargetRef{Quality: string(t.Quality), ConnectionId: t.ConnectionID, ExternalId: t.ExternalID})
}
pconns, err := routerProtoConns(conns)
if err != nil {
return nil, err
}
resp, err := client.CheckStatus(ctx, &pluginv1.CheckStatusRequest{
CapabilityId: capabilityID, Request: routerDescriptor(req), Targets: refs, Connections: pconns,
})
if err != nil {
return nil, err
}
out := make([]RouterTargetStatus, 0, len(resp.GetStatuses()))
for _, st := range resp.GetStatuses() {
out = append(out, RouterTargetStatus{
Quality: Quality(st.GetQuality()), ConnectionID: st.GetConnectionId(),
Status: Status(st.GetStatus()), ExternalStatus: st.GetExternalStatus(), Message: st.GetMessage(),
})
}
return out, nil
}
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 {
return nil, err
}
pconn, err := routerProtoConn(conn)
if err != nil {
return nil, err
}
resp, err := client.ListConfigOptions(ctx, &pluginv1.ListConfigOptionsRequest{
CapabilityId: capabilityID, Connection: pconn,
})
if err != nil {
return nil, err
}
out := map[string][]RouterOption{}
for field, list := range resp.GetOptionsByField() {
opts := make([]RouterOption, 0, len(list.GetOptions()))
for _, o := range list.GetOptions() {
opts = append(opts, RouterOption{Value: o.GetValue(), Label: o.GetLabel()})
}
out[field] = opts
}
return out, nil
}
func (p *pluginRouterProvider) TestConnection(ctx context.Context, installationID int, capabilityID string, conn ResolvedRouterConnection) (bool, string, error) {
client, err := p.resolver.RequestRouterClient(ctx, installationID, capabilityID)
if err != nil {
return false, "", err
}
pconn, err := routerProtoConn(conn)
if err != nil {
return false, "", err
}
resp, err := client.TestConnection(ctx, &pluginv1.TestConnectionRequest{
CapabilityId: capabilityID, Connection: pconn,
})
if err != nil {
return false, "", err
}
return resp.GetOk(), resp.GetMessage(), nil
}
func (p *pluginRouterProvider) Validate(ctx context.Context, installationID int, capabilityID string, conn ResolvedRouterConnection, siblings []ResolvedRouterConnection) (map[string]string, string, error) {
client, err := p.resolver.RequestRouterClient(ctx, installationID, capabilityID)
if err != nil {
return nil, "", err
}
pc, err := routerProtoConn(conn)
if err != nil {
return nil, "", err
}
sibs, err := routerProtoConns(siblings)
if err != nil {
return nil, "", err
}
resp, err := client.Validate(ctx, &pluginv1.ValidateRequest{CapabilityId: capabilityID, Connection: pc, Siblings: sibs})
if err != nil {
return nil, "", err
}
return resp.GetFieldErrors(), resp.GetFormError(), nil
}
+139
View File
@@ -0,0 +1,139 @@
package requests
import (
"context"
"testing"
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
)
type fakeRouterClient struct {
lastReq *pluginv1.FulfillRequest
lastCheckReq *pluginv1.CheckStatusRequest
statuses []*pluginv1.TargetStatus
optionsByField map[string]*pluginv1.ConfigOptionList
validateResp *pluginv1.ValidateResponse
}
func (f *fakeRouterClient) Fulfill(_ context.Context, req *pluginv1.FulfillRequest) (*pluginv1.FulfillResponse, error) {
f.lastReq = req
return &pluginv1.FulfillResponse{Targets: []*pluginv1.FulfillmentTarget{
{Quality: "1080p", ConnectionId: "c1", ExternalId: "7", Status: "queued"},
}}, nil
}
func (f *fakeRouterClient) CheckStatus(_ context.Context, req *pluginv1.CheckStatusRequest) (*pluginv1.CheckStatusResponse, error) {
f.lastCheckReq = req
return &pluginv1.CheckStatusResponse{Statuses: f.statuses}, nil
}
func (f *fakeRouterClient) ListConfigOptions(context.Context, *pluginv1.ListConfigOptionsRequest) (*pluginv1.ListConfigOptionsResponse, error) {
return &pluginv1.ListConfigOptionsResponse{OptionsByField: f.optionsByField}, nil
}
func (f *fakeRouterClient) TestConnection(context.Context, *pluginv1.TestConnectionRequest) (*pluginv1.TestConnectionResponse, error) {
return &pluginv1.TestConnectionResponse{Ok: true}, nil
}
func (f *fakeRouterClient) Validate(context.Context, *pluginv1.ValidateRequest) (*pluginv1.ValidateResponse, error) {
return f.validateResp, nil
}
type fakeRouterResolver struct{ c RouterClient }
func (r fakeRouterResolver) RequestRouterClient(context.Context, int, string) (RouterClient, error) {
return r.c, nil
}
func TestPluginRouterProviderFulfillTranslates(t *testing.T) {
fc := &fakeRouterClient{}
p := NewPluginRouterProvider(fakeRouterResolver{c: fc})
year := 2020
req := Request{MediaType: MediaTypeMovie, TMDBID: 42, Title: "X", Year: &year}
targets, msg, err := p.Fulfill(context.Background(), 1, "arr", req, []Quality{Quality1080p},
[]ResolvedRouterConnection{{ID: "c1", BaseURL: "http://r", APIKey: "k", Config: map[string]any{"service_kind": "radarr"}}})
if err != nil {
t.Fatalf("Fulfill: %v", err)
}
if msg != "" || len(targets) != 1 || targets[0].Status != StatusQueued || targets[0].ExternalID != "7" {
t.Fatalf("unexpected result: %+v msg=%q", targets, msg)
}
gotQ := fc.lastReq.GetQualities()
if fc.lastReq.GetRequest().GetExternalIds()["tmdb"] != "42" || len(gotQ) != 1 {
t.Fatalf("descriptor/qualities not forwarded: %+v", fc.lastReq)
}
if gotQ[0].GetId() != "1080p" || gotQ[0].GetIs4K() {
t.Fatalf("1080p should be id=1080p is4k=false, got %+v", gotQ[0])
}
}
func TestPluginRouterProviderCheckStatusTranslates(t *testing.T) {
fc := &fakeRouterClient{statuses: []*pluginv1.TargetStatus{
{Quality: "1080p", ConnectionId: "c1", Status: "downloading", ExternalStatus: "grabbed", Message: "in queue"},
{Quality: "2160p", ConnectionId: "c2", Status: "completed", ExternalStatus: "imported", Message: ""},
}}
p := NewPluginRouterProvider(fakeRouterResolver{c: fc})
req := Request{MediaType: MediaTypeMovie, TMDBID: 42, Title: "X"}
refs := []RouterTargetRef{
{Quality: Quality1080p, ConnectionID: "c1", ExternalID: "7"},
{Quality: Quality2160p, ConnectionID: "c2", ExternalID: "8"},
}
out, err := p.CheckStatus(context.Background(), 1, "arr", req, refs,
[]ResolvedRouterConnection{{ID: "c1", BaseURL: "http://r", APIKey: "k", Config: map[string]any{"service_kind": "radarr"}}})
if err != nil {
t.Fatalf("CheckStatus: %v", err)
}
if len(out) != 2 {
t.Fatalf("expected 2 statuses, got %d", len(out))
}
if out[0].Quality != Quality1080p || out[0].ConnectionID != "c1" ||
out[0].Status != StatusDownloading || out[0].ExternalStatus != "grabbed" || out[0].Message != "in queue" {
t.Fatalf("status[0] mismapped: %+v", out[0])
}
if out[1].Quality != Quality2160p || out[1].ConnectionID != "c2" || out[1].Status != StatusCompleted || out[1].ExternalStatus != "imported" {
t.Fatalf("status[1] mismapped: %+v", out[1])
}
if got := fc.lastCheckReq.GetTargets(); len(got) != 2 ||
got[0].GetConnectionId() != "c1" || got[0].GetExternalId() != "7" ||
got[1].GetConnectionId() != "c2" || got[1].GetExternalId() != "8" {
t.Fatalf("refs not forwarded: %+v", fc.lastCheckReq.GetTargets())
}
}
func TestPluginRouterProviderListConfigOptionsTranslates(t *testing.T) {
fc := &fakeRouterClient{optionsByField: map[string]*pluginv1.ConfigOptionList{
"root_folder": {Options: []*pluginv1.ConfigOption{
{Value: "/movies", Label: "Movies"},
{Value: "/movies4k", Label: "Movies 4K"},
}},
"quality_profile_id": {Options: []*pluginv1.ConfigOption{
{Value: "1", Label: "HD-1080p"},
}},
}}
p := NewPluginRouterProvider(fakeRouterResolver{c: fc})
out, err := p.ListConfigOptions(context.Background(), 1, "arr",
ResolvedRouterConnection{ID: "c1", BaseURL: "http://r", APIKey: "k", Config: map[string]any{"service_kind": "radarr"}})
if err != nil {
t.Fatalf("ListConfigOptions: %v", err)
}
if len(out) != 2 {
t.Fatalf("expected 2 fields, got %d: %+v", len(out), out)
}
rf := out["root_folder"]
if len(rf) != 2 || rf[0].Value != "/movies" || rf[0].Label != "Movies" || rf[1].Value != "/movies4k" || rf[1].Label != "Movies 4K" {
t.Fatalf("root_folder mismapped: %+v", rf)
}
qp := out["quality_profile_id"]
if len(qp) != 1 || qp[0].Value != "1" || qp[0].Label != "HD-1080p" {
t.Fatalf("quality_profile_id mismapped: %+v", qp)
}
}
func TestPluginRouterProviderValidateTranslates(t *testing.T) {
fc := &fakeRouterClient{validateResp: &pluginv1.ValidateResponse{
FieldErrors: map[string]string{"is_default": "cannot be 4K"}, FormError: "",
}}
p := NewPluginRouterProvider(fakeRouterResolver{c: fc})
fe, form, err := p.Validate(context.Background(), 1, "arr",
ResolvedRouterConnection{ID: "c1", Config: map[string]any{"service_kind": "radarr"}},
nil)
if err != nil || form != "" || fe["is_default"] != "cannot be 4K" {
t.Fatalf("unexpected: fe=%v form=%q err=%v", fe, form, err)
}
}
-176
View File
@@ -1,176 +0,0 @@
package radarr
import (
"context"
"fmt"
"net/http"
"net/url"
"strconv"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
"github.com/Silo-Server/silo-server/internal/requests/arrclient"
)
type Client struct {
httpClient *http.Client
}
type movieResource struct {
ID int `json:"id,omitempty"`
Title string `json:"title,omitempty"`
TMDBID int `json:"tmdbId,omitempty"`
Year int `json:"year,omitempty"`
TitleSlug string `json:"titleSlug,omitempty"`
QualityProfileID int `json:"qualityProfileId,omitempty"`
RootFolderPath string `json:"rootFolderPath,omitempty"`
Monitored bool `json:"monitored"`
MinimumAvailability string `json:"minimumAvailability,omitempty"`
Tags []int `json:"tags,omitempty"`
AddOptions addMovieOptions `json:"addOptions,omitempty"`
}
type addMovieOptions struct {
SearchForMovie bool `json:"searchForMovie"`
Monitor string `json:"monitor,omitempty"`
}
func NewClient(httpClient *http.Client) *Client {
return &Client{httpClient: httpClient}
}
func (c *Client) ListMovieIntegrationOptions(ctx context.Context, integration mediarequests.Integration) (*mediarequests.IntegrationOptions, error) {
client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient)
rootFolders, err := arrclient.ListRootFolders(ctx, client)
if err != nil {
return nil, err
}
qualityProfiles, err := arrclient.ListQualityProfiles(ctx, client)
if err != nil {
return nil, err
}
tags, err := arrclient.ListTags(ctx, client)
if err != nil {
return nil, err
}
return &mediarequests.IntegrationOptions{
Kind: "radarr",
RootFolders: rootFolders,
QualityProfiles: qualityProfiles,
Tags: tags,
}, nil
}
func (c *Client) SubmitMovie(ctx context.Context, req mediarequests.Request, integration mediarequests.Integration) (mediarequests.FulfillmentResult, error) {
if req.MediaType != mediarequests.MediaTypeMovie {
return mediarequests.FulfillmentResult{}, fmt.Errorf("radarr: request is not a movie")
}
if integration.QualityProfileID == nil {
return mediarequests.FulfillmentResult{}, fmt.Errorf("radarr: quality profile is required")
}
client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient)
movie, err := c.lookupMovie(ctx, client, req.TMDBID)
if err != nil {
return mediarequests.FulfillmentResult{}, err
}
movie.RootFolderPath = integration.RootFolder
movie.QualityProfileID = *integration.QualityProfileID
movie.Monitored = arrclient.BoolOption(integration.Options, "monitored", true)
movie.MinimumAvailability = arrclient.StringOption(integration.Options, "minimum_availability", "released")
movie.Tags = integration.Tags
movie.AddOptions = addMovieOptions{
SearchForMovie: arrclient.BoolOption(
integration.Options,
"search_for_movie",
arrclient.BoolOption(integration.Options, "search_on_add", true),
),
Monitor: arrclient.StringOption(integration.Options, "monitor", "movieOnly"),
}
var created movieResource
if err := client.PostJSON(ctx, "/api/v3/movie", movie, &created); err != nil {
if !arrclient.IsEmptyOrTruncatedDecodeError(err) {
return mediarequests.FulfillmentResult{}, err
}
// POST accepted but Radarr returned an empty body. Recover the
// new movie's Radarr ID by listing movies filtered by TMDB ID;
// without the ID the reconcile loop cannot advance the request.
if found, lookErr := c.findMovieByTMDBID(ctx, client, req.TMDBID); lookErr == nil && found.ID > 0 {
return resultFromMovie(found), nil
}
return arrclient.AcceptedWithoutResponse("radarr"), nil
}
return resultFromMovie(created), nil
}
func (c *Client) findMovieByTMDBID(ctx context.Context, client *arrclient.Client, tmdbID int) (movieResource, error) {
values := url.Values{}
values.Set("tmdbId", strconv.Itoa(tmdbID))
var matches []movieResource
if err := client.GetJSON(ctx, "/api/v3/movie?"+values.Encode(), &matches); err != nil {
return movieResource{}, err
}
for _, m := range matches {
if m.ID > 0 {
return m, nil
}
}
return movieResource{}, fmt.Errorf("radarr: movie not found after add for tmdb_id %d", tmdbID)
}
func (c *Client) CheckMovieStatus(ctx context.Context, req mediarequests.Request, integration mediarequests.Integration) (mediarequests.FulfillmentStatus, error) {
client := arrclient.New(integration.BaseURL, integration.APIKeyRef, c.httpClient)
movieID, _ := strconv.Atoi(req.ExternalID)
if movieID <= 0 {
return mediarequests.FulfillmentStatus{
Status: mediarequests.StatusQueued,
IntegrationKind: "radarr",
ExternalStatus: "external_id_unavailable",
}, nil
}
queues, err := c.queueDetails(ctx, client, movieID)
if err != nil {
return mediarequests.FulfillmentStatus{}, err
}
evaluation := arrclient.EvaluateQueue(queues)
return arrclient.StatusFromQueueEvaluation("radarr", movieID, evaluation), nil
}
func (c *Client) lookupMovie(ctx context.Context, client *arrclient.Client, tmdbID int) (movieResource, error) {
values := url.Values{}
values.Set("tmdbId", strconv.Itoa(tmdbID))
// Radarr's /api/v3/movie/lookup/tmdb returns a single MovieResource, unlike
// /api/v3/movie/lookup which returns an array. Missing IDs return non-2xx
// (handled as HTTPError upstream), so a successful response is the movie.
var movie movieResource
if err := client.GetJSON(ctx, "/api/v3/movie/lookup/tmdb?"+values.Encode(), &movie); err != nil {
return movieResource{}, err
}
if movie.TMDBID == 0 {
movie.TMDBID = tmdbID
}
return movie, nil
}
func (c *Client) queueDetails(ctx context.Context, client *arrclient.Client, movieID int) ([]arrclient.QueueResource, error) {
values := url.Values{}
values.Set("movieId", strconv.Itoa(movieID))
var queues []arrclient.QueueResource
if err := client.GetJSON(ctx, "/api/v3/queue/details?"+values.Encode(), &queues); err != nil {
return nil, err
}
return queues, nil
}
func resultFromMovie(movie movieResource) mediarequests.FulfillmentResult {
externalID := ""
if movie.ID > 0 {
externalID = strconv.Itoa(movie.ID)
}
return mediarequests.FulfillmentResult{
IntegrationKind: "radarr",
ExternalID: externalID,
ExternalStatus: "queued",
}
}
-236
View File
@@ -1,236 +0,0 @@
package radarr
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
)
func TestSubmitMovieAddsLookupResult(t *testing.T) {
qualityProfileID := 7
var posted movieResource
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Api-Key"); got != "radarr-key" {
t.Fatalf("X-Api-Key = %q, want radarr-key", got)
}
switch r.URL.Path {
case "/api/v3/movie":
if r.Method == http.MethodGet {
if got := r.URL.Query().Get("tmdbId"); got != "550" {
t.Fatalf("tmdbId = %q, want 550", got)
}
w.Write([]byte(`[]`))
return
}
if r.Method == http.MethodPost {
if err := json.NewDecoder(r.Body).Decode(&posted); err != nil {
t.Fatalf("decode posted movie: %v", err)
}
w.Write([]byte(`{"id":42,"tmdbId":550}`))
return
}
case "/api/v3/movie/lookup/tmdb":
if got := r.URL.Query().Get("tmdbId"); got != "550" {
t.Fatalf("lookup tmdbId = %q, want 550", got)
}
w.Write([]byte(`{"title":"Fight Club","tmdbId":550,"titleSlug":"fight-club"}`))
return
}
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}))
defer server.Close()
client := NewClient(server.Client())
result, err := client.SubmitMovie(context.Background(), mediarequests.Request{
MediaType: mediarequests.MediaTypeMovie,
TMDBID: 550,
Title: "Fight Club",
}, mediarequests.Integration{
Kind: "radarr",
BaseURL: server.URL,
APIKeyRef: "radarr-key",
RootFolder: "/movies",
QualityProfileID: &qualityProfileID,
Options: map[string]any{
"search_on_add": false,
},
})
if err != nil {
t.Fatalf("SubmitMovie returned error: %v", err)
}
if result.ExternalID != "42" || result.IntegrationKind != "radarr" {
t.Fatalf("result = %+v, want radarr external id 42", result)
}
if posted.RootFolderPath != "/movies" || posted.QualityProfileID != qualityProfileID {
t.Fatalf("posted movie = %+v, missing root folder/quality profile", posted)
}
if posted.AddOptions.SearchForMovie {
t.Fatalf("searchForMovie = true, want false")
}
}
func TestSubmitMovieRecoversFromEmptyAddResponse(t *testing.T) {
qualityProfileID := 7
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v3/movie/lookup/tmdb":
w.Write([]byte(`{"title":"Fight Club","tmdbId":550,"titleSlug":"fight-club"}`))
case "/api/v3/movie":
if r.Method == http.MethodPost {
w.WriteHeader(http.StatusCreated)
return
}
if r.Method == http.MethodGet {
if got := r.URL.Query().Get("tmdbId"); got != "550" {
t.Fatalf("tmdbId = %q, want 550", got)
}
w.Write([]byte(`[{"id":99,"tmdbId":550,"title":"Fight Club"}]`))
return
}
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
}))
defer server.Close()
client := NewClient(server.Client())
result, err := client.SubmitMovie(context.Background(), mediarequests.Request{
MediaType: mediarequests.MediaTypeMovie,
TMDBID: 550,
Title: "Fight Club",
}, mediarequests.Integration{
Kind: "radarr",
BaseURL: server.URL,
APIKeyRef: "radarr-key",
RootFolder: "/movies",
QualityProfileID: &qualityProfileID,
})
if err != nil {
t.Fatalf("SubmitMovie returned error: %v", err)
}
if result.ExternalID != "99" {
t.Fatalf("ExternalID = %q, want 99 (recovered after empty 201)", result.ExternalID)
}
if result.ExternalStatus != "queued" {
t.Fatalf("ExternalStatus = %q, want queued", result.ExternalStatus)
}
}
func TestSubmitMovieFallsBackWhenEmptyResponseAndLookupFails(t *testing.T) {
qualityProfileID := 7
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v3/movie/lookup/tmdb":
w.Write([]byte(`{"title":"Fight Club","tmdbId":550,"titleSlug":"fight-club"}`))
case "/api/v3/movie":
if r.Method == http.MethodPost {
w.WriteHeader(http.StatusCreated)
return
}
if r.Method == http.MethodGet {
w.Write([]byte(`[]`))
return
}
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
}))
defer server.Close()
client := NewClient(server.Client())
result, err := client.SubmitMovie(context.Background(), mediarequests.Request{
MediaType: mediarequests.MediaTypeMovie,
TMDBID: 550,
Title: "Fight Club",
}, mediarequests.Integration{
Kind: "radarr",
BaseURL: server.URL,
APIKeyRef: "radarr-key",
RootFolder: "/movies",
QualityProfileID: &qualityProfileID,
})
if err != nil {
t.Fatalf("SubmitMovie returned error: %v", err)
}
if result.ExternalStatus != "accepted_without_response" {
t.Fatalf("ExternalStatus = %q, want accepted_without_response", result.ExternalStatus)
}
}
func TestCheckMovieStatusReadsQueueDetails(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Api-Key"); got != "radarr-key" {
t.Fatalf("X-Api-Key = %q, want radarr-key", got)
}
if r.URL.Path != "/api/v3/queue/details" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
if got := r.URL.Query().Get("movieId"); got != "42" {
t.Fatalf("movieId = %q, want 42", got)
}
w.Write([]byte(`[{"movieId":42,"status":"downloading","trackedDownloadState":"downloading"}]`))
}))
defer server.Close()
client := NewClient(server.Client())
status, err := client.CheckMovieStatus(context.Background(), mediarequests.Request{
MediaType: mediarequests.MediaTypeMovie,
TMDBID: 550,
ExternalID: "42",
}, mediarequests.Integration{
Kind: "radarr",
BaseURL: server.URL,
APIKeyRef: "radarr-key",
})
if err != nil {
t.Fatalf("CheckMovieStatus returned error: %v", err)
}
if status.Status != mediarequests.StatusDownloading || status.ExternalStatus != "downloading/downloading" {
t.Fatalf("status = %+v, want downloading", status)
}
}
func TestListMovieIntegrationOptionsLoadsChoices(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Api-Key"); got != "radarr-key" {
t.Fatalf("X-Api-Key = %q, want radarr-key", got)
}
switch r.URL.Path {
case "/api/v3/rootfolder":
w.Write([]byte(`[{"path":"/movies","freeSpace":123,"totalSpace":456,"accessible":true}]`))
case "/api/v3/qualityprofile":
w.Write([]byte(`[{"id":7,"name":"HD-1080p"}]`))
case "/api/v3/tag":
w.Write([]byte(`[{"id":2,"label":"requests"}]`))
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer server.Close()
client := NewClient(server.Client())
options, err := client.ListMovieIntegrationOptions(context.Background(), mediarequests.Integration{
Kind: "radarr",
BaseURL: server.URL,
APIKeyRef: "radarr-key",
})
if err != nil {
t.Fatalf("ListMovieIntegrationOptions returned error: %v", err)
}
if options.Kind != "radarr" {
t.Fatalf("kind = %q, want radarr", options.Kind)
}
if len(options.RootFolders) != 1 || options.RootFolders[0].Path != "/movies" {
t.Fatalf("root folders = %+v, want /movies", options.RootFolders)
}
if len(options.QualityProfiles) != 1 || options.QualityProfiles[0].ID != 7 {
t.Fatalf("quality profiles = %+v, want id 7", options.QualityProfiles)
}
if len(options.Tags) != 1 || options.Tags[0].ID != 2 {
t.Fatalf("tags = %+v, want id 2", options.Tags)
}
}
+54 -101
View File
@@ -26,14 +26,10 @@ func NewRepository(pool *pgxpool.Pool, cipher *secret.Cipher) *Repository {
return &Repository{pool: pool, cipher: cipher}
}
// apiKeyAAD binds a request_integrations api_key_ref ciphertext to its row.
func apiKeyAAD(id string) string {
return secret.RowAAD("request_integrations", "api_key_ref", id)
}
// encryptAPIKey encrypts a non-empty, trimmed API key bound to the integration
// id. An empty key returns "" so the update path's keep-existing CASE sentinel
// still fires (and an absent key stays absent).
func (r *Repository) encryptAPIKey(id, apiKey string) (string, error) {
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
@@ -471,13 +467,12 @@ func (r *Repository) SetOutcome(ctx context.Context, id string, outcome Outcome,
return req, nil
}
const integrationColumns = `id, kind, name, enabled, base_url, api_key_ref,
root_folder, quality_profile_id, tags, is_4k, is_default, is_default_4k,
anime_enabled, anime_quality_profile_id, anime_root_folder, anime_tags,
options, last_check_at, last_check_status, last_check_error, updated_at`
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`
func (r *Repository) ListIntegrations(ctx context.Context) ([]Integration, error) {
rows, err := r.pool.Query(ctx, `SELECT `+integrationColumns+` FROM request_integrations ORDER BY kind, name`)
rows, err := r.pool.Query(ctx, `SELECT `+integrationColumns+` FROM request_integrations ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("list request integrations: %w", err)
}
@@ -522,12 +517,20 @@ func (r *Repository) UpdateIntegration(ctx context.Context, i Integration) (*Int
// tx) so the same SQL is reused by the plain create path and the transactional
// SaveIntegrationWithDefaults path.
func (r *Repository) insertIntegration(ctx context.Context, exec requestExecutor, i Integration) (*Integration, error) {
if i.Options == nil {
i.Options = map[string]any{}
if i.PluginConfig == nil {
i.PluginConfig = map[string]any{}
}
options, err := json.Marshal(i.Options)
pluginConfig, err := json.Marshal(i.PluginConfig)
if err != nil {
return nil, fmt.Errorf("marshal options: %w", err)
return nil, fmt.Errorf("marshal plugin config: %w", err)
}
// capability_id is the capability sub-id ("arr"/"seerr"), validated non-empty
// upstream in validateInstance; persist it verbatim (never default it to the
// capability type, which the plugin runtime can't resolve).
capabilityID := strings.TrimSpace(i.CapabilityID)
supportedMediaTypes := i.SupportedMediaTypes
if supportedMediaTypes == nil {
supportedMediaTypes = []string{}
}
apiKeyRef, err := r.encryptAPIKey(i.ID, i.APIKeyRef)
if err != nil {
@@ -535,17 +538,13 @@ func (r *Repository) insertIntegration(ctx context.Context, exec requestExecutor
}
row := exec.QueryRow(ctx, `
INSERT INTO request_integrations (
id, kind, name, enabled, base_url, api_key_ref, root_folder,
quality_profile_id, tags, is_4k, is_default, is_default_4k,
anime_enabled, anime_quality_profile_id, anime_root_folder, anime_tags,
options, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17, now())
id, name, enabled, base_url, api_key_ref,
capability_id, installation_id, supported_media_types,
plugin_config, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9, now())
RETURNING `+integrationColumns,
i.ID, i.Kind, strings.TrimSpace(i.Name), i.Enabled, strings.TrimSpace(i.BaseURL),
apiKeyRef, strings.TrimSpace(i.RootFolder), i.QualityProfileID,
int32Slice(i.Tags), i.Is4K, i.IsDefault, i.IsDefault4K, i.AnimeEnabled,
i.AnimeQualityProfileID, strings.TrimSpace(i.AnimeRootFolder), int32Slice(i.AnimeTags),
options)
i.ID, strings.TrimSpace(i.Name), i.Enabled, strings.TrimSpace(i.BaseURL),
apiKeyRef, capabilityID, i.InstallationID, supportedMediaTypes, pluginConfig)
out, err := r.scanIntegration(row)
if err != nil {
return nil, fmt.Errorf("create request integration: %w", err)
@@ -556,12 +555,20 @@ func (r *Repository) insertIntegration(ctx context.Context, exec requestExecutor
// updateIntegration runs the integration UPDATE against any executor (pool or
// tx) so the plain update path and SaveIntegrationWithDefaults share the SQL.
func (r *Repository) updateIntegration(ctx context.Context, exec requestExecutor, i Integration) (*Integration, error) {
if i.Options == nil {
i.Options = map[string]any{}
if i.PluginConfig == nil {
i.PluginConfig = map[string]any{}
}
options, err := json.Marshal(i.Options)
pluginConfig, err := json.Marshal(i.PluginConfig)
if err != nil {
return nil, fmt.Errorf("marshal options: %w", err)
return nil, fmt.Errorf("marshal plugin config: %w", err)
}
// capability_id is the capability sub-id ("arr"/"seerr"), validated non-empty
// upstream in validateInstance; persist it verbatim (never default it to the
// capability type, which the plugin runtime can't resolve).
capabilityID := strings.TrimSpace(i.CapabilityID)
supportedMediaTypes := i.SupportedMediaTypes
if supportedMediaTypes == nil {
supportedMediaTypes = []string{}
}
// Encrypt the incoming key; an empty result preserves the keep-existing CASE
// (a blank edit leaves the stored key untouched).
@@ -573,17 +580,12 @@ func (r *Repository) updateIntegration(ctx context.Context, exec requestExecutor
UPDATE request_integrations SET
name=$2, enabled=$3, base_url=$4,
api_key_ref = CASE WHEN $5 = '' THEN api_key_ref ELSE $5 END,
root_folder=$6, quality_profile_id=$7, tags=$8, is_4k=$9,
is_default=$10, is_default_4k=$11, anime_enabled=$12,
anime_quality_profile_id=$13, anime_root_folder=$14, anime_tags=$15,
options=$16, updated_at=now()
capability_id=$6, installation_id=$7,
supported_media_types=$8, plugin_config=$9, updated_at=now()
WHERE id=$1
RETURNING `+integrationColumns,
i.ID, strings.TrimSpace(i.Name), i.Enabled, strings.TrimSpace(i.BaseURL),
apiKeyRef, strings.TrimSpace(i.RootFolder), i.QualityProfileID,
int32Slice(i.Tags), i.Is4K, i.IsDefault, i.IsDefault4K, i.AnimeEnabled,
i.AnimeQualityProfileID, strings.TrimSpace(i.AnimeRootFolder), int32Slice(i.AnimeTags),
options)
apiKeyRef, capabilityID, i.InstallationID, supportedMediaTypes, pluginConfig)
out, err := r.scanIntegration(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -594,9 +596,10 @@ func (r *Repository) updateIntegration(ctx context.Context, exec requestExecutor
return &out, nil
}
// SaveIntegrationWithDefaults clears the conflicting kind default(s) and
// creates/updates the instance in a single transaction so a save failure can
// never leave the kind with zero defaults.
// SaveIntegrationWithDefaults creates/updates the instance in a single
// transaction. The host no longer enforces a single HD/4K default per kind;
// the request_router plugin's RouteTargets picks the first is_default
// connection from plugin_config, so the save path is a plain insert-or-update.
func (r *Repository) SaveIntegrationWithDefaults(ctx context.Context, in Integration, isCreate bool) (*Integration, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
@@ -604,17 +607,6 @@ func (r *Repository) SaveIntegrationWithDefaults(ctx context.Context, in Integra
}
defer tx.Rollback(ctx)
if in.IsDefault {
if err := r.ClearDefault(ctx, tx, in.Kind, false, in.ID); err != nil {
return nil, err
}
}
if in.IsDefault4K {
if err := r.ClearDefault(ctx, tx, in.Kind, true, in.ID); err != nil {
return nil, err
}
}
var out *Integration
if isCreate {
out, err = r.insertIntegration(ctx, tx, in)
@@ -669,21 +661,6 @@ func (r *Repository) DeleteIntegration(ctx context.Context, id string) error {
return nil
}
// ClearDefault unsets the HD (or 4K) default flag for every instance of a kind
// except excludeID, so saving an instance that is itself the new default does
// not clear its own freshly-written flag.
func (r *Repository) ClearDefault(ctx context.Context, exec requestExecutor, kind string, fourK bool, excludeID string) error {
col := "is_default"
if fourK {
col = "is_default_4k"
}
_, err := exec.Exec(ctx, `UPDATE request_integrations SET `+col+` = false WHERE kind = $1 AND id <> $2`, kind, excludeID)
if err != nil {
return fmt.Errorf("clear default: %w", err)
}
return nil
}
func (r *Repository) recordEvent(ctx context.Context, exec requestExecutor, requestID, eventType string, actor Viewer, message string) error {
var actorUserID any
if actor.UserID > 0 {
@@ -794,15 +771,13 @@ type integrationScanner interface {
func (r *Repository) scanIntegration(row integrationScanner) (Integration, error) {
var i Integration
var quality, animeQuality sql.NullInt64
var tags, animeTags []int32
var optionsRaw []byte
var installationID sql.NullInt64
var pluginConfigRaw []byte
var lastCheckAt sql.NullTime
if err := row.Scan(
&i.ID, &i.Kind, &i.Name, &i.Enabled, &i.BaseURL, &i.APIKeyRef,
&i.RootFolder, &quality, &tags, &i.Is4K, &i.IsDefault, &i.IsDefault4K,
&i.AnimeEnabled, &animeQuality, &i.AnimeRootFolder, &animeTags,
&optionsRaw, &lastCheckAt, &i.LastCheckStatus, &i.LastCheckError, &i.UpdatedAt,
&i.ID, &i.Name, &i.Enabled, &i.BaseURL, &i.APIKeyRef,
&lastCheckAt, &i.LastCheckStatus, &i.LastCheckError, &i.UpdatedAt,
&i.CapabilityID, &installationID, &i.SupportedMediaTypes, &pluginConfigRaw,
); err != nil {
return Integration{}, err
}
@@ -814,42 +789,20 @@ func (r *Repository) scanIntegration(row integrationScanner) (Integration, error
return Integration{}, fmt.Errorf("decrypt request integration %s api key: %w", i.ID, err)
}
i.APIKeyRef = apiKey
if quality.Valid {
v := int(quality.Int64)
i.QualityProfileID = &v
if installationID.Valid {
v := int(installationID.Int64)
i.InstallationID = &v
}
if animeQuality.Valid {
v := int(animeQuality.Int64)
i.AnimeQualityProfileID = &v
}
i.Tags = intsFromInt32(tags)
i.AnimeTags = intsFromInt32(animeTags)
if len(optionsRaw) > 0 {
if err := json.Unmarshal(optionsRaw, &i.Options); err != nil {
return Integration{}, fmt.Errorf("unmarshal request integration options for %s: %w", i.ID, err)
if len(pluginConfigRaw) > 0 {
if err := json.Unmarshal(pluginConfigRaw, &i.PluginConfig); err != nil {
return Integration{}, fmt.Errorf("unmarshal request integration plugin config for %s: %w", i.ID, err)
}
}
if i.Options == nil {
i.Options = map[string]any{}
if i.PluginConfig == nil {
i.PluginConfig = map[string]any{}
}
if lastCheckAt.Valid {
i.LastCheckAt = &lastCheckAt.Time
}
return i, nil
}
func int32Slice(values []int) []int32 {
out := make([]int32, 0, len(values))
for _, value := range values {
out = append(out, int32(value))
}
return out
}
func intsFromInt32(values []int32) []int {
out := make([]int, 0, len(values))
for _, value := range values {
out = append(out, int(value))
}
return out
}
@@ -0,0 +1,59 @@
package requests
import (
"testing"
"github.com/Silo-Server/silo-server/internal/secret"
)
// TestEncryptAPIKeyRoundTripAndAAD guards the security-critical invariants of the
// at-rest credential cipher adopted from #95: encryptAPIKey must produce
// ciphertext that DecryptIfEncrypted inverts under the SAME id-bound AAD that
// secret's backfill writes, a blank key must encrypt to "" (the keep-existing
// update sentinel), and the AAD must be row-bound. A drift in apiKeyAAD or the
// envelope would silently corrupt or leak every stored connection api key with no
// other test catching it (there is no DB harness for scanIntegration).
func TestEncryptAPIKeyRoundTripAndAAD(t *testing.T) {
cipher, err := secret.New([]byte("unit-test-master-key-with-at-least-32-bytes"))
if err != nil {
t.Fatalf("secret.New: %v", err)
}
r := &Repository{cipher: cipher}
const id = "integ-1"
const key = "00178d9a3893480cbf122d29f7aca1a0"
enc, err := r.encryptAPIKey(id, key)
if err != nil {
t.Fatalf("encryptAPIKey: %v", err)
}
if enc == "" || enc == key {
t.Fatalf("encryptAPIKey did not encrypt: %q", enc)
}
// The AAD must equal what secret.BackfillReferencedColumns wrote, or rows
// encrypted by the startup backfill fail to decrypt on read.
if got, want := apiKeyAAD(id), secret.RowAAD("request_integrations", "api_key_ref", id); got != want {
t.Fatalf("apiKeyAAD = %q, want %q (must match #95 backfill)", got, want)
}
dec, err := cipher.DecryptIfEncrypted(enc, apiKeyAAD(id))
if err != nil {
t.Fatalf("DecryptIfEncrypted: %v", err)
}
if dec != key {
t.Fatalf("round-trip = %q, want %q", dec, key)
}
// A blank/whitespace key encrypts to "" so updateIntegration's
// CASE WHEN $5 = '' sentinel preserves the stored ciphertext.
if blank, err := r.encryptAPIKey(id, " "); err != nil || blank != "" {
t.Fatalf("encryptAPIKey(blank) = %q, %v; want \"\", nil (keep-existing sentinel)", blank, err)
}
// AAD is row-bound: ciphertext from one row must not authenticate under
// another row's id.
if _, err := cipher.DecryptIfEncrypted(enc, apiKeyAAD("integ-2")); err == nil {
t.Fatal("ciphertext decrypted under a different id's AAD — AAD is not row-bound")
}
}
-85
View File
@@ -1,85 +0,0 @@
package requests
import "github.com/Silo-Server/silo-server/internal/access"
// plannedTarget is a routing decision: which instance, at which quality, with
// which profile/folder/tags resolved (standard vs anime).
type plannedTarget struct {
Instance Integration
Quality Quality
IsAnime bool
}
func integrationKindForMediaType(mediaType MediaType) string {
if mediaType == MediaTypeSeries {
return "sonarr"
}
return "radarr"
}
// routeTargets decides the fulfillment targets for an approved request.
// 1080p is always desired; 2160p is added when the requester's ceiling allows
// 4K OR force-dual is on. A quality is emitted only if its default instance
// exists for the kind.
func routeTargets(req Request, ceiling string, settings Settings, instances []Integration) []plannedTarget {
kind := integrationKindForMediaType(req.MediaType)
wants4K := settings.ForceDualQuality || access.CompareQuality(ceiling, access.PlaybackQuality4K) >= 0
var hd, uhd *Integration
for i := range instances {
in := instances[i]
if in.Kind != kind || !in.Enabled {
continue
}
if in.IsDefault && hd == nil {
hd = &instances[i]
}
if in.IsDefault4K && uhd == nil {
uhd = &instances[i]
}
}
var out []plannedTarget
if hd != nil {
out = append(out, plannedTarget{Instance: *hd, Quality: Quality1080p, IsAnime: req.IsAnime && hd.AnimeEnabled})
}
if wants4K && uhd != nil {
out = append(out, plannedTarget{Instance: *uhd, Quality: Quality2160p, IsAnime: req.IsAnime && uhd.AnimeEnabled})
}
return out
}
// resolveInstance returns a copy of the instance with root folder / quality
// profile / tags (and Sonarr series_type) set for standard vs anime fulfillment.
func resolveInstance(pt plannedTarget) Integration {
in := pt.Instance
if in.Options == nil {
in.Options = map[string]any{}
} else {
clone := make(map[string]any, len(in.Options))
for k, v := range in.Options {
clone[k] = v
}
in.Options = clone
}
if pt.IsAnime {
// Anime fields are overrides: only replace the standard value when the
// anime counterpart is set, so an admin can enable anime detection while
// reusing the standard root folder / quality profile / tags for any field
// they leave blank (rather than clearing them into an invalid submission).
if in.AnimeRootFolder != "" {
in.RootFolder = in.AnimeRootFolder
}
if in.AnimeQualityProfileID != nil {
in.QualityProfileID = in.AnimeQualityProfileID
}
if len(in.AnimeTags) > 0 {
in.Tags = in.AnimeTags
}
if in.Kind == "sonarr" {
in.Options["series_type"] = "anime"
}
}
return in
}
-52
View File
@@ -1,52 +0,0 @@
package requests
import "testing"
func inst(kind, id string, def, def4k, anime bool) Integration {
qp := 1
return Integration{
ID: id, Kind: kind, Name: id, Enabled: true, BaseURL: "http://x",
APIKeyRef: "k", RootFolder: "/std", QualityProfileID: &qp,
Is4K: def4k, IsDefault: def, IsDefault4K: def4k, AnimeEnabled: anime,
}
}
func TestRouteTargets(t *testing.T) {
hd := inst("radarr", "hd", true, false, false)
uhd := inst("radarr", "uhd", false, true, false)
hdAnime := inst("radarr", "hda", true, false, true)
cases := []struct {
name string
req Request
ceiling string
force bool
instances []Integration
want []Quality
wantAnime bool
}{
{"hd only, sd user", Request{MediaType: MediaTypeMovie}, "1080p", false, []Integration{hd, uhd}, []Quality{Quality1080p}, false},
{"4k user dual", Request{MediaType: MediaTypeMovie}, "2160p", false, []Integration{hd, uhd}, []Quality{Quality1080p, Quality2160p}, false},
{"force dual overrides role", Request{MediaType: MediaTypeMovie}, "1080p", true, []Integration{hd, uhd}, []Quality{Quality1080p, Quality2160p}, false},
{"4k user but no 4k default", Request{MediaType: MediaTypeMovie}, "2160p", false, []Integration{hd}, []Quality{Quality1080p}, false},
{"no hd default", Request{MediaType: MediaTypeMovie}, "2160p", false, []Integration{uhd}, []Quality{Quality2160p}, false},
{"anime on anime-enabled hd", Request{MediaType: MediaTypeMovie, IsAnime: true}, "1080p", false, []Integration{hdAnime}, []Quality{Quality1080p}, true},
{"empty ceiling hd only", Request{MediaType: MediaTypeMovie}, "", false, []Integration{hd, uhd}, []Quality{Quality1080p}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := routeTargets(tc.req, tc.ceiling, Settings{ForceDualQuality: tc.force}, tc.instances)
if len(got) != len(tc.want) {
t.Fatalf("got %d targets, want %d (%v)", len(got), len(tc.want), got)
}
for i, q := range tc.want {
if got[i].Quality != q {
t.Fatalf("target %d quality = %s, want %s", i, got[i].Quality, q)
}
if got[i].IsAnime != tc.wantAnime {
t.Fatalf("target %d isAnime = %v, want %v", i, got[i].IsAnime, tc.wantAnime)
}
}
})
}
}
+494 -292
View File
@@ -27,30 +27,6 @@ type TMDBExternalIDClient interface {
const externalIDHydrationConcurrency = 4
type MovieFulfillmentAdapter interface {
SubmitMovie(ctx context.Context, req Request, integration Integration) (FulfillmentResult, error)
}
type SeriesFulfillmentAdapter interface {
SubmitSeries(ctx context.Context, req Request, integration Integration) (FulfillmentResult, error)
}
type MovieStatusAdapter interface {
CheckMovieStatus(ctx context.Context, req Request, integration Integration) (FulfillmentStatus, error)
}
type SeriesStatusAdapter interface {
CheckSeriesStatus(ctx context.Context, req Request, integration Integration) (FulfillmentStatus, error)
}
type MovieIntegrationOptionsAdapter interface {
ListMovieIntegrationOptions(ctx context.Context, integration Integration) (*IntegrationOptions, error)
}
type SeriesIntegrationOptionsAdapter interface {
ListSeriesIntegrationOptions(ctx context.Context, integration Integration) (*IntegrationOptions, error)
}
type EntitlementResolver interface {
// MaxPlaybackQuality returns the requester's effective playback-quality
// ceiling (already combining account- and profile-level caps). Empty string
@@ -58,14 +34,20 @@ type EntitlementResolver interface {
MaxPlaybackQuality(ctx context.Context, userID int, profileID string) (string, error)
}
// RequesterIdentityResolver resolves a requesting user id into the identity a
// per-user request_router plugin needs (e.g. Seerr attribution by email).
type RequesterIdentityResolver interface {
ResolveRequester(ctx context.Context, userID int) (email, username string, err error)
}
type Service struct {
store Store
tmdb TMDBClient
presence PresenceResolver
movieAdapter MovieFulfillmentAdapter
seriesAdapter SeriesFulfillmentAdapter
entitlements EntitlementResolver
Now func() time.Time
store Store
tmdb TMDBClient
presence PresenceResolver
router RequestRouterProvider
entitlements EntitlementResolver
requesterIdentity RequesterIdentityResolver
Now func() time.Time
}
type DiscoverySection struct {
@@ -86,13 +68,28 @@ func NewService(store Store, tmdbClient TMDBClient, presence PresenceResolver) *
}
}
func (s *Service) SetFulfillmentAdapters(movie MovieFulfillmentAdapter, series SeriesFulfillmentAdapter) {
s.movieAdapter = movie
s.seriesAdapter = series
}
func (s *Service) SetRouterProvider(p RequestRouterProvider) { s.router = p }
func (s *Service) SetEntitlementResolver(r EntitlementResolver) { s.entitlements = r }
func (s *Service) SetRequesterIdentityResolver(r RequesterIdentityResolver) {
s.requesterIdentity = r
}
// populateRequesterIdentity fills req.RequesterEmail/Username from the resolver.
// Nil resolver or any error leaves them empty (the plugin then behaves as admin).
func (s *Service) populateRequesterIdentity(ctx context.Context, req *Request) {
if s.requesterIdentity == nil || req.RequestedByUserID <= 0 {
return
}
email, username, err := s.requesterIdentity.ResolveRequester(ctx, req.RequestedByUserID)
if err != nil {
slog.WarnContext(ctx, "requests: requester identity resolve failed; attributing to admin", "user_id", req.RequestedByUserID, "error", err)
return
}
req.RequesterEmail, req.RequesterUsername = email, username
}
func (s *Service) requesterCeiling(ctx context.Context, userID int, profileID string) string {
if s.entitlements == nil {
return "" // no resolver -> unlimited (1080p baseline still applies)
@@ -104,6 +101,94 @@ func (s *Service) requesterCeiling(ctx context.Context, userID int, profileID st
return q
}
// 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 {
out := []Quality{Quality1080p}
ceiling := 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
}
// fulfillContext caches the global fulfillment inputs for one reconcile cycle
// (or a single Approve/Retry) so integrations and settings are fetched once
// instead of per request. API keys need no cache here: the repository decrypts
// api_key_ref on read, so Integration.APIKeyRef already holds the literal key.
type fulfillContext struct {
integrations []Integration
settings Settings
}
func (s *Service) newFulfillContext(ctx context.Context) (*fulfillContext, error) {
integrations, err := s.store.ListIntegrations(ctx)
if err != nil {
return nil, err
}
settings, err := s.store.GetSettings(ctx)
if err != nil {
return nil, err
}
return &fulfillContext{integrations: integrations, settings: settings}, nil
}
// resolveRouterConnections turns enabled request_router integrations that serve
// the given media type into ResolvedRouterConnections (api key resolved to
// 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-
// 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
// first plugin. A connection whose api key cannot be resolved (or resolves empty)
// is skipped rather than aborting the whole request — a sibling healthy
// connection can still fulfill it, and an unauthenticated request is never sent.
func (s *Service) resolveRouterConnections(ctx context.Context, fc *fulfillContext, mediaType MediaType) ([]ResolvedRouterConnection, int, string, error) {
var conns []ResolvedRouterConnection
installationID, capabilityID := 0, ""
chosen := false
for _, in := range fc.integrations {
if !eligibleRouterConnection(in, mediaType) {
continue
}
// Contain to the first chosen (installation, capability): a plugin may
// expose more than one request_router capability, and a connection of a
// different capability must never be handed to the chosen one.
if chosen && (*in.InstallationID != installationID || in.CapabilityID != capabilityID) {
continue
}
// in.APIKeyRef was decrypted by the repo on read; empty means unconfigured.
apiKey := strings.TrimSpace(in.APIKeyRef)
if apiKey == "" {
slog.WarnContext(ctx, "requests: skipping router connection with no api key", "connection_id", in.ID)
continue
}
// Lock on the first SUCCESSFULLY resolved connection so a skipped
// bad-key connection never pins the installation/capability.
if !chosen {
installationID, capabilityID, chosen = *in.InstallationID, in.CapabilityID, true
}
conns = append(conns, ResolvedRouterConnection{ID: in.ID, BaseURL: in.BaseURL, APIKey: apiKey, Config: in.PluginConfig})
}
return conns, installationID, capabilityID, nil
}
// 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.
func eligibleRouterConnection(in Integration, mediaType MediaType) bool {
return in.Enabled && in.CapabilityID != "" && in.InstallationID != nil &&
integrationSupportsMediaType(in, mediaType)
}
func (s *Service) Search(ctx context.Context, viewer Viewer, query string, mediaType MediaType, page int) (*MediaPage, error) {
if s == nil || s.store == nil || s.tmdb == nil {
return nil, fmt.Errorf("request service is not configured")
@@ -371,7 +456,7 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create
return nil, err
}
if req.Status == StatusApproved {
return s.submitApprovedRequest(ctx, *req, viewer)
return s.submitApprovedRequest(ctx, *req, viewer, nil)
}
return req, nil
}
@@ -507,7 +592,7 @@ func (s *Service) Approve(ctx context.Context, viewer Viewer, id string) (*Reque
if err != nil {
return nil, err
}
return s.submitApprovedRequest(ctx, *approved, viewer)
return s.submitApprovedRequest(ctx, *approved, viewer, nil)
}
func (s *Service) Decline(ctx context.Context, viewer Viewer, id, reason string) (*Request, error) {
@@ -576,69 +661,16 @@ func (s *Service) Retry(ctx context.Context, viewer Viewer, id string) (*Request
if req.Outcome != OutcomeFailed {
return nil, ErrInvalidState
}
active, err := s.store.SetOutcome(ctx, req.ID, OutcomeActive, viewer, "retry requested")
if _, err := s.store.SetOutcome(ctx, req.ID, OutcomeActive, viewer, "retry requested"); err != nil {
return nil, err
}
// submitApprovedRequest only re-submits qualities lacking a healthy target, so
// it is idempotent; gate it on the approved status it expects.
active, err := s.store.SetStatus(ctx, req.ID, StatusApproved, viewer)
if err != nil {
return nil, err
}
instances, err := s.store.ListIntegrations(ctx)
if err != nil {
return nil, err
}
settings, err := s.store.GetSettings(ctx)
if err != nil {
return nil, err
}
existing, err := s.store.ListTargets(ctx, active.ID)
if err != nil {
return nil, err
}
ceiling := s.requesterCeiling(ctx, active.RequestedByUserID, active.RequestedByProfileID)
planned := routeTargets(*active, ceiling, settings, instances)
if len(planned) == 0 {
return s.markSubmissionFailed(ctx, active.ID, viewer,
fmt.Errorf("no %s instance configured for the requested quality",
integrationKindForMediaType(active.MediaType)))
}
return s.submitPlannedTargets(ctx, *active, planned, existing, viewer)
}
// submitPlannedTargets submits each planned target idempotently against the
// already-recorded targets: a non-failed target for a quality is left alone, a
// failed one is deleted and re-submitted, and a missing one is submitted fresh.
// This is shared by Retry and the reconcile-driven submit so a re-run never
// violates the UNIQUE(request_id, quality) constraint.
func (s *Service) submitPlannedTargets(ctx context.Context, req Request, planned []plannedTarget, existing []Target, actor Viewer) (*Request, error) {
hasOK := make(map[Quality]bool)
for _, t := range existing {
if t.Status != StatusFailed {
hasOK[t.Quality] = true
}
}
latest := &req
for _, pt := range planned {
if hasOK[pt.Quality] {
continue // healthy target already exists for this quality
}
// remove the stale failed target for this quality before re-submitting
for _, t := range existing {
if t.Quality == pt.Quality && t.Status == StatusFailed {
if err := s.store.DeleteTarget(ctx, t.ID); err != nil {
return nil, err
}
}
}
updated, err := s.submitPlannedTarget(ctx, req, pt, actor)
if err != nil {
return nil, err
}
if updated != nil {
latest = updated
}
}
return latest, nil
return s.submitApprovedRequest(ctx, *active, viewer, nil)
}
func (s *Service) ReconcileRequests(ctx context.Context, limit int) (ReconcileResult, error) {
@@ -652,12 +684,16 @@ func (s *Service) ReconcileRequests(ctx context.Context, limit int) (ReconcileRe
if err != nil {
return ReconcileResult{}, err
}
fc, err := s.newFulfillContext(ctx)
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)
change, err := s.reconcileRequest(ctx, *req, fc)
if err != nil {
slog.WarnContext(ctx, "request reconcile failed",
"request_id", req.ID,
@@ -765,14 +801,17 @@ func (s *Service) CreateIntegration(ctx context.Context, viewer Viewer, in Integ
if !viewer.IsAdmin {
return nil, ErrForbidden
}
if err := validateInstance(&in); err != nil {
return nil, err
}
id, err := idgen.NextID()
if err != nil {
return nil, err
}
in.ID = id
if err := validateInstance(&in); err != nil {
return nil, err
}
if err := s.validateViaPlugin(ctx, in); err != nil {
return nil, err
}
return s.store.SaveIntegrationWithDefaults(ctx, in, true)
}
@@ -786,9 +825,86 @@ func (s *Service) UpdateIntegration(ctx context.Context, viewer Viewer, in Integ
if err := validateInstance(&in); err != nil {
return nil, err
}
if err := s.validateViaPlugin(ctx, in); err != nil {
return nil, err
}
return s.store.SaveIntegrationWithDefaults(ctx, in, false)
}
// validateViaPlugin asks the bound request_router plugin to validate the
// connection config on save. Field/form errors are surfaced as *ValidationError
// so the API layer can render them inline.
func (s *Service) validateViaPlugin(ctx context.Context, in Integration) error {
if s.router == nil || in.InstallationID == nil {
return nil
}
// On UPDATE the client omits api_key_ref ("leave blank to keep saved key"),
// so we would otherwise validate against an empty credential. Mirror
// LoadIntegrationOptions's backfill: load the stored row by id and reuse the
// saved (already-decrypted) api key (and BaseURL/PluginConfig if also blank).
// Nil-safe — a brand-new id has no stored row, so just proceed with what the
// body carries.
if strings.TrimSpace(in.APIKeyRef) == "" && strings.TrimSpace(in.ID) != "" {
stored, err := s.store.GetIntegration(ctx, in.ID)
if err != nil && !errors.Is(err, ErrNotFound) {
return err
}
if stored != nil {
// 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) {
return &ValidationError{FieldErrors: map[string]string{"api_key_ref": "re-enter the API key when changing the base URL"}}
}
in.APIKeyRef = stored.APIKeyRef
if strings.TrimSpace(in.BaseURL) == "" {
in.BaseURL = stored.BaseURL
}
if in.PluginConfig == nil {
in.PluginConfig = stored.PluginConfig
}
}
}
// in.APIKeyRef is the decrypted literal (from the body, or backfilled from the
// stored row above).
apiKey := strings.TrimSpace(in.APIKeyRef)
conn := ResolvedRouterConnection{ID: in.ID, BaseURL: in.BaseURL, APIKey: apiKey, Config: in.PluginConfig}
siblings, err := s.siblingConnections(ctx, in)
if err != nil {
return err
}
fe, form, err := s.router.Validate(ctx, *in.InstallationID, in.CapabilityID, conn, siblings)
if err != nil {
return err
}
if len(fe) > 0 || form != "" {
return &ValidationError{FieldErrors: fe, FormError: form}
}
return nil
}
// siblingConnections returns the other connections bound to the same plugin
// installation as `in` (self excluded), carrying only id + config so a plugin
// can enforce cross-connection rules without the host resolving sibling
// credentials.
func (s *Service) siblingConnections(ctx context.Context, in Integration) ([]ResolvedRouterConnection, error) {
if in.InstallationID == nil {
return nil, nil
}
all, err := s.store.ListIntegrations(ctx)
if err != nil {
return nil, err
}
var out []ResolvedRouterConnection
for _, other := range all {
if other.ID == in.ID || other.InstallationID == nil || *other.InstallationID != *in.InstallationID {
continue
}
out = append(out, ResolvedRouterConnection{ID: other.ID, Config: other.PluginConfig})
}
return out, nil
}
func (s *Service) DeleteIntegration(ctx context.Context, viewer Viewer, id string) error {
if !viewer.IsAdmin {
return ErrForbidden
@@ -797,78 +913,68 @@ func (s *Service) DeleteIntegration(ctx context.Context, viewer Viewer, id strin
}
func validateInstance(in *Integration) error {
in.Kind = strings.TrimSpace(in.Kind)
if in.Kind != "radarr" && in.Kind != "sonarr" {
return fmt.Errorf("%w: kind must be radarr or sonarr", ErrInvalidInput)
}
if strings.TrimSpace(in.Name) == "" {
return fmt.Errorf("%w: name is required", ErrInvalidInput)
}
if in.IsDefault && in.Is4K {
return fmt.Errorf("%w: the HD default cannot be a 4K server", ErrInvalidInput)
// capability_id carries the capability SUB-ID ("arr"/"seerr"), not the type:
// the host resolves the plugin via requireCapability("request_router.v1", id),
// which keys on (type, id), so storing the type "request_router.v1" here
// resolves nothing. Matches the scan_source/metadata convention
// (autoscan_sources.capability_id = "arr"). The bound plugin's Validate RPC is
// the authority on whether the sub-id names a real capability.
in.CapabilityID = strings.TrimSpace(in.CapabilityID)
if in.CapabilityID == "" {
return fmt.Errorf("%w: capability_id is required", ErrInvalidInput)
}
if in.IsDefault4K && !in.Is4K {
return fmt.Errorf("%w: the 4K default must be a 4K server", ErrInvalidInput)
if in.InstallationID == nil {
return fmt.Errorf("%w: installation_id is required", ErrInvalidInput)
}
// The is_default/is_4k/is_default_4k cross-field consistency check is owned by
// the request_router plugin's Validate RPC, which surfaces it as an inline
// field error (better UX than a generic host 400). See validateViaPlugin.
return nil
}
func (s *Service) LoadIntegrationOptions(ctx context.Context, viewer Viewer, integration Integration) (*IntegrationOptions, error) {
func (s *Service) LoadIntegrationOptions(ctx context.Context, viewer Viewer, integration Integration) (map[string][]RouterOption, error) {
if !viewer.IsAdmin {
return nil, ErrForbidden
}
// For a saved instance the request body carries only the path id (no kind and
// often no creds), so resolve the saved row by id and backfill what the body
// omitted. This makes "Test connection" reuse the correct per-instance key
// (each kind can have multiple instances) instead of borrowing a sibling's.
// For a saved instance the request body carries only the path id (no creds and
// often no plugin wiring), so resolve the saved row by id and backfill what the
// body omitted. This makes "Test connection" reuse the correct per-instance key
// (each plugin can have multiple connections) instead of borrowing a sibling's.
if id := strings.TrimSpace(integration.ID); id != "" && id != "new" {
stored, err := s.store.GetIntegration(ctx, id)
if err != nil && !errors.Is(err, ErrNotFound) {
return nil, err
}
if stored != nil {
if strings.TrimSpace(integration.Kind) == "" {
integration.Kind = stored.Kind
}
submittedBaseURL := strings.TrimSpace(integration.BaseURL)
storedBaseURL := strings.TrimSpace(stored.BaseURL)
if strings.TrimSpace(integration.BaseURL) == "" {
integration.BaseURL = stored.BaseURL
}
if strings.TrimSpace(integration.APIKeyRef) == "" {
if strings.TrimSpace(integration.APIKeyRef) == "" && (submittedBaseURL == "" || submittedBaseURL == storedBaseURL) {
integration.APIKeyRef = stored.APIKeyRef
}
if strings.TrimSpace(integration.CapabilityID) == "" {
integration.CapabilityID = stored.CapabilityID
}
if integration.InstallationID == nil {
integration.InstallationID = stored.InstallationID
}
if integration.PluginConfig == nil {
integration.PluginConfig = stored.PluginConfig
}
}
}
normalized, err := normalizeIntegrationConnection(integration)
if err != nil {
return nil, err
}
if strings.TrimSpace(normalized.BaseURL) == "" {
return nil, fmt.Errorf("%w: base_url is required", ErrInvalidInput)
}
if strings.TrimSpace(normalized.APIKeyRef) == "" {
return nil, fmt.Errorf("%w: api_key_ref is required", ErrInvalidInput)
}
// APIKeyRef is already the literal key: a saved instance was decrypted by
// GetIntegration above; a new instance carries the key the admin just typed.
resolved := normalized
switch resolved.Kind {
case "radarr":
adapter, ok := s.movieAdapter.(MovieIntegrationOptionsAdapter)
if !ok {
return nil, fmt.Errorf("request radarr integration options are not configured")
}
return adapter.ListMovieIntegrationOptions(ctx, resolved)
case "sonarr":
adapter, ok := s.seriesAdapter.(SeriesIntegrationOptionsAdapter)
if !ok {
return nil, fmt.Errorf("request sonarr integration options are not configured")
}
return adapter.ListSeriesIntegrationOptions(ctx, resolved)
default:
return nil, fmt.Errorf("%w: invalid integration kind", ErrInvalidInput)
apiKey := strings.TrimSpace(integration.APIKeyRef)
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}
return s.router.ListConfigOptions(ctx, *integration.InstallationID, integration.CapabilityID, conn)
}
func (s *Service) EffectivePolicy(ctx context.Context, userID int) (EffectivePolicy, error) {
@@ -1139,105 +1245,247 @@ func (s *Service) detectRequestAnime(ctx context.Context, mediaType MediaType, t
return detectAnime(detail.KeywordIDs)
}
// integrationConfigured reports whether a fulfillment backend exists for the
// media type, gating auto-approval (pending vs approved). It uses the same
// router-connection selection as resolveRouterConnections — an enabled
// request_router.v1 connection with an installation — and additionally honors a
// connection's declared media-type support so a movie request only auto-approves
// when a router connection supporting "movie" exists.
func (s *Service) integrationConfigured(ctx context.Context, mediaType MediaType) (bool, error) {
instances, err := s.store.ListIntegrations(ctx)
if err != nil {
return false, err
}
kind := integrationKindForMediaType(mediaType)
for _, in := range instances {
if in.Kind == kind && in.Enabled && (in.IsDefault || in.IsDefault4K) && integrationIsConfigured(in) {
if eligibleRouterConnection(in, mediaType) &&
strings.TrimSpace(in.BaseURL) != "" && strings.TrimSpace(in.APIKeyRef) != "" {
return true, nil
}
}
return false, nil
}
func (s *Service) submitApprovedRequest(ctx context.Context, req Request, actor Viewer) (*Request, error) {
// 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 {
if len(in.SupportedMediaTypes) == 0 {
return true
}
for _, mt := range in.SupportedMediaTypes {
if mt == string(mediaType) {
return true
}
}
return false
}
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
}
instances, err := s.store.ListIntegrations(ctx)
if s.router == nil {
return s.markSubmissionFailed(ctx, req.ID, actor, fmt.Errorf("no fulfillment backend configured"))
}
if fc == nil {
built, err := s.newFulfillContext(ctx)
if err != nil {
return nil, err
}
fc = built
}
conns, installationID, capabilityID, err := s.resolveRouterConnections(ctx, fc, req.MediaType)
if err != nil {
return nil, err
}
settings, err := s.store.GetSettings(ctx)
if err != nil {
return nil, err
if len(conns) == 0 {
// Distinguish "no backend at all" from the migration breakage where an
// existing connection row exists but its installation_id is NULL (the row
// predates the plugin install and was never re-bound).
msg := "no fulfillment backend configured"
for _, in := range fc.integrations {
if in.Enabled && in.CapabilityID != "" && in.InstallationID == nil {
msg = "request backend connection is not bound to a plugin installation; re-save it in admin"
break
}
}
return s.markSubmissionFailed(ctx, req.ID, actor, errors.New(msg))
}
ceiling := s.requesterCeiling(ctx, req.RequestedByUserID, req.RequestedByProfileID)
planned := routeTargets(req, ceiling, settings, instances)
if len(planned) == 0 {
return s.markSubmissionFailed(ctx, req.ID, actor,
fmt.Errorf("no %s instance configured for the requested quality",
integrationKindForMediaType(req.MediaType)))
}
// Reconcile can re-run submit while the request is still 'approved'; skip
// qualities that already have a live target and replace failed ones so the
// UNIQUE(request_id, quality) constraint is never violated.
existing, err := s.store.ListTargets(ctx, req.ID)
if err != nil {
return nil, err
}
return s.submitPlannedTargets(ctx, req, planned, existing, actor)
}
// submitPlannedTarget creates a target row, submits it to the adapter, and
// records the result (queued or failed) via UpdateTargetStatus (which recomputes
// the request aggregate). Returns the latest request snapshot.
func (s *Service) submitPlannedTarget(ctx context.Context, req Request, pt plannedTarget, actor Viewer) (*Request, error) {
resolved := resolveInstance(pt)
// The repo already decrypted the stored key; a blank one means unconfigured.
if strings.TrimSpace(resolved.APIKeyRef) == "" {
return s.createFailedTarget(ctx, req, pt, resolved, "missing api key", actor)
healthy := map[Quality]bool{}
for _, t := range existing {
if t.Status != StatusFailed {
healthy[t.Quality] = true
}
}
target, err := s.store.CreateTarget(ctx, Target{
RequestID: req.ID, IntegrationID: resolved.ID, IntegrationKind: resolved.Kind,
Quality: pt.Quality, IsAnime: pt.IsAnime, Status: StatusQueued,
})
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 {
return &req, nil
}
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
}
}
}
}
}
s.populateRequesterIdentity(ctx, &req)
targets, msg, err := s.router.Fulfill(ctx, installationID, capabilityID, req, want, conns)
if err != nil {
return nil, err
}
result, serr := s.submitTarget(ctx, req, resolved)
if serr != nil {
return s.store.UpdateTargetStatus(ctx, target.ID, StatusFailed, "", "", serr.Error(), actor)
if len(targets) == 0 {
if msg == "" {
msg = "fulfillment backend created no targets"
}
return s.markSubmissionFailed(ctx, req.ID, actor, errors.New(msg))
}
return s.store.UpdateTargetStatus(ctx, target.ID, StatusQueued,
result.ExternalID, result.ExternalStatus, "", actor)
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.
validQuality := map[Quality]bool{Quality1080p: true, Quality2160p: true}
validStatus := map[Status]bool{StatusQueued: true, StatusDownloading: true, StatusCompleted: true, StatusFailed: true}
returned := map[Quality]bool{}
for _, rt := range targets {
if !validQuality[rt.Quality] {
slog.WarnContext(ctx, "requests: plugin returned unknown quality; skipping", "request_id", req.ID, "quality", string(rt.Quality))
continue
}
if returned[rt.Quality] || healthy[rt.Quality] {
continue // dup-in-batch, or a healthy target already exists for this quality
}
if rt.ConnectionID != "" {
if _, ok := connKind[rt.ConnectionID]; !ok {
slog.WarnContext(ctx, "requests: plugin returned unknown connection id; skipping target", "request_id", req.ID, "connection_id", rt.ConnectionID)
continue
}
}
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
}
status := rt.Status
if status == "" || !validStatus[status] {
status = StatusQueued // coerce unknown/empty status to the DB-valid default
}
updated, err := s.store.UpdateTargetStatus(ctx, created.ID, status, rt.ExternalID, rt.ExternalStatus, rt.Message, actor)
if err != nil {
return nil, err
}
if updated != nil {
latest = updated
}
}
// Any wanted quality the plugin did not fulfill is recorded as a failed target
// rather than silently dropped, so it stays visible and Retry re-attempts it
// (a failed target is not "healthy").
const noTargetMsg = "fulfillment backend returned no target for this quality"
for _, q := range want {
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
}
}
return latest, nil
}
func (s *Service) createFailedTarget(ctx context.Context, req Request, pt plannedTarget, resolved Integration, msg string, actor Viewer) (*Request, error) {
target, err := s.store.CreateTarget(ctx, Target{
RequestID: req.ID, IntegrationID: resolved.ID, IntegrationKind: resolved.Kind,
Quality: pt.Quality, IsAnime: pt.IsAnime, Status: StatusFailed, LastError: msg,
})
if err != nil {
return nil, err
// connectionKindByID maps each connection id to its plugin-declared service kind
// (e.g. "radarr"/"sonarr") from PluginConfig["service_kind"], for the
// integration_kind column on persisted targets. Missing kinds map to "".
func connectionKindByID(conns []ResolvedRouterConnection) map[string]string {
out := make(map[string]string, len(conns))
for _, c := range conns {
out[c.ID] = ""
if c.Config != nil {
if kind, ok := c.Config["service_kind"].(string); ok {
out[c.ID] = kind
}
}
}
return s.store.UpdateTargetStatus(ctx, target.ID, StatusFailed, "", "", msg, actor)
return out
}
// submitTarget calls the correct adapter with a per-target Request copy. The
// adapters read root folder/quality/tags (and Sonarr series_type) from the
// resolved Integration.
func (s *Service) submitTarget(ctx context.Context, req Request, resolved Integration) (FulfillmentResult, error) {
switch req.MediaType {
case MediaTypeMovie:
if s.movieAdapter == nil {
return FulfillmentResult{}, fmt.Errorf("no movie adapter configured")
func filterUnconfiguredOptionalQualities(qualities []Quality, conns []ResolvedRouterConnection) []Quality {
out := make([]Quality, 0, len(qualities))
for _, q := range qualities {
if q == Quality2160p && !routerQualityConfigured(q, conns) {
continue
}
return s.movieAdapter.SubmitMovie(ctx, req, resolved)
case MediaTypeSeries:
if s.seriesAdapter == nil {
return FulfillmentResult{}, fmt.Errorf("no series adapter configured")
}
return s.seriesAdapter.SubmitSeries(ctx, req, resolved)
default:
return FulfillmentResult{}, fmt.Errorf("unsupported media type %q", req.MediaType)
out = append(out, q)
}
return out
}
func routerQualityConfigured(q Quality, conns []ResolvedRouterConnection) bool {
usesTieredDefaults := false
for _, conn := range conns {
if conn.Config == nil {
continue
}
if hasRouterQualityKey(conn.Config) {
usesTieredDefaults = true
}
if q == Quality2160p && boolConfig(conn.Config, "is_default_4k") {
return true
}
}
// Generic request_router implementations may not expose arr-style HD/4K
// default flags. In that case, preserve the host's requested qualities and
// let the plugin decide what it can fulfill.
return !usesTieredDefaults
}
func hasRouterQualityKey(config map[string]any) bool {
for _, key := range []string{"is_default", "is_default_4k", "is_4k"} {
if _, ok := config[key]; ok {
return true
}
}
return false
}
func boolConfig(config map[string]any, key string) bool {
v, ok := config[key]
if !ok {
return false
}
b, ok := v.(bool)
return ok && b
}
func (s *Service) markSubmissionFailed(ctx context.Context, requestID string, actor Viewer, submitErr error) (*Request, error) {
@@ -1259,7 +1507,7 @@ const (
reconcileFailed reconcileChange = "failed"
)
func (s *Service) reconcileRequest(ctx context.Context, req Request) (reconcileChange, error) {
func (s *Service) reconcileRequest(ctx context.Context, req Request, fc *fulfillContext) (reconcileChange, error) {
completed, err := s.requestAvailable(ctx, req)
if err != nil {
return reconcileUnchanged, err
@@ -1286,7 +1534,7 @@ func (s *Service) reconcileRequest(ctx context.Context, req Request) (reconcileC
}
if req.Status == StatusApproved {
updated, err := s.submitApprovedRequest(ctx, req, Viewer{})
updated, err := s.submitApprovedRequest(ctx, req, Viewer{}, fc)
if err != nil {
return reconcileUnchanged, err
}
@@ -1304,41 +1552,51 @@ func (s *Service) reconcileRequest(ctx context.Context, req Request) (reconcileC
if err != nil {
return reconcileUnchanged, err
}
instances, err := s.store.ListIntegrations(ctx)
if s.router == nil {
return reconcileUnchanged, nil
}
conns, installationID, capabilityID, err := s.resolveRouterConnections(ctx, fc, req.MediaType)
if err != nil {
return reconcileUnchanged, err
}
byID := make(map[string]Integration, len(instances))
for _, in := range instances {
byID[in.ID] = in
if len(conns) == 0 {
return reconcileUnchanged, nil
}
change := reconcileUnchanged
var refs []RouterTargetRef
for _, t := range targets {
if t.Status == StatusCompleted || t.Status == StatusFailed {
continue
}
in, ok := byID[t.IntegrationID]
if !ok {
refs = append(refs, RouterTargetRef{Quality: t.Quality, ConnectionID: t.IntegrationID, ExternalID: t.ExternalID})
}
if len(refs) == 0 {
return reconcileUnchanged, nil
}
statuses, err := s.router.CheckStatus(ctx, installationID, capabilityID, req, refs, conns)
if err != nil {
return reconcileUnchanged, err
}
change := reconcileUnchanged
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]
break
}
}
if target == nil || target.Status == StatusCompleted || target.Status == StatusFailed {
continue
}
// in.APIKeyRef was decrypted by ListIntegrations; skip if unconfigured.
if strings.TrimSpace(in.APIKeyRef) == "" {
newStatus := st.Status
if newStatus == "" || newStatus == target.Status {
continue
}
probe := req
probe.ExternalID = t.ExternalID
st, err := s.checkFulfillmentStatus(ctx, probe, in)
if err != nil {
continue
}
if st.Status == "" && st.Outcome == "" {
continue
}
newStatus := targetStatusFromFulfillment(st)
if newStatus == t.Status {
continue
}
if _, err := s.store.UpdateTargetStatus(ctx, t.ID, newStatus, st.ExternalID, st.ExternalStatus, "", Viewer{}); err != nil {
if _, err := s.store.UpdateTargetStatus(ctx, target.ID, newStatus, "", st.ExternalStatus, st.Message, Viewer{}); err != nil {
return reconcileUnchanged, err
}
switch newStatus {
@@ -1357,20 +1615,6 @@ func (s *Service) reconcileRequest(ctx context.Context, req Request) (reconcileC
return change, nil
}
func targetStatusFromFulfillment(st FulfillmentStatus) Status {
switch st.Status {
case StatusCompleted:
return StatusCompleted
case StatusDownloading:
return StatusDownloading
default:
if st.Outcome == OutcomeFailed {
return StatusFailed
}
return StatusQueued
}
}
// hasLiveTargets reports whether the request has any non-terminal (queued or
// downloading) fulfillment target.
func (s *Service) hasLiveTargets(ctx context.Context, requestID string) (bool, error) {
@@ -1394,33 +1638,6 @@ func (s *Service) requestAvailable(ctx context.Context, req Request) (bool, erro
return matches[req.TMDBID].Available, nil
}
func (s *Service) checkFulfillmentStatus(ctx context.Context, req Request, resolved Integration) (FulfillmentStatus, error) {
switch req.MediaType {
case MediaTypeMovie:
checker, ok := s.movieAdapter.(MovieStatusAdapter)
if !ok {
return FulfillmentStatus{}, nil
}
return checker.CheckMovieStatus(ctx, req, resolved)
case MediaTypeSeries:
checker, ok := s.seriesAdapter.(SeriesStatusAdapter)
if !ok {
return FulfillmentStatus{}, nil
}
return checker.CheckSeriesStatus(ctx, req, resolved)
default:
return FulfillmentStatus{}, nil
}
}
func integrationIsConfigured(integration Integration) bool {
return integration.Enabled &&
strings.TrimSpace(integration.BaseURL) != "" &&
strings.TrimSpace(integration.APIKeyRef) != "" &&
strings.TrimSpace(integration.RootFolder) != "" &&
integration.QualityProfileID != nil
}
func (s *Service) now() time.Time {
if s.Now != nil {
return s.Now()
@@ -1529,21 +1746,6 @@ func normalizeUserLimit(limit UserLimit) (UserLimit, error) {
return limit, nil
}
func normalizeIntegrationConnection(integration Integration) (Integration, error) {
integration.Kind = strings.ToLower(strings.TrimSpace(integration.Kind))
switch integration.Kind {
case "radarr", "sonarr":
default:
return Integration{}, fmt.Errorf("%w: invalid integration kind", ErrInvalidInput)
}
integration.BaseURL = strings.TrimRight(strings.TrimSpace(integration.BaseURL), "/")
integration.APIKeyRef = strings.TrimSpace(integration.APIKeyRef)
if integration.Options == nil {
integration.Options = map[string]any{}
}
return integration, nil
}
func normalizeMediaType(mediaType MediaType) (MediaType, error) {
switch MediaType(strings.ToLower(strings.TrimSpace(string(mediaType)))) {
case MediaTypeMovie:

Some files were not shown because too many files have changed in this diff Show More