* 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>
1815 lines
56 KiB
Go
1815 lines
56 KiB
Go
package requests
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/access"
|
|
"github.com/Silo-Server/silo-server/internal/idgen"
|
|
"github.com/Silo-Server/silo-server/internal/metadata/tmdb"
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
type TMDBClient interface {
|
|
SearchMedia(ctx context.Context, mediaType, query string, page int) (*tmdb.MediaPage, error)
|
|
DiscoverSection(ctx context.Context, section string, page int) (*tmdb.MediaPage, error)
|
|
GetMediaDetail(ctx context.Context, mediaType string, id int) (*tmdb.MediaDetail, error)
|
|
DiscoverPage(ctx context.Context, mediaType string, params tmdb.DiscoverParams, page int) (*tmdb.MediaPage, error)
|
|
}
|
|
|
|
type TMDBExternalIDClient interface {
|
|
GetExternalIDs(ctx context.Context, mediaType string, id int) (*tmdb.ExternalIDs, error)
|
|
}
|
|
|
|
const externalIDHydrationConcurrency = 4
|
|
|
|
type EntitlementResolver interface {
|
|
// MaxPlaybackQuality returns the requester's effective playback-quality
|
|
// ceiling (already combining account- and profile-level caps). Empty string
|
|
// means "no cap".
|
|
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
|
|
router RequestRouterProvider
|
|
entitlements EntitlementResolver
|
|
requesterIdentity RequesterIdentityResolver
|
|
Now func() time.Time
|
|
}
|
|
|
|
type DiscoverySection struct {
|
|
Key string `json:"key"`
|
|
Title string `json:"title"`
|
|
Page int `json:"page"`
|
|
TotalPages int `json:"total_pages"`
|
|
TotalResults int `json:"total_results"`
|
|
Results []MediaResult `json:"results"`
|
|
}
|
|
|
|
func NewService(store Store, tmdbClient TMDBClient, presence PresenceResolver) *Service {
|
|
return &Service{
|
|
store: store,
|
|
tmdb: tmdbClient,
|
|
presence: presence,
|
|
Now: func() time.Time { return time.Now().UTC() },
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
q, err := s.entitlements.MaxPlaybackQuality(ctx, userID, profileID)
|
|
if err != nil {
|
|
return access.PlaybackQualityStandard // fail safe: HD only
|
|
}
|
|
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")
|
|
}
|
|
if err := s.ensureRequestsEnabled(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
mediaType, err := normalizeSearchMediaType(mediaType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
query = strings.TrimSpace(query)
|
|
if query == "" {
|
|
return nil, fmt.Errorf("%w: query is required", ErrInvalidInput)
|
|
}
|
|
raw, err := s.tmdb.SearchMedia(ctx, string(mediaType), query, page)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.enrichPage(ctx, viewer, raw)
|
|
}
|
|
|
|
func (s *Service) Discover(ctx context.Context, viewer Viewer, section string, page int) (*DiscoverySection, error) {
|
|
if s == nil || s.store == nil || s.tmdb == nil {
|
|
return nil, fmt.Errorf("request service is not configured")
|
|
}
|
|
if err := s.ensureRequestsEnabled(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
section = strings.TrimSpace(section)
|
|
if _, ok := discoverySectionTitles[section]; !ok {
|
|
return nil, fmt.Errorf("%w: invalid discovery section", ErrInvalidInput)
|
|
}
|
|
raw, err := s.tmdb.DiscoverSection(ctx, section, page)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
enriched, err := s.enrichPage(ctx, viewer, raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &DiscoverySection{
|
|
Key: section,
|
|
Title: discoverySectionTitles[section],
|
|
Page: enriched.Page,
|
|
TotalPages: enriched.TotalPages,
|
|
TotalResults: enriched.TotalResults,
|
|
Results: enriched.Results,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) DiscoverAll(ctx context.Context, viewer Viewer) ([]DiscoverySection, error) {
|
|
if s == nil || s.store == nil || s.tmdb == nil {
|
|
return nil, fmt.Errorf("request service is not configured")
|
|
}
|
|
if err := s.ensureRequestsEnabled(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
sections := make([]DiscoverySection, len(discoverySectionOrder))
|
|
group, gctx := errgroup.WithContext(ctx)
|
|
group.SetLimit(externalIDHydrationConcurrency)
|
|
for i, key := range discoverySectionOrder {
|
|
i, key := i, key
|
|
group.Go(func() error {
|
|
section, err := s.Discover(gctx, viewer, key, 1)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sections[i] = *section
|
|
return nil
|
|
})
|
|
}
|
|
if err := group.Wait(); err != nil {
|
|
return nil, err
|
|
}
|
|
return sections, nil
|
|
}
|
|
|
|
// GetDetail fetches a TMDB detail payload and overlays the same availability /
|
|
// request-state signals used by search and discovery. Recommendations carry
|
|
// their own per-item state so the detail page can render them as request cards.
|
|
func (s *Service) GetDetail(ctx context.Context, viewer Viewer, mediaType MediaType, tmdbID int) (*MediaDetail, error) {
|
|
if s == nil || s.store == nil || s.tmdb == nil {
|
|
return nil, fmt.Errorf("request service is not configured")
|
|
}
|
|
if err := s.ensureRequestsEnabled(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
mediaType, err := normalizeMediaType(mediaType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if tmdbID <= 0 {
|
|
return nil, fmt.Errorf("%w: tmdb id is required", ErrInvalidInput)
|
|
}
|
|
|
|
raw, err := s.tmdb.GetMediaDetail(ctx, string(mediaType), tmdbID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if raw == nil {
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
policy, err := s.EffectivePolicy(ctx, viewer.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
primaryPresence, err := s.lookupAvailable(ctx, mediaType, []int{raw.ID})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
primaryMatch := primaryPresence[raw.ID]
|
|
primaryRequests, err := s.store.ListActiveByTMDB(ctx, mediaType, []int{raw.ID})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
detail := &MediaDetail{
|
|
MediaType: mediaType,
|
|
TMDBID: raw.ID,
|
|
IMDbID: raw.IMDbID,
|
|
Title: raw.Title,
|
|
OriginalTitle: raw.OriginalTitle,
|
|
Tagline: raw.Tagline,
|
|
Overview: raw.Overview,
|
|
PosterPath: raw.PosterPath,
|
|
BackdropPath: raw.BackdropPath,
|
|
ReleaseDate: raw.ReleaseDate,
|
|
Year: raw.Year,
|
|
Runtime: raw.Runtime,
|
|
Genres: raw.Genres,
|
|
VoteAverage: raw.VoteAverage,
|
|
VoteCount: raw.VoteCount,
|
|
Status: raw.Status,
|
|
Homepage: raw.Homepage,
|
|
ContentRating: raw.ContentRating,
|
|
ProductionCompanies: raw.ProductionCompanies,
|
|
NumberOfSeasons: raw.NumberOfSeasons,
|
|
NumberOfEpisodes: raw.NumberOfEpisodes,
|
|
FirstAirDate: raw.FirstAirDate,
|
|
LastAirDate: raw.LastAirDate,
|
|
Networks: raw.Networks,
|
|
Director: raw.Director,
|
|
Creators: raw.Creators,
|
|
Availability: availabilityValue(primaryMatch.Available),
|
|
LibraryContentID: primaryMatch.ContentID,
|
|
Request: requestStateFor(viewer, policy, primaryMatch.Available, primaryRequests[raw.ID]),
|
|
}
|
|
if raw.TVDBID > 0 {
|
|
tvdb := raw.TVDBID
|
|
detail.TVDBID = &tvdb
|
|
}
|
|
if len(raw.Cast) > 0 {
|
|
detail.Cast = make([]MediaCastMember, 0, len(raw.Cast))
|
|
for _, member := range raw.Cast {
|
|
detail.Cast = append(detail.Cast, MediaCastMember{
|
|
Name: member.Name,
|
|
Character: member.Character,
|
|
ProfilePath: member.ProfilePath,
|
|
Order: member.Order,
|
|
})
|
|
}
|
|
}
|
|
|
|
if len(raw.Recommendations) > 0 {
|
|
recPage := &tmdb.MediaPage{Results: raw.Recommendations}
|
|
enriched, err := s.enrichPage(ctx, viewer, recPage)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
detail.Recommendations = enriched.Results
|
|
}
|
|
|
|
return detail, nil
|
|
}
|
|
|
|
func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input CreateRequestInput) (*Request, error) {
|
|
if err := validateViewer(viewer); err != nil {
|
|
return nil, err
|
|
}
|
|
if s == nil || s.store == nil {
|
|
return nil, fmt.Errorf("request service is not configured")
|
|
}
|
|
if err := s.ensureRequestsEnabled(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
normalized, err := normalizeCreateInput(input)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.enrichExternalIDs(ctx, &normalized)
|
|
isAnime := s.detectRequestAnime(ctx, normalized.MediaType, normalized.TMDBID)
|
|
|
|
matches, err := s.lookupPresence(ctx, normalized.MediaType, []PresenceCandidate{createPresenceCandidate(normalized)})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if matches[normalized.TMDBID].Available {
|
|
return nil, ErrAlreadyAvailable
|
|
}
|
|
|
|
active, err := s.store.ListActiveByTMDB(ctx, normalized.MediaType, []int{normalized.TMDBID})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if active[normalized.TMDBID] != nil {
|
|
return nil, ErrAlreadyRequested
|
|
}
|
|
|
|
// Re-requesting media that previously failed (e.g., transient integration
|
|
// error) should not leave stale failed rows behind in user/admin lists.
|
|
if _, err := s.store.DeleteFailedByTMDB(ctx, normalized.MediaType, normalized.TMDBID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
policy, err := s.EffectivePolicy(ctx, viewer.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateCreatePolicy(policy); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
id, err := idgen.NextID()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
status := StatusPending
|
|
if policy.AutoApprove {
|
|
configured, err := s.integrationConfigured(ctx, normalized.MediaType)
|
|
if err == nil && configured {
|
|
status = StatusApproved
|
|
}
|
|
}
|
|
record := CreateRequestRecord{
|
|
ID: id,
|
|
Input: normalized,
|
|
Status: status,
|
|
Outcome: OutcomeActive,
|
|
IsAnime: isAnime,
|
|
Requester: viewer,
|
|
Now: s.now(),
|
|
}
|
|
if !policy.Unlimited {
|
|
record.Quota = &QuotaCheck{
|
|
UserID: viewer.UserID,
|
|
WindowStart: policy.WindowStart,
|
|
MaxRequests: policy.MaxRequests,
|
|
}
|
|
}
|
|
req, err := s.store.CreateRequest(ctx, record)
|
|
if err != nil {
|
|
if errors.Is(err, ErrAlreadyRequested) {
|
|
return nil, ErrAlreadyRequested
|
|
}
|
|
if errors.Is(err, ErrQuotaExceeded) {
|
|
return nil, QuotaError{
|
|
Used: policy.MaxRequests,
|
|
Limit: policy.MaxRequests,
|
|
WindowDays: policy.WindowDays,
|
|
}
|
|
}
|
|
return nil, err
|
|
}
|
|
if req.Status == StatusApproved {
|
|
return s.submitApprovedRequest(ctx, *req, viewer, nil)
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
func (s *Service) ListMine(ctx context.Context, viewer Viewer, filter ListFilter) ([]*Request, error) {
|
|
if viewer.UserID == 0 {
|
|
return nil, ErrForbidden
|
|
}
|
|
if err := s.ensureRequestsEnabled(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
reqs, err := s.store.ListMine(ctx, viewer.UserID, normalizeListFilter(filter))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.attachTargets(ctx, reqs...); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.attachLibraryContent(ctx, reqs...); err != nil {
|
|
return nil, err
|
|
}
|
|
return reqs, nil
|
|
}
|
|
|
|
func (s *Service) ListAdmin(ctx context.Context, viewer Viewer, filter ListFilter) ([]*Request, error) {
|
|
if !viewer.IsAdmin {
|
|
return nil, ErrForbidden
|
|
}
|
|
reqs, err := s.store.ListAdmin(ctx, normalizeListFilter(filter))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.attachTargets(ctx, reqs...); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.attachLibraryContent(ctx, reqs...); err != nil {
|
|
return nil, err
|
|
}
|
|
return reqs, nil
|
|
}
|
|
|
|
// attachTargets loads and attaches the per-instance fulfillment targets for each
|
|
// request so callers (admin queue, detail view) can surface multi-target status.
|
|
func (s *Service) attachTargets(ctx context.Context, reqs ...*Request) error {
|
|
for _, r := range reqs {
|
|
if r == nil {
|
|
continue
|
|
}
|
|
targets, err := s.store.ListTargets(ctx, r.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
r.Targets = targets
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) attachLibraryContent(ctx context.Context, reqs ...*Request) error {
|
|
if s == nil || s.presence == nil || len(reqs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
type requestKey struct {
|
|
mediaType MediaType
|
|
tmdbID int
|
|
}
|
|
|
|
candidatesByType := map[MediaType][]PresenceCandidate{}
|
|
requestsByKey := map[requestKey][]*Request{}
|
|
seen := map[requestKey]bool{}
|
|
for _, req := range reqs {
|
|
if req == nil || req.TMDBID <= 0 {
|
|
continue
|
|
}
|
|
key := requestKey{mediaType: req.MediaType, tmdbID: req.TMDBID}
|
|
requestsByKey[key] = append(requestsByKey[key], req)
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
candidatesByType[req.MediaType] = append(candidatesByType[req.MediaType], requestPresenceCandidate(*req))
|
|
}
|
|
|
|
for mediaType, candidates := range candidatesByType {
|
|
matches, err := s.lookupPresence(ctx, mediaType, candidates)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for tmdbID, match := range matches {
|
|
if !match.Available || strings.TrimSpace(match.ContentID) == "" {
|
|
continue
|
|
}
|
|
for _, req := range requestsByKey[requestKey{mediaType: mediaType, tmdbID: tmdbID}] {
|
|
req.LibraryContentID = match.ContentID
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) GetRequest(ctx context.Context, viewer Viewer, id string) (*Request, error) {
|
|
if err := s.ensureRequestsEnabled(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
req, err := s.store.GetRequest(ctx, strings.TrimSpace(id))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !viewer.IsAdmin && req.RequestedByUserID != viewer.UserID {
|
|
return nil, ErrForbidden
|
|
}
|
|
if err := s.attachTargets(ctx, req); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.attachLibraryContent(ctx, req); err != nil {
|
|
return nil, err
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
func (s *Service) Approve(ctx context.Context, viewer Viewer, id string) (*Request, error) {
|
|
if !viewer.IsAdmin {
|
|
return nil, ErrForbidden
|
|
}
|
|
req, err := s.store.GetRequest(ctx, strings.TrimSpace(id))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if req.Outcome != OutcomeActive || req.Status != StatusPending {
|
|
return nil, ErrInvalidState
|
|
}
|
|
approved, err := s.store.SetStatus(ctx, req.ID, StatusApproved, viewer)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.submitApprovedRequest(ctx, *approved, viewer, nil)
|
|
}
|
|
|
|
func (s *Service) Decline(ctx context.Context, viewer Viewer, id, reason string) (*Request, error) {
|
|
if !viewer.IsAdmin {
|
|
return nil, ErrForbidden
|
|
}
|
|
req, err := s.store.GetRequest(ctx, strings.TrimSpace(id))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Approved requests are pending submission by the reconciler; declining
|
|
// while submission may be in flight risks a divergent external state.
|
|
if req.Outcome != OutcomeActive ||
|
|
req.Status == StatusApproved ||
|
|
req.Status == StatusCompleted ||
|
|
req.Status == StatusQueued ||
|
|
req.Status == StatusDownloading ||
|
|
strings.TrimSpace(req.ExternalID) != "" ||
|
|
strings.TrimSpace(req.IntegrationKind) != "" {
|
|
return nil, ErrInvalidState
|
|
}
|
|
return s.store.SetOutcome(ctx, req.ID, OutcomeDeclined, viewer, reason)
|
|
}
|
|
|
|
// Cancel withdraws a request that has not yet been submitted to a downstream
|
|
// integration. Owners can cancel their own pending requests; admins can cancel
|
|
// any active request that has not entered the fulfillment pipeline. Requests
|
|
// already approved, queued, downloading, or completed cannot be cancelled —
|
|
// callers should decline (admin) or wait for completion in those cases.
|
|
func (s *Service) Cancel(ctx context.Context, viewer Viewer, id, reason string) (*Request, error) {
|
|
if viewer.UserID == 0 {
|
|
return nil, ErrForbidden
|
|
}
|
|
if !viewer.IsAdmin {
|
|
if err := s.ensureRequestsEnabled(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
req, err := s.store.GetRequest(ctx, strings.TrimSpace(id))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !viewer.IsAdmin && req.RequestedByUserID != viewer.UserID {
|
|
return nil, ErrForbidden
|
|
}
|
|
if req.Outcome != OutcomeActive ||
|
|
req.Status == StatusApproved ||
|
|
req.Status == StatusCompleted ||
|
|
req.Status == StatusQueued ||
|
|
req.Status == StatusDownloading ||
|
|
strings.TrimSpace(req.ExternalID) != "" ||
|
|
strings.TrimSpace(req.IntegrationKind) != "" {
|
|
return nil, ErrInvalidState
|
|
}
|
|
return s.store.SetOutcome(ctx, req.ID, OutcomeCancelled, viewer, reason)
|
|
}
|
|
|
|
func (s *Service) Retry(ctx context.Context, viewer Viewer, id string) (*Request, error) {
|
|
if !viewer.IsAdmin {
|
|
return nil, ErrForbidden
|
|
}
|
|
req, err := s.store.GetRequest(ctx, strings.TrimSpace(id))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if req.Outcome != OutcomeFailed {
|
|
return nil, ErrInvalidState
|
|
}
|
|
if _, err := s.store.SetOutcome(ctx, req.ID, OutcomeActive, viewer, "retry requested"); err != nil {
|
|
return nil, err
|
|
}
|
|
// submitApprovedRequest only re-submits qualities lacking a healthy target, so
|
|
// it is idempotent; gate it on the approved status it expects.
|
|
active, err := s.store.SetStatus(ctx, req.ID, StatusApproved, viewer)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.submitApprovedRequest(ctx, *active, viewer, nil)
|
|
}
|
|
|
|
func (s *Service) ReconcileRequests(ctx context.Context, limit int) (ReconcileResult, error) {
|
|
if s == nil || s.store == nil {
|
|
return ReconcileResult{}, fmt.Errorf("request service is not configured")
|
|
}
|
|
if limit <= 0 || limit > 500 {
|
|
limit = 100
|
|
}
|
|
candidates, err := s.store.ListReconciliationCandidates(ctx, limit)
|
|
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, fc)
|
|
if err != nil {
|
|
slog.WarnContext(ctx, "request reconcile failed",
|
|
"request_id", req.ID,
|
|
"media_type", req.MediaType,
|
|
"tmdb_id", req.TMDBID,
|
|
"status", req.Status,
|
|
"integration_kind", req.IntegrationKind,
|
|
"err", err,
|
|
)
|
|
result.Errors++
|
|
continue
|
|
}
|
|
switch change {
|
|
case reconcileSubmitted:
|
|
result.Submitted++
|
|
case reconcileDownloading:
|
|
result.Downloading++
|
|
case reconcileCompleted:
|
|
result.Completed++
|
|
case reconcileFailed:
|
|
result.Failed++
|
|
case reconcileSkipped:
|
|
result.Skipped++
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) GetSettings(ctx context.Context, viewer Viewer) (Settings, error) {
|
|
if !viewer.IsAdmin {
|
|
return Settings{}, ErrForbidden
|
|
}
|
|
return s.store.GetSettings(ctx)
|
|
}
|
|
|
|
func (s *Service) GetFeatureStatus(ctx context.Context, _ Viewer) (FeatureStatus, error) {
|
|
settings, err := s.store.GetSettings(ctx)
|
|
if err != nil {
|
|
return FeatureStatus{}, err
|
|
}
|
|
return FeatureStatus{RequestsEnabled: settings.RequestsEnabled}, nil
|
|
}
|
|
|
|
func (s *Service) ensureRequestsEnabled(ctx context.Context) error {
|
|
settings, err := s.store.GetSettings(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !settings.RequestsEnabled {
|
|
return ErrRequestsDisabled
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) UpdateSettings(ctx context.Context, viewer Viewer, settings Settings) (Settings, error) {
|
|
if !viewer.IsAdmin {
|
|
return Settings{}, ErrForbidden
|
|
}
|
|
if settings.GlobalMaxRequests < 0 || settings.GlobalWindowDays <= 0 {
|
|
return Settings{}, fmt.Errorf("%w: invalid request settings", ErrInvalidInput)
|
|
}
|
|
return s.store.UpdateSettings(ctx, settings)
|
|
}
|
|
|
|
func (s *Service) GetUserLimit(ctx context.Context, viewer Viewer, userID int) (*UserLimit, error) {
|
|
if !viewer.IsAdmin {
|
|
return nil, ErrForbidden
|
|
}
|
|
if userID <= 0 {
|
|
return nil, fmt.Errorf("%w: invalid user id", ErrInvalidInput)
|
|
}
|
|
limit, err := s.store.GetUserLimit(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if limit != nil {
|
|
return limit, nil
|
|
}
|
|
return &UserLimit{
|
|
UserID: userID,
|
|
LimitMode: LimitModeInherit,
|
|
ApprovalMode: ApprovalModeInherit,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) UpsertUserLimit(ctx context.Context, viewer Viewer, limit UserLimit) (*UserLimit, error) {
|
|
if !viewer.IsAdmin {
|
|
return nil, ErrForbidden
|
|
}
|
|
normalized, err := normalizeUserLimit(limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.store.UpsertUserLimit(ctx, normalized)
|
|
}
|
|
|
|
func (s *Service) ListIntegrations(ctx context.Context, viewer Viewer) ([]Integration, error) {
|
|
if !viewer.IsAdmin {
|
|
return nil, ErrForbidden
|
|
}
|
|
return s.store.ListIntegrations(ctx)
|
|
}
|
|
|
|
func (s *Service) CreateIntegration(ctx context.Context, viewer Viewer, in Integration) (*Integration, error) {
|
|
if !viewer.IsAdmin {
|
|
return nil, ErrForbidden
|
|
}
|
|
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)
|
|
}
|
|
|
|
func (s *Service) UpdateIntegration(ctx context.Context, viewer Viewer, in Integration) (*Integration, error) {
|
|
if !viewer.IsAdmin {
|
|
return nil, ErrForbidden
|
|
}
|
|
if strings.TrimSpace(in.ID) == "" {
|
|
return nil, fmt.Errorf("%w: integration id required", ErrInvalidInput)
|
|
}
|
|
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
|
|
}
|
|
return s.store.DeleteIntegration(ctx, strings.TrimSpace(id))
|
|
}
|
|
|
|
func validateInstance(in *Integration) error {
|
|
if strings.TrimSpace(in.Name) == "" {
|
|
return fmt.Errorf("%w: name is required", 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.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) (map[string][]RouterOption, error) {
|
|
if !viewer.IsAdmin {
|
|
return nil, ErrForbidden
|
|
}
|
|
// 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 {
|
|
submittedBaseURL := strings.TrimSpace(integration.BaseURL)
|
|
storedBaseURL := strings.TrimSpace(stored.BaseURL)
|
|
if strings.TrimSpace(integration.BaseURL) == "" {
|
|
integration.BaseURL = stored.BaseURL
|
|
}
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
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) {
|
|
settings, err := s.store.GetSettings(ctx)
|
|
if err != nil {
|
|
return EffectivePolicy{}, err
|
|
}
|
|
limit, err := s.store.GetUserLimit(ctx, userID)
|
|
if err != nil {
|
|
return EffectivePolicy{}, err
|
|
}
|
|
|
|
policy := EffectivePolicy{
|
|
RequestsEnabled: settings.RequestsEnabled,
|
|
MaxRequests: settings.GlobalMaxRequests,
|
|
WindowDays: settings.GlobalWindowDays,
|
|
AutoApprove: settings.GlobalAutoApprovalEnabled,
|
|
}
|
|
if policy.WindowDays <= 0 {
|
|
policy.WindowDays = 7
|
|
}
|
|
if limit != nil {
|
|
switch limit.LimitMode {
|
|
case LimitModeBlocked:
|
|
policy.Blocked = true
|
|
case LimitModeUnlimited:
|
|
policy.Unlimited = true
|
|
case LimitModeCustom:
|
|
if limit.MaxRequests != nil {
|
|
policy.MaxRequests = *limit.MaxRequests
|
|
}
|
|
if limit.WindowDays != nil && *limit.WindowDays > 0 {
|
|
policy.WindowDays = *limit.WindowDays
|
|
}
|
|
}
|
|
switch limit.ApprovalMode {
|
|
case ApprovalModeBlocked:
|
|
policy.Blocked = true
|
|
case ApprovalModeManual:
|
|
policy.AutoApprove = false
|
|
case ApprovalModeAuto:
|
|
policy.AutoApprove = true
|
|
}
|
|
}
|
|
|
|
policy.WindowStart = s.now().AddDate(0, 0, -policy.WindowDays)
|
|
if !policy.Unlimited {
|
|
used, err := s.store.CountUserRequestsSince(ctx, userID, policy.WindowStart)
|
|
if err != nil {
|
|
return EffectivePolicy{}, err
|
|
}
|
|
policy.Used = used
|
|
policy.Remaining = policy.MaxRequests - used
|
|
if policy.Remaining < 0 {
|
|
policy.Remaining = 0
|
|
}
|
|
}
|
|
return policy, nil
|
|
}
|
|
|
|
func (s *Service) enrichPage(ctx context.Context, viewer Viewer, raw *tmdb.MediaPage) (*MediaPage, error) {
|
|
if raw == nil {
|
|
return &MediaPage{Results: []MediaResult{}}, nil
|
|
}
|
|
policy, err := s.EffectivePolicy(ctx, viewer.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
idsByType := map[MediaType][]int{}
|
|
for _, item := range raw.Results {
|
|
mediaType, err := normalizeMediaType(MediaType(item.MediaType))
|
|
if err != nil || item.ID <= 0 {
|
|
continue
|
|
}
|
|
idsByType[mediaType] = append(idsByType[mediaType], item.ID)
|
|
}
|
|
|
|
available := map[MediaType]map[int]PresenceMatch{}
|
|
active := map[MediaType]map[int]*Request{}
|
|
for mediaType, ids := range idsByType {
|
|
presence, err := s.lookupAvailable(ctx, mediaType, ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
available[mediaType] = presence
|
|
requests, err := s.store.ListActiveByTMDB(ctx, mediaType, ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
active[mediaType] = requests
|
|
}
|
|
|
|
out := &MediaPage{
|
|
Page: raw.Page,
|
|
TotalPages: raw.TotalPages,
|
|
TotalResults: raw.TotalResults,
|
|
Results: make([]MediaResult, 0, len(raw.Results)),
|
|
}
|
|
for _, item := range raw.Results {
|
|
mediaType, err := normalizeMediaType(MediaType(item.MediaType))
|
|
if err != nil || item.ID <= 0 {
|
|
continue
|
|
}
|
|
match := available[mediaType][item.ID]
|
|
activeRequest := active[mediaType][item.ID]
|
|
out.Results = append(out.Results, MediaResult{
|
|
MediaType: mediaType,
|
|
TMDBID: item.ID,
|
|
Title: item.Title,
|
|
Year: item.Year,
|
|
Overview: item.Overview,
|
|
PosterPath: item.PosterPath,
|
|
BackdropPath: item.BackdropPath,
|
|
ReleaseDate: item.ReleaseDate,
|
|
Popularity: item.Popularity,
|
|
VoteAverage: item.VoteAverage,
|
|
Availability: availabilityValue(match.Available),
|
|
LibraryContentID: match.ContentID,
|
|
Request: requestStateFor(viewer, policy, match.Available, activeRequest),
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Service) lookupPresence(ctx context.Context, mediaType MediaType, candidates []PresenceCandidate) (map[int]PresenceMatch, error) {
|
|
if s.presence == nil {
|
|
return map[int]PresenceMatch{}, nil
|
|
}
|
|
return s.presence.Lookup(ctx, mediaType, candidates)
|
|
}
|
|
|
|
func requestPresenceCandidate(req Request) PresenceCandidate {
|
|
candidate := PresenceCandidate{
|
|
TMDBID: req.TMDBID,
|
|
IMDbID: strings.TrimSpace(req.IMDbID),
|
|
}
|
|
if req.TVDBID != nil && *req.TVDBID > 0 {
|
|
tvdbID := *req.TVDBID
|
|
candidate.TVDBID = &tvdbID
|
|
}
|
|
return candidate
|
|
}
|
|
|
|
func createPresenceCandidate(input CreateRequestInput) PresenceCandidate {
|
|
candidate := PresenceCandidate{
|
|
TMDBID: input.TMDBID,
|
|
IMDbID: strings.TrimSpace(input.IMDbID),
|
|
}
|
|
if input.TVDBID != nil && *input.TVDBID > 0 {
|
|
tvdbID := *input.TVDBID
|
|
candidate.TVDBID = &tvdbID
|
|
}
|
|
return candidate
|
|
}
|
|
|
|
func (s *Service) hydratePresenceCandidate(ctx context.Context, mediaType MediaType, candidate PresenceCandidate) PresenceCandidate {
|
|
if candidate.TMDBID <= 0 {
|
|
return candidate
|
|
}
|
|
client, ok := s.tmdb.(TMDBExternalIDClient)
|
|
if !ok {
|
|
return candidate
|
|
}
|
|
externalIDs, err := client.GetExternalIDs(ctx, tmdbMediaType(mediaType), candidate.TMDBID)
|
|
if err != nil || externalIDs == nil {
|
|
return candidate
|
|
}
|
|
if candidate.IMDbID == "" {
|
|
candidate.IMDbID = strings.TrimSpace(externalIDs.IMDbID)
|
|
}
|
|
if candidate.TVDBID == nil && externalIDs.TVDBID > 0 {
|
|
tvdbID := externalIDs.TVDBID
|
|
candidate.TVDBID = &tvdbID
|
|
}
|
|
return candidate
|
|
}
|
|
|
|
func (s *Service) hydratePresenceCandidates(ctx context.Context, mediaType MediaType, candidates []PresenceCandidate) []PresenceCandidate {
|
|
if len(candidates) == 0 {
|
|
return candidates
|
|
}
|
|
if _, ok := s.tmdb.(TMDBExternalIDClient); !ok {
|
|
return candidates
|
|
}
|
|
|
|
hydrated := append([]PresenceCandidate(nil), candidates...)
|
|
if externalIDHydrationConcurrency <= 1 {
|
|
for i := range hydrated {
|
|
if ctx.Err() != nil {
|
|
return hydrated
|
|
}
|
|
hydrated[i] = s.hydratePresenceCandidate(ctx, mediaType, hydrated[i])
|
|
}
|
|
return hydrated
|
|
}
|
|
|
|
group, groupCtx := errgroup.WithContext(ctx)
|
|
group.SetLimit(externalIDHydrationConcurrency)
|
|
for i := range hydrated {
|
|
if groupCtx.Err() != nil {
|
|
break
|
|
}
|
|
i := i
|
|
group.Go(func() error {
|
|
if err := groupCtx.Err(); err != nil {
|
|
return err
|
|
}
|
|
hydrated[i] = s.hydratePresenceCandidate(groupCtx, mediaType, hydrated[i])
|
|
return nil
|
|
})
|
|
}
|
|
_ = group.Wait()
|
|
return hydrated
|
|
}
|
|
|
|
func tmdbMediaType(mediaType MediaType) string {
|
|
if mediaType == MediaTypeSeries {
|
|
return "tv"
|
|
}
|
|
return "movie"
|
|
}
|
|
|
|
func (s *Service) lookupAvailable(ctx context.Context, mediaType MediaType, ids []int) (map[int]PresenceMatch, error) {
|
|
if s.presence == nil {
|
|
return map[int]PresenceMatch{}, nil
|
|
}
|
|
candidates := make([]PresenceCandidate, 0, len(ids))
|
|
for _, id := range ids {
|
|
if id > 0 {
|
|
candidates = append(candidates, PresenceCandidate{TMDBID: id})
|
|
}
|
|
}
|
|
candidates = s.hydratePresenceCandidates(ctx, mediaType, candidates)
|
|
matches, err := s.lookupPresence(ctx, mediaType, candidates)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return matches, nil
|
|
}
|
|
|
|
func (s *Service) enrichExternalIDs(ctx context.Context, input *CreateRequestInput) {
|
|
if input == nil {
|
|
return
|
|
}
|
|
client, ok := s.tmdb.(TMDBExternalIDClient)
|
|
if !ok {
|
|
return
|
|
}
|
|
externalIDs, err := client.GetExternalIDs(ctx, tmdbMediaType(input.MediaType), input.TMDBID)
|
|
if err != nil || externalIDs == nil {
|
|
return
|
|
}
|
|
if input.IMDbID == "" {
|
|
input.IMDbID = strings.TrimSpace(externalIDs.IMDbID)
|
|
}
|
|
if input.TVDBID == nil && externalIDs.TVDBID > 0 {
|
|
tvdbID := externalIDs.TVDBID
|
|
input.TVDBID = &tvdbID
|
|
}
|
|
}
|
|
|
|
func (s *Service) detectRequestAnime(ctx context.Context, mediaType MediaType, tmdbID int) bool {
|
|
detail, err := s.tmdb.GetMediaDetail(ctx, tmdbMediaType(mediaType), tmdbID)
|
|
if err != nil || detail == nil {
|
|
return false
|
|
}
|
|
return detectAnime(detail.KeywordIDs)
|
|
}
|
|
|
|
// integrationConfigured reports whether a fulfillment backend exists for the
|
|
// media type, gating auto-approval (pending vs approved). It uses the same
|
|
// router-connection selection as resolveRouterConnections — an enabled
|
|
// request_router.v1 connection with an installation — and additionally honors a
|
|
// connection's declared media-type support so a movie request only auto-approves
|
|
// when a router connection supporting "movie" exists.
|
|
func (s *Service) integrationConfigured(ctx context.Context, mediaType MediaType) (bool, error) {
|
|
instances, err := s.store.ListIntegrations(ctx)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
for _, in := range instances {
|
|
if eligibleRouterConnection(in, mediaType) &&
|
|
strings.TrimSpace(in.BaseURL) != "" && strings.TrimSpace(in.APIKeyRef) != "" {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// integrationSupportsMediaType reports whether a router connection serves the
|
|
// given media type. An empty SupportedMediaTypes is treated as "supports all".
|
|
func integrationSupportsMediaType(in Integration, mediaType MediaType) bool {
|
|
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
|
|
}
|
|
if s.router == nil {
|
|
return s.markSubmissionFailed(ctx, req.ID, actor, fmt.Errorf("no fulfillment backend configured"))
|
|
}
|
|
if fc == nil {
|
|
built, err := s.newFulfillContext(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
fc = built
|
|
}
|
|
conns, installationID, capabilityID, err := s.resolveRouterConnections(ctx, fc, req.MediaType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(conns) == 0 {
|
|
// Distinguish "no backend at all" from the migration breakage where an
|
|
// existing connection row exists but its installation_id is NULL (the row
|
|
// predates the plugin install and was never re-bound).
|
|
msg := "no fulfillment backend configured"
|
|
for _, in := range fc.integrations {
|
|
if in.Enabled && in.CapabilityID != "" && in.InstallationID == nil {
|
|
msg = "request backend connection is not bound to a plugin installation; re-save it in admin"
|
|
break
|
|
}
|
|
}
|
|
return s.markSubmissionFailed(ctx, req.ID, actor, errors.New(msg))
|
|
}
|
|
existing, err := s.store.ListTargets(ctx, req.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
healthy := map[Quality]bool{}
|
|
for _, t := range existing {
|
|
if t.Status != StatusFailed {
|
|
healthy[t.Quality] = true
|
|
}
|
|
}
|
|
allowed := s.allowedQualities(ctx, req, fc.settings)
|
|
if !fc.settings.ForceDualQuality {
|
|
allowed = filterUnconfiguredOptionalQualities(allowed, conns)
|
|
}
|
|
var want []Quality
|
|
for _, q := range allowed {
|
|
if !healthy[q] {
|
|
want = append(want, q)
|
|
}
|
|
}
|
|
if len(want) == 0 {
|
|
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
|
|
}
|
|
if len(targets) == 0 {
|
|
if msg == "" {
|
|
msg = "fulfillment backend created no targets"
|
|
}
|
|
return s.markSubmissionFailed(ctx, req.ID, actor, errors.New(msg))
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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 out
|
|
}
|
|
|
|
func filterUnconfiguredOptionalQualities(qualities []Quality, conns []ResolvedRouterConnection) []Quality {
|
|
out := make([]Quality, 0, len(qualities))
|
|
for _, q := range qualities {
|
|
if q == Quality2160p && !routerQualityConfigured(q, conns) {
|
|
continue
|
|
}
|
|
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) {
|
|
failed, err := s.store.SetOutcome(ctx, requestID, OutcomeFailed, actor, submitErr.Error())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("submit request failed: %w; mark failed: %v", submitErr, err)
|
|
}
|
|
return failed, nil
|
|
}
|
|
|
|
type reconcileChange string
|
|
|
|
const (
|
|
reconcileUnchanged reconcileChange = "unchanged"
|
|
reconcileSkipped reconcileChange = "skipped"
|
|
reconcileSubmitted reconcileChange = "submitted"
|
|
reconcileDownloading reconcileChange = "downloading"
|
|
reconcileCompleted reconcileChange = "completed"
|
|
reconcileFailed reconcileChange = "failed"
|
|
)
|
|
|
|
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
|
|
}
|
|
if completed {
|
|
// The presence check is quality-agnostic (TMDB id only), so it must not
|
|
// force-complete a request whose targets are still in flight — that would
|
|
// orphan in-progress downloads. Only take the shortcut for legacy/no-live
|
|
// -target requests; otherwise let per-target reconcile + aggregate drive
|
|
// completion.
|
|
hasLiveTargets, err := s.hasLiveTargets(ctx, req.ID)
|
|
if err != nil {
|
|
return reconcileUnchanged, err
|
|
}
|
|
if !hasLiveTargets {
|
|
if req.Status == StatusCompleted {
|
|
return reconcileUnchanged, nil
|
|
}
|
|
if _, err := s.store.SetStatus(ctx, req.ID, StatusCompleted, Viewer{}); err != nil {
|
|
return reconcileUnchanged, err
|
|
}
|
|
return reconcileCompleted, nil
|
|
}
|
|
}
|
|
|
|
if req.Status == StatusApproved {
|
|
updated, err := s.submitApprovedRequest(ctx, req, Viewer{}, fc)
|
|
if err != nil {
|
|
return reconcileUnchanged, err
|
|
}
|
|
switch {
|
|
case updated.Outcome == OutcomeFailed:
|
|
return reconcileFailed, nil
|
|
case updated.Status == StatusQueued:
|
|
return reconcileSubmitted, nil
|
|
default:
|
|
return reconcileSkipped, nil
|
|
}
|
|
}
|
|
|
|
targets, err := s.store.ListTargets(ctx, req.ID)
|
|
if err != nil {
|
|
return reconcileUnchanged, err
|
|
}
|
|
if s.router == nil {
|
|
return reconcileUnchanged, nil
|
|
}
|
|
conns, installationID, capabilityID, err := s.resolveRouterConnections(ctx, fc, req.MediaType)
|
|
if err != nil {
|
|
return reconcileUnchanged, err
|
|
}
|
|
if len(conns) == 0 {
|
|
return reconcileUnchanged, nil
|
|
}
|
|
|
|
var refs []RouterTargetRef
|
|
for _, t := range targets {
|
|
if t.Status == StatusCompleted || t.Status == StatusFailed {
|
|
continue
|
|
}
|
|
refs = append(refs, RouterTargetRef{Quality: t.Quality, ConnectionID: t.IntegrationID, ExternalID: t.ExternalID})
|
|
}
|
|
if len(refs) == 0 {
|
|
return reconcileUnchanged, nil
|
|
}
|
|
|
|
statuses, err := s.router.CheckStatus(ctx, installationID, capabilityID, req, refs, conns)
|
|
if err != nil {
|
|
return reconcileUnchanged, err
|
|
}
|
|
|
|
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
|
|
}
|
|
newStatus := st.Status
|
|
if newStatus == "" || newStatus == target.Status {
|
|
continue
|
|
}
|
|
if _, err := s.store.UpdateTargetStatus(ctx, target.ID, newStatus, "", st.ExternalStatus, st.Message, Viewer{}); err != nil {
|
|
return reconcileUnchanged, err
|
|
}
|
|
switch newStatus {
|
|
case StatusCompleted:
|
|
change = reconcileCompleted
|
|
case StatusDownloading:
|
|
if change == reconcileUnchanged {
|
|
change = reconcileDownloading
|
|
}
|
|
case StatusFailed:
|
|
if change == reconcileUnchanged {
|
|
change = reconcileFailed
|
|
}
|
|
}
|
|
}
|
|
return change, nil
|
|
}
|
|
|
|
// 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) {
|
|
targets, err := s.store.ListTargets(ctx, requestID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
for _, t := range targets {
|
|
if t.Status == StatusQueued || t.Status == StatusDownloading {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func (s *Service) requestAvailable(ctx context.Context, req Request) (bool, error) {
|
|
matches, err := s.lookupPresence(ctx, req.MediaType, []PresenceCandidate{requestPresenceCandidate(req)})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return matches[req.TMDBID].Available, nil
|
|
}
|
|
|
|
func (s *Service) now() time.Time {
|
|
if s.Now != nil {
|
|
return s.Now()
|
|
}
|
|
return time.Now().UTC()
|
|
}
|
|
|
|
func requestStateFor(viewer Viewer, policy EffectivePolicy, available bool, req *Request) RequestState {
|
|
if req != nil {
|
|
state := RequestState{
|
|
Status: req.Status,
|
|
Requestable: false,
|
|
Reason: "already_requested",
|
|
}
|
|
if viewer.IsAdmin || req.RequestedByUserID == viewer.UserID {
|
|
state.RequestID = req.ID
|
|
}
|
|
return state
|
|
}
|
|
switch {
|
|
case available:
|
|
return RequestState{Requestable: false, Reason: "already_available"}
|
|
case !policy.RequestsEnabled:
|
|
return RequestState{Requestable: false, Reason: "requests_disabled"}
|
|
case policy.Blocked:
|
|
return RequestState{Requestable: false, Reason: "blocked"}
|
|
case !policy.Unlimited && policy.Used >= policy.MaxRequests:
|
|
return RequestState{Requestable: false, Reason: "quota_exceeded"}
|
|
default:
|
|
return RequestState{Requestable: true}
|
|
}
|
|
}
|
|
|
|
func validateCreatePolicy(policy EffectivePolicy) error {
|
|
switch {
|
|
case !policy.RequestsEnabled:
|
|
return ErrRequestsDisabled
|
|
case policy.Blocked:
|
|
return ErrUserBlocked
|
|
case !policy.Unlimited && policy.Used >= policy.MaxRequests:
|
|
return QuotaError{Used: policy.Used, Limit: policy.MaxRequests, WindowDays: policy.WindowDays}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func validateViewer(viewer Viewer) error {
|
|
if viewer.UserID == 0 {
|
|
return ErrForbidden
|
|
}
|
|
if strings.TrimSpace(viewer.ProfileID) == "" {
|
|
return fmt.Errorf("%w: profile is required", ErrInvalidInput)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeCreateInput(input CreateRequestInput) (CreateRequestInput, error) {
|
|
mediaType, err := normalizeMediaType(input.MediaType)
|
|
if err != nil {
|
|
return CreateRequestInput{}, err
|
|
}
|
|
input.MediaType = mediaType
|
|
input.Title = strings.TrimSpace(input.Title)
|
|
input.IMDbID = strings.TrimSpace(input.IMDbID)
|
|
input.Overview = strings.TrimSpace(input.Overview)
|
|
input.PosterPath = strings.TrimSpace(input.PosterPath)
|
|
input.BackdropPath = strings.TrimSpace(input.BackdropPath)
|
|
if input.TMDBID <= 0 {
|
|
return CreateRequestInput{}, fmt.Errorf("%w: tmdb_id is required", ErrInvalidInput)
|
|
}
|
|
if input.Title == "" {
|
|
return CreateRequestInput{}, fmt.Errorf("%w: title is required", ErrInvalidInput)
|
|
}
|
|
return input, nil
|
|
}
|
|
|
|
func normalizeUserLimit(limit UserLimit) (UserLimit, error) {
|
|
if limit.UserID <= 0 {
|
|
return UserLimit{}, fmt.Errorf("%w: invalid user id", ErrInvalidInput)
|
|
}
|
|
switch limit.LimitMode {
|
|
case "", LimitModeInherit:
|
|
limit.LimitMode = LimitModeInherit
|
|
limit.MaxRequests = nil
|
|
limit.WindowDays = nil
|
|
case LimitModeCustom:
|
|
if limit.MaxRequests == nil || limit.WindowDays == nil || *limit.MaxRequests < 0 || *limit.WindowDays <= 0 {
|
|
return UserLimit{}, fmt.Errorf("%w: custom limits require max_requests >= 0 and window_days > 0", ErrInvalidInput)
|
|
}
|
|
case LimitModeUnlimited:
|
|
limit.MaxRequests = nil
|
|
limit.WindowDays = nil
|
|
case LimitModeBlocked:
|
|
limit.MaxRequests = nil
|
|
limit.WindowDays = nil
|
|
default:
|
|
return UserLimit{}, fmt.Errorf("%w: invalid limit mode", ErrInvalidInput)
|
|
}
|
|
switch limit.ApprovalMode {
|
|
case "", ApprovalModeInherit:
|
|
limit.ApprovalMode = ApprovalModeInherit
|
|
case ApprovalModeManual, ApprovalModeAuto, ApprovalModeBlocked:
|
|
default:
|
|
return UserLimit{}, fmt.Errorf("%w: invalid approval mode", ErrInvalidInput)
|
|
}
|
|
return limit, nil
|
|
}
|
|
|
|
func normalizeMediaType(mediaType MediaType) (MediaType, error) {
|
|
switch MediaType(strings.ToLower(strings.TrimSpace(string(mediaType)))) {
|
|
case MediaTypeMovie:
|
|
return MediaTypeMovie, nil
|
|
case MediaTypeSeries, "tv":
|
|
return MediaTypeSeries, nil
|
|
default:
|
|
return "", ErrInvalidMediaType
|
|
}
|
|
}
|
|
|
|
func normalizeSearchMediaType(mediaType MediaType) (MediaType, error) {
|
|
switch MediaType(strings.ToLower(strings.TrimSpace(string(mediaType)))) {
|
|
case "", MediaTypeAll:
|
|
return MediaTypeAll, nil
|
|
case MediaTypeMovie:
|
|
return MediaTypeMovie, nil
|
|
case MediaTypeSeries, "tv":
|
|
return MediaTypeSeries, nil
|
|
default:
|
|
return "", ErrInvalidMediaType
|
|
}
|
|
}
|
|
|
|
const (
|
|
defaultRequestListLimit = 50
|
|
maxRequestListLimit = 100
|
|
)
|
|
|
|
func normalizeListFilter(filter ListFilter) ListFilter {
|
|
if filter.Limit <= 0 {
|
|
filter.Limit = defaultRequestListLimit
|
|
}
|
|
if filter.Limit > maxRequestListLimit {
|
|
filter.Limit = maxRequestListLimit
|
|
}
|
|
if filter.Offset < 0 {
|
|
filter.Offset = 0
|
|
}
|
|
return filter
|
|
}
|
|
|
|
func availabilityValue(available bool) Availability {
|
|
if available {
|
|
return AvailabilityAvailable
|
|
}
|
|
return AvailabilityMissing
|
|
}
|
|
|
|
var discoverySectionOrder = []string{
|
|
"trending_movies",
|
|
"trending_series",
|
|
"popular_movies",
|
|
"popular_series",
|
|
"upcoming_movies",
|
|
"on_air_series",
|
|
}
|
|
|
|
var discoverySectionTitles = map[string]string{
|
|
"trending_movies": "Trending Movies",
|
|
"trending_series": "Trending Series",
|
|
"popular_movies": "Popular Movies",
|
|
"popular_series": "Popular Series",
|
|
"upcoming_movies": "Upcoming Movies",
|
|
"on_air_series": "On Air Series",
|
|
}
|