Files
silo-server/internal/requests/repository.go
T
2c714e4ef2 feat(requests): pluginize request fulfillment behind request_router.v1 (#104)
* docs: design spec for pluginizing requests fulfillment

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

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

* docs: implementation plan for requests pluginization

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

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

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

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

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

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

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

* feat(requests): plugin-backed RequestRouterProvider seam

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs: drop internal superpowers specs/plans from PR

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

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

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

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

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

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

Fixes #105

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

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

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

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

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

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

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

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

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

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

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

* fix(requests): handle router review edge cases

* fix(web): resolve schema form build casing

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-09 13:00:48 -04:00

809 lines
24 KiB
Go

package requests
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/secret"
)
type Repository struct {
pool *pgxpool.Pool
cipher *secret.Cipher
}
func NewRepository(pool *pgxpool.Pool, cipher *secret.Cipher) *Repository {
return &Repository{pool: pool, cipher: cipher}
}
func apiKeyAAD(id string) string {
return secret.RowAAD("request_integrations", "api_key_ref", id)
}
func (r *Repository) encryptAPIKey(id, apiKey string) (string, error) {
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
return "", nil
}
return r.cipher.Encrypt(apiKey, apiKeyAAD(id))
}
func (r *Repository) GetSettings(ctx context.Context) (Settings, error) {
var s Settings
err := r.pool.QueryRow(ctx, `
SELECT requests_enabled, global_max_requests, global_window_days,
global_auto_approval_enabled, force_dual_quality, updated_at
FROM request_settings
WHERE id = true
`).Scan(&s.RequestsEnabled, &s.GlobalMaxRequests, &s.GlobalWindowDays, &s.GlobalAutoApprovalEnabled, &s.ForceDualQuality, &s.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Settings{
RequestsEnabled: false,
GlobalMaxRequests: 5,
GlobalWindowDays: 7,
GlobalAutoApprovalEnabled: false,
}, nil
}
return Settings{}, fmt.Errorf("get request settings: %w", err)
}
return s, nil
}
func (r *Repository) UpdateSettings(ctx context.Context, settings Settings) (Settings, error) {
if settings.GlobalWindowDays <= 0 {
settings.GlobalWindowDays = 7
}
if settings.GlobalMaxRequests < 0 {
settings.GlobalMaxRequests = 0
}
var s Settings
err := r.pool.QueryRow(ctx, `
INSERT INTO request_settings (
id, requests_enabled, global_max_requests, global_window_days,
global_auto_approval_enabled, force_dual_quality, updated_at
)
VALUES (true, $1, $2, $3, $4, $5, now())
ON CONFLICT (id) DO UPDATE SET
requests_enabled = EXCLUDED.requests_enabled,
global_max_requests = EXCLUDED.global_max_requests,
global_window_days = EXCLUDED.global_window_days,
global_auto_approval_enabled = EXCLUDED.global_auto_approval_enabled,
force_dual_quality = EXCLUDED.force_dual_quality,
updated_at = now()
RETURNING requests_enabled, global_max_requests, global_window_days,
global_auto_approval_enabled, force_dual_quality, updated_at
`, settings.RequestsEnabled, settings.GlobalMaxRequests, settings.GlobalWindowDays, settings.GlobalAutoApprovalEnabled, settings.ForceDualQuality).
Scan(&s.RequestsEnabled, &s.GlobalMaxRequests, &s.GlobalWindowDays, &s.GlobalAutoApprovalEnabled, &s.ForceDualQuality, &s.UpdatedAt)
if err != nil {
return Settings{}, fmt.Errorf("update request settings: %w", err)
}
return s, nil
}
func (r *Repository) GetUserLimit(ctx context.Context, userID int) (*UserLimit, error) {
var row UserLimit
var max, window sql.NullInt64
err := r.pool.QueryRow(ctx, `
SELECT user_id, limit_mode, max_requests, window_days, approval_mode, updated_at
FROM request_user_limits
WHERE user_id = $1
`, userID).Scan(&row.UserID, &row.LimitMode, &max, &window, &row.ApprovalMode, &row.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("get request user limit: %w", err)
}
if max.Valid {
v := int(max.Int64)
row.MaxRequests = &v
}
if window.Valid {
v := int(window.Int64)
row.WindowDays = &v
}
return &row, nil
}
func (r *Repository) UpsertUserLimit(ctx context.Context, limit UserLimit) (*UserLimit, error) {
var max, window any
if limit.MaxRequests != nil {
max = *limit.MaxRequests
}
if limit.WindowDays != nil {
window = *limit.WindowDays
}
var row UserLimit
var scannedMax, scannedWindow sql.NullInt64
err := r.pool.QueryRow(ctx, `
INSERT INTO request_user_limits (
user_id, limit_mode, max_requests, window_days, approval_mode, updated_at
)
VALUES ($1, $2, $3, $4, $5, now())
ON CONFLICT (user_id) DO UPDATE SET
limit_mode = EXCLUDED.limit_mode,
max_requests = EXCLUDED.max_requests,
window_days = EXCLUDED.window_days,
approval_mode = EXCLUDED.approval_mode,
updated_at = now()
RETURNING user_id, limit_mode, max_requests, window_days, approval_mode, updated_at
`, limit.UserID, limit.LimitMode, max, window, limit.ApprovalMode).
Scan(&row.UserID, &row.LimitMode, &scannedMax, &scannedWindow, &row.ApprovalMode, &row.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("upsert request user limit: %w", err)
}
if scannedMax.Valid {
v := int(scannedMax.Int64)
row.MaxRequests = &v
}
if scannedWindow.Valid {
v := int(scannedWindow.Int64)
row.WindowDays = &v
}
return &row, nil
}
func (r *Repository) CountUserRequestsSince(ctx context.Context, userID int, since time.Time) (int, error) {
var count int
if err := r.pool.QueryRow(ctx, `
SELECT COUNT(*)
FROM media_requests
WHERE requested_by_user_id = $1
AND created_at >= $2
`, userID, since).Scan(&count); err != nil {
return 0, fmt.Errorf("count user requests: %w", err)
}
return count, nil
}
func (r *Repository) ListActiveByTMDB(ctx context.Context, mediaType MediaType, tmdbIDs []int) (map[int]*Request, error) {
if len(tmdbIDs) == 0 {
return map[int]*Request{}, nil
}
rows, err := r.pool.Query(ctx, requestSelectSQL()+`
WHERE media_type = $1
AND provider = 'tmdb'
AND tmdb_id = ANY($2)
AND outcome = 'active'
AND status <> 'completed'
`, mediaType, tmdbIDs)
if err != nil {
return nil, fmt.Errorf("list active requests by tmdb: %w", err)
}
defer rows.Close()
out := map[int]*Request{}
for rows.Next() {
req, err := scanRequest(rows)
if err != nil {
return nil, err
}
out[req.TMDBID] = req
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate active requests by tmdb: %w", err)
}
return out, nil
}
func (r *Repository) DeleteFailedByTMDB(ctx context.Context, mediaType MediaType, tmdbID int) (int, error) {
if tmdbID <= 0 {
return 0, nil
}
tag, err := r.pool.Exec(ctx, `
DELETE FROM media_requests
WHERE media_type = $1
AND provider = 'tmdb'
AND tmdb_id = $2
AND outcome = 'failed'
`, mediaType, tmdbID)
if err != nil {
return 0, fmt.Errorf("delete failed requests by tmdb: %w", err)
}
return int(tag.RowsAffected()), nil
}
// quotaLockNamespace partitions advisory locks so request-quota locks do not
// collide with advisory locks held elsewhere in the database. The value is
// arbitrary; what matters is that it is stable.
const quotaLockNamespace = 139
func (r *Repository) CreateRequest(ctx context.Context, input CreateRequestRecord) (*Request, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin create request transaction: %w", err)
}
defer tx.Rollback(ctx)
if input.Quota != nil {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1::int4, $2::int4)`,
quotaLockNamespace, input.Quota.UserID); err != nil {
return nil, fmt.Errorf("acquire request quota lock: %w", err)
}
var count int
if err := tx.QueryRow(ctx, `
SELECT COUNT(*)
FROM media_requests
WHERE requested_by_user_id = $1
AND created_at >= $2
`, input.Quota.UserID, input.Quota.WindowStart).Scan(&count); err != nil {
return nil, fmt.Errorf("count requests for quota: %w", err)
}
if count >= input.Quota.MaxRequests {
return nil, ErrQuotaExceeded
}
}
now := input.Now
if now.IsZero() {
now = time.Now().UTC()
}
status := input.Status
if status == "" {
status = StatusPending
}
outcome := input.Outcome
if outcome == "" {
outcome = OutcomeActive
}
var approvedAt any
if status != StatusPending {
approvedAt = now
}
req, err := r.insertRequest(ctx, tx, input, status, outcome, now, approvedAt)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return nil, ErrAlreadyRequested
}
return nil, err
}
if err := r.recordEvent(ctx, tx, req.ID, "created", input.Requester, ""); err != nil {
return nil, err
}
if status == StatusApproved {
if err := r.recordEvent(ctx, tx, req.ID, "approved", input.Requester, "auto approved"); err != nil {
return nil, err
}
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit create request transaction: %w", err)
}
return req, nil
}
type requestExecutor interface {
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
}
func (r *Repository) insertRequest(
ctx context.Context,
exec requestExecutor,
input CreateRequestRecord,
status Status,
outcome Outcome,
now time.Time,
approvedAt any,
) (*Request, error) {
var tvdbID any
if input.Input.TVDBID != nil {
tvdbID = *input.Input.TVDBID
}
var year any
if input.Input.Year != nil {
year = *input.Input.Year
}
row := exec.QueryRow(ctx, `
INSERT INTO media_requests (
id, provider, media_type, tmdb_id, tvdb_id, imdb_id, title, year,
overview, poster_path, backdrop_path, status, outcome,
requested_by_user_id, requested_by_profile_id, is_anime, created_at, updated_at, approved_at
)
VALUES (
$1, 'tmdb', $2, $3, $4, $5, $6, $7,
$8, $9, $10, $11, $12,
$13, $14, $15, $16, $16, $17
)
RETURNING `+requestColumns(), input.ID, input.Input.MediaType, input.Input.TMDBID, tvdbID,
strings.TrimSpace(input.Input.IMDbID), strings.TrimSpace(input.Input.Title), year,
strings.TrimSpace(input.Input.Overview), strings.TrimSpace(input.Input.PosterPath),
strings.TrimSpace(input.Input.BackdropPath), status, outcome,
input.Requester.UserID, input.Requester.ProfileID, input.IsAnime, now, approvedAt)
req, err := scanRequest(row)
if err != nil {
return nil, fmt.Errorf("insert request: %w", err)
}
return req, nil
}
func (r *Repository) GetRequest(ctx context.Context, id string) (*Request, error) {
req, err := scanRequest(r.pool.QueryRow(ctx, requestSelectSQL()+`
WHERE id = $1
`, id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return req, nil
}
func (r *Repository) ListReconciliationCandidates(ctx context.Context, limit int) ([]*Request, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := r.pool.Query(ctx, requestSelectSQL()+`
WHERE outcome = 'active'
AND status IN ('approved', 'queued', 'downloading')
ORDER BY updated_at ASC
LIMIT $1
`, limit)
if err != nil {
return nil, fmt.Errorf("list request reconciliation candidates: %w", err)
}
defer rows.Close()
var out []*Request
for rows.Next() {
req, err := scanRequest(rows)
if err != nil {
return nil, err
}
out = append(out, req)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate request reconciliation candidates: %w", err)
}
return out, nil
}
func (r *Repository) ListMine(ctx context.Context, userID int, filter ListFilter) ([]*Request, error) {
sqlText, args := buildRequestListSQL("requested_by_user_id = $1", []any{userID}, filter)
return r.listRequests(ctx, sqlText, args)
}
func (r *Repository) ListAdmin(ctx context.Context, filter ListFilter) ([]*Request, error) {
sqlText, args := buildRequestListSQL("true", nil, filter)
return r.listRequests(ctx, sqlText, args)
}
func (r *Repository) listRequests(ctx context.Context, sqlText string, args []any) ([]*Request, error) {
rows, err := r.pool.Query(ctx, sqlText, args...)
if err != nil {
return nil, fmt.Errorf("list requests: %w", err)
}
defer rows.Close()
var out []*Request
for rows.Next() {
req, err := scanRequest(rows)
if err != nil {
return nil, err
}
out = append(out, req)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate requests: %w", err)
}
return out, nil
}
func (r *Repository) SetStatus(ctx context.Context, id string, status Status, actor Viewer) (*Request, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin request status transaction: %w", err)
}
defer tx.Rollback(ctx)
req, err := scanRequest(tx.QueryRow(ctx, `
UPDATE media_requests
SET status = $2,
updated_at = now(),
approved_at = CASE WHEN $2 = 'approved' AND approved_at IS NULL THEN now() ELSE approved_at END,
completed_at = CASE WHEN $2 = 'completed' AND completed_at IS NULL THEN now() ELSE completed_at END
WHERE id = $1
RETURNING `+requestColumns(), id, status))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("set request status: %w", err)
}
if err := r.recordEvent(ctx, tx, id, "status_"+string(status), actor, ""); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit request status transaction: %w", err)
}
return req, nil
}
func (r *Repository) SetOutcome(ctx context.Context, id string, outcome Outcome, actor Viewer, message string) (*Request, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin request outcome transaction: %w", err)
}
defer tx.Rollback(ctx)
req, err := scanRequest(tx.QueryRow(ctx, `
UPDATE media_requests
SET outcome = $2,
last_error = CASE
WHEN $2 = 'failed' THEN $3
WHEN $2 = 'active' THEN ''
ELSE last_error
END,
updated_at = now()
WHERE id = $1
RETURNING `+requestColumns(), id, outcome, strings.TrimSpace(message)))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("set request outcome: %w", err)
}
if err := r.recordEvent(ctx, tx, id, "outcome_"+string(outcome), actor, message); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit request outcome transaction: %w", err)
}
return req, nil
}
const integrationColumns = `id, name, enabled, base_url, api_key_ref,
last_check_at, last_check_status, last_check_error, updated_at,
capability_id, installation_id, supported_media_types, plugin_config`
func (r *Repository) ListIntegrations(ctx context.Context) ([]Integration, error) {
rows, err := r.pool.Query(ctx, `SELECT `+integrationColumns+` FROM request_integrations ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("list request integrations: %w", err)
}
defer rows.Close()
var out []Integration
for rows.Next() {
integration, err := r.scanIntegration(rows)
if err != nil {
return nil, err
}
out = append(out, integration)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate request integrations: %w", err)
}
return out, nil
}
func (r *Repository) GetIntegration(ctx context.Context, id string) (*Integration, error) {
row := r.pool.QueryRow(ctx, `SELECT `+integrationColumns+
` FROM request_integrations WHERE id = $1`, id)
i, err := r.scanIntegration(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("get request integration: %w", err)
}
return &i, nil
}
func (r *Repository) CreateIntegration(ctx context.Context, i Integration) (*Integration, error) {
return r.insertIntegration(ctx, r.pool, i)
}
func (r *Repository) UpdateIntegration(ctx context.Context, i Integration) (*Integration, error) {
return r.updateIntegration(ctx, r.pool, i)
}
// insertIntegration runs the integration INSERT against any executor (pool or
// tx) so the same SQL is reused by the plain create path and the transactional
// SaveIntegrationWithDefaults path.
func (r *Repository) insertIntegration(ctx context.Context, exec requestExecutor, i Integration) (*Integration, error) {
if i.PluginConfig == nil {
i.PluginConfig = map[string]any{}
}
pluginConfig, err := json.Marshal(i.PluginConfig)
if err != nil {
return nil, fmt.Errorf("marshal plugin config: %w", err)
}
// capability_id is the capability sub-id ("arr"/"seerr"), validated non-empty
// upstream in validateInstance; persist it verbatim (never default it to the
// capability type, which the plugin runtime can't resolve).
capabilityID := strings.TrimSpace(i.CapabilityID)
supportedMediaTypes := i.SupportedMediaTypes
if supportedMediaTypes == nil {
supportedMediaTypes = []string{}
}
apiKeyRef, err := r.encryptAPIKey(i.ID, i.APIKeyRef)
if err != nil {
return nil, fmt.Errorf("encrypt api key: %w", err)
}
row := exec.QueryRow(ctx, `
INSERT INTO request_integrations (
id, name, enabled, base_url, api_key_ref,
capability_id, installation_id, supported_media_types,
plugin_config, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9, now())
RETURNING `+integrationColumns,
i.ID, strings.TrimSpace(i.Name), i.Enabled, strings.TrimSpace(i.BaseURL),
apiKeyRef, capabilityID, i.InstallationID, supportedMediaTypes, pluginConfig)
out, err := r.scanIntegration(row)
if err != nil {
return nil, fmt.Errorf("create request integration: %w", err)
}
return &out, nil
}
// updateIntegration runs the integration UPDATE against any executor (pool or
// tx) so the plain update path and SaveIntegrationWithDefaults share the SQL.
func (r *Repository) updateIntegration(ctx context.Context, exec requestExecutor, i Integration) (*Integration, error) {
if i.PluginConfig == nil {
i.PluginConfig = map[string]any{}
}
pluginConfig, err := json.Marshal(i.PluginConfig)
if err != nil {
return nil, fmt.Errorf("marshal plugin config: %w", err)
}
// capability_id is the capability sub-id ("arr"/"seerr"), validated non-empty
// upstream in validateInstance; persist it verbatim (never default it to the
// capability type, which the plugin runtime can't resolve).
capabilityID := strings.TrimSpace(i.CapabilityID)
supportedMediaTypes := i.SupportedMediaTypes
if supportedMediaTypes == nil {
supportedMediaTypes = []string{}
}
// Encrypt the incoming key; an empty result preserves the keep-existing CASE
// (a blank edit leaves the stored key untouched).
apiKeyRef, err := r.encryptAPIKey(i.ID, i.APIKeyRef)
if err != nil {
return nil, fmt.Errorf("encrypt api key: %w", err)
}
row := exec.QueryRow(ctx, `
UPDATE request_integrations SET
name=$2, enabled=$3, base_url=$4,
api_key_ref = CASE WHEN $5 = '' THEN api_key_ref ELSE $5 END,
capability_id=$6, installation_id=$7,
supported_media_types=$8, plugin_config=$9, updated_at=now()
WHERE id=$1
RETURNING `+integrationColumns,
i.ID, strings.TrimSpace(i.Name), i.Enabled, strings.TrimSpace(i.BaseURL),
apiKeyRef, capabilityID, i.InstallationID, supportedMediaTypes, pluginConfig)
out, err := r.scanIntegration(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("update request integration: %w", err)
}
return &out, nil
}
// SaveIntegrationWithDefaults creates/updates the instance in a single
// transaction. The host no longer enforces a single HD/4K default per kind;
// the request_router plugin's RouteTargets picks the first is_default
// connection from plugin_config, so the save path is a plain insert-or-update.
func (r *Repository) SaveIntegrationWithDefaults(ctx context.Context, in Integration, isCreate bool) (*Integration, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin save integration: %w", err)
}
defer tx.Rollback(ctx)
var out *Integration
if isCreate {
out, err = r.insertIntegration(ctx, tx, in)
} else {
out, err = r.updateIntegration(ctx, tx, in)
}
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit save integration: %w", err)
}
return out, nil
}
func (r *Repository) DeleteIntegration(ctx context.Context, id string) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin delete integration: %w", err)
}
defer tx.Rollback(ctx)
var lockedID string
if err := tx.QueryRow(ctx, `
SELECT id FROM request_integrations WHERE id = $1 FOR UPDATE
`, id).Scan(&lockedID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return fmt.Errorf("lock request integration: %w", err)
}
var hasLiveTargets bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM media_request_targets
WHERE integration_id = $1 AND status IN ('queued', 'downloading')
)
`, id).Scan(&hasLiveTargets); err != nil {
return fmt.Errorf("check integration targets: %w", err)
}
if hasLiveTargets {
return ErrInvalidState
}
if _, err := tx.Exec(ctx, `DELETE FROM request_integrations WHERE id = $1`, id); err != nil {
return fmt.Errorf("delete request integration: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit delete integration: %w", err)
}
return nil
}
func (r *Repository) recordEvent(ctx context.Context, exec requestExecutor, requestID, eventType string, actor Viewer, message string) error {
var actorUserID any
if actor.UserID > 0 {
actorUserID = actor.UserID
}
_, err := exec.Exec(ctx, `
INSERT INTO media_request_events (
request_id, event_type, actor_user_id, actor_profile_id, message
)
VALUES ($1, $2, $3, $4, $5)
`, requestID, eventType, actorUserID, actor.ProfileID, strings.TrimSpace(message))
if err != nil {
return fmt.Errorf("record request event: %w", err)
}
return nil
}
func buildRequestListSQL(baseCondition string, baseArgs []any, filter ListFilter) (string, []any) {
args := append([]any(nil), baseArgs...)
conditions := []string{baseCondition}
if filter.Status != "" {
args = append(args, filter.Status)
conditions = append(conditions, "status = $"+strconv.Itoa(len(args)))
}
if filter.Outcome != "" {
args = append(args, filter.Outcome)
conditions = append(conditions, "outcome = $"+strconv.Itoa(len(args)))
}
limit := filter.Limit
if limit <= 0 || limit > 100 {
limit = 50
}
offset := filter.Offset
if offset < 0 {
offset = 0
}
args = append(args, limit, offset)
return requestSelectSQL() + `
WHERE ` + strings.Join(conditions, " AND ") + `
ORDER BY created_at DESC
LIMIT $` + strconv.Itoa(len(args)-1) + ` OFFSET $` + strconv.Itoa(len(args)), args
}
func requestSelectSQL() string {
return "SELECT " + requestColumns() + " FROM media_requests "
}
func requestColumns() string {
return `id, provider, media_type, tmdb_id, tvdb_id, imdb_id, title, year,
overview, poster_path, backdrop_path, status, outcome,
requested_by_user_id, requested_by_profile_id, is_anime,
last_error, created_at, updated_at, approved_at, completed_at`
}
type requestScanner interface {
Scan(dest ...any) error
}
func scanRequest(row requestScanner) (*Request, error) {
var req Request
var tvdbID, year sql.NullInt64
var approvedAt, completedAt sql.NullTime
if err := row.Scan(
&req.ID,
&req.Provider,
&req.MediaType,
&req.TMDBID,
&tvdbID,
&req.IMDbID,
&req.Title,
&year,
&req.Overview,
&req.PosterPath,
&req.BackdropPath,
&req.Status,
&req.Outcome,
&req.RequestedByUserID,
&req.RequestedByProfileID,
&req.IsAnime,
&req.LastError,
&req.CreatedAt,
&req.UpdatedAt,
&approvedAt,
&completedAt,
); err != nil {
return nil, err
}
if tvdbID.Valid {
v := int(tvdbID.Int64)
req.TVDBID = &v
}
if year.Valid {
v := int(year.Int64)
req.Year = &v
}
if approvedAt.Valid {
req.ApprovedAt = &approvedAt.Time
}
if completedAt.Valid {
req.CompletedAt = &completedAt.Time
}
return &req, nil
}
type integrationScanner interface {
Scan(dest ...any) error
}
func (r *Repository) scanIntegration(row integrationScanner) (Integration, error) {
var i Integration
var installationID sql.NullInt64
var pluginConfigRaw []byte
var lastCheckAt sql.NullTime
if err := row.Scan(
&i.ID, &i.Name, &i.Enabled, &i.BaseURL, &i.APIKeyRef,
&lastCheckAt, &i.LastCheckStatus, &i.LastCheckError, &i.UpdatedAt,
&i.CapabilityID, &installationID, &i.SupportedMediaTypes, &pluginConfigRaw,
); err != nil {
return Integration{}, err
}
// Decrypt the stored api key (read-path contract: legacy plaintext passes
// through, enc:v1: values decrypt, corrupt ciphertext errors). Callers
// receive the literal key — there is no longer a ref/literal ambiguity.
apiKey, err := r.cipher.DecryptIfEncrypted(i.APIKeyRef, apiKeyAAD(i.ID))
if err != nil {
return Integration{}, fmt.Errorf("decrypt request integration %s api key: %w", i.ID, err)
}
i.APIKeyRef = apiKey
if installationID.Valid {
v := int(installationID.Int64)
i.InstallationID = &v
}
if len(pluginConfigRaw) > 0 {
if err := json.Unmarshal(pluginConfigRaw, &i.PluginConfig); err != nil {
return Integration{}, fmt.Errorf("unmarshal request integration plugin config for %s: %w", i.ID, err)
}
}
if i.PluginConfig == nil {
i.PluginConfig = map[string]any{}
}
if lastCheckAt.Valid {
i.LastCheckAt = &lastCheckAt.Time
}
return i, nil
}