* 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>
681 lines
22 KiB
Go
681 lines
22 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
|
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
|
|
)
|
|
|
|
type RequestService interface {
|
|
Search(ctx context.Context, viewer mediarequests.Viewer, query string, mediaType mediarequests.MediaType, page int) (*mediarequests.MediaPage, error)
|
|
Discover(ctx context.Context, viewer mediarequests.Viewer, section string, page int) (*mediarequests.DiscoverySection, error)
|
|
DiscoverAll(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverySection, error)
|
|
GetDetail(ctx context.Context, viewer mediarequests.Viewer, mediaType mediarequests.MediaType, tmdbID int) (*mediarequests.MediaDetail, error)
|
|
CreateRequest(ctx context.Context, viewer mediarequests.Viewer, input mediarequests.CreateRequestInput) (*mediarequests.Request, error)
|
|
ListMine(ctx context.Context, viewer mediarequests.Viewer, filter mediarequests.ListFilter) ([]*mediarequests.Request, error)
|
|
ListAdmin(ctx context.Context, viewer mediarequests.Viewer, filter mediarequests.ListFilter) ([]*mediarequests.Request, error)
|
|
GetRequest(ctx context.Context, viewer mediarequests.Viewer, id string) (*mediarequests.Request, error)
|
|
Approve(ctx context.Context, viewer mediarequests.Viewer, id string) (*mediarequests.Request, error)
|
|
Decline(ctx context.Context, viewer mediarequests.Viewer, id, reason string) (*mediarequests.Request, error)
|
|
Cancel(ctx context.Context, viewer mediarequests.Viewer, id, reason string) (*mediarequests.Request, error)
|
|
Retry(ctx context.Context, viewer mediarequests.Viewer, id string) (*mediarequests.Request, error)
|
|
GetFeatureStatus(ctx context.Context, viewer mediarequests.Viewer) (mediarequests.FeatureStatus, error)
|
|
GetSettings(ctx context.Context, viewer mediarequests.Viewer) (mediarequests.Settings, error)
|
|
UpdateSettings(ctx context.Context, viewer mediarequests.Viewer, settings mediarequests.Settings) (mediarequests.Settings, error)
|
|
GetUserLimit(ctx context.Context, viewer mediarequests.Viewer, userID int) (*mediarequests.UserLimit, error)
|
|
UpsertUserLimit(ctx context.Context, viewer mediarequests.Viewer, limit mediarequests.UserLimit) (*mediarequests.UserLimit, error)
|
|
ListIntegrations(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.Integration, error)
|
|
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) (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)
|
|
ListGenres(ctx context.Context, viewer mediarequests.Viewer) ([]mediarequests.DiscoverBrandCard, error)
|
|
BrowseStudio(ctx context.Context, viewer mediarequests.Viewer, slug, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error)
|
|
BrowseNetwork(ctx context.Context, viewer mediarequests.Viewer, slug, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error)
|
|
BrowseGenre(ctx context.Context, viewer mediarequests.Viewer, slug string, mediaType mediarequests.MediaType, sort string, page int) (*mediarequests.DiscoverBrowseResponse, error)
|
|
}
|
|
|
|
type RequestsHandler struct {
|
|
service RequestService
|
|
}
|
|
|
|
func NewRequestsHandler(service RequestService) *RequestsHandler {
|
|
return &RequestsHandler{service: service}
|
|
}
|
|
|
|
func (h *RequestsHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
|
|
viewer, ok := requestViewer(w, r, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
page, ok := parsePositiveIntQuery(w, r, "page", 1)
|
|
if !ok {
|
|
return
|
|
}
|
|
result, err := h.service.Search(
|
|
r.Context(),
|
|
viewer,
|
|
r.URL.Query().Get("q"),
|
|
mediarequests.MediaType(r.URL.Query().Get("media_type")),
|
|
page,
|
|
)
|
|
if err != nil {
|
|
writeRequestServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
func (h *RequestsHandler) HandleDiscover(w http.ResponseWriter, r *http.Request) {
|
|
viewer, ok := requestViewer(w, r, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
sections, err := h.service.DiscoverAll(r.Context(), viewer)
|
|
if err != nil {
|
|
writeRequestServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, struct {
|
|
Sections []mediarequests.DiscoverySection `json:"sections"`
|
|
}{Sections: sections})
|
|
}
|
|
|
|
func (h *RequestsHandler) HandleDiscoverSection(w http.ResponseWriter, r *http.Request) {
|
|
viewer, ok := requestViewer(w, r, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
page, ok := parsePositiveIntQuery(w, r, "page", 1)
|
|
if !ok {
|
|
return
|
|
}
|
|
section, err := h.service.Discover(r.Context(), viewer, chi.URLParam(r, "section"), page)
|
|
if err != nil {
|
|
writeRequestServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, section)
|
|
}
|
|
|
|
func (h *RequestsHandler) HandleListStudios(w http.ResponseWriter, r *http.Request) {
|
|
viewer, ok := requestViewer(w, r, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
studios, err := h.service.ListStudios(r.Context(), viewer)
|
|
if err != nil {
|
|
writeRequestServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, struct {
|
|
Studios []mediarequests.DiscoverBrandCard `json:"studios"`
|
|
}{Studios: studios})
|
|
}
|
|
|
|
func (h *RequestsHandler) HandleListNetworks(w http.ResponseWriter, r *http.Request) {
|
|
viewer, ok := requestViewer(w, r, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
networks, err := h.service.ListNetworks(r.Context(), viewer)
|
|
if err != nil {
|
|
writeRequestServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, struct {
|
|
Networks []mediarequests.DiscoverBrandCard `json:"networks"`
|
|
}{Networks: networks})
|
|
}
|
|
|
|
func (h *RequestsHandler) HandleListGenres(w http.ResponseWriter, r *http.Request) {
|
|
viewer, ok := requestViewer(w, r, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
genres, err := h.service.ListGenres(r.Context(), viewer)
|
|
if err != nil {
|
|
writeRequestServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, struct {
|
|
Genres []mediarequests.DiscoverBrandCard `json:"genres"`
|
|
}{Genres: genres})
|
|
}
|
|
|
|
func (h *RequestsHandler) HandleBrowseStudio(w http.ResponseWriter, r *http.Request) {
|
|
viewer, ok := requestViewer(w, r, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
page, ok := parsePositiveIntQuery(w, r, "page", 1)
|
|
if !ok {
|
|
return
|
|
}
|
|
slug := strings.TrimSpace(chi.URLParam(r, "slug"))
|
|
sort := strings.TrimSpace(r.URL.Query().Get("sort"))
|
|
resp, err := h.service.BrowseStudio(r.Context(), viewer, slug, sort, page)
|
|
if err != nil {
|
|
writeRequestServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *RequestsHandler) HandleBrowseNetwork(w http.ResponseWriter, r *http.Request) {
|
|
viewer, ok := requestViewer(w, r, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
page, ok := parsePositiveIntQuery(w, r, "page", 1)
|
|
if !ok {
|
|
return
|
|
}
|
|
slug := strings.TrimSpace(chi.URLParam(r, "slug"))
|
|
sort := strings.TrimSpace(r.URL.Query().Get("sort"))
|
|
resp, err := h.service.BrowseNetwork(r.Context(), viewer, slug, sort, page)
|
|
if err != nil {
|
|
writeRequestServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *RequestsHandler) HandleBrowseGenre(w http.ResponseWriter, r *http.Request) {
|
|
viewer, ok := requestViewer(w, r, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
page, ok := parsePositiveIntQuery(w, r, "page", 1)
|
|
if !ok {
|
|
return
|
|
}
|
|
slug := strings.TrimSpace(chi.URLParam(r, "slug"))
|
|
sort := strings.TrimSpace(r.URL.Query().Get("sort"))
|
|
mediaType := mediarequests.MediaType(strings.TrimSpace(r.URL.Query().Get("media_type")))
|
|
resp, err := h.service.BrowseGenre(r.Context(), viewer, slug, mediaType, sort, page)
|
|
if err != nil {
|
|
writeRequestServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *RequestsHandler) HandleGetDetail(w http.ResponseWriter, r *http.Request) {
|
|
viewer, ok := requestViewer(w, r, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
mediaType := mediarequests.MediaType(strings.TrimSpace(chi.URLParam(r, "media_type")))
|
|
tmdbID, err := strconv.Atoi(strings.TrimSpace(chi.URLParam(r, "tmdb_id")))
|
|
if err != nil || tmdbID <= 0 {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid tmdb id")
|
|
return
|
|
}
|
|
detail, err := h.service.GetDetail(r.Context(), viewer, mediaType, tmdbID)
|
|
if err != nil {
|
|
writeRequestServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, detail)
|
|
}
|
|
|
|
func (h *RequestsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
|
|
viewer, ok := requestViewer(w, r, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
var input mediarequests.CreateRequestInput
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
req, err := h.service.CreateRequest(r.Context(), viewer, input)
|
|
if err != nil {
|
|
writeRequestServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, req)
|
|
}
|
|
|
|
func (h *RequestsHandler) HandleListMine(w http.ResponseWriter, r *http.Request) {
|
|
viewer, ok := requestViewer(w, r, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
requests, err := h.service.ListMine(r.Context(), viewer, parseRequestListFilter(r))
|
|
if err != nil {
|
|
writeRequestServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, struct {
|
|
Requests []*mediarequests.Request `json:"requests"`
|
|
}{Requests: requests})
|
|
}
|
|
|
|
func (h *RequestsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
|
|
viewer, ok := requestViewer(w, r, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
req, err := h.service.GetRequest(r.Context(), viewer, chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
writeRequestServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, req)
|
|
}
|
|
|
|
func (h *RequestsHandler) HandleAdminList(w http.ResponseWriter, r *http.Request) {
|
|
viewer, ok := requestViewer(w, r, false)
|
|
if !ok {
|
|
return
|
|
}
|
|
requests, err := h.service.ListAdmin(r.Context(), viewer, parseRequestListFilter(r))
|
|
if err != nil {
|
|
writeRequestServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, struct {
|
|
Requests []*mediarequests.Request `json:"requests"`
|
|
}{Requests: requests})
|
|
}
|
|
|
|
func (h *RequestsHandler) HandleApprove(w http.ResponseWriter, r *http.Request) {
|
|
viewer, ok := requestViewer(w, r, false)
|
|
if !ok {
|
|
return
|
|
}
|
|
req, err := h.service.Approve(r.Context(), viewer, chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
writeRequestServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, req)
|
|
}
|
|
|
|
func (h *RequestsHandler) HandleDecline(w http.ResponseWriter, r *http.Request) {
|
|
viewer, ok := requestViewer(w, r, false)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
Reason string `json:"reason"`
|
|
}
|
|
if r.Body != nil {
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
}
|
|
req, err := h.service.Decline(r.Context(), viewer, chi.URLParam(r, "id"), body.Reason)
|
|
if err != nil {
|
|
writeRequestServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, req)
|
|
}
|
|
|
|
func (h *RequestsHandler) HandleCancel(w http.ResponseWriter, r *http.Request) {
|
|
viewer, ok := requestViewer(w, r, false)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
Reason string `json:"reason"`
|
|
}
|
|
if r.Body != nil {
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
}
|
|
req, err := h.service.Cancel(r.Context(), viewer, chi.URLParam(r, "id"), body.Reason)
|
|
if err != nil {
|
|
writeRequestServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, req)
|
|
}
|
|
|
|
func (h *RequestsHandler) HandleRetry(w http.ResponseWriter, r *http.Request) {
|
|
viewer, ok := requestViewer(w, r, false)
|
|
if !ok {
|
|
return
|
|
}
|
|
req, err := h.service.Retry(r.Context(), viewer, chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
writeRequestServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, req)
|
|
}
|
|
|
|
func (h *RequestsHandler) HandleGetStatus(w http.ResponseWriter, r *http.Request) {
|
|
viewer, ok := requestViewer(w, r, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
status, err := h.service.GetFeatureStatus(r.Context(), viewer)
|
|
if err != nil {
|
|
writeRequestServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, status)
|
|
}
|
|
|
|
func (h *RequestsHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request) {
|
|
viewer, ok := requestViewer(w, r, false)
|
|
if !ok {
|
|
return
|
|
}
|
|
settings, err := h.service.GetSettings(r.Context(), viewer)
|
|
if err != nil {
|
|
writeRequestServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, settings)
|
|
}
|
|
|
|
func (h *RequestsHandler) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
|
viewer, ok := requestViewer(w, r, false)
|
|
if !ok {
|
|
return
|
|
}
|
|
var settings mediarequests.Settings
|
|
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
updated, err := h.service.UpdateSettings(r.Context(), viewer, settings)
|
|
if err != nil {
|
|
writeRequestServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, updated)
|
|
}
|
|
|
|
func (h *RequestsHandler) HandleListIntegrations(w http.ResponseWriter, r *http.Request) {
|
|
viewer, ok := requestViewer(w, r, false)
|
|
if !ok {
|
|
return
|
|
}
|
|
integrations, err := h.service.ListIntegrations(r.Context(), viewer)
|
|
if err != nil {
|
|
writeRequestServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, struct {
|
|
Integrations []requestIntegrationResponse `json:"integrations"`
|
|
}{Integrations: toIntegrationResponses(integrations)})
|
|
}
|
|
|
|
func (h *RequestsHandler) HandleCreateIntegration(w http.ResponseWriter, r *http.Request) {
|
|
viewer, ok := requestViewer(w, r, false)
|
|
if !ok {
|
|
return
|
|
}
|
|
var integration mediarequests.Integration
|
|
if err := json.NewDecoder(r.Body).Decode(&integration); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
created, err := h.service.CreateIntegration(r.Context(), viewer, integration)
|
|
if err != nil {
|
|
writeRequestServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, requestIntegrationResponseFrom(*created))
|
|
}
|
|
|
|
func (h *RequestsHandler) HandleUpdateIntegration(w http.ResponseWriter, r *http.Request) {
|
|
viewer, ok := requestViewer(w, r, false)
|
|
if !ok {
|
|
return
|
|
}
|
|
var integration mediarequests.Integration
|
|
if err := json.NewDecoder(r.Body).Decode(&integration); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
integration.ID = chi.URLParam(r, "id")
|
|
updated, err := h.service.UpdateIntegration(r.Context(), viewer, integration)
|
|
if err != nil {
|
|
writeRequestServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, requestIntegrationResponseFrom(*updated))
|
|
}
|
|
|
|
func (h *RequestsHandler) HandleDeleteIntegration(w http.ResponseWriter, r *http.Request) {
|
|
viewer, ok := requestViewer(w, r, false)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := h.service.DeleteIntegration(r.Context(), viewer, chi.URLParam(r, "id")); err != nil {
|
|
writeRequestServiceError(w, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *RequestsHandler) HandleLoadIntegrationOptions(w http.ResponseWriter, r *http.Request) {
|
|
viewer, ok := requestViewer(w, r, false)
|
|
if !ok {
|
|
return
|
|
}
|
|
var integration mediarequests.Integration
|
|
if r.Body != nil {
|
|
if err := json.NewDecoder(r.Body).Decode(&integration); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
}
|
|
if id := strings.TrimSpace(chi.URLParam(r, "id")); id != "" {
|
|
integration.ID = id
|
|
}
|
|
options, err := h.service.LoadIntegrationOptions(r.Context(), viewer, integration)
|
|
if err != nil {
|
|
writeRequestServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, options)
|
|
}
|
|
|
|
func (h *RequestsHandler) HandleGetUserLimit(w http.ResponseWriter, r *http.Request) {
|
|
viewer, ok := requestViewer(w, r, false)
|
|
if !ok {
|
|
return
|
|
}
|
|
userID, ok := parsePositivePathInt(w, r, "user_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
limit, err := h.service.GetUserLimit(r.Context(), viewer, userID)
|
|
if err != nil {
|
|
writeRequestServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, limit)
|
|
}
|
|
|
|
func (h *RequestsHandler) HandleUpdateUserLimit(w http.ResponseWriter, r *http.Request) {
|
|
viewer, ok := requestViewer(w, r, false)
|
|
if !ok {
|
|
return
|
|
}
|
|
userID, ok := parsePositivePathInt(w, r, "user_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
var limit mediarequests.UserLimit
|
|
if err := json.NewDecoder(r.Body).Decode(&limit); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
limit.UserID = userID
|
|
updated, err := h.service.UpsertUserLimit(r.Context(), viewer, limit)
|
|
if err != nil {
|
|
writeRequestServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, updated)
|
|
}
|
|
|
|
func requestViewer(w http.ResponseWriter, r *http.Request, requireProfile bool) (mediarequests.Viewer, bool) {
|
|
claims := apimw.GetClaims(r.Context())
|
|
if claims == nil || claims.UserID == 0 {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
|
return mediarequests.Viewer{}, false
|
|
}
|
|
profileID := strings.TrimSpace(apimw.GetProfileID(r.Context()))
|
|
if requireProfile && profileID == "" {
|
|
writeError(w, http.StatusBadRequest, "profile_required", "Profile is required")
|
|
return mediarequests.Viewer{}, false
|
|
}
|
|
return mediarequests.Viewer{
|
|
UserID: claims.UserID,
|
|
ProfileID: profileID,
|
|
IsAdmin: claims.Role == "admin",
|
|
}, true
|
|
}
|
|
|
|
func parseRequestListFilter(r *http.Request) mediarequests.ListFilter {
|
|
q := r.URL.Query()
|
|
limit, _ := strconv.Atoi(q.Get("limit"))
|
|
offset, _ := strconv.Atoi(q.Get("offset"))
|
|
return mediarequests.ListFilter{
|
|
Status: mediarequests.Status(strings.TrimSpace(q.Get("status"))),
|
|
Outcome: mediarequests.Outcome(strings.TrimSpace(q.Get("outcome"))),
|
|
Limit: limit,
|
|
Offset: offset,
|
|
}
|
|
}
|
|
|
|
func parsePositiveIntQuery(w http.ResponseWriter, r *http.Request, key string, fallback int) (int, bool) {
|
|
raw := strings.TrimSpace(r.URL.Query().Get(key))
|
|
if raw == "" {
|
|
return fallback, true
|
|
}
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil || value <= 0 {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid "+key)
|
|
return 0, false
|
|
}
|
|
return value, true
|
|
}
|
|
|
|
func parsePositivePathInt(w http.ResponseWriter, r *http.Request, key string) (int, bool) {
|
|
value, err := strconv.Atoi(strings.TrimSpace(chi.URLParam(r, key)))
|
|
if err != nil || value <= 0 {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid "+key)
|
|
return 0, false
|
|
}
|
|
return value, true
|
|
}
|
|
|
|
type requestIntegrationResponse struct {
|
|
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 {
|
|
return requestIntegrationResponse{
|
|
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,
|
|
}
|
|
}
|
|
|
|
func toIntegrationResponses(integrations []mediarequests.Integration) []requestIntegrationResponse {
|
|
out := make([]requestIntegrationResponse, 0, len(integrations))
|
|
for _, integration := range integrations {
|
|
out = append(out, requestIntegrationResponseFrom(integration))
|
|
}
|
|
return out
|
|
}
|
|
|
|
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, "a):
|
|
writeJSON(w, http.StatusTooManyRequests, struct {
|
|
Error string `json:"error"`
|
|
Message string `json:"message"`
|
|
Used int `json:"used"`
|
|
Limit int `json:"limit"`
|
|
WindowDays int `json:"window_days"`
|
|
}{
|
|
Error: "quota_exceeded",
|
|
Message: "Request quota exceeded",
|
|
Used: quota.Used,
|
|
Limit: quota.Limit,
|
|
WindowDays: quota.WindowDays,
|
|
})
|
|
case errors.Is(err, mediarequests.ErrInvalidInput), errors.Is(err, mediarequests.ErrInvalidMediaType):
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
case errors.Is(err, mediarequests.ErrRequestsDisabled):
|
|
writeError(w, http.StatusForbidden, "requests_disabled", "Requests are disabled")
|
|
case errors.Is(err, mediarequests.ErrUserBlocked):
|
|
writeError(w, http.StatusForbidden, "requesting_blocked", "User is blocked from requesting")
|
|
case errors.Is(err, mediarequests.ErrAlreadyAvailable):
|
|
writeError(w, http.StatusConflict, "already_available", "Media is already available")
|
|
case errors.Is(err, mediarequests.ErrAlreadyRequested):
|
|
writeError(w, http.StatusConflict, "already_requested", "Media is already requested")
|
|
case errors.Is(err, mediarequests.ErrForbidden):
|
|
writeError(w, http.StatusForbidden, "forbidden", "Request access denied")
|
|
case errors.Is(err, mediarequests.ErrNotFound):
|
|
writeError(w, http.StatusNotFound, "not_found", "Request not found")
|
|
case errors.Is(err, mediarequests.ErrInvalidState):
|
|
writeError(w, http.StatusConflict, "invalid_state", "Request is not in a valid state for this action")
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Request operation failed")
|
|
}
|
|
}
|