* docs: design spec for multi-instance Sonarr/Radarr request routing
Seerr-style multi-instance arr management inside Silo's request system:
many instances per kind, HD/4K default routing, entitlement-driven
dual-quality fan-out, per-instance anime overrides (keyword 210024),
and a one-to-many media_request_targets model.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: implementation plan for multi-instance arr request routing
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(requests): migration for multi-instance arr routing
Adds migration 169 to convert request_integrations from a one-row-per-kind
table keyed on `kind` to a multi-instance table keyed on `id`, with HD/4K
defaults, anime overrides, and a new one-to-many media_request_targets table
for per-quality fulfillment tracking.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(requests): instance, target, and dual-quality types
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(requests): id-based integration CRUD
Replace upsert-by-kind (UpsertIntegration/UpsertIntegrations) with
GetIntegration, CreateIntegration, UpdateIntegration, DeleteIntegration,
and ClearDefault. Rewrites scanIntegration and integrationColumns to cover
all new multi-instance columns (id, name, is_4k, is_default, is_default_4k,
anime_* fields). Updates the Store interface accordingly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(requests): target persistence and aggregate status
* feat(tmdb): expose keyword ids and original language on detail
* feat(requests): Seerr-exact anime detection (keyword 210024)
* feat(requests): quality/anime routing engine
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(requests): force_dual_quality setting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(requests): multi-target fulfillment, reconcile, retry, and instance CRUD
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(api): request integration CRUD endpoints, targets in responses, entitlement wiring
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): multi-instance request integration types and CRUD hooks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(web): multi-instance arr manager, dual-quality toggle, per-target queue
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): UX review fixes for arr manager (delete confirm, switch hints, test feedback, dirty + target status)
* fix(requests): address code-review findings (test-connection by id, HD-only default ceiling, retryable partial failure, idempotent submit, transactional defaults, presence/target reconcile, auto-approve gate)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: address CodeRabbit review (anime override fallback, non-null slices, save gate, a11y, DeleteTarget not-found)
- routing: anime fields only override standard root/profile/tags when set,
so enabling anime with blank fields reuses standard values instead of
clearing them into an invalid submission
- api: normalize nil Tags/AnimeTags to [] so they serialize as arrays not null
- web: require an API key before saving a NEW instance; add aria-expanded/
aria-controls to the anime-overrides disclosure toggle
- repo: DeleteTarget returns ErrNotFound when no row was deleted
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(requests): address PR review findings
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Use toEqual on the whole parsed envelope (not just .name) for the
subtitle_translation_* parser cases so payload-field parsing is covered, and
clarify the optional-label validation comment.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backend:
- client.go: handle the io.ReadAll error on the chat response (retry rather
than misparse a truncated read), and stop logging raw upstream response
bodies (log byte count instead of subtitle/model text).
- srt.go: reject negative/reversed cue time ranges in parseTimingLine.
- translator.go: fail fast on a nil client, non-positive batch size, or empty
target language instead of panicking / looping.
Frontend:
- VideoPlayer: clear the pending translation resume timer when the file
changes, so a stale 30s callback can't fire against the new media.
- useSubtitleTracks: dedup cues on source times, not delay-shifted display
times, so adjusting subtitle delay can't re-add the same cue.
- SubtitleMenu: only show "Translate with AI" when a translatable (non-live,
text/non-bitmap) source exists, matching the modal's filter.
- IntegrationsSettings: validate AI settings before saving (non-empty
endpoint/model when enabled, positive concurrency) instead of persisting an
unusable config.
- realtime-protocol: validate optional label/message fields are strings; add
parser tests for the subtitle_translation_* events.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
UpdateProgress/CompleteJob/FailJob now only update a job that is still
pending/running, so a job cancelled or reaped as stale can't be resurrected
by a late write from its own worker goroutine. Defense-in-depth alongside the
queued-job heartbeat fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Heartbeat queued jobs (before the semaphore), not just running ones, so
the stale-job reaper can't fail a job that is alive but waiting for a slot
(which would otherwise let it resurrect itself on acquire, or admit a
duplicate once the partial unique index no longer sees it as active).
- Filter the translate modal's source list to the formats the server accepts
(text external/downloaded, non-bitmap embedded), so an unsupported source
is prevented up front instead of failing the job asynchronously.
- Tell a duplicate requester (e.g. a second viewer, or after a reload) that a
translation is already in progress instead of silently closing; the
finished track still arrives via the subtitle_ready refresh.
- Note in the admin card that AI subtitle settings take effect after a
restart (the running service snapshots them at construction).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add server-side AI subtitle translation backed by any OpenAI-compatible
chat endpoint (OpenAI, Groq, a local Ollama/llama.cpp server). A viewer
picks a source track and target language in the player; the server runs a
bounded, resumable job pipeline that translates SRT/VTT cues in batches and
streams them back over the realtime websocket so playback pauses, fills in
cues near the playhead, and resumes. The finished track is persisted as an
ordinary downloaded subtitle, so it reaches every client through the
existing subtitle pipeline with no client changes.
- Job lifecycle persisted in subtitle_ai_jobs (migration 168): enqueue with
idempotency, bounded concurrency, progress/heartbeat, cancellation, and
crash recovery.
- New realtime events (subtitle_ready + subtitle_translation_*) with a
per-session notifier; the player renders a synthetic "live" track fed by
websocket cues. Timestamps never leave the server, so timing can't drift.
- Admin settings card for endpoint / model / concurrency.
Player + lifecycle hardening (from the code review of this feature):
- Hand off from the live track to the persisted track on completion
(selected by downloaded-subtitle id) and on the subtitle_ready broadcast,
so the saved track survives a reload and a mid-stream socket drop.
- Never persist the synthetic live-track sentinel index as a subtitle
preference; restore the prior selection on failure; only auto-resume
playback if the viewer was actually playing.
- Resume promptly when the playhead is past the last cue; rebuild the live
track on a new job; O(batch) live-cue ingestion instead of O(n^2).
Reliability:
- Root translation jobs in the application context so shutdown cancels them.
- Heartbeat-based stale-job reaper (safe across multiple instances) replaces
the table-wide startup reset.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shared WebSocket upgrader rejected handshakes unless the browser's
Origin host exactly matched r.Host. Behind a TLS-terminating CDN/proxy
that rewrites Host to the internal origin (carrying the public host in
X-Forwarded-Host), this comparison always failed and every realtime
socket 403'd at the handshake — playback control, events, watch-together
rooms, and admin log streaming all share the upgrader.
checkWebSocketOrigin now also accepts an Origin matching X-Forwarded-Host,
keeping the same-origin CSRF guard intact while supporting proxied
deployments. Extract a shared forwardedHost helper (first hop of a
multi-proxy list) and reuse it from requestBaseURL, replacing the
duplicated inline parse. Also reject opaque (empty-host) origins
explicitly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Extract hardcoded trending snapshot source/window to named constants.
- Collapse the three identical personal-preset nil-checks into one case.
- Persist the selected preset through the shared storage util (try/catch
wrapped) instead of raw localStorage with manual SSR guards.
- Derive KNOWN_FILTERS from PRESET_OPTIONS so the lists can't drift.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Popular reflects server-wide watch counts, which are sparse on a
low-traffic server. Hidden from the selector, URL allowlist, and
empty-state nudge; backend filter and the CalendarFilter type are
left intact so re-enabling is a one-line change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Also drops the now-unused Filter/UserID/ProfileID fields from the
blendUpcomingIntoDiscoverRows CalendarFilter literal in recommendations.go,
which only wants an unrestricted windowed query.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The local-airtime change re-sorted calendar events in Go using air_at,
the absolute UTC instant, which is nil whenever air_timezone is unset.
Since air_timezone is only inferred for a few networks/countries, most
events fell through to the alphabetical title tiebreak while still
displaying their raw air_time, so each day appeared scrambled.
Sort each local day by the wall-clock time the viewer actually sees,
mirroring the client: zoned events convert air_at into the viewer
timezone, unzoned events use the raw air_time, and date-only entries
(no air_time) sort last. The timezone reasoning lives in the new
catalog.CalendarEventLocalTime helper.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
episodeCatalogSelectBody is the derived "mi" relation that episode catalog
hydration and preview read qualifiedListItemColumns("mi") from. The local
episode airtimes feature (26370616) added air_timezone to the shared column
lists but not to this hand-written subquery, so the outer projection
referenced mi.air_timezone, which the subquery never exposed.
Postgres returns SQLSTATE 42703 (undefined_column), which is not one of the
codes episodeCatalogEntriesUnavailable treats as "fast path unavailable" (it
only catches 42P01/42883), so episode catalog requests failed with HTTP 500
instead of degrading. movie and series scopes query media_items directly, so
the column is present there and only episode scope broke.
Add si.air_timezone to the subquery, and add a regression test asserting that
episodeCatalogSelectBody exposes every column qualifiedListItemColumns reads
off mi, so future additions to the shared column lists cannot silently drift
from the episode read model again.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>