Compare commits

..
Author SHA1 Message Date
QuickandClaude Fable 5 5b70dc7357 web: default subtitle background style to Box 75%
Matches the Apple clients' default so a profile with no saved appearance
renders the same on every client. (Android's default remains None and is
tracked separately.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146hwg1Q15YdeUhd9f37NWA
2026-07-04 10:51:33 -04:00
QuickandClaude Fable 5 ade3a3c5d4 docs: add GitHub Sponsors funding and restructure README for selfhosters
Add .github/FUNDING.yml to enable the Sponsor button, refocus the
README on selfhoster users with a Highlights section and a Supporting
Silo section, and move contributor-facing content (dev workflow, make
targets, migrations, tests, project structure) into DEVELOPMENT.md.

AI-use disclosure: written with Claude Code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:14:58 -04:00
ce06dc8ca5 fix(web): show Resume Reading on the manga CTA when the target volume is in progress (#280)
The series hero CTA only inspected chapter.read, so a volume with saved
partial progress (e.g. 18%) still rendered "Start Reading" — the button
contradicted the progress indicator on the same page. The resume target
with progress > 0 now renders "Resume Reading"; untouched series keep
"Start Reading" and mid-series targets keep "Continue".

Fixes #188

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:58:54 -04:00
604bbf1a0f feat(playback): unified restart-resilient playback (native + jellycompat) (#174)
* feat(playback): unified restart-resilient playback via shared TranscodeManager

Make direct, remux, and native HLS transcode sessions survive a server
restart through one shared flow instead of per-method paths. A missing
in-memory session becomes a reconstruct trigger, not a 404: the server
rebuilds the session from a tiny durable recipe card plus the position the
client re-supplies on its next request.

- internal/playback/transcode_manager.go: shared TranscodeManager owning the
  transcodes map, recipe-card lifecycle, reconstruct single-flight +
  concurrency cap, LoadOrReconstructSession front door, ReconstructSession /
  ReconstructTranscode, and orphan cleanup. ~90% is logic moved out of the
  native handler (no behavior change), not new surface.
- internal/playback/recipecard.go + recipecard_postgres.go: RecipeCard with a
  PlayMethod discriminator (direct/remux/transcode; empty decodes as transcode
  for back-compat) behind a swappable, nil-safe RecipeStore interface backed by
  transcode_recipes.
- internal/playback/session.go: RegisterReconstructed inserts a rebuilt Session
  under its existing id (no UUID mint, no limit double-count, race-yielding).
- internal/playback/transcode.go: CloseProcess keeps the output dir so a
  reconstruct winner keeps serving; Close removes it.
- internal/api/handlers: drain the transcode lifecycle into the manager; wire
  reconstruct into the stream/segment serve paths; re-bind ownership to the live
  caller (refuse userID==0/mismatch); card-aware orphan cleanup.
- migrations: add transcode_recipes (expires_at TTL, filter-on-read, indexed).

Ownership stays two-factor: an authenticated caller AND a session.UserID that
matches; the card stores no secrets and identity is re-resolved per request.

Tests: recipe-card round-trip/legacy-decode/disabled-noop, RegisterReconstructed
insert/race/concurrency, close-vs-close-process dir semantics, the
LoadOrReconstructSession status matrix, and the reconstruct concurrency cap.

AI-use: implemented with AI assistance (design, implementation, adversarial review).

* feat(jellycompat): reconstruct transcodes across restart via shared manager

Bring Jellyfin (jellycompat) HLS playback onto the same restart-resilient flow
as the native path. Previously jellycompat owned a separate PlaybackHandler with
a private transcodes map and a duplicated transcode lifecycle that never grew the
reconstruct half, so an in-flight Jellyfin transcode died on restart and the next
segment request 404'd.

- Embed the shared playback.TranscodeManager and delete the duplicate lifecycle,
  so jellycompat gets reconstruct, the concurrency cap, the node-affinity rule,
  and the card lifecycle for free.
- internal/jellycompat/playback_sessions_postgres.go: DurableCompatPlaybackStore,
  a write-through cache over jellycompat_playback_sessions behind the new
  CompatPlaybackStore interface (nil pool degrades to cache-only). This persists
  the load-bearing PlaySessionId -> UpstreamSessionID mapping (plus media sources,
  route item id, seek) so it survives a restart instead of vanishing with the map.
- Write a recipe card on compat transcode start keyed by the upstream session id,
  using the native StreamAppUserID so the ownership re-bind matches; reconstruct
  the upstream session and the transcode seeked to the requested seg_NNNNN.
- migrations: add jellycompat_playback_sessions (expires_at TTL + compat_token
  index, full PlaybackSession in data JSONB).

Auth is mapped to the native user id before reconstruct so the same two-factor
ownership check and userID==0/mismatch refusal apply unchanged.

Tests: DB-gated (SILO_TEST_DATABASE_URL) durable-store round-trip proving a
session written by one instance reloads in a fresh one (the restart case), plus
a nil-pool cache-only path; existing handler tests updated to the manager.

AI-use: implemented with AI assistance (design, implementation, adversarial review).

* docs(playback): consolidate unified playback reconstruction design

Replace the three overlapping playback docs (the native Postgres
restart-resilience spec, the jellycompat plan, and the unification spec) with a
single self-contained design at
docs/superpowers/specs/unified-playback-reconstruct.md.

The doc leads with the unified design — the one-idea reconstruct model, a strong
visual flow of a restart mid-playback, the shared TranscodeManager + recipe card,
the two swappable durable stores, security, the concurrency cap and node-affinity
constraint, preconditions, and verification. The design history and rationale
(reconstruct-not-rehydrate, phased delivery, Redis-vs-Postgres, token-as-
descriptor, failure analysis) move to an appendix. It references no other md file.

AI-use: written with AI assistance.

* fix(playback): address review on restart-resilient playback

Four fixes from PR review of the unified reconstruction work:

- Rewrite the recipe card on audio-track change. HandleChangeAudioTrack only
  updated the in-memory session/transcode, so after a restart reconstruct
  resumed with the stale AudioTrackIndex/TranscodeAudio (and stale play method)
  from the start-time card. Re-save the card (direct/remux/transcode) with the
  switched state, mirroring the start-card pattern.
- Guard nil TranscodeManager in LoadOrReconstructSession and ReconstructSession.
  StreamHandler.TM is documented optional (tests/minimal setups); a missing
  session previously panicked in recipeEnabled instead of returning
  SessionMissing. ReconstructTranscode already guarded nil; make the two
  siblings consistent.
- Reject direct/remux cards in doReconstructTranscode before spawning ffmpeg, so
  a non-transcode card id can never enter the HLS reconstruction path.
- Log a non-success status from the remote transcode-node DELETE in
  CloseTranscodeSession; a 401/404/500 was previously silent.

AI-use: implemented with AI assistance.

* fix(playback): harden restart-resilient compat sessions

* feat(playback): token-carried reconstruction across restarts

Build on the shared TranscodeManager (introduced earlier in this branch) so a
playback session survives an API-server or transcode-node restart without the
client re-negotiating, and retire the Postgres transcode_recipes store in favor
of a recipe carried inside the signed stream token.

- RecipeCard encodes the byte-affecting encode parameters and rides inside the
  stream token; LoadOrReconstructSession rebuilds the in-memory Session (and,
  for integrated transcodes, the ffmpeg process) on a cold miss, single-flighted
  per session and paced by a spawn semaphore. Removes recipecard_postgres.go and
  the 20260617233705_add_transcode_recipes migration.
- transcodenode reconstructs a lost ffmpeg node-side from the forwarded token.
- TR-lease: proxy/streamauth enforce a revocation deny-marker on every served
  segment, with a 500ms Redis timeout, a bounded per-session "allowed" cache
  (3s TTL, expiry-first graceful eviction), and a degraded-fail-open counter.

Review hardening folded in:
- Manifest/segment handlers do the in-memory session lookup first and only
  verify the stream token on a reconstruct miss (token HMAC was per-segment).
- Copy-mode reconstruct never applies the encoded-only seg*dur seek, at spawn
  time or via the recovery path: RestartSeekTarget reports "unresolved" for a
  copy session whose manifest cannot yet map the segment, so the client retries
  instead of seeking to a fabricated source time.
- Crash teardown is a compare-and-delete (CloseTranscodeSessionIf returns
  whether it matched); the crash closure tears down the playback session only
  when it matched, so a session reconstructed under the same id is not killed.
- Reconstruct enforces the same per-user stream/transcode caps as a fresh start
  (RegisterReconstructedWithLimits), closing a token-replay slot bypass.

AI-use disclosure: implemented with AI assistance (Claude Code), including a
two-round multi-agent adversarial review whose findings drove the hardening.

* feat(jellycompat): node-side transcode reconstruct via shared recipe store

Make Jellyfin-compat playback sessions survive a server or transcode-node
restart by reusing the shared TranscodeManager reconstruct path and a durable
recipe store, on top of the durable compat session store added earlier in this
branch.

- Node-side transcode reconstruct goes through the shared recipe store; the
  recipe is persisted to the control-plane store (Redis) when a dedicated
  transcode node is used so the node can rebuild ffmpeg after its own restart.
- Adopt the shared manager's API (3-arg OnFFmpegCrash carrying the dead session,
  guarded CloseTranscodeSessionIf, RegisterReconstructedWithLimits).

Review hardening folded in:
- Recipe lifecycle: noderecipe.Store gains Delete, called on deliberate
  teardown (stop, method-switch discard, node stop/force-reload) so a stopped
  session cannot be resurrected by a buffered request after a node restart;
  crash paths intentionally keep the recipe so a resume can reconstruct.
- Crash closure tears down the upstream session only when the guarded transcode
  close matched, so a reconstructed successor is never left orphaned.
- Copy-mode segment recovery surfaces a retryable not-found instead of a
  wrong-position restart, matching the native and node paths.
- Durable Update is now a SELECT ... FOR UPDATE transaction, removing the
  lost-update clobber that could silently drop a transcode recipe.
- Empty-token route resolution no longer falls back to an unbounded full-table
  scan; DB expiry filters bind the injected clock; the redundant re-Get is gone.

AI-use disclosure: implemented with AI assistance (Claude Code), including a
two-round multi-agent adversarial review whose findings drove the hardening.

* docs(playback): consolidate restart-resilient playback design

Replace the superpowers spec with a single architecture record describing the
token-carried recipe card, the shared TranscodeManager reconstruct path for
direct/remux/transcode, the jellycompat durable session + node recipe store, and
the revocation-lease model with its fail-open tradeoff.

AI-use disclosure: written with AI assistance (Claude Code).

* docs(playback): correct jellycompat node-recipe rationale in comments

The noderecipe / transcode-node / jellycompat comments justified the Redis
recipe store with "a Jellyfin client cannot round-trip a token". The real
reason: the node-hop token is server-minted and could carry the recipe, but the
recipe is mutated in place under a stable session id (a /Sessions/Playing/Progress
audio switch restarts ffmpeg without re-minting the client's token) and a
third-party Jellyfin client cannot be driven to refresh a stale token, so the
node must reconstruct from a server-authoritative, node-reachable store.

Aligns the comments with docs/architecture/restart-resilient-playback.md §10.
Comment-only; no behavior change.

* refactor(playback): remove deny-lease revocation, defer to future PR

The deny-lease stream-revocation mechanism (the internal/streamauth
package, its silo:streamauth:<sid> Redis markers, the proxy Allowed()
enforcement, and the admin Stop/Terminate deny write) only ever enforced
on the offload-proxy topology and was a silent no-op on the integrated
single box and the dedicated transcode node. Rather than ship a partial
revocation feature that looks complete but isn't, remove it wholesale and
defer a uniform cross-topology revocation design to a dedicated follow-up.

Removed: internal/streamauth (package + tests); the LeaseDenier field,
StreamLeaseDenier interface, and denyStreamLease helper in playback.go;
the admin deny write; the router/main wiring; and the proxy verifyToken
Allowed() gate. The unified-reconstruct core (recipe-token,
LoadOrReconstructSession) is orthogonal and untouched.

Known limitation (now on every topology): admin Terminate and user Stop
tear down the live in-memory session and ffmpeg producer, but a still-valid
stream token can reconstruct the session until its 24h TTL expires. No
node-side byte-withholding ships in this PR.

docs/architecture/restart-resilient-playback.md is updated to mark the
revocation/deny-lease sections as deferred and to drop the overstated
"instant revocation on admin kill" claim.

* fix(playback): allow zero-caller bearer on transcode reconstruct

The authless HLS transcode delivery routes (master.m3u8 / segment) treat
the session UUID as the bearer credential, so a real request carries
requestUserID == 0. The live serve path already allows this, but
ReconstructSession hard-rejected a zero caller, so a request that worked
before a restart became SessionMissing -> 404 after the in-memory session
was gone, breaking the restart resilience these routes advertise.

Match the live-path contract in LoadOrReconstructSession: allow a zero
caller (UUID-as-bearer) and refuse only a non-zero caller that mismatches
the card owner. The reconstructed session is bound to card.UserID either
way. Adds TestReconstructSession_Ownership covering both cases.

* fix(jellycompat): re-persist recipe on local audio switch

A Jellyfin client switching audio on an integrated/local compat transcode
restarted live ffmpeg with the new track but did not re-persist
PlaybackSession.Recipe. The remote branch already re-persists via
startRemoteTranscode -> persistTranscodeRecipe. After a central restart,
reconstruct rebuilt ffmpeg from the stale Recipe.AudioTrackIndex, so the
integrated session resumed on the original audio track.

Persist the updated recipe (best-effort) after a successful Restart in the
local branch, mirroring the remote branch, so the durable
Recipe.AudioTrackIndex tracks live ffmpeg. Adds a regression test.

* fix(playback): strip stream token from proxied transcode-node URL

proxyToTranscodeNode appended the client's raw query string to the internal
transcode-node URL and logged that URL on transport failure. When a remote
transcode runs without a separate proxy node, that query carries
?st=<signed JWT> — a 24h bearer reconstruction descriptor exposing the
media path and recipe claims — placing the token into internal requests and
error logs.

Strip the "st" param before building targetURL, preserving any other query
params. The token is neither forwarded to the node nor present in the
logged URL. Header-forwarding of the token (so the node can reconstruct) is
a separate follow-up (#6).

* fix(playback): fail open on transient limit-provider error in reconstruct

During the reconstruct wave right after a restart (Postgres under peak
load), a transient limit-provider DB error was collapsed into a hard 404,
permanently stopping playback for a user within their limits. limitsForUser
wrapped any provider error, RegisterReconstructedWithLimits propagated it,
and ReconstructSession mapped every error to SessionMissing -> 404 -
indistinguishable from a genuine over-cap rejection.

Distinguish the two: tag provider errors with a new ErrLimitProviderUnavailable
sentinel and, during reconstruct, fail OPEN on a provider error (admit via
RegisterReconstructed + log a degraded warning) rather than refuse - mirroring
the reliability-first fail-open-on-dependency-error philosophy. A genuine
ErrTooManyStreams / ErrTooManyTranscodes over-cap still refuses. Adds tests
for both the fail-open and still-refused paths.

* fix(playback): forward stream token to transcode node as header

The dedicated transcode node's reconstruct path reads the stream token only
from the X-Silo-Stream-Token header, but proxyToTranscodeNode forwarded only
the node-API bearer token (and #5 now strips st from the URL). So when the
central API proxied to the node and the node self-restarted, it could not
reconstruct from the recipe-complete native token -> 404.

Capture st before stripping it from the URL, verify it at the API boundary
(streamtoken.Verify + SessionID match, mirroring the node's own check), and
forward it as X-Silo-Stream-Token. Best-effort: a missing/invalid token never
blocks the live proxy, and the token is still kept out of the forwarded URL
and logs.

* fix(playback): restart node ffmpeg on native remote audio switch

A native audio-track switch on an offloaded/remote transcode was a no-op at
the node yet returned 200 with a fresh URL: HandleChangeAudioTrack restarted
ffmpeg only when the API owned a LOCAL TranscodeSession, so for an offloaded
transcode the node kept serving the OLD audio (the node consults the token
only on a session miss). The replacement URL was also minted from identity-
only claims, so a later node restart 404'd.

For the offloaded transcode case (detected via session.TranscodeNodeURL),
POST a fresh /transcode/start to the node with the new AudioTrackIndex
(handleStart tears down and restarts ffmpeg) and mint the replacement proxy
URL from a full RecipeCard so reconstruct survives a node restart. The encode
recipe is derived from the durable session target fields plus the file,
mirroring HandleStartTranscode. A concrete SegmentDuration
(playback.DefaultSegmentDuration) is embedded rather than 0: the node's token
completeness gate treats SegmentDuration<=0 as incomplete and falls back to a
recipe store the native path never populates, which would 404 on a node
restart - the exact resilience this path provides. A failed node POST now
surfaces 502 rather than a false 200. Remux and non-offloaded (local)
transcode paths keep their prior identity-claim URLs unchanged.

Known limitation: Session does not persist the original SegmentDuration or
SubtitleTrackIndex/SubtitleBurnIn, so a remote audio switch resets subtitle
selection to none and assumes the default segment length; a client that
started with a non-default segment length will resegment on switch. Making
that state durable on the session is a follow-up.

* docs(playback): scrub stale deny-lease/revalidator comments

The deny-lease revocation mechanism and its "central revalidator" were removed
earlier in this branch, but four comments still described them as live
(transcode_manager.go, noderecipe/store.go, streamtoken/token.go,
proxy/server.go). Reword them to match the shipped behavior: ownership claims
are re-resolved at reconstruct, the noderecipe store shares Redis only with the
node-session tracker, and a sub-TTL hard cut depends on a node-side revocation
mechanism that is deferred to a future PR.

* fix(jellycompat): surface durable playback-session write failures

DurableCompatPlaybackStore.Update applied the in-memory mutation and then
swallowed every Postgres commit-failure path, returning nil. Callers that
promise restart resilience (persistTranscodeRecipe's recipe write, the
upstream-session binds in streams.go) were told the session was durably
persisted when only the cache held it, so a transient DB hiccup could leave
the next restart reloading a stale row (wrong audio track) or 404ing.

updateDB now returns the genuine DB round-trip error (begin/query/unmarshal/
marshal/exec/commit); Update propagates it while still applying the in-memory
mutation so live state stays correct. A nil pool and a genuinely absent/expired
row remain best-effort (return nil) — only real infrastructure failures
propagate, so existing rollback paths fire exactly when durability is lost.

Part of #174

* fix(playback): re-inject stream token into proxied transcode manifests

API-proxied remote transcode manifests dropped the reconstruct token from
their segment URLs, so playback died after a node or API restart. When a
remote transcode has no separate proxy node, the client loads its manifest via
the API-local path; proxyToTranscodeNode strips the signed token ("st") from
the forwarded URL (keeping it off node URLs and logs, forwarded only as the
X-Silo-Stream-Token header), and the node builds relative segment URIs from
that token-less query. The segment URLs the client received carried no token,
and the proxy only re-attached the header when an incoming segment request
already had "st" — which it never did — so a restart made those segments
non-reconstructable and they 404'd.

proxyToTranscodeNode now rewrites the manifest body at the boundary: every
segment and #EXT-X-MAP init URI gets the client-facing, API-verified token
re-appended (new playback.AppendManifestQueryParam helper), so the client's
later segment fetches carry "st" again and reconstruct after a restart. The
token still never reaches the node URL or its logs. Only 200 .m3u8 responses
are rewritten (Content-Length corrected); segments stream through untouched.

Part of #174

* fix(playback): preserve subtitle/cadence recipe across offloaded audio switch

Switching audio on a remote (offloaded) transcode with burned-in subtitles
silently dropped them, and reset a non-default segment cadence. The offloaded
audio-switch restart rebuilt the node start request from Session state, but
Session/SessionStreamState retained no subtitle or segment-duration state
(only the live local ts.Opts() and the RecipeCard did), so the branch
hard-coded SubtitleTrackIndex:-1, SubtitleBurnIn:false and
SegmentDuration:Default — signing that altered recipe into the replacement
stream token. An audio switch then changed bytes beyond audio selection, and
any later reconstruct kept the wrong no-subtitle/wrong-cadence recipe.

Persist the byte-affecting recipe on the session: SubtitleTrackIndex,
SubtitleBurnIn and SegmentDuration are added to Session/SessionStreamState,
populated at start (finalizeTranscodeStart) and on post-restart reconstruct
(ReconstructSession from the card), carried forward on every audio-switch
state update, and read back when rebuilding the offloaded node request and its
recipe card. The restart now reproduces the exact live stream. Also resolves
the M-4b non-default segment_duration reset.

Part of #174

* fix(playback): serialize transcode spawn paths with a per-session lock

Reconstruct was single-flighted only against other reconstructs, so a
restart-driven segment reconstruct racing a quality/seek/audio fresh start
could spawn two ffmpeg processes writing the same output directory at once —
segment corruption, partial-write closes, orphaned processes, and skewed
active-job accounting. The atomic register-after-spawn (GetOrRegister / the
reconstruct compare-on-register) prevented a map leak but not the concurrent
disk writers, because the losing path had already spawned. The dedicated
transcode node had the same split between handleStart and spawnReconstruct.

Add a refcounted per-session lifecycle lock to both TranscodeManager and the
node Server, held across "check existing -> spawn -> register":
- reconstruct (doReconstructTranscode / spawnReconstruct) re-checks under the
  lock and yields to any live session instead of spawning a duplicate;
- the native and jellycompat fresh-start paths take the lock around their
  spawn+register (the native path also closes any session a reconstruct rebuilt
  in the meantime so its fresh ffmpeg is the sole writer);
- the node handleStart holds it across teardown+spawn+register.
The refcount drops the map entry once no path holds/waits, keeping it bounded.
GetOrRegisterTranscodeSession is removed — the lock supersedes it and keeping a
register-after-spawn primitive would invite reintroducing the race.

Part of #174

* fix(playback): serialize restart re-spawn under the session lifecycle lock

TranscodeSession.Restart() releases s.mu across cancel -> wait-for-done ->
re-exec and spawns ffmpeg into opts.OutputDir without holding the per-session
lifecycle lock. LockSessionLifecycle's contract (fresh start, restart,
reconstruct) requires restart to hold it too, but all five callers invoked
Restart unlocked: native audio-switch and segment-recovery, compat
audio-switch and segment-recovery, and the transcode-node segment-recovery.

A restart racing another restart (audio-switch vs segment-recovery) or a
fresh-start/reconstruct could land two ffmpeg processes writing the same
segment directory -- mixed timelines, init.mp4/segment mismatch, and an
orphaned-but-still-writing ffmpeg -- the exact concurrent-writer corruption
the lifecycle lock exists to prevent.

Add RestartSessionLocked (TranscodeManager) and restartSessionLocked (node
Server) that hold LockSessionLifecycle only across the cancel->respawn
transition, re-check that the handle is still the live mapped session under
the lock, and return ErrSessionSuperseded rather than re-spawning a stale
handle. Route all five call sites through them. The lock is released before
callers wait on segments so recovery latency is unchanged.

Tests: gating (restart blocks until the lifecycle lock frees, then spawns),
concurrent-restart serialization, and superseded re-check on both the manager
(covers native + compat) and node lock owners.

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-07-02 14:23:14 -04:00
631d95530a fix(catalogseed): cast v.id to bigint in bulkUpdatePeople (#274)
A standalone VALUES clause gives Postgres no target column to infer
parameter types from, so v.id defaulted to text and the UPDATE ... FROM
join predicate became bigint = text, failing seed imports with SQLSTATE
42883 during the people-update phase.

Fixes #267

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 14:08:15 -04:00
d590eda22a fix(libraries): exclude manga chapters from admin Unmatched queue (#275)
* fix(libraries): exclude manga chapters from admin Unmatched queue

Manga chapter rows are internal sub-units resolved through their series
and intentionally stay 'pending' with type='ebook', so they flooded the
admin Unmatched Items view even after the series matched successfully.
Apply the shared MangaChapterExclusionWhere guard to both the count and
list queries, matching every other catalog listing surface.

Fixes #204

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(libraries): scope unmatched-queue regression test with a unique per-run tag

The regression test seeded fixed literal titles and asserted an exact
total for q=Unmatched+Test, which is collision-prone against a shared
SILO_TEST_DATABASE_URL database: leftover or concurrently seeded rows
matching the literal query would skew the count and flake the test.

Embed a unique per-run tag (issue204-<unixnano>) in every seeded title
and scope the search query to that tag (URL-encoded), so the exact
total assertion only ever sees this run's rows. Assertions are
otherwise unchanged: the manga chapter stays excluded, the plain
pending ebook stays present, and total == 1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-07-02 14:08:02 -04:00
c75c519e3d fix(metadata): stop manual rematch from resurrecting recorded stale IDs (#276)
* fix(metadata): stop manual rematch from resurrecting recorded stale IDs

The Apply Match flow (ModeIdentify) re-injected durable provider IDs into
the identify request without checking stale_media_ids, so a known-dead
tmdb ID rode along, 404ed again during the Phase-2 fetch, and was
re-recorded with a fresh last_seen_at — the item never left the Stale
External IDs list and jumped back to the top after every rematch.

Filter recorded-stale IDs out of the injected durable set in
prepareProcessRequest. Caller-supplied IDs are untouched, so an admin
deliberately re-selecting a previously-stale ID still retries it (which
is also why the ModeIdentify suppression guard in processInternal stays).

Fixes #268

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(metadata): normalize provider-id keys so stale-ID suppression can't be bypassed by casing

Review on PR #276 flagged that suppressRecordedStaleProviderIDs lowercases
and trims the stored stale row's provider before looking it up in the
incoming map, while the map keys are used verbatim, and that
HandleApplyItemMatch passes req.ProviderIDs from the JSON body straight
into metadata.Process without the normalization the search endpoint
applies. A caller-supplied key like "TMDB" or " tmdb " therefore defeated
the suppression. The same normalization gap was previously flagged on
PR #182.

Fix both layers:
- HandleApplyItemMatch now runs req.ProviderIDs through
  normalizeMatchProviderIDs (same semantics as the search endpoint) and
  returns 400 when no non-blank entries remain, mirroring the existing
  empty-map rejection.
- suppressRecordedStaleProviderIDs now indexes the incoming map by
  normalized key and deletes the matching original keys, so suppression
  is robust regardless of caller casing or padding.

Adds regression tests at both layers: a metadata-level case where the
durable row arrives as "TMDB " while the stale row records "tmdb", and
handler-level cases asserting apply normalizes keys/values and rejects
all-blank provider-id maps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-07-02 14:07:34 -04:00
012006e7a7 test(access): add ListProgressSince to stubStore so tests compile (#277)
PR #258 added ListProgressSince to the userstore.UserStore interface but
did not update the access resolver test stub, breaking test compilation
for internal/access on main.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 14:07:28 -04:00
f983c54e87 fix(metadata): re-fetch titles/overviews when a library's metadata language changes (#278)
* fix(metadata): re-fetch titles/overviews when a library's metadata language changes

An item's default_metadata_language was stamped once at first match and
never updated: the canonical-language pin in mergeAndPersist routed any
refresh in a different language into the localization tables, the
upserts' COALESCE kept the old stamp forever, quick-mode library refresh
skipped complete items entirely, and changing the language in
HandleUpdateLibrary triggered nothing. Items stayed in the old language
no matter how often the admin refreshed (#211).

Four coupled changes:
- ProcessRequest.AdoptLanguage: folder-scoped manual refreshes adopt the
  library's language as the item's new canonical language when it
  differs from the stamp, rewriting the base row instead of localizing
  to the side. Only ModeManualRefresh adopts — scheduled refreshes merge
  fill-empty and would restamp without rewriting the text.
- Upsert language pins inverted (media_items, seasons, episodes): prefer
  the incoming non-empty default_metadata_language over the existing
  stamp. All existing callers send the unchanged stamp or empty, so
  behavior is unchanged outside adoption; the restamp is atomic with the
  canonical write.
- Quick-mode refresh lister now includes complete items whose stamp
  differs from the library's configured language.
- HandleUpdateLibrary enqueues a quick library metadata refresh when the
  metadata language changes, mirroring the paths-change rescan trigger.

Fixes #211

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(metadata): gate language adoption on field locks and library agreement

Two hardening fixes for AdoptLanguage from review of #278:

- Locked fields defeat the restamp: title/overview merges honor per-field
  locks, but the restamp was unconditional. An item with both language-
  bearing fields locked kept its old-language text yet got stamped the
  new language, so the quick-refresh mismatch predicate never flagged it
  again. mergeAndPersist now skips adoption when both name and overview
  are locked, falling back to the non-adopting behavior: the stamp stays
  put, isCanonicalWrite goes false, and the fetch routes to the
  localization tables exactly like a non-adopting refresh in that
  language does today. One locked field still adopts — the other is
  actually rewritten.

- Multi-library flip-flop: an item in libraries with different metadata
  languages had its canonical base row rewritten to whichever library
  refreshed last, oscillating forever. Process now requires every
  library containing the item (media_item_libraries) to resolve to the
  adoption target before setting AdoptLanguage, via the existing
  GetDistinctMetadataLanguagesForItem (which applies the same empty→en
  default as resolveFolderLanguage). Disagreement or a lookup failure
  keeps the current stamp — stable beats flip-flopping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-07-02 14:07:21 -04:00
a4402d4621 fix(playback): stop seek-restart churn and premature paused-session reaping (#279)
* fix(playback): stop seek-restart churn and premature paused-session reaping

Two latent defects (present since the initial migration) explain the
cross-client freezes on resume-after-pause, intro-skip, and manual seek
reported in #243:

1. Dueling transcode restarts. TranscodeSession.Restart had no
   concurrency guard, and SegmentRecoveryDecision told concurrent
   segment requests to restart again while a restart was already in
   flight (the restart window runs with running=false, so requests hit
   the restart-happy transcode_not_running branch before the Restarting
   check). Pipelined HLS segment requests therefore spawned restarts
   that kept killing the ffmpeg the player was waiting on — a 30s stall
   and another restart, surfacing as buffer-then-freeze. Restart is now
   single-flight and the recovery decision waits out an in-flight
   restart (WaitForSegment already polls politely through one).

2. Paused sessions were reaped after 2 minutes, killing the transcode
   with no revival path — every resume request then 404s. Clients that
   stop progress reporting while paused (tvOS; backgrounded web tabs)
   froze on play after a >5 minute pause. Paused grace is now 30
   minutes in both the in-memory session manager and the DB session
   cleaner.

Also unifies post-restart re-arming: the throttler and exit monitor are
re-armed via a restart hook installed at session creation and fired by
Restart itself, so the jellycompat seek path (which previously re-armed
nothing) and the audio-switch path (which re-armed only the throttler)
now behave identically to web segment recovery.

Follow-up (not in this PR): lazily revive a reaped session's transcode
on resume instead of 404ing, so even multi-hour pauses recover.

Fixes #243

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(playback): scope the restart-hook comment to handler-created sessions

The comment above SetRestartHook claimed the hook covers restarts from
'jellycompat seek', but jellycompat creates its own TranscodeSessions via
playback.StartTranscode in a separate registry and calls Restart directly;
those sessions never get the hook and never had throttler/exit-monitor
wiring to re-arm. Reword the comment (and the matching test comment) to
scope the guarantee to handler-created sessions.

Also fix TestRestartInvokesRestartHook to resolve `true` via PATH instead
of hardcoding /bin/true, which does not exist on macOS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-07-02 14:06:25 -04:00
866392fecd feat(notifications): announce new audiobooks and ebooks on server channels (#260)
Audiobook and ebook libraries previously never entered the Recently
Added pipeline: availability detection only ran for TV/movie/mixed
libraries and release_events only knew episode/movie kinds, so server
channels (Discord/generic webhooks) could not announce new audiobooks
or ebooks.

Generalize the movie path into a flat-item-kind registry
(internal/notifications/item_kind.go) driving availability detection,
recording, channel toggles, payload rendering, test fixtures, and the
admin backfill seeder. New kinds share a kind-discriminated
item_availability table; movie_availability stays as-is. Channels gain
notify_new_audiobooks/notify_new_ebooks toggles (default on, additive
API fields) and embeds carry the author from item_people. Flood-safe by
construction: existing libraries seed silently on their first
post-upgrade full scan.

Extract internal/librarykind to replace the is*LibraryType helper
copies that had drifted across scanner, libraryingest, and metadata
(metadata's movie check silently included mixed; now spelled
explicitly).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:42:28 -04:00
287ff3658f fix(jellycompat): keep still-playing direct-play sessions in the activity view (#262)
* test(jellycompat): implement ListProgressSince on userstore test fake

PR #258 added ListProgressSince to the userstore.UserStore interface but
missed the progressCountingStore stub, leaving the jellycompat test build
broken on main. Stub it with the same panic("unused") the sibling methods
use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(jellycompat): keep still-playing direct-play sessions in the activity view

An Infuse Static=true direct play skips PlaybackInfo, so its playback
reports carry a client-generated PlaySessionId. handlePlaybackReport only
resolved reports by exact PlaySessionId, so every report silently no-op'd:
the admin Activity View position froze, nothing refreshed the upstream
session's activity, and stale cleanup reaped the session ~45s in while the
client kept playing. Once reaped, ensureUpstreamPlayback early-returned the
dangling session id forever, so the stream card never came back.

Fix all three legs:
- handlePlaybackReport falls back to the same token-scoped route lookup the
  stream path uses (ItemId, then MediaSourceId) when the PlaySessionId is
  unknown, and revives a reaped upstream session when a progress report
  proves the client is still playing.
- HandleVideoStream marks an in-flight media transport around direct-play
  and remux serving, mirroring the native stream handler, so long-lived
  range transfers keep the session alive without progress reports.
- ensureUpstreamPlayback verifies the upstream session still exists before
  reusing it, recreating it under the same play session when it was reaped.

FindByRoute now compares RouteItemID with UUID normalization so report ids
match regardless of client dash/case formatting.

Closes #244

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(jellycompat): harden report fallback and upstream revive per adversarial review

Address three review findings on the issue #244 fix:

- Deterministic report binding: the stream path now records the client's
  own PlaySessionId (Static=true direct play never learns the server id)
  as an alias on the play session it binds, and playback reports resolve
  by that alias before falling back to the ambiguous item/source route
  scan. A Stopped report that only matched by bare route no longer tears
  the session down (it may not own it when the same item plays twice
  under one token); stale cleanup owns that path.
- Same-method revive closes any transcode still keyed to the reaped
  upstream id before recreating the session, so a second ffmpeg cannot
  start alongside an orphaned one.
- Attaching a new upstream session is now a compare-and-swap on the
  observed upstream id: the loser of a concurrent revive/range-request
  race stops its session and adopts the winner instead of leaving an
  orphan counted against the user's stream limits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(jellycompat): guard alias ambiguity and method switches in upstream attach

Second-round adversarial findings on the issue #244 fix:

- A client PlaySessionId alias now only resolves when it identifies
  exactly one live session for the token, and the candidate must agree
  with the report's ItemId/MediaSourceId. A reused or stale client id
  degrades to the route scan (progress still lands on the right item)
  instead of binding an arbitrary session, and an ambiguous Stopped
  report tears nothing down.
- A CAS loser only adopts the concurrent winner when it serves the same
  play method; a concurrent method switch surfaces 409 Conflict instead
  of continuing with mismatched transcode bookkeeping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(jellycompat): key audio-selection updates by the resolved play session id

Review follow-up (PR #262): after an alias or route fallback,
req.PlaySessionID is the client's own generated id and not a playback
store key, so the audio-track-change branch in handlePlaybackReport
silently dropped selection updates for Static direct-play sessions.
Use the resolved playSession.ID for the store mutation and logging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:37:32 -04:00
b250dbb59b fix(watchtogether): harden realtime sync, room lifecycle, and lobby UX (#273)
* fix(watchtogether): harden realtime sync, room lifecycle, and lobby UX

Remediates all findings from a deep review of the Watch Together feature.

Server:
- Serialize every websocket write (pong/error replies bypassed the write
  mutex, racing broadcasts on the same gorilla conn)
- Send room_closed with a reason on terminal connect failures so clients
  stop reconnecting to dead rooms
- Persist room state outside the service-wide mutex via a shared
  generation-CAS helper; drop ~450 lines of dead duplicated methods
- Measure transport latency from server-side ping/pong RTT (was one-way
  client-clock delta, poisoned by clock skew) and clamp the lead time
- Re-evaluate readiness when a waiting participant disconnects and add a
  30s waiting deadline that skips stragglers (activates ignoreWait)
- Guard the host-disconnect close timer against reconnect races
- Clamp buffering-report anchor moves; clear stale member sessions on
  selection change
- Janitor: evict empty live rooms and close rooms idle >24h
- Snapshot gains an additive members list with profile display names

Web:
- Surface terminal room errors (REST 404/410/403 and WS error codes) as
  closedReason instead of reconnecting forever on "Connecting..."
- Memoize the playback-sync hook and narrow VideoPlayer's video-listener
  effect deps to stop re-subscribing 13 listeners on every render
- Preserve invite-link destination through login/profile guards
- Lobby: terminal ended/missing-token states with CTAs, End-room confirm
  dialog, toast feedback via shared action helpers (dedup with player),
  participant list with guest Leave, mobile-visible connection status,
  document title, a11y labels/focus reveal, unified status dot component
- Delete dead useWatchTogetherRoom hook (345 lines, zero importers)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(watchtogether): guard indexed access in join-page keyboard nav for noUncheckedIndexedAccess

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(watchtogether): reconcile CAS conflicts without regressing newer state; roll back unpersisted waiting-resume

Addresses Codex review on PR #273:
- persistRoomChangeLocked now undoes the failed writer's optimistic
  generation increment and only adopts the refreshed database row when it
  is at least as new as the local copy, so a stale conflict refresh can
  no longer overwrite a concurrent writer's newer in-memory state (and a
  failed write can no longer leave a phantom generation)
- maybeResumeFromWaitingLocked restores the waiting state and re-arms the
  deadline when the resume transition fails to persist, instead of
  broadcasting a resume the database never recorded

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:34:02 -04:00
26089950af fix(player): update jassub so ASS subtitles stop rendering corrupted at original resolution (#263)
* fix(player): update jassub so ASS subtitles stop rendering corrupted at original resolution

At original resolution the web player renders ASS subtitles client-side via
JASSUB (lower resolutions transcode with server-side burn-in, which is why
dropping the quality worked around the bug). jassub 2.4.2's WebGL2 shaders
declare mediump precision, so on GPUs where mediump is fp16 the fragment
shader's texel coordinates quantize on large canvases and glyphs come out
sheared/banded (issue #209). Upstream fixed this in 2.5.5 ("always use highp
on webgl2"); 2.4.3–2.5.6 also fix wasm loading on current WebKit/Chromium,
subs not clearing between repaints, and zero-size resize handling.

The bump needs two companions:

- jassub >= 2.5.4 no longer ships its built-in default font file but still
  points at it, which would silently blank every track that relies on the
  default (we disable queryFonts). Vendor Liberation Sans (SIL OFL 1.1, the
  same file 2.4.2 shipped) and pass it via availableFonts so the missing
  asset is never referenced.
- Exclude jassub from Vite's dep optimizer (its module worker otherwise 404s
  in dev) and prebundle its two CJS deps for ESM interop.

Verified with a standalone harness (Playwright Chromium + WebKit 26.5):
2.4.2 fails to even instantiate its wasm on those engines, while 2.5.6
initializes and renders correctly with the vendored default font.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(player): colocate SIL OFL license with vendored Liberation Sans

Adversarial review flagged the redistributed font binary shipping without
its license text; copy the repo's existing Liberation license (same one
used for the pdfjs standard fonts) next to the asset and record provenance
in the import comment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:12:27 -04:00
d1fd7c236f fix(jellycompat): sync activity dashboard immediately on compat playback start/stop (#261)
* fix(jellycompat): sync activity dashboard immediately on compat playback start/stop

Compat (Infuse/Jellyfin) sessions were only reconciled into
playback_sessions_sync on the periodic 15s reconciler tick, so stopped
streams lingered in the admin activity dashboard and overlapped with
newly started ones as ghost sessions. Native playback handlers already
trigger an immediate SessionSyncer.SyncNow on start/stop; wire the same
syncer into the jellycompat playback handler and flush after
teardownPlaySession (Stopped report + ActiveEncodings teardown) and
after a new upstream session starts.

The stop-path sync detaches from request cancellation
(context.WithoutCancel) because clients often drop the connection right
after reporting a stop.

Also adds the ListProgressSince method to the jellycompat test fake so
the package's tests compile again after the downloads-v2 interface
change.

Part of #205

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(worker): serialize SyncNow and bound compat request-path session sync

Adversarial review follow-ups: SyncNow snapshots could commit out of
order when request-path syncs raced the periodic tick (an older
snapshot committing last would resurrect stopped sessions or drop
fresh ones), and the detached stop-path sync had no deadline, letting
a stalled DB pin request goroutines. Serialize snapshot capture +
reconcile under one lock and cap request-path syncs at 5s so failures
degrade to the periodic tick.

Part of #205

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(worker): coalesce immediate session syncs instead of queueing on a lock

Review iteration 2: a stop-triggered sync queued on a bare mutex could
expire its 5s deadline waiting behind a slow tick and fail before
removing the stopped row. Coalesce instead: one owner reconciles at a
time, callers that arrive mid-flight return immediately after flagging
a follow-up pass, and the owner re-captures a fresh (post-change)
snapshot afterwards. Request goroutines never block behind another
sync, snapshots still commit in capture order, and an expired-context
owner leaves the queued pass for the next tick instead of burning it.

Part of #205

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:07:09 -04:00
18e9856227 fix(web): detect HDR via video-dynamic-range media query for Firefox (#259)
* fix(web): detect HDR via video-dynamic-range media query for Firefox

Firefox's (dynamic-range: high) query reflects the browser canvas and
reports standard even on HDR-enabled displays, so the player capability
check always sent hdr=false. Firefox 116+ exposes the video plane via
(video-dynamic-range: high); accept either query. Unknown media features
parse as non-matching, so the extra query is safe in other browsers.

Closes #206

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(web): keep Window receiver when passing matchMedia to HDR helper

Invoking a detached matchMedia reference throws (Illegal invocation /
TypeError) in some browsers, crashing capability detection instead of
reporting HDR. Wrap it in an arrow so the Window receiver is preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:13:51 -04:00
57fbc1e0b4 perf(literaryworks): cut Postgres load on ebook↔audiobook auto-linking (#253)
* perf(literaryworks): cut Postgres load on ebook↔audiobook auto-linking

Rescans and candidate matching were driving high Postgres CPU on book
libraries.

- AutoLinkContent now checks literary_work_items with a cheap indexed
  lookup before GetMatchItem, so unchanged already-linked books skip the
  heavy triple-lateral query on every rescan.
- ListMatchCandidates is split into an indexable ID-selection phase
  (title / provider EXISTS / series EXISTS) and a hydration phase, so the
  per-row lateral aggregates run only for the LIMIT candidates kept rather
  than for every opposite-format book.
- Add a partial LOWER(title) index scoped to ebook/audiobook so the OR of
  title/provider/series filters can be driven entirely by indexes.

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

* fix(migrations): drop invalid leftover index before concurrent rebuild

An interrupted CREATE INDEX CONCURRENTLY (cancel/restart) can leave an
INVALID idx_media_items_books_title_lower; IF NOT EXISTS would then skip
the rebuild while Goose records success. Add the preflight invalid-index
drop used by the repo's other concurrent-index migrations.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:21:25 -04:00
9cae868a27 feat(downloads): offline sync for mobile — downloads v2 (#258)
* feat(downloads): offline sync for mobile (downloads v2)

Replace internal/download with a unified internal/downloads package and add
fully-offline download + watch-sync support for mobile clients, across five
independently-shippable phases:

- Phase 0: reshape the downloads table and the /downloads contract to be
  device- and format-aware; add GET /downloads/capability; extend
  DownloadConfig (default-off keys); update the web download hooks/components in
  lockstep. This is the one approved pre-lock exception to the additive-only
  /api/v1 rule (the web app is the only consumer and is updated together).
- Phase 1: managed device-library entries (create/list/PATCH/delete/serve),
  keyed on the X-Silo-Device-Id header.
- Phase 2: offline playback manifest plus artwork/subtitle proxy endpoints that
  strip every presigned URL (inline thumbhashes + authenticated proxies).
- Phase 3: prepare-to-file (remux + transcode-to-single-file) as a durable,
  leased artifact queue with startup recovery, hosted on the task manager;
  playback.PrepareFile emits one +faststart MP4. Adds the admin transcode
  toggle and per-artifact LRU cleanup.
- Phase 4: offline progress reconciliation -- a clamped event_at LWW key plus a
  server-assigned synced_seq cursor on watch_progress; an optional clamped
  updated_at on POST /sync/progress and an opaque ?since= cursor on
  GET /progress (additive; existing callers unaffected).

Security & reliability invariants, each with an acceptance test:
1. Server-owned sync ordering: ?since= delta delivery is driven only by the
   server-assigned synced_seq; the client clock is bounded (event_at, clamped
   to now+skew) and used only for last-write-wins on the caller's own profile.
2. Full profile+device authorization on every managed endpoint, with a
   per-profile content/library access re-check before serving any bytes/assets.
3. Durable artifact recovery: a transactionally-claimed (FOR UPDATE SKIP
   LOCKED), lease-heartbeat, attempt-counted queue with a startup sweep, so no
   crash strands a download in preparing and concurrent workers never
   double-encode.

Migrations are timestamped Goose files: reshape downloads (device/format);
download_artifacts (durable queue); watch_progress event_at/synced_seq.

DB-backed acceptance tests skip without SILO_TEST_DATABASE_URL and run in CI;
the invariant-1 progress test also runs against the real SQLite backend locally.

Client repos (silo-android, silo-apple) consume the reshaped /downloads/*
contract and the updated_at/?since= progress fields and require coordinated
follow-up.

Implements the maintainer-approved v1 capability proposal for offline sync
(downloads v2).

AI-use disclosure: implemented by Claude (Claude Code) from the approved design
doc under docs/superpowers/specs, with human review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(downloads): series & season downloads + client-pull monitoring

Build season downloads and a "monitor a series" capability on top of the
downloads v2 (offline sync for mobile) work.

Season downloads:
- POST /downloads accepts season_number (with series:true) to download one
  season. CreateSeries/CreateSeason share one body via a listEpisodes closure
  and register managed entries under a shared batch_id (original-only). Episode
  files are resolved in a single batched query.

Series monitoring (auto-download), client-driven:
- New device-scoped download_subscriptions table with a Sonarr-style mode
  (all | future | latest_season | specific_seasons), a client-enforced
  delete_watched flag, and a max_storage_bytes cap. The server never deletes
  on-device files; retention and the hard cap are the client's, the server
  only soft-gates registration.
- The client calls POST /downloads/subscriptions/sync on open / background
  refresh; the server registers the in-scope, not-yet-downloaded episodes
  (idempotent via the managed-entry unique index) and the device pulls them on
  its own schedule. No background worker and no dependency on the notifications
  subsystem. latest_season follows new seasons (>= subscribe-time season);
  future excludes the back catalog via air date.
- Subscription CRUD + sync are profile+device authorized (device id from the
  X-Silo-Device-Id header only) with a per-request content-access re-check. The
  capability endpoint advertises season_download / series_monitoring /
  monitoring_modes.

Also lands the downloads-v2 work already present in the tree: durable artifact
(remux/transcode) preparation and offline watch-progress reconciliation, plus
the design-spec updates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* WIP: epitaxy pre-switch from feat/downloads-v2-offline-sync

* test(downloads): fix deterministic ID collision in reconcile test

Artifact IDs are time-sortable, so two artifacts created in the same
moment share their first 8 chars; combined with a captured timestamp the
two preparing-download IDs collided on downloads_pkey. Use the full
artifact ID, which is unique per row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): support sqlite userdb backend for managed downloads

With the sqlite userdb backend, profiles live only in per-user SQLite
stores and public.user_profiles stays empty, so user_devices'
profile FK made every managed create/subscription/offline-sync request
fail with an FK violation. Drop the FK (shared Postgres tables must not
FK profile tables — same rule as notifications) and replace the lost
cascade with an app-level purge on profile deletion, wired through
ProfileHandler for both backends. DB-backed regression tests cover the
no-Postgres-profile-row path and the purge cascade.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): dispatch encode kick asynchronously

triggerDrain invoked the kick inline, and the kick (taskmanager RunTask)
executes the encode task on the caller's goroutine — so a POST
/api/v1/downloads with a bitrate quality blocked the HTTP request on the
entire queue drain, ffmpeg encodes included, delaying the 202 by minutes
on an idle queue. Dispatch the kick on a goroutine; the task manager
already serializes concurrent runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): enforce per-user quota on the encode pipeline

Two gaps let a user bypass MaxConcurrentPerUser entirely for prepared
downloads: artifact-backed rows are created in 'preparing' (never
'queued'/'downloading'), which CountActiveByUser didn't count, and
createArtifactDownload enqueued the encode job before limiter.Check, so
even a 429-rejected request left a job the worker would transcode.
Count 'preparing' as active and check the limiter before Ensure; managed
replacements stay quota-exempt since they don't add a row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): protect ephemeral artifact links from LRU eviction

HasActiveLink only counted managed (device_id IS NOT NULL) rows, so
under a byte budget Cleanup could delete an artifact still referenced by
a ready-but-unfetched ephemeral web download — permanently 404ing a row
the API kept listing as ready (the artifact row is gone, so recovery
can't re-queue it). Any non-terminal link now protects the artifact;
only artifacts whose links are all cancelled/failed/revoked are
evictable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): batch manifests skip bad entries instead of failing whole batch

One deleted or access-filtered episode made GET
/downloads/batches/{id}/manifests 404 for the entire season, so a
client could no longer fetch manifests for the still-valid entries.
Report unbuildable entries in a skipped[] array (revoked | not_found |
error) alongside the delivered manifests, mirroring the create path's
skip idiom. Also cut the batch cost: the shared series detail is
resolved once per batch instead of once per episode, and buildSubtitles
reuses the already-loaded media file instead of re-querying it per
manifest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(migrations): wrap DO block in StatementBegin/End markers

Under NO TRANSACTION goose splits statements on semicolons, so the
dollar-quoted DO block failed every fresh install with 'unterminated
dollar-quoted string' (SQLSTATE 42601). Already-applied databases are
unaffected. Same fix is being applied to main; identical content merges
cleanly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): allow season 0 (Specials) in season downloads

season_number was a plain int dispatched with '> 0', so requesting the
Specials season was indistinguishable from omitting the field and
silently broadened to a full-series download. Dispatch on pointer
presence, treat 0 as the Specials season, and reject negatives with 400.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): capability quality_presets is never JSON null

PresetsFor returned a nil slice when downloads are disabled or the user
lacks the permission, and Capability's []string{} initialization was
immediately overwritten by it — so GET /downloads/capability serialized
"quality_presets": null where the contract documents an array.
Normalize at the source so every caller inherits the guarantee.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): subscription sync correctness + batched registration

Three subscription fixes:

- A paused subscription no longer syncs: PATCHing scope (or pausing and
  changing scope in one request) registered episodes for a monitor the
  user had just stopped, inconsistently with SyncSubscriptions' guard.
- SubModeFuture compares calendar days (UTC): air_date is date-only, so
  the strict instant comparison permanently excluded episodes airing the
  same day the user subscribed; episodes with no air date now fall back
  to their ingest time instead of never registering.
- Registration is one batched fetch (GetManagedEntriesByKeys) plus one
  batched INSERT ... ON CONFLICT DO NOTHING RETURNING
  (CreateManagedEntriesBatch) instead of a SELECT+INSERT per episode —
  a 300-episode series cost ~600 sequential round trips per request and
  every no-op sync re-walked the full set. RETURNING yields exactly the
  new rows, so the sync response's 'registered' count now honestly
  reports 0 in the steady state instead of the full in-scope count on
  every app open. The now-unused InsertManagedEntryIfAbsent is removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(userstore): stamp triggers own the event_at LWW key

MarkProgressBatch (jellycompat series mark-played) advanced updated_at
but never event_at, and both stamp triggers only defaulted event_at when
NULL — so a queued offline event with a client time between the row's
old event_at and the mark could win SetProgressIfNewer and resurrect a
stale resume position that then re-synced to every device.

Make the triggers authoritative instead of adding a tenth hand-written
SET clause: whenever an UPDATE changes updated_at without explicitly
changing event_at, the trigger advances the LWW key; writes that do set
event_at (offline sync's clamped client event time) keep their value.
Postgres gets a CREATE OR REPLACE migration; SQLite gets a v12 userdb
migration that drops and reinstalls the trigger bodies (CREATE TRIGGER
IF NOT EXISTS never replaces). Conformance tests cover both batch paths,
the preserved-client-time invariant, and the v11→v12 upgrade.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): lifecycle hygiene — squash migrations, dead status, stale-row sweeps

Migrations: fold the 20260621 corrective migration back into the base
Downloads V2 migrations (its columns/constraints already exist there)
and fix the reshape Down, which re-added the narrow status CHECK without
collapsing managed-lifecycle rows first — rollback aborted on any DB
with preparing/ready/revoked rows; validated against a live row. Branch
databases that applied the corrective migration need its version row
removed: DELETE FROM goose_db_version WHERE version_id = 20260621020459.

Code: drop the dead 'registered' status (nothing ever wrote it; the
lifecycle is preparing -> ready; 'revoked' stays reserved for the
planned admin revoke flow) along with unused KindDirect and
ErrInvalidFormat.

Sweeps: Cleanup now runs an age-based hygiene pass independent of the
byte budget — cold terminally-failed artifacts (with .part leftovers),
orphaned ready artifacts no download row references, and ephemeral web
rows older than their convenience-record lifetime (also unpinning their
artifacts and bounding GET /downloads growth). The byte budget remains
the disk quota per the limits & restrictions design.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(downloads): sync API doc with v2 fixes; HEAD on file route; Android handoff

Document the contract changes from the review fixes: batch-manifest
skipped[] shape, honest subscription 'registered' semantics, season 0 =
Specials, always-array quality_presets, bytes_sent actual behavior,
ephemeral 7-day retention, header-pairing requirement, progress-delta
deletion caveat, and the ready/failed push event schema (new §9.4).
Add an Android client handoff section (§11) mirroring the Apple one,
register HEAD on /downloads/{id}/file for download stacks that probe
before ranged GETs, and add season_number to the web create-request
type. Flag the /direct-download session-token-in-URL tradeoff; a
short-lived download-scoped URL is a follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor: consolidate download/progress helpers, prune dead code, gate sweeps

Behavior-preserving consolidation from the Downloads V2 review:

- appendVideoFilterArgs: one home for the burn-in/hwaccel -vf selection,
  shared by the HLS builder and the single-file prepare builder (the
  drift pattern that already bit tone-mapping once).
- userstore.ResolveProgressState: one home for the min-resume/watched
  threshold rule, replacing five identical copies across both store
  backends and the offline-sync ingest.
- Download file selection ranks resolutions via access.CompareQuality
  (adds 4320p, agrees with playback) instead of a private switch.
- writeSubtitle uses the shared subtitles.SubtitleContentType mapping.
- config.DefaultTranscodeDir replaces three '/tmp/silo-transcode'
  literals.
- Read-side quality/revision defaulting helpers removed: insertArgs plus
  the NOT NULL/CHECK schema already guarantee the invariant.
- Dead code removed: Repository.ListByUser, SubscriptionRepository.
  ListActiveBySeries, and the stale auto-register-worker comments (the
  design is client-pull; no worker exists).
- Redundant left-prefix indexes dropped from the base migrations (their
  unique indexes serve the same prefixes).
- recover()'s disk-presence sweep and the stale-row hygiene sweep run on
  startup then hourly instead of every 30s tick (both are O(cache
  size)).
- gofmt/prettier fixes for pre-existing drift in handlers/playback.go
  and pages/Profiles.tsx.

Deferred (noted for follow-ups): quality-ladder preset table collides
with the drafted download limits & restrictions design, which specifies
its own ladder helper; Download-literal construction consolidation and
the managed-identity value object remain open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(downloads): draft download limits & restrictions design

Design input for the follow-up v1 capability proposal (quality ceiling,
batch size cap, per-user quantity/bandwidth overrides). Committed with
downloads v2 because the remediation work explicitly defers the quality
ladder refactor and revocation wiring to this spec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(progress): reject malformed updated_at; clamp negative progress inputs

Review findings on #258:

- A malformed (non-RFC3339) updated_at in POST /sync/progress previously
  parsed to the zero time, which clampEventAt treated as "now" — letting a
  stale offline event win LWW as a fresh server-time write. The item is now
  rejected with a per-item error instead.
- ResolveProgressState now clamps negative position/duration before
  classification so no backend can persist negative progress through
  UpdateProgress/SetProgress.
- The online-write event_at invariant test is table-driven over both
  SetProgress and UpdateProgress, which share the same contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(downloads): close review gaps — permission gates, file-access recheck, artifact-true manifests

Review findings on #258:

- UpdateSubscription now applies the same feature/DownloadAllowed gate as
  CreateSubscription and SyncSubscriptions; a PATCH could previously
  re-activate or widen a monitor and register managed rows after an admin
  disabled downloads or revoked the user.
- Serving download bytes (managed and ephemeral) and /direct-download now
  mirror playback's per-file authorization via catalog.FileAllowedByAccess:
  library scope and the profile's max playback quality are re-checked at
  serve time, with artifact-backed rows checked against the artifact's
  resolution (a 720p transcode of a 4K source stays servable under a 1080p
  ceiling).
- Offline manifests for remux/transcode entries now describe the prepared
  artifact (container, codecs, resolution, single selected audio track)
  instead of the catalog source file the client never receives.
- ArtifactRepository.Requeue reports ErrNotFound when the row was
  concurrently swept; ArtifactManager.Ensure recreates the job in that case
  instead of linking downloads to a dead artifact id.
- "No downloadable episodes" is a sentinel (mapped to 404
  no_downloadable_episodes) rather than a bare error that surfaced as 500.
- Subscription season_numbers are bounds-checked (0–9999) before the int32
  narrowing in the repo could silently wrap them.
- HandlePatchDownload reuses requireManaged instead of hand-rolling the
  same managed-identity checks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:05:36 -04:00
1e8f79fe18 fix(api): surface ListDownloadedSubtitles errors instead of masking as 404 (#256)
The web subtitle handler wrapped the downloaded-subtitle lookup in
`if err == nil { ... }`, so a real DB/backing-store failure from
ListDownloadedSubtitles was silently swallowed and control fell through
to a generic 404 ("Subtitle track not found") with no server-side signal.
This made genuine internal failures look like an intermittent client-side
"subtitles won't render" bug and left nothing in the logs to diagnose.

On error, log at ERROR (with file_id/track/error, matching the sibling
font-extraction path) and return 500 — mirroring the neighbouring error
handling in the same branch (S3 GetObject -> 502, ConvertToVTT -> 500).
The genuine not-found fall-through is preserved for the case where the
listing succeeds but the requested index is out of range.

Closes #248

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 18:13:46 -04:00
QuickandClaude Fable 5 9c6252dace chore: gitignore docker-compose.local.yml dev-deploy override
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 18:11:57 -04:00
8b3ebc78b6 fix(api): show symlinked directories in folder browser (#257)
os.ReadDir reports lstat semantics, so a symlink pointing at a directory
has IsDir() == false and was silently dropped by HandleBrowse. Library
folders reached through directory symlinks (e.g. a Docker bind mount whose
entries link into a FUSE mount) never appeared in the folder picker even
though the targets were valid and browsable.

Resolve non-directory entries with os.Stat to follow the link and include
them when the target is a directory; entries whose target cannot be stat'd
(broken or looping links) are skipped rather than erroring the request.
Regular directories keep the lstat fast path and take no extra syscall.

Closes #208

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 18:10:56 -04:00
2486345679 Reduce metadata image cache R2 churn (#249)
* Reduce metadata image cache R2 churn

* Preserve image cache failure cooldowns

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
2026-07-01 17:37:06 -04:00
eb35141ecc fix(watchsync): correct Trakt TV episode sync payload (#254)
* fix(watchsync): omit empty Trakt episode ids so series mark-watched targets the right show

omitempty on a struct value is a no-op in encoding/json, so the history
export sent all-zero episode ids ({tmdb:0,tvdb:0}); Trakt matched the
degenerate id to one default show, mis-recording every watched series.
Make episode IDs a *traktIDs pointer and attach it only when a real id
exists, else use the show + season/number fallback (mirroring scrobble).
Adds a debug log on the show-fallback path and payload tests.

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

* fix(watchsync): sync TV episodes to Trakt via valid nested shows payload

Episode history exports were dropped by Trakt because they were emitted
into the flat episodes[] array with a bogus sibling show object plus
season/number keys — a shape the Trakt API does not accept, so it
silently discarded them (200/201 with no history recorded). Movies were
unaffected since they always carry their own external IDs.

Two coordinated changes:

- watchstate/identity.go: ResolveHistoryIdentity now carries the
  episode's own imdb/tmdb/tvdb IDs (already stored on the episodes
  table) so episodes with real IDs export via the flat episodes[].ids
  form, matching how movies work.

- watchsync/providers/trakt/provider.go: episodes without their own ID
  now serialize into the correct nested shows[].seasons[].episodes[]
  structure keyed by the show's IDs, merging episodes by show/season.
  Same fix applied to the history-remove payload. Empty-payload guards
  account for the new shows[] list.

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

* fix(watchstate): keep episode identity when episode has its own IDs

Address CodeRabbit review on PR #254: the episode identity builder
dropped the whole identity whenever series IDs were empty, so episodes
with a valid episode IMDb/TMDB/TVDB ID but no series IDs never reached
the flat episodes[].ids Trakt path. Only require series IDs when the
episode has no IDs of its own (nested show fallback).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 17:36:47 -04:00
cf0db385f3 Add Apple push notifications support (#255)
* Add push notifications support

* fix(notifications): address push notification review findings

- Gate the capability endpoint's apple_push availability on the admin
  delivery toggle, matching web push: Available now means setup will
  actually deliver.
- Reject direct admin writes to push_relay_deployment_id/api_key; the
  relay issues them as a pair during registration and a lone write
  desyncs them (and poisons the next rotation request).
- Purge a device's registrations under other profiles when it
  re-registers, so a profile switch on a shared device stops the old
  profile's pushes (attempts cascade); adds a DB-backed test.
- Extract the shared channelDispatcher core + retry sweep and rebuild
  the webhook/web push/Apple push dispatchers on it instead of keeping
  three copies of the worker-pool/retry loop.
- Deduplicate relay URL validation (admin setting + register flow) and
  the push outbox attempt-building loops behind shared helpers.
- Cap free-text decline reasons in notification display bodies.
- Fix TestHandleApplePushDisplayDB expectations to match the shared
  display copy (test previously failed under SILO_TEST_DATABASE_URL).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(notifications): route push relay URL writes through registration only

Direct writes to notifications.push_relay_url via the admin settings
endpoint bypassed the relay registration flow, letting the stored URL
drift out of sync with the deployment id / API key pair the relay
minted for it. Reject the URL alongside the deployment id and API key
in the settings handler; POST /admin/notifications/push/relay/register
remains the only path that persists all three together.

The admin UI's Relay URL field now edits local draft state and is
applied by the Register/Rotate action instead of the settings save.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 17:25:16 -04:00
eafa22a3c3 fix(jellycompat): improve audio track selection and display titles (#240)
- preferredAudioStreamIndex: when default track is TrueHD/MLP and device
  profile doesn't explicitly list that codec, falls back to best compat
  track (EAC3 > AC3 > DTS > AAC…), same language preferred. Prevents
  Android TV clients receiving undecoded lossless passthrough streams.
- audioTrackDisplayTitle: structured display title (e.g. "English - EAC3 5.1")
  replacing the raw firstNonEmpty fallback. Suppresses generic embedded
  labels (Stereo, Surround) that add no information.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 09:06:38 -04:00
CoffeeKnyteandGitHub 652b5d1e9f perf(jellycompat): fast-paths for slow Jellyfin-compat queries (#247)
* perf(jellycompat): fast-paths for slow Jellyfin-compat queries

Rebuilds the jellycompat slow-query fast-paths cleanly on main, without
the hub-sections virtual-library feature (which is intentionally absent
on this branch). Each path is bounded so behavior stays predictable
under load.

Fast-paths:
- Episode catalog: add the load-bearing `si.type = 'series'` hydration
  guard plus a matching entry-scan series-parent guard, so the page and
  count scans stay aligned with hydration (no under-filled pages, no
  inflated TotalRecordCount from podcast/audiobook episode rows).
- /Shows/{id}/Episodes AdjacentTo: serve a bounded prev/self/next
  neighbor window with series-membership validation and a cross-series
  id guard, falling back to the full listing.
- Resume: serve Continue Watching through the capped native
  continue-watching fetcher (next-up suppressed, in-progress only)
  instead of loadProgressPage's unbounded scan. Wires only the
  sections read-time fetcher (StoreProvider) into ItemsHandler; no
  hub-section/virtual-library exposure.
- Played-history: push the type + library filter down into SQL.
- recently_added: fan a no-parentId browse out across libraries with an
  app-side dedup merge, clamped to BrowseFilters.MaxLimit, and exclude
  compat-hidden (audiobook/podcast) libraries from the fan-out.
- Detail fields: batch per-item GetItemDetail in the Latest/browse
  detail-field paths via GetItemDetailsByIDs, with per-item fallback.
- Search: term-length and result-count guards for aux search.

* perf(jellycompat): clamp Resume fast-path fetch to maxResumeItems

A large client-supplied StartIndex on /UserItems/Resume inflated
loadResumeViaSections' fetchLimit (pageSize + StartIndex) and fed it
straight into ResolvedSection.ItemLimit, letting the continue-watching
fetcher scan up to its continueProgressMaxScanned hard cap (1000 rows)
instead of stopping near maxResumeItems. Clamp fetchLimit to
maxResumeItems and short-circuit to an empty page when StartIndex is at
or beyond the cap (no visible row can land there), keeping the capped
fetcher's scan bounded and predictable under load.

Addresses CodeRabbit review finding on PR #247.
2026-07-01 09:06:24 -04:00
CoffeeKnyteandGitHub 4fb8f6a711 fix(metadata): merge duplicate people on refresh instead of looping on 23505 (#250)
* fix(metadata): merge duplicate people on refresh instead of looping on 23505

The background person-refresh worker re-selected the same people every cycle.
When a refresh resolved an external id (tmdb_id/imdb_id) already held by another
people row, the UPDATE violated a partial-unique index and raised SQLSTATE
23505; the tx rolled back so updated_at never advanced and FindCandidates
re-qualified the row forever. The underlying cause is two people rows for the
same human created from credits ingested with disjoint id sets (one tmdb-only,
one imdb-only) that BatchFindOrCreate never reconciled.

PersonRepository.Update now reconciles the collision instead of failing:

- The common no-collision path stays a single plain transaction (no added cost).
- On a 23505, updateResolvingConflicts runs the whole reconciliation in ONE
  transaction, retrying the write via savepoints so it commits atomically: it
  locks both rows FOR UPDATE in id order, then either merges the partner into
  the survivor (repoint item_people skipping duplicate credits, fold the
  partner's ids/fields onto the survivor, delete the partner) or, when the rows
  are not confidently the same human, drops just the conflicting id.
- canMergePeople requires compatible ids AND matching names, so a provider that
  hands the same id to two genuinely different people cannot trigger a
  destructive delete; that case falls to the non-destructive drop.
- A row merged away concurrently surfaces as pgx.ErrNoRows, which the refresh
  service maps to ErrPersonNotFound.

Existing stuck rows self-heal: they are still re-selected each cycle and now
merge (or drop) instead of looping, draining the warning population to zero.

Adds unit tests for the merge-decision logic (guard, compatibility,
field-folding, constraint mapping).

AI-use disclosure: implemented and adversarially reviewed with AI assistance.

* fix(metadata): preserve survivor's existing id when declining a person merge

The non-mergeable branch of resolveExternalIDConflict blanked the
conflicting external-id field before retrying the write. Because
execPersonUpdate is a full-row UPDATE, the retry persisted an empty
string and returned success, silently dropping a previously-valid
provider id (e.g. the admin PATCH path that mutates an existing id into
a colliding value) instead of leaving the row unchanged as the 23505
did.

Restore the locked survivor's currently-persisted value for the field so
the retried write is a no-op on that column: it commits without looping,
without deleting a possibly-distinct person, and without blanking an id
the survivor already held. Writing a row's own current value back can
never violate the unique index, so the field will not re-trigger the
conflict. The refresh-worker path is unchanged (survivor value is empty).

Convert clearExternalIDField into a general setExternalIDField setter and
extend its unit test to cover set-to-value and set-to-empty.
2026-07-01 09:05:58 -04:00
CoffeeKnyteandGitHub 1dbcf2cd9a fix(jellycompat): guard aux search paths and index-back person search (#252)
* fix(jellycompat): guard aux search paths and index-back person search

Short, recursive type-ahead terms (e.g. a single "a") against the
PostgreSQL people index and the in-memory collection/box-set filter
produced multi-second scans that pegged the server when a client fired
one search per keystroke. Every jellycompat search path except the
Meilisearch-backed /Items media search now rejects a provided SearchTerm
shorter than 3 runes without touching any backend, and caps results at
20 regardless of the client-requested Limit:
- /Persons        (PostgreSQL people scan)
- /Search/Hints   (catalog search)
- /Items BoxSet   (in-memory collection filter)

Shared policy + helpers live in search_guard.go (auxSearchMinTermLen=2,
i.e. reject 1-2 runes / allow 3+, and auxSearchMaxResults=20) with unit
coverage. The 3-rune floor is deliberate: 3 runes is the point where a
pg_trgm trigram index becomes usable, so the gate lines up with the index
and still lets legitimate short titles/names ("300", "Saw", 3-letter
actors) and 3-char type-ahead hints through.

PersonRepository.Search filters with `name ILIKE '%'||$1||'%'` rather
than `LOWER(name) LIKE '%'||LOWER($1)||'%'`. The old expression filtered
on LOWER(name), which the trigram GIN index idx_people_name_trgm (built
on name) could not serve, so every search fell back to an ordered index
scan on idx_people_name that walked the whole table for rare terms
(~300-400ms on the 889k-row production people table). ILIKE on name lets
pg_trgm serve rare 3+ char terms from the trigram index, while the planner
still picks the ordered btree scan with early termination for common
terms. ILIKE is case-insensitive, so behavior is preserved (including the
pre-existing treatment of % and _ in the term as LIKE wildcards).

Verified on production silo-postgres (889,302 people), parameterized query
under both custom and generic plan cache modes:
  rare 3-char 'qzx':  304ms -> 1-8ms   (trgm bitmap index)
  rare 4-char 'zzzz': 329ms -> 1-2ms   (trgm bitmap index)
  common 3-char 'ann':  15ms -> 7-74ms (btree early-stop / trgm bitmap)
New worst case across all terms is ~83ms (generic-plan common 3-char).

* fix(jellycompat): stop gating meilisearch hints, clamp box-set search

Two review follow-ups on the aux-search guards:

- HandleSearchHints is served by the catalog (Meilisearch-backed) search
  provider, which already bounds and short-term-handles its own results.
  Gating it with auxSearchTermTooShort contradicted the guard's own
  "non-Meilisearch paths only" policy and hid valid 1-2 char titles
  ("Up", "It") from global type-ahead. Drop the too-short gate there;
  keep the empty-term check and the result clamp.

- handleBoxSetsList gated short terms but passed query.limit straight to
  slicePage, which treats <=0 as no cap, so a box-set *search* was
  uncapped unlike Persons/Hints. Clamp the limit when a search term is
  present; empty-term browse keeps its client paging window.
2026-07-01 09:05:41 -04:00
Quick 75cf5e3b11 fix(nextup): include completed or partially watched items in NextUp query 2026-06-29 10:46:22 -04:00
QuickandGitHub 8afe783277 fix(jellycompat): honor StartItemId episode queues (#242)
* fix(jellycompat): honor StartItemId episode queues

* fix(jellycompat): compact episode queue models
2026-06-27 22:02:54 -04:00
QuickandGitHub 9cea2bb4f0 fix(metadata): enable AI translation on season and episode pages (#238) 2026-06-27 21:44:28 -04:00
QuickandGitHub a0fbe2cdda [codex] feat(jellycompat): use catalog search provider (#236)
* feat(jellycompat): use catalog search provider

* fix(jellycompat): route video search buckets to provider
2026-06-27 20:10:48 -04:00
QuickandClaude Opus 4.8 4d2a990de1 chore(skills): harden issue-to-pr with researched best practices
Folds in findings from Anthropic's Claude Code guidance and the agentic-SWE
literature:

- Manual-only trigger (disable-model-invocation) for this side-effecting,
  PR-creating workflow, per Anthropic's fix-issue template.
- Reproduce-first hard gate: confirm the bug exists on current main (red test
  before the fix); stop and report if it doesn't reproduce, rather than patching
  correct/stale code.
- Verification is the primary correctness signal; full-suite run for regressions;
  forbid reaching green by weakening/skipping tests or relaxing CI.
- Adversarial-review loop: confirm each finding is real/grounded before acting,
  fix only correctness/requirement-affecting findings (no gold-plating), and cap
  the loop at 2 (max 3) iterations before escalating.
- Guards against hallucinated APIs, plan-proportional-to-scope, and long-run
  context-loss (progress + failed-approaches notes, re-assert guardrails after
  compaction).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:07:36 -04:00
QuickandClaude Opus 4.8 96a279819e chore(skills): add issue-to-pr skill (worktree → fix → adversarial review → PR)
Adds a committed, shared Claude Code skill that takes a GitHub issue number,
creates an isolated worktree off the latest main, finds the root cause or
scopes the feature, implements and verifies the change, runs a Codex
adversarial review, and opens a ready PR.

Carves a narrow exception into the .gitignore /.claude/ rule so only this
skill directory is tracked; everything else under .claude/ stays ignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:07:36 -04:00
443445f0fe fix(catalog): make media_items scalar columns NOT NULL to stop NULL-scan crashes (#228)
media_items had many columns the Go model (models.MediaItem) declares as
non-pointer string/int fields, but migration 001 left them nullable. Five
scanners (catalog item_repo, catalog browse, jellycompat, sections, and
the catalog API handler) read these straight into the non-pointer fields,
so a NULL row panics with "cannot scan NULL into *string" (or *int).
item_repo papers over a subset (poster/backdrop/logo/metadata paths) with
COALESCE in its SELECT, but the other four scanners read the same columns
raw and crash; sort_title/original_title/etc. are not coalesced anywhere.

No writer stores a meaningful NULL (every insert/upsert passes the Go
field, '' or 0 at worst) and all sort/filter SQL already collapses NULL
and '' (e.g. COALESCE(NULLIF(BTRIM(sort_title), ''), title); "poster_path
IS NULL OR poster_path = ''"). Enforce the invariant the code already
assumes at the schema level rather than scattering COALESCE across every
current and future scanner. Mirrors what later migrations already did for
original_language, show_status, default_metadata_language, and the
*_source_path columns (all NOT NULL DEFAULT '').

- Migration: backfill existing NULLs, then NOT NULL DEFAULT '' on 15 text
  columns (sort_title, original_title, content_rating, overview, tagline,
  imdb_id/tmdb_id/tvdb_id, poster_path, poster_thumbhash, backdrop_path,
  backdrop_thumbhash, logo_path, metadata_s3_path, metadata_etag) and
  NOT NULL DEFAULT 0 on year/runtime.
- item_repo: SetLocalPoster and UpdateArtworkIfSourceMatches now store ''
  for empty thumbhashes (was NULLIF($,'')) — the only deliberate NULL
  writers — matching the upsert path.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:06:01 -04:00
02e62767a1 feat(watchsync): sync watchlists with Trakt/Simkl/MDBList (#227)
* feat(watchsync): sync watchlists with Trakt/Simkl/MDBList

Extend the watch-providers feature to sync a user's watchlist, generalizing
the existing favorites pipeline rather than duplicating it.

What changed
- Generalize the favorites sync into one ListKind-parameterized pipeline
  (internal/watchsync/lists.go) driving both favorites and watchlist; the
  per-favorites service methods are replaced by kind-generic ones. The shadow
  table watch_provider_favorite_items becomes watch_provider_list_items with a
  list_kind discriminator.
- Providers: Trakt gains watchlist sync (/sync/watchlist, distinct from
  favorites); Simkl gains plan-to-watch sync; MDBList is re-mapped from
  favorites to watchlist (its only list is a watchlist) — its capabilities now
  report import_favorites=false / import_watchlist=true, and the migration
  re-binds existing MDBList connections.
- Auto-remove watched items from the watchlist: a standalone, default-on
  profile preference (user_profiles.remove_watched_from_watchlist) removes a
  movie when watched and a series once every episode is watched. Implemented as
  watchstate.CompletionObserver (internal/watchlist.Maintainer), wired into the
  manual mark-watched, playback-stop, and jellycompat mark-played paths.
- Optional MDBList sort-order mirroring: an opt-in, capability-gated toggle
  mirrors MDBList's watchlist order into Silo via user_watchlist.sort_index;
  ListWatchlist orders by sort_index then added_at, so both /api/v1/watchlist
  and the catalog watchlist view inherit it.
- Real-time + scheduled: local add/remove pushes to connected providers
  immediately (removals gated by the opt-in removals toggle); the hourly job is
  the inbound/import + retry/reconcile path.
- Web: watch-provider settings gain watchlist import/export/removals and
  "mirror watchlist order" toggles plus watchlist sync stats.

Why
- The favorites and watchlist pipelines are ~90% identical; generalizing keeps
  one code path (per CLAUDE.md's anti-duplication guidance) instead of cloning.

API/compat
- All new fields on ConnectionStatus/Capabilities/ConnectionUpdate/SyncRun and
  the web types are additive (Silo v1 additive-only rule). No existing field is
  renamed, removed, or retyped.

Risks / follow-up
- MDBList capability flip is intentional and client-visible: silo-android /
  silo-apple may need to surface MDBList under the watchlist (not favorites) UI.
- MDBList existing users: their MDBList list previously mirrored Silo favorites
  and now mirrors Silo watchlist; the first post-migration sync is a union
  (removals default off), so nothing is destructively purged.
- Order mirroring reflects the order MDBList returns from /watchlist/items
  (couldn't confirm against their docs — Cloudflare-blocked); if it ever
  diverges from the UI sort, a sort param is the small follow-up.

Tests: new maintainer (auto-remove) and watchlist-order unit tests; provider +
service tests updated. go build, go test (affected pkgs), migrate-validate,
verify-local-paths, web prettier/eslint/tsc all pass.

AI-use disclosure: implemented with Claude Code (Claude Opus 4.8).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(watchsync): update list shadow table references

* fix(watchsync): address review — retry/progress + error propagation

Addresses CodeRabbit review on #227:
- maintainer: propagate transient catalog lookup errors instead of silently
  treating every items.GetByID failure as "maybe an episode".
- exportList: mark every queued item not confirmed sent (not_found, failed, or
  omitted) so the pending loop always advances; the next run's upsert clears the
  error and re-attempts, so transient failures still retry.
- removePendingListItems + realtime removal: treat Sent and NotFound as
  reconciled; leave true failures pending (no last_error, which would strand
  them from the removal query) so the scheduled run retries, using in-memory
  dedupe to terminate the loop.
- exportLocalListItems: send the normalized items (with computed
  ProviderItemKey), not the original event slice.
- UpdateConnection: clear mirrored watchlist order before persisting the disable
  and propagate failures, so a failed clear can't report "disabled" while
  sort_index ordering is still active.
- web: include favorite + watchlist removal counts in the exported "sent" total.
- test: align serviceFakeRepo list-state with Postgres (clear last_error on
  successful transitions); add maintainer error-propagation test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:05:53 -04:00
b4a66b29e2 fix(web): preserve untouched metadata levels when toggling a provider (#226)
toggleLevelProvider merged the toggled level into the levelChains state
object, which starts empty. After the first toggle the state collapsed to
a single level, so once chainDirty flipped true activeLevelChains dropped
every untouched level and the dialog rendered them empty.

Seed from the full resolved activeLevelChains map and write it back the
same way reorderLevel already does, so disabling a source on one TV level
no longer blanks the others on the first toggle.

The earlier redesign (b9a9613) only patched the per-level source fallback,
which kept the toggled level's items but did not preserve the rest of the
map; builds containing it still reproduced the issue.

Part of #213

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:46:40 -04:00
6854eac80e fix(web/deps): bump vulnerable transitive deps via pnpm overrides (#225)
Resolves 8 open Dependabot alerts in web/pnpm-lock.yaml (all dev-only
transitive dependencies) by pinning patched versions through the existing
pnpm.overrides mechanism:

- undici  7.24.7 -> 7.28.0  (via jsdom; alerts #43-#49, incl. 2 high:
  TLS cert validation bypass & cross-origin routing via SOCKS5 ProxyAgent)
- js-yaml 4.1.1  -> 4.2.0   (via @eslint/eslintrc; alert #39, quadratic DoS)
- @babel/core 7.29.0 -> 7.29.6 (via @vitejs/plugin-react; alert #38,
  arbitrary file read via sourceMappingURL)

No vulnerable versions remain in the lockfile. Web build and lint pass;
the only failing tests (ServerStorageStep ResizeObserver) are pre-existing
on main and unrelated to these bumps.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:37:43 -04:00
CoffeeKnyteandGitHub 6414061022 fix(collections): correct multi-library sync — match all bound libraries and dedupe items (#184)
* fix(collections): sync matches across all bound libraries

Collection sync resolved item membership against only the legacy
library_collections.library_id, so a collection bound to multiple
libraries (N:N library_collection_libraries) had its items collapsed to a
single library on every sync.

Switch the MDBList/TMDB/Trakt sync matchers to the existing
LibraryItemRepository.GetItemsInFolders over collection.LibraryIDs.
GetByID already populates LibraryIDs from the N:N table (falling back to
[LibraryID]), so single-library collections are unaffected. No schema
change; listing and the catalog open path were already N:N-aware.

Unblocks merging duplicated per-library collections into single
multi-library collections.

* fix(collections): dedupe matched items by media_item_id before ReplaceItems

A collection sync resolves source entries (external IDs) to library
content_ids and appends one item per matched entry without deduping by
media_item_id. When two different source entries resolve to the same
library item, the same content_id is appended twice; ReplaceItems then
hits the library_collection_items composite PK (collection_id,
media_item_id) with a duplicate-key violation, failing the whole sync
with an HTTP 500 and zero items.

The multi-library union added in the previous commit (GetItemsInFolders
over collection.LibraryIDs) widens the matched set and so raises the
collision probability, making this latent bug more likely to fire.

Dedupe at the single choke point in ReplaceItems (first occurrence wins,
preserving rank order) so all five sync paths are covered at once, and
renumber position over the surviving rows so it stays dense.

Also log the underlying error in HandleSyncAdminCollection, which
previously mapped every non-sentinel sync error to a generic 500 with
no log line, making this failure invisible on the server.
2026-06-26 12:28:14 -04:00
a3aa93534d fix(jellycompat): authenticate Android TV token-less direct play (#200)
* fix(jellycompat): authenticate Android TV token-less direct play

Stock Jellyfin Android TV ignores the api_key-bearing DirectStreamUrl
returned from PlaybackInfo and builds its own direct-play URL with no
auth header, no api_key/ApiKey query param, and no PlaySessionId. The
media request arrives via the player's HTTP stack (okhttp) with
auth_kind=none, so PlaybackSessionAuth 401s it — the client retries,
falls back to a transcode that stalls, and surfaces "player error".

Add a third fallback in PlaybackSessionAuth, scoped strictly to the
direct-play video stream routes (/Videos/{id}/stream[.{container}]) via
the chi route pattern so /Items/{id}/Download stays protected: anchor
auth on the PlaybackSession negotiated during the preceding (already
authenticated) PlaybackInfo, looked up by mediaSourceId when present
(else the route item id), and resolve its CompatToken.

Covered by tests: token-less direct play with a matching session
succeeds, no matching session 401s, and Download is not loosened.

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

* fix(jellycompat): require session item match + expand direct-play tests

Address CodeRabbit review on PR #200:
- Require the matched PlaybackSession's RouteItemID to equal the requested
  route item before authorizing, so a mediaSourceId cannot authorize a
  stream for a different item.
- Seed the compat session in the 401 tests so they fail on route/session
  scoping rather than a missing session.
- Table-drive the positive test across /Videos/{id}/stream and
  /Videos/{id}/stream.{container}, plus the route-item lookup branch when
  mediaSourceId is absent; add a cross-item denial test.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:27:37 -04:00
QuickandGitHub d83f7fadef fix(audiobooks): mirror ABS playback into live sessions (#203) 2026-06-26 12:27:12 -04:00
185a90b048 fix(jellycompat): honor client subtitle selection in PlaybackInfo (#222)
* fix(jellycompat): honor client subtitle selection in PlaybackInfo

Jellyfin clients send the chosen subtitle as SubtitleStreamIndex on
PlaybackInfo, but the compat layer only ever advertised the media's
embedded default subtitle (or none). Picking a subtitle before playback
had no effect and the video started without it — and an explicit
"subtitles off" was ignored when the media had a default subtitle.

Mirror the existing audio-selection plumbing for subtitles:
- parse SubtitleStreamIndex from the PlaybackInfo body and query
- persist it as SelectedSubtitleStreamIndex on the play-session source
- advertise it via DefaultSubtitleStreamIndex and flip IsDefault onto the
  chosen stream (embedded, external, or Silo-downloaded)
- honor a negative index as an explicit "subtitles off"

The selection is validated against streamable tracks (bitmap subs that
require burn-in are excluded, matching delivery) and the downloaded
subtitle range, falling back to the media default when invalid.

Out of scope (follow-ups): bitmap-subtitle burn-in transcode wiring and
mid-playback subtitle changes on progress reports.

Part of #217

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(jellycompat): handle downloaded-subtitle lookup failure in selection

Don't let a transient ListDownloadedSubtitles error masquerade as "no
downloaded subtitles", which silently downgraded a valid subtitle
selection to the media default. The lookup error is now logged, and
resolution honors a requested index it cannot validate (it may be a
downloaded subtitle) instead of discarding the user's choice, while
embedded/external selections continue to resolve normally.

Addresses CodeRabbit review feedback on #222.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:26:27 -04:00
QuickandGitHub 78d4f9acbf Merge pull request #223 from Silo-Server/codex/silodiscordtriage-httpsdiscordappcomchannels148783
Fix semantic-disabled Meilisearch settings
2026-06-26 12:12:31 -04:00
Quick 7f67bb80ad Fix semantic-disabled Meilisearch settings
- Omit Meilisearch embedders unless semantic search is enabled
- Default semantic ratio setting to 0.50
2026-06-26 12:09:45 -04:00
QuickandGitHub b21c3eea37 Merge pull request #221 from Silo-Server/codex/meilisearch-short-title-search
fix(search): tighten short meilisearch title queries
2026-06-26 10:53:11 -04:00
Quick f57c95e813 fix(search): update default semantic ratio for meilisearch settings 2026-06-26 10:47:02 -04:00
Quick a181586139 fix(search): keep meilisearch active during pending sync 2026-06-26 10:46:42 -04:00
Quick dd2784c0ee fix(search): tighten short meilisearch title queries 2026-06-26 10:13:15 -04:00
QuickandGitHub 4473f8c60b Merge pull request #220 from Silo-Server/codex/search-provider-interface
feat(search): add provider interface with initial Meilisearch support
2026-06-26 09:32:53 -04:00
Quick 125e94b429 fix(search): allow zero meilisearch task uid 2026-06-26 09:29:40 -04:00
QuickandClaude Opus 4.8 2a943550d4 fix(search): fold semantic toggle into catalog index schema version
catalogSearchMeilisearchSchemaVersion hashed the embedder, canonical dimensions, and index types but not SemanticEnabled. Because attachDocumentVectors omits _vectors when semantic is off, toggling semantic on without a full rebuild left previously indexed (and incrementally synced) documents without vectors while the Postgres coverage gate still reported the type "ready" — silently degrading hybrid ranking until a manual rebuild.

Folding SemanticEnabled into the schema version makes a toggle diverge from the stored version, so the provider falls back to keyword, sync skips, the admin status surfaces the schema mismatch, and a rebuild is required to restore hybrid — consistent with how embedder and index-type changes already behave.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 08:43:09 -04:00
QuickandClaude Opus 4.8 6b45cfd855 fix(scanner): track podcast show audio paths during folder reconcile
Extract listPodcastShowAudioFiles and record each show's audio paths in
seenPaths during ScanPodcastFolder so reconciliation does not treat
still-present episodes as removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 08:34:18 -04:00
QuickandClaude Opus 4.8 ed6c084c68 feat(search): gate index events by active provider and harden rebuild reconcile
Completes the search-provider-interface wiring that the catalog hardening
commits already call into:

- Skip the transactional search-index-event write path when Meilisearch is
  not the active provider (ItemRepository.WithActiveSearchProvider /
  SearchIndexEventRepository.disabledByActiveProvider).
- Dead-letter catalog_search_index_events after 10 attempts instead of
  retrying forever.
- Track the rebuild high-water mark (MaxEventID / MarkProcessedThrough) and
  persist last_processed_event_id in UpdateStateAfterRebuild so a rebuild
  reconciles events enqueued during the rebuild.
- Validate (read-only) the embedding lock when embedding a search query
  instead of establishing/mutating it.
- Surface total_exact on the legacy /items browse response.
- Wire the active catalog search provider into the scanner and item repo at
  startup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 08:34:04 -04:00
QuickandClaude Opus 4.8 91b5105f3c docs(search): add hybrid semantic search hardening plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 08:24:14 -04:00
QuickandClaude Opus 4.8 7080491f89 feat(web): surface semantic coverage and embedder capability in search admin
Render the new `semantic` block from the admin catalog-search status
endpoint in the Search Settings page: semantic readiness (with
disabled reason), overall vector coverage %, coverage-updated
timestamp, embedder capability, and a compact per-type coverage list.
Reuses the existing StatusRow/FieldGroup/Badge patterns and adds a
formatPercent helper; keeps the existing Vectorized Documents row.

The TS `semantic?` field is optional and the whole block is guarded so
a new frontend talking to an older backend during rollout degrades
gracefully.

Part of the hybrid-semantic-search hardening work (Task 8).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:10:33 -04:00
QuickandClaude Opus 4.8 13ac4b753c perf(recommendations): split embedding backfill into cheap and text-stale passes
EmbedAll previously ran the full text-staleness CTE on every page, paying
five item_people LATERAL joins per eligible row just to detect whether an
item's canonical text had drifted - even when the real work was embedding
brand-new (missing) items during active backfill.

Restructure EmbedAll into two passes:

- Pass 1 (cheap): drain missing/model-stale items via the repurposed
  ItemsNeedingEmbedding query (single LEFT JOIN, no LATERAL), paged by a
  content_id cursor so a failed/skipped item is retried next run instead of
  stalling the page.
- Pass 2 (expensive): only once Pass 1 fully drains, run one bounded
  ListEmbeddingTextCandidates scan (LIMIT embeddingTextStaleQuotaPerRun=200)
  to re-embed text-drifted items. Re-embedding refreshes canonical_text, so
  handled rows drop out next run - no Pass 2 cursor needed.

Coverage-first tradeoff (documented in EmbedAll): under steady state Pass 1
drains every run so text-stale items stay fresh; only under pathological
continuous heavy ingest does Pass 2 get skipped, deliberately prioritizing
covering new items over re-embedding changed ones.

Supporting changes:
- ItemsNeedingEmbedding gains an afterID cursor + ORDER BY; SQL extracted to
  buildItemsNeedingEmbeddingSQL for a cheap-shape unit test (no item_people /
  LATERAL).
- Add an `embedder` interface seam (Engine.embClient) so EmbedAll is testable
  with a fake; *embeddings.Client still satisfies it.
- Extract embedBatch to DRY both passes, preserving quota/billing early-return,
  single-item fallback, ensureEmbeddingLock-before-upsert, and skip-on-store-error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:02:20 -04:00
QuickandClaude Opus 4.8 a5ef464d95 feat(search): validate embedder capability and expose semantic status
Add a rate-limited Meilisearch embedder capability check and surface a
semantic-coverage block in the admin search status.

Capability validation confirms the active index declares the configured
embedder with source="userProvided" and dimensions matching the canonical
embedding dimension, then runs a unit-vector hybrid probe to confirm the
index accepts the request shape. The whole path (GetSettings + evaluate +
probe) is cached for 5m and NEVER trips the circuit, marks a fallback, or
modifies unhealthyUntil, so a misconfigured embedder cannot take keyword
search down. The pure evaluator (evaluateEmbedderSettings) is unit-tested
across missing/wrong-source/wrong-dimensions/ok with distinct reasons.

The admin status gains a Semantic block read entirely from the in-memory
coverage snapshot (Snapshot + CoverageReady) with no new DB query in
Status(); document_count/vector_document_count are unchanged (additive
only). buildSemanticStatus is pure and unit-tested (nil snapshot and a
populated, deterministically-sorted snapshot).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:43:21 -04:00
QuickandClaude Opus 4.8 dadd6f4769 feat(search): surface search diagnostics on catalog responses
Add per-query search observability to /api/v1/catalog. The internal
CatalogSearchResult now carries Mode + SemanticUsed alongside the existing
Provider + FallbackReason; all four are plumbed through CatalogResult (which
previously dropped them in resolveDirectSearchSource) and exposed as an
additive, omitempty search_diagnostics object on the catalog response.

Two correctness points:
- For Meilisearch, Mode/SemanticUsed are derived from the POST-downgrade
  request (baseSearchReq.Hybrid != nil) at the end of searchMeilisearch, so a
  hybrid->keyword self-downgrade reports mode="keyword", semantic_used=false.
- search_diagnostics is emitted iff a provider search actually ran
  (result.Provider != ""), which naturally omits browse, preview,
  non-relevance-sort q=, and group=work paths (none set Provider).

API additive-only: existing response fields keep declaration order and stay
byte-stable; only the new omitempty field is added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:32:30 -04:00
QuickandClaude Opus 4.8 4cc92c1579 feat(search): gate hybrid on vector coverage readiness
Wire the semantic-coverage building blocks together so the Meilisearch
provider only goes hybrid once the active embedding model covers enough of
the requested item types.

- Add Coverage SemanticCoverageGate to MeilisearchProviderConfig and consult
  it in buildMeilisearchSearchRequest after the wordcount check; when
  not-ready, record "semantic_not_ready: <reason>" as a diagnostic fallback
  and stay keyword-only (shouldUseSemanticSearch's bool contract unchanged).
- Build and own a semanticCoverageTracker in
  NewCatalogSearchServiceFromSettings, but only when semantic is enabled and
  a real pool is present. Derive the model provider via a panic-safe comma-ok
  helper (semanticModelProvider) so a nil or non-implementing vectorizer
  degrades to a not-ready gate instead of asserting a nil interface.
- Add StartCoverageRefresh and call it from NewRouter on deps.AppContext so
  the refresher runs for the process lifetime.

Coverage == nil preserves today's behavior exactly; existing hybrid and
SkipTotal tests stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:17:47 -04:00
QuickandClaude Opus 4.8 f40611409d feat(search): semantic coverage tracker with hysteresis gate
Add the in-memory coverage gate the search hot path will consult to
decide whether semantic results may be served for a set of item types.

The tracker reads an atomic.Pointer snapshot with no DB and no lock on
the read path, refreshes single-flight under a mutex on a 2-minute
ticker, and applies per-type hysteresis (enable 0.90 / disable 0.80,
holding the previous latch inside the band) to avoid flapping. It
collapses to not-ready when the active embedding model changes so a
stale latch cannot leak across models, and fails safe: a not-yet-
computed snapshot reports not-ready (never panics), and a model-lookup
or count-query error retains the last-good snapshot instead of
publishing zeros.

Refresh counts through the existing catalogSemanticCoverageByType via an
injectable fetch seam, so the behavior is unit-testable with canned
counts and a fake model provider (no pgx.Rows faking). Reuses the
Task-1 coverageQuerier and extends catalogTypeCoverage with Ratio/Ready,
filled by the pure computeCoverageSnapshot helper. Adds the
SemanticCoverageGate interface beside the other provider interfaces;
Task 4 wires the gate and starts the refresher.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:06:51 -04:00
QuickandClaude Opus 4.8 e21c35cb7f feat(search): expose active embedding model from lock
Add recommendations.Engine.ActiveEmbeddingModel, which reads the
embedding lock from server_settings and returns the locked model (or ""
when no lock is established). Define the catalog
CatalogSemanticModelProvider interface so a later task can scope vector
coverage checks to the active model; the recommendations Engine already
satisfies it. No wiring yet (Task 4 injects recEngine as this provider).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:51:36 -04:00
QuickandClaude Opus 4.8 927d7764a4 feat(search): model-filtered embed-eligible coverage counts
Establish how semantic vector "coverage" is counted: current-model
embeddings over embed-eligible items, per media type, from Postgres.

- Move the embed-eligibility predicate to a single source of truth in
  embeddingvectors.ItemEligibilityWhereClause; recommendations now
  delegates to it (output byte-identical).
- Add catalogSemanticCoverageByType (per-type eligible/vectorized) and
  define the coverageQuerier interface in catalog. Both numerator and
  denominator apply the eligibility predicate, so vectorized never
  exceeds eligible (C1 guarantee) even with a stale embedding on a
  now-unmatched item.
- countCatalogSearchVectorDocuments now takes a coverageQuerier and a
  model, applies the eligibility + model filter, and is the per-type
  numerator summed across types. Update all four callers (pass "").
- Add idx_media_item_embeddings_model (CONCURRENTLY, self-healing
  guard) so the model filter does not scan the embeddings table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:43:23 -04:00
Quick 20f36fdc31 fix(search): address provider review comments 2026-06-25 21:31:20 -04:00
Quick 5089e17ee6 fix(scanner): reconcile podcast rescans 2026-06-25 20:57:28 -04:00
Quick 07f2dd5a8f fix(admin): bound autoscan activity backlog 2026-06-25 20:50:45 -04:00
Quick 40329f616d perf(search): speed up catalog query results 2026-06-25 20:46:08 -04:00
Quick fb3a21c332 fix(search): batch orphan delete index events 2026-06-25 17:26:19 -04:00
Quick 86bc69c0a2 fix(search): defer meilisearch key check to runtime 2026-06-25 17:24:08 -04:00
Quick 6df388b25b fix(search): harden search index review paths 2026-06-25 17:00:45 -04:00
Quick 6ca427096b Add catalog search provider support 2026-06-25 16:20:14 -04:00
QuickandGitHub 89c435f046 Merge pull request #219 from Silo-Server/codex/fix-image-resolver-build
fix(plugins): restore image resolver startup build
2026-06-25 15:09:18 -04:00
Quick af52ea7bcc fix(plugins): use scoped installation store for image resolver 2026-06-25 15:08:55 -04:00
QuickandGitHub 13c0dde538 Merge pull request #218 from Silo-Server/codex/image-resolver-capability
feat(plugins): add image resolver registry
2026-06-25 15:01:07 -04:00
Quick 2045b7a0b2 feat(plugins): add image resolver registry 2026-06-25 14:47:48 -04:00
Quick 5c0027b54b fix(metadata): dedupe availability during re-id 2026-06-25 13:29:30 -04:00
87159b0a38 feat(collections): add profile-scoped display filters (#191)
* feat(collections): add profile-scoped display filters

* refactor(collections): dedup display-filter helpers per review

Address code-review feedback on the profile-scoped display filters
without changing behavior:

- Widen CompletedHistoryItemMap to accept ProgressCompletionStore and
  drop the duplicate completedHistoryItemMapForProgress copy.
- Extract the duplicated MDBList candidate retry loop into a generic
  collectionutil.FetchMDBListWithFallback helper, used by both the user
  and library collection syncers, and cover it with unit tests.
- Reuse validateOptionalLibraryIDs in HandleUpdateCollection instead of
  an inline positive-ID loop.
- Import the shared COLLECTION_{WATCH,MEDIA}_FILTER_OPTIONS in the
  template config form rather than redefining them locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(collections): sanitize query_definition library_ids fallback

readSourceConfigLibraryIDs validated source_config.library_ids (finite,
positive, truncated, deduplicated) but returned the query_definition
fallback raw, so legacy rows could surface zero/negative/duplicate IDs
that the backend now rejects on save. Extract a shared sanitizer and
apply it to both paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(docs): This makes the agents annoying to work with

* Improve playback session handling

* Support collection source order in catalog filters

* fix(collections): address display filter review feedback

* refactor(catalog): remove duplicate collection query params

* Hide episode media scope for collection overlays

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 11:03:38 -04:00
Quick 227986094b Propagate playback client metadata through session sync 2026-06-23 14:26:14 -04:00
Quick 9819f0ff12 Merge branch 'main' of https://github.com/Silo-Server/silo-server 2026-06-23 14:17:21 -04:00
QuickandGitHub dc44e13bfa Filter restricted library sections and share collection queries (#190)
* fix(collections): prevent collection query cache collision

* Filter restricted library sections and share collection queries
2026-06-23 14:16:31 -04:00
Quick 3ad77095c4 fix(collections): prevent collection query cache collision 2026-06-22 18:46:13 -04:00
QuickandClaude Opus 4.8 0726d9430e docs: add Silo trademark & brand policy
Add TRADEMARK.md (canonical brand policy), a brand-asset NOTICE under
web/public, and a License & Trademarks section in the README. Documentation
only; no application code changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:24:49 -04:00
544a262924 fix(jellycompat): accept Jellyfin current ApiKey query param for stream auth (#186)
Silo's ExtractToken only honored the legacy 'api_key' query parameter and
the X-Emby-Token/X-Mediabrowser-Token headers. Real Jellyfin's
AuthorizationContext treats 'ApiKey' (PascalCase) as the current,
always-enabled query token and 'api_key' as legacy (gated behind
EnableLegacyAuthorization). Native Jellyfin clients that build their own
direct-play /Videos/{id}/stream URLs (incl. Jellyfin Android TV) send
'ApiKey', which Silo rejected — the request arrived with auth_kind=none and
the route returned 401, surfacing as a client 'playbackerror'.

Match both 'ApiKey' and 'api_key' case-insensitively in ExtractToken and in
the authKind log classifier. Strictly additive: existing header and
api_key paths are unchanged.

Verified against the live server: /Videos/{id}/stream?...&ApiKey=<tok>
returned 401 before and is accepted after; &api_key=<tok> and the
X-Emby-Token header continue to return 206.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 16:20:04 -04:00
RXWatcherandGitHub 187f29e163 Merge pull request #183 from Silo-Server/fix/episodes-season-id-index-migration
Fix episodes season index migration
2026-06-19 06:23:21 +02:00
rxwatcher 729468be30 fix: wrap episodes season index migration block 2026-06-19 06:13:26 +02:00
QuickandGitHub de931f784a Fix ebook match dialog to support generic provider IDs (#182)
* fix(metadata): match non-video items with content-aware providers

* fix(web): guard invalid match year parsing
2026-06-18 20:19:47 -04:00
QuickandGitHub b84c040d27 Reinforce first profile empty state layout (#181) 2026-06-18 19:49:06 -04:00
QuickandGitHub cac435c4b9 Fix watch-state unwatch sync across user data and Jellyfin mappings (#179)
* Refine playback session handling and API responses

* fix(watchstate): harden completed-history visibility
2026-06-18 19:17:22 -04:00
dependabot[bot]andGitHub 08997264d2 build(deps): bump react-router from 7.15.0 to 7.15.1 in /web 2026-06-18 16:05:08 -04:00
dependabot[bot]andGitHub 42fd55db08 build(deps-dev): bump vite from 6.4.2 to 6.4.3 in /web 2026-06-18 16:04:45 -04:00
QuickandGitHub 7e532ea982 [codex] fix season page loading (#178)
* fix(catalog): speed up season page loads

* fix: address season loading review feedback
2026-06-18 14:18:35 -04:00
QuickandGitHub b3198276f7 [codex] fix(subtitles): stream live transcribe_translate cues (#177)
* fix(subtitles): stream live transcribe_translate cues

* fix(subtitles): harden live AI transcription
2026-06-18 12:16:27 -04:00
CoffeeKnyteandGitHub 6e1f79e8a9 feat(jellycompat): expose library collections as an auto-shown Collections view (#175)
* feat(jellycompat): expose library collections as a Collections library view

Surface server library collections as a top-level Jellyfin "Collections"
library (CollectionType "boxsets") so compat clients see them as the first
library in /UserViews and can browse them by ParentId. The BoxSet machinery
(list/detail/children) already existed; this adds the library wrapper.

- catalog: add LibraryCollectionRepository.AnyVisibleInLibraries, an
  index-only EXISTS probe (no item join/aggregation) used to gate the view so
  an empty Collections tab never shows. Mirrors collectionVisible semantics
  (multi-library scope rows or the legacy single library_id fallback).
- jellycompat: add the synthetic Collections CollectionFolder (fixed Jellyfin
  sentinel ID, stable across servers), prepend it to the user's views when a
  visible collection exists, and route ParentId/Items-by-ID for that sentinel
  to the existing BoxSet listing. ChildCount is left omitted (no per-call
  count, no unwatched badge).

AI-use disclosure: implemented with assistance from Claude.

* fix(jellycompat): display collection posters and a generated Collections tile

Library collections surfaced as Jellyfin BoxSets showed blank cards: their
poster_url is frequently a bundled frontend template path
(/images/collection-templates/x.jpg), which the compat image route passed
through unchanged and then rejected in parseRemoteImageURL (no scheme/host),
returning BadGateway. Clients probed Images/Primary and got nothing.

- images: serve app-relative artwork (bundled template posters) straight from
  the embedded frontend FS (new ImagesHandler.frontendFS), with content-type
  and cache headers. Wired through jellycompat.Dependencies.FrontendFS.
- poster_gen: on-the-fly gradient poster generator (per-title hue, centered
  white caption with black outline, gobold/opentype), memoized in a bounded
  cache. Used for the synthetic Collections library tile and as a fallback for
  collections without usable artwork, so cards are never blank.
- consolidate collection/view image routing in HandleItemImage, authorized by
  the signed tag or an authenticated, visibility-checked session.

AI-use disclosure: implemented with assistance from Claude.

* fix(jellycompat): declare 2:3 PrimaryImageAspectRatio on BoxSets and Collections tile

Clients defaulted collection cards to a square and crop the 2:3 poster to fit.
Set PrimaryImageAspectRatio (portrait 2/3) on the BoxSet DTO and the synthetic
Collections library tile so the full poster is shown, matching Jellyfin.

AI-use disclosure: implemented with assistance from Claude.
2026-06-18 10:20:48 -04:00
14ffc91dfb [codex] Expand provider image cache queue (#176)
* feat(metadata): expand provider image cache queue

* fix(metadata): harden provider image cache queue

Addresses bug-review feedback from Codex/CodeRabbit on the metadata image
cache pipeline. All findings validated against the code before fixing;
false positives (rows/connection deadlock, PhotoSourcePath merge coupling)
were confirmed non-issues and left unchanged.

- Honor metadata.cache_images for the background processor. The
  cache_metadata_images task was registered whenever S3 was configured,
  so merely enabling object storage downloaded the entire provider-artwork
  catalog even with caching disabled. Add ImageCacheProcessor.SetEnabled,
  gate RunOnce/RunUntilIdle on it, and wire it (with hot reload) from
  cfg.Metadata.CacheImages in main.go.
- Guard terminal job updates with lease ownership. EnqueueBatch can
  repurpose a running row with a new source; MarkSucceeded/MarkFailed
  keyed on id alone let a stale worker finalize the replacement job and
  drop the new artwork. Thread locked_by through and add
  status='running' AND locked_by=$n guards.
- Avoid uploading stale jobs onto the live artwork key. Verify the
  target still references the job's source (CurrentTargetSourcePath)
  before CacheImage, so a job whose source an admin/refresh already
  replaced cannot overwrite the deterministic storage object.
- COALESCE nullable external IDs in EnqueueExistingProviderArtwork. A
  NULL tmdb_id/tvdb_id/imdb_id on any candidate failed the scan and
  aborted the whole cache run; matches the existing item_repo pattern.
- Stop re-downloading the catalog every 30 days. Discovery now skips
  targets whose *_path is already a cached relative path, making the
  cached row the durable dedup marker instead of the prunable job row.
- Decouple catalog sweeps from queue draining. RunOnce no longer runs
  discovery per batch; RunUntilIdle sweeps only when the queue drains and
  throttles full sweeps to every 15m, so idle installs stop full-scanning
  every entity table each minute.
- Requeue claimed-but-unstarted jobs on cancellation. Acquire the
  semaphore before spawning workers and RequeueClaimed any jobs not yet
  started, instead of leaving them locked until the 15m lease expires.
- Skip the backoff sleep after the final upload attempt in
  putObjectWithRetry (saves ~1.5s on permanent failures).
- Add the s3/file/local/upload/generated exclusion to the seasons and
  episodes backfill in migration 20260617184537 for consistency with the
  later migration (the bad backfill was inert downstream, but the
  asymmetry is removed).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:07:58 -04:00
Quick fbe54ffec5 Merge branch 'codex/notifications-request-burst' 2026-06-17 13:49:18 -04:00
Quick 562ae635d7 feat: improve audiobook groups and notification refresh 2026-06-17 13:48:57 -04:00
Quick cf3e68e02a fix(jellycompat): honor codec profile playback limits 2026-06-17 13:12:18 -04:00
Quick 20e0dd7fe2 Fix Jellyfin parent browsing and release dedupe
- List seasons or episodes correctly for Jellyfin parent item queries
- Deduplicate episode availability by logical episode identity
- Improve audiobook title fallbacks and existing item updates
2026-06-17 13:12:18 -04:00
e99079abf8 Server-side Kindle→EPUB conversion (mobi/azw/azw3) for in-app reading (#171)
* Kindle->EPUB conversion: design + proven wasm build pipeline

Server-side MOBI/AZW/AZW3 -> EPUB conversion so the Android in-app reader
can render Kindle-family ebooks. Conversion runs in-process via libmobi's
mobitool compiled to wasm32-wasi, executed by wazero (pure Go) -- no cgo,
no external binary, arch-independent, sandboxed untrusted input.

This commit lands the design + the validated build artifact (spike done):
- docs/.../2026-06-17-kindle-epub-conversion-design.md (Codex-reviewed;
  9 review fixes folded in: failure contract, strong cache key + negative
  cache, wazero command-module specifics, FS-sandbox tightening,
  double-gated capability, serve headers, .wasm guardrails).
- tools/mobitool-wasm/{Dockerfile,README.md}: reproducible build of
  mobitool.wasm (wasi-sdk 25, libmobi 9062742, zlib 1.3.1->wasm), with a
  smoke-conversion gate. Build proven on native amd64.
- internal/ebookconvert/mobitool.wasm (+ .sha256): canonical artifact,
  built on amd64. go:embed target for the converter package (next).

Spike proven on amd64: -e EPUB path works with --with-libxml2=no (internal
xmlwriter); converts MOBI6/KF8/HUFF-CDIC/unicode -> well-formed EPUB;
verified end-to-end under wazero (WASI preopen + argv + _start). Build
gotcha: link libmobi against real (wasm) zlib, not --with-zlib=no, to avoid
miniz duplicate-symbol clash with mobitool's zip miniz. DRM gotcha:
mobitool prints "Document is encrypted" to stdout but exits 0 -> detect via
stdout + output validation, not exit code.

Not yet implemented: internal/ebookconvert Go package (wazero harness +
cache + singleflight), read-handler wiring, admin flag, client capability.
v1-scope proposal required before PR.

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

* ebookconvert: converter core + cache (Codex-reviewed)

internal/ebookconvert: in-process MOBI/AZW/AZW3 -> EPUB via the embedded
mobitool.wasm on wazero. Converter compiles the module once and instantiates
per conversion (isolated). Cache adds on-disk, singleflighted, size-bounded,
negative-cached conversion keyed by file identity + module fingerprint.

18 tests pass (DRM-free->valid EPUB, DRM->ErrDRMProtected + no output,
oversize/corrupt/missing/timeout/cancel/after-close, 6/8-way concurrent,
EPUB structural validation incl. stored-mimetype + container rootfile,
cache miss/hit/key-change/singleflight/eviction/negative-cache).

Codex review fixes folded in:
- timeout/cancel classified before generic nonzero exit (WithCloseOnContextDone
  surfaces sys.ExitError special codes); no more bogus "exit <huge>".
- DRM detection scoped to known mobitool diagnostic LINES (Document is
  encrypted / DRM key not found / Invalid DRM pid / DRM expired / DRM support
  not included) -> no false-positive on book text; Print Replica -> clear fail.
- WithMemoryLimitPages cap; capped stdout/stderr writers; MaxOutputBytes.
- read-only fs.FS input mount + dedicated writable out dir; documented that
  FS isolation ultimately relies on running as a non-root user (memory-safety
  is the WASM boundary). validateEpub now requires STORED mimetype + verifies
  the container.xml OPF rootfile exists. Atomic moveFile. Closed-guard.

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

* ebookconvert: wire Kindle->EPUB into the read handler + capability endpoint

Server now transparently serves Kindle-family ebooks as EPUB when the admin
flag ebook.kindle_conversion_enabled is on and the WASM converter initialized.

- handlers.EbookConversion (converter + per-request flag predicate) on the read
  handler; HandleReadFile -> h.serveEbook. Kindle + enabled -> cached EPUB with
  X-Silo-Ebook-Conversion: converted, epub MIME, ETag = exact conversion cache
  key, must-revalidate. Failure (DRM/corrupt/oversize/unservable) -> raw
  original + X-Silo-Ebook-Conversion: failed + no-store, so the client opens
  externally. Context cancel propagates (not a conversion verdict).
- GET /api/v1/ebooks/capability advertises {enabled, source_formats,
  served_format, header contract}; enabled only when flag on AND converter
  wired (double gate) so the Android client can decide whether to flip
  mobi/azw/azw3 to in-app.
- router: buildEbookConversion compiles the module once at startup (feature off
  if it fails), cache dir is a sibling of TranscodeDir, flag read per request.

Codex review fixes folded in: ETag derived from the exact SourceKey cache key
(id+size+mtime+oshash+module version), not a weaker hash; no-store on the raw
fallback; open/stat failure of a produced EPUB falls back to raw per the
contract instead of 500. 10 handler tests pass.

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

* fix(ebookconvert): harden conversion cache, HEAD path, and artifact verification

Addresses adversarial review + CodeRabbit findings on the Kindle->EPUB feature.

Correctness:
- Stop poisoning the negative cache on transient timeouts. Introduce
  ErrConversionTimedOut (distinct, non-wrapping ErrConversionFailed); classify
  the per-call timeout as transient and propagate a caller's cancel/deadline
  verbatim instead of reclassifying it as a conversion failure. remember() now
  only caches deterministic verdicts (DRM / failed), so a one-off timeout under
  load no longer wedges a convertible book onto raw-fallback for 6h.
- Detach the singleflight conversion from any single caller's context (DoChan +
  context.WithoutCancel), so one caller cancelling no longer aborts the shared
  work for the others; the cache is still populated for the next reader.
- enforceBudget never evicts the entry it is about to return, and skips other
  conversions' in-flight "converting-*" temp files.
- Cache hits refresh mtime so the mtime-ordered budget eviction is a real LRU,
  not FIFO.

Read path:
- HEAD is now cache-only via Cache.Lookup: a hit serves real converted headers,
  a negatively-cached source serves the failed contract, a miss advertises the
  converted representation cheaply without triggering a (minute-long, ~1 GiB)
  conversion. The GET still delivers the body + authoritative verdict.
- The admin flag is read through a short-TTL predicate so the read path and the
  capability endpoint no longer hit the DB per request.

Artifact / build:
- Add an in-code provenance test (embedded mobitool.wasm matches its recorded
  sha256) and a self-hosted CI job that runs the ebookconvert smoke conversions
  + provenance check, so the committed wasm can't silently rot.
- Pin + checksum-verify wasmtime in the build Dockerfile (drop curl|bash).

Docs: correct the design doc cache-key + setting-name descriptions, document the
HEAD/timeout/LRU semantics and resource limits, note DRM-marker brittleness, and
fix the README markdown table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci: remove ebookconvert workflow

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-17 13:09:55 -04:00
9b111649f3 perf+fix(audiobooks): detail page, browse, sessions & scanner (#169)
* perf(catalog): fix audiobook detail N+1 + slow people facets

Audiobook detail pages were slow in proportion to track count (up to 433
files/book). Root causes, found by EXPLAIN ANALYZE on the live DB:

- effectiveAudioSelection ran 3-4 user-store queries (profile, audio pref,
  library pref) per file inside buildPlaybackInfo's loop, though the results
  are invariant across a request. Introduce a request-scoped audioPrefResolver
  that memoizes the store lookups (library prefs keyed by folder); a 400-file
  audiobook now issues each query once instead of per file. Selection logic is
  unchanged (audioPreference returns a copy so the original-language sentinel
  is still resolved per file).
- buildAudiobookExtension ran its four independent related-content queries
  serially; run them concurrently so latency is the slowest, not the sum.
- author/narrator browse facets did a full people-table scan; add a
  (kind, content_id, person_id) index so the facet resolves from an index-only
  scan of just that kind's credits (~112ms -> ~49ms on the live library).

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

* perf(catalog): cache audiobook author/narrator group browse

The Authors/Narrators audiobook pages were slow on cold load and slow again
after a hard refresh (fast only while the React Query client cache was warm).

Root cause (EXPLAIN ANALYZE on live, 31K-audiobook library): the grouped
browse query is ~234ms/page, there are ~13K distinct authors, and the client
pages through the entire list on every load (sequential 500-row requests). With
no server-side cache each of the ~20 pages re-ran the full aggregation
(COUNT(*) OVER() forces it), so a cold load was ~20x234ms. The client's 60s
staleTime was the only thing making a warm revisit fast; a refresh wiped it.

Fix: AudiobookGroupsCache caches the full sorted group list per (library,
group_by, sort, viewer) for 60s (matching the client staleTime, so no extra
staleness) and serves every page as an in-memory slice — one aggregation per
window instead of one per page, and a refresh is a cache hit. Also raise the
client page size 500->2000 so fewer sequential round-trips are needed now that
a larger page is a cheap slice.

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

* perf(settings): throttle per-request device last_seen upserts

Device-setting reads (HandleGetDeviceSetting, HandleGetEffectiveSettings,
HandleGetEffectiveSubtitleAppearance) each registered the request's device — an
INSERT ... ON CONFLICT upsert of last_seen_at on a single per-device row. A page
that fetches many settings fired hundreds of these concurrently; they serialized
on that row's lock (observed 100-237ms each, ~250 per page load in the slow
query log), taxing every settings fetch.

Throttle device registration to one upsert per (profile, device) per 5 minutes
via an in-process TTL cache, marking the device seen before the upsert so a
concurrent burst collapses to a single write. last_seen_at stays fresh to within
the window. Reads no longer issue a contended write on the hot path.

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

* perf+fix(audiobooks): probe-repair, resume position, cache storm, groups reveal, hot-row + stats index

From the full audiobook code review (EXPLAIN + slow-query trace on live):

- #1 (P0, detail-page killer): NeedsCriticalProbeRepair required video codec/
  resolution/tracks, which audio-only files never have, so PlaybackProbeEnsurer
  re-ran ffprobe per file on every detail/watch load (up to N serial spawns for
  an N-track book) and never converged. Gate video-field checks on the file
  actually having a video stream. TDD.
- #3 (P0): abs session-sync rewound the resume cursor — UpdateProgressPosition
  did an unconditional SET with no monotonic guard, ignored its error, and
  no-op'd when no row existed (first-listen resume lost). Now a finish-preserving
  GREATEST upsert; caller logs failures.
- #4 (P0 perf): progress reports fired every ~10s invalidated all of
  catalogKeys.all → refetched every active browse/detail query incl the 13k
  audiobook group lists. Scope invalidation to the reported item's detail.
- #6 (P1 perf): Authors/Narrators page rendered all ~13k groups + cover images
  at once (main-thread freeze). Incremental reveal: render a capped window, grow
  on scroll via IntersectionObserver.
- #10: throttle abs TouchToken last_seen upsert (one per token per 5min) — same
  hot-row contention class as the device fix.
- #8: index abs_playback_sessions (user_id, profile_id, started_at) for the
  listening-stats aggregations.

Deferred (need contract/validation): listening-time idempotency (client delta-vs-
cumulative), scanner deleted-file reconcile, abs session retention job, abs list-
handler batch fetch, scanner-output P2s (need re-backfill).

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

* perf(audiobooks): batch-fetch abs list/shelf handlers (kill N+1)

handleSimilarItems, handleItemsInProgress, and handleGetMyProgress called
MediaStore.GetAudiobookByID once per row — up to ~500 single fetches (each a
few queries) on app open. Add GetAudiobooksByIDs (one access-scoped fetch +
people/series hydrated once for the whole set) and look results up from the
returned map, preserving order. Underlying primitives (GetByIDsWithAccess,
hydratePeople/Series) were already batch-capable.

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

* fix(audiobooks): reconcile deleted files on scan + prune session history

#5: ScanAudiobookFolder only ever upserted — deleted/renamed books leaked
media_items/media_files/memberships forever. Mirror the ebook reconcile: collect
seenPaths during the walk, MarkMissing files no longer on disk, then
reconcileLibraryMemberships. Safety mirrors ebooks/video: an inaccessible root
(unmounted source) is skipped entirely, and a walk that saw zero files while the
DB has rows only reconciles after operator cleanup confirmation
(ebookEmptyCleanupAllowed) — so a flapping mount can't wipe the catalog. Soft
mark only; the existing grace-period purge hard-deletes later. Reconcile runs
only on a fully-completed (non-cancelled) scan. (#9 coarse case already handled:
audiobookFolderShouldSkip skips unchanged folders; per-file reuse deferred.)

#8-retention: abs_playback_sessions grew unbounded (one row per play-start, never
deleted) and fed every listening-stats scan. Add an hourly sweep in
SessionCleaner: close abandoned open sessions (no /close, stopped syncing >24h)
and delete closed sessions older than 90 days. Mirrors the recommendation_cache /
missing-files prune pattern.

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

* fix(audiobooks): address max-effort code-review findings

From /code-review max on the pre-PR diff:
- DATA RACE (P0): SessionCleaner.lastABSSessionPrune is read+written by both the
  15s ticker goroutine and the shutdown-path CleanStale call (main.go defers
  Stop() to after that call). Guard the prune-due gate with a mutex. (CleanStale
  was stateless before this branch, so concurrent calls were previously safe.)
- ScanAudiobookFolder hardcoded fullScan=true into the empty-walk cleanup guard,
  but it's also called from ScanSubtree (incremental scans). An empty subtree
  scan would wrongly consume the operator's one-shot empty-cleanup allowance and
  warn. Thread a real fullScan flag (true from ScanFolder, false from the two
  subtree call sites), mirroring the ebook path.
- Revert UpdateProgressPosition to UPDATE-only (drop the INSERT-on-missing):
  keep the monotonic GREATEST + finish guard that fixes the resume rewind, but
  restore the no-op-on-missing contract so a stray sync tick can't resurrect
  just-cleared progress or create a zero-duration continue-listening row.
- Clamp the audiobook-groups handler limit (paging moved into the cache, leaving
  the old 500/page bound stranded); also gofmt the Scanner struct.

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

* fix(audiobooks): address review feedback for scanner and stats

* fix(audiobooks): address review feedback

* fix(audiobooks): retry failed session prune

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-17 09:51:43 -04:00
f207b87ff7 fix(catalog): return user_data for audiobooks on /watch/{id} (#144)
HandleGetWatchDetail populated user_data (resume position) only for
movie/episode/ebook, so an audiobook fetched via /watch returned a null
position_seconds even though progress is recorded the same way. The
/catalog/items/{id} path already includes audiobooks; this brings the watch
endpoint in line so both expose the saved position.

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 20:32:48 -04:00
c4cbcddeae feat(manga): manga library type — series grouping, reading loop, AniList/MangaDex metadata + status badge (#138)
* docs: design spec for manga library type (host sub-project)

Forks the ebooks library type into a 'manga' type: series detected from the
folder tree as a first-class type='manga' item, .cbz/.cbr chapters stay
readable ebook items linked via a new manga_chapters table, browse shows series
cards, enrichment targets the series item at content level 'manga'. Hands off to
a follow-on plugin spec for the manga metadata source.

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

* docs: implementation plan for manga library type (host)

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

* feat(scanner): manga filename index/volume parser

* feat(scanner): manga series-name-from-folder detection

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

* docs(plan): align manga DB/scanner tasks to scanner pure-planner pattern (no test-DB)

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

* test(scanner): manga parser corpus regression

Add TestParseMangaIndexCorpus — 36 real-world scanlation filenames
covering bare chapter, decimal chapter, v/vol-prefix volume, and
c/ch-prefix chapter patterns; asserts <5% miss rate.

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

* feat(db): manga_chapters link table

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

* feat(scanner): manga_chapters repository + pure chapter-write mapping

Adds mangaChapterWrite (pure, unit-tested), upsertMangaChapter, and
listMangaChapters following the ebook/audiobook thin-SQL pattern.

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

* feat(scanner): recognize manga library type

Add isMangaLibraryType helper (unexported, matching the style of
isEbookLibraryType / isAudiobookLibraryType) with a corresponding
TestIsMangaLibraryType unit test.

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

* feat(api): manga library content level

Map library type "manga" to content level ["manga"] in
metadataContentLevelsForLibraryType so that seedDefaultChain seeds a
manga-level metadata provider chain when a manga library is created.

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

* feat(scanner): route manga libraries to a manga scan path

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

* feat(scanner): group manga chapters under a manga series item

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

* fix(scanner): give manga series item a library membership so it browses

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

* feat(catalog): browse manga libraries as series

Accept "manga" as a valid media_scope so a manga library browses only its
type='manga' series items; the per-chapter type='ebook' items are naturally
excluded because MediaScopeItemTypes("manga") expands to {"manga"}. Add the
manga default library sections (scoped to media_scope='manga') so the library
feed shows series cards. Refresh the two media_scope validation error messages.

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

* feat(catalog): manga series detail lists chapters

For a type='manga' item, attach its chapters to the detail response via a new
MangaDetailExtension. fetchMangaChapters joins manga_chapters to media_items on
the chapter content ID, scopes to the series, and orders by chapter_index
(NULLS LAST) then sort_title — matching the scanner's chapter ordering.

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

* feat(web): manga detail types + library browse scoping

Add MangaChapter/MangaDetailExtension TS types mirroring the host
catalog structs, wire manga? onto ItemDetail, and admit "manga" as a
QueryDefinition.media_scope. Scope manga libraries to media_scope=manga
in browse (host expands it to type=manga series items) while reusing the
ebook sort universe via getLibrarySortRelevanceScope. Add isMangaLibraryType.

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

* feat(web): manga series detail with volume-grouped chapter list

Add MangaContent detail view: a DetailHero series header plus a chapter
list grouped by volume. groupMangaChapters (pure, unit-tested) buckets
chapters by their volume token, orders chapters within a group by
chapter_index (nulls last) and orders groups by their minimum index;
loose (volume-less) chapters collapse into a trailing "Chapters" group.
Each chapter links to the existing ebook reader by content_id alone
(file_id is optional — the reader resolves the file server-side), reusing
buildMediaPlayHref. Admit "manga" into ItemDetail.type and wire the
detail switch. Continue-reading is deferred (needs per-chapter progress
fan-out / a last-read timestamp not in the current payload).

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

* fix(web): handle manga in playable-type + collection filter-scope unions

Adding "manga" to the shared ItemDetail["type"] and
QueryDefinition["media_scope"] unions leaked into consumers with narrower
local types, breaking the production tsc build. Fixes:

- mediaNavigation: admit "manga" into PlayableMediaType. Manga series are
  not directly playable (you open the detail page and read a chapter,
  itself an ebook item), so buildMediaPlayHref falls through to the item
  href for them, like series/season.
- FilterRuleEditor: add "manga" to FilterRuleMediaScope and relabel
  "watched" -> "Read" for manga as well as ebook (manga is read).
- CollectionGuidedRulesEditor: add "manga" to GuidedFormState.mediaScope,
  a "Manga" media-type option, ebook-like "Read Status" labels, and map
  manga -> ebook sort-relevance scope (manga has no dedicated sort scope).
- CatalogFilterBar (cascading leak surfaced after the above): add a
  "Manga" scope option and map manga -> ebook sort-relevance scope in both
  scope handlers.

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

* feat(web): offer manga as a library type in the create dialog

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

* feat(scanner): strip scene-release junk from manga series names

Add cleanMangaSeriesName which repeatedly strips trailing parenthetical
groups (year, year-range, Digital, release-group tags) then trims any
dangling dash, so folder names like "404 Demons (Digital) (Oak)" resolve
to "404 Demons". Wire it into mangaSeriesFromPath so both the series
title and the mangaSeriesGroupKey identity key use the cleaned value.

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

* feat(web): flat volume/chapter manga list; nest only multi-chapter volumes

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

* fix(scanner): parse manga index after stripping series-name prefix

Numbers inside a series title (e.g. "404 Demons", "365 Days to the
Wedding") were wrongly grabbed as the chapter number because
parseMangaIndex matched the first bare number in the full filename.
mangaIndexForFile now strips the series-name prefix before delegating
to parseMangaIndex, so only the number that follows the title is used.
reconcileMangaFile in manga_scan.go is updated to call mangaIndexForFile
instead of parseMangaIndex directly.

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

* fix(scanner): stop missing-file reconcile from deleting manga series items

Manga series items are file-less virtual parents; the shared
ReconcileFolderMembership swept them every scan because they have no
media_file. Exclude type='manga' from file-presence membership reconciliation,
and add a manga-scan step that deletes only series with zero remaining chapters.

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

* fix(ebooks): exclude manga chapters from individual ebook enrichment

Manga chapters are type='ebook' parts of a series; the ebook enrichment sweep
was searching each one against book sources (Gutenberg/Anna's/etc.) and failing
in a pointless storm. Exclude items with a manga_chapters link; series-level
enrichment is handled separately.

* docs: design spec for manga metadata plugin + series enrichment (sub-project 2)

New silo-plugin-manga-metadata (AniList, high-confidence matching) + a host
MangaEnricher for type='manga' series; default-enabled metadata source for manga
libraries.

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

* docs: implementation plan for manga metadata plugin + series enrichment

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

* feat(db): manga_enrichment_state table

Mirrors ebook_enrichment_state: dedicated failure counter for the manga
enrichment sweep so it does not contend with media_items.refresh_failures.

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

* feat(manga): series enricher (claims type='manga', resolves manga chain)

* feat(manga): sync_manga_metadata task + enricher wiring

* feat(catalog): expose manga chapter/volume counts in browse

Add manga_chapter_count and manga_volume_count to browse cards so the
frontend can render a Vols N / Ch N chip on manga series. The counts come
from two index-backed correlated subqueries over manga_chapters in the
browse SELECT (mangaCountColumns), scanned positionally before added_at and
nilled out for non-manga rows. Threaded through models.MediaItem and exposed
on the itemListResponse JSON card.

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

* fix(sections): scope manga home recent sections to type=manga series

A manga library mixes type='manga' series with type='ebook' chapters, so
the auto-generated home 'Recently Added/Released in <Library>' rows surfaced
the junk chapter filenames. Add GeneratedHomeLibraryRecentConfigScoped which
emits the modern QueryDefinition shape (library_ids + media_scope) so a manga
library's generated home rows filter to type='manga' only. Other library
types are unchanged.

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

* fix(catalog): exclude manga chapters from browse/section/search surfaces

Manga CHAPTER items (type='ebook' rows linked into a type='manga' series
via manga_chapters) were leaking into catalog browse, section resolution,
and search as standalone items showing junk filenames. They are internal
sub-units of the series and only the series should appear.

There is no single shared item-listing chokepoint: browse, the query/preview
executor, and search each build their own WHERE. Add a shared, index-backed
anti-join predicate (manga_chapters.chapter_content_id is the PK) via
mangaChapterExclusionWhere and wire it into all three builders. By-id fetch
paths that legitimately resolve chapters (ebook reader, continue-reading,
series detail chapter list) use separate queries and are unaffected.

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

* fix(scanner): use #NN as the manga volume for Vol.YYYY #NN releases

mangaVolYearIssue early-return was returning the year token (e.g. "Vol.2003")
as the volume label, which the frontend couldn't prettify to "Volume N".
Now returns "v<issue>" (e.g. "v04") so the existing frontend regex ^v?(\d+)$
renders it as "Volume 4" correctly.

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

* feat(web): manga count chip on posters

Add an optional manga_chapter_count / manga_volume_count to the browse
item type and render a top-right "Vols N" / "Ch N" chip on ItemCard,
strictly gated on type==='manga'. The label prefers "Vols" when the
volume count dominates, "Ch" otherwise; the chip is hidden when the
chapter count is missing or non-positive. No other card type renders it.

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

* fix(web): manga reader back returns to series (no loop)

The ebook reader's back action defaulted to the chapter's own item
detail (/item/<chapter>), whose back returned to the reader — an
infinite loop for manga chapters. The reader now honors an explicit
backTo search param when present, navigating there instead. Absent for
normal ebooks, so their back behavior is unchanged. Only manga chapter
rows pass backTo, keeping the fix manga-only.

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

* feat(web): manga chapter row actions (read/mark-read/download)

Each manga chapter/volume row now offers Read (the existing reader link,
now carrying a backTo to the series), Mark-read (the shared watched-state
mutation per chapter content_id), and Download (lazily fetches the
chapter's file versions on demand and opens the shared
DownloadVersionPicker, gated on user.download_allowed). The
volume-unit / loose-chapter / section structure from buildMangaList is
unchanged. Scoped to MangaContent only; EbookContent is untouched.

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

* fix(web): validate reader backTo param is a safe in-app relative path

Prevents open-redirect / javascript:-URI XSS from a crafted ?backTo= URL.

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

* feat(catalog): include per-chapter read state in manga detail

Manga chapters are ebook items, so a chapter is "read" when the viewer's
ebook_reader_progress row crosses the finished threshold. fetchMangaChapters
now LEFT JOINs that table scoped to the AccessFilter's user_id/profile_id and
exposes a per-chapter Read bool on MangaChapter, threaded through
buildMangaExtension. The detail payload previously carried no read state, so
the row toggle always started unread.

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

* feat(web): manga rows reflect read state on load

MangaChapter now carries an optional read flag from the detail payload, and
MangaRow seeds its mark-read toggle from chapter.read instead of always
starting unread. The optimistic toggle + shared watched mutation are
unchanged; only the initial value is seeded.

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

* fix(sections): exclude manga chapters from recently-added/released/random + other library-listing sections

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

* feat(sections): manga recently-added/released cards show the latest volume's cover

* fix(manga): keep enrichment honest about no-match vs enriched, batch 50->200

- sweep stats now separate enriched / no_match / failed: a stamped no-match
  was counted (and logged) as an enrichment, which masked a collapse of the
  real match rate during the backfill
- batch size 50 -> 200 (SILO_MANGA_ENRICH_BATCH overrides): with the plugin
  serving GetMetadata from its search cache an item costs one rate-limited
  AniList request, so a sweep still fits the 5-minute task interval

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(manga): size enrich batch to the 5-minute interval at AniList's real budget

140 items x ~2.1s/request fits the interval; an overlong sweep makes the task
manager drop the next trigger and the effective rate falls below the AniList
budget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(catalog): manga count chip data missing from library browse

manga_chapter_count/manga_volume_count were only added to BrowseRepository,
but /library/{id}?tab=library flows through previewQuerySource ->
QueryExecutor.PreviewPage, which selects qualifiedListItemColumns and scans
with scanItems - so manga cards never carried the counts and the Vols/Ch
poster chip stayed hidden.

Append mangaCountColumns to the preview-page SELECT and scan them via a new
scanItemsWithMangaCounts (nil for non-manga rows, mirroring scanBrowseItems).
Extract listItemScanDests so the three scan variants share one destination
list instead of duplicating the 48-column scan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): manga chip reads 'X Volumes · X Chapters', menu verbs say Read

- chip: show distinct-volume and loose-chapter counts side by side instead
  of the single 'Vols N'/'Ch N' heuristic; mangaCountColumns now counts
  DISTINCT volume tokens (rows sharing a volume are one volume) and only
  un-volumed rows as chapters
- watched-state labels: type='manga' fell through to the video default, so
  the card dot menu and detail page said 'Mark Watched' - manga now uses
  the ebook reading verbs (Mark Read / Mark Unread, 'Marked as read' toast)
- format MangaContent.test.tsx (pre-existing prettier miss)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(manga): backdrop enrichment - banner hero art + backdrop-only backfill

- cache remote backdrops like posters (cacheRemoteImages generalizes the
  poster-only path; failures keep the provider URL, which still renders)
- claim arm for enriched items missing a backdrop: fetched by stored
  provider ID (search skipped - no rate spend, no re-match risk) and only
  the backdrop is written; stamping after the attempt keeps banner-less
  series from being re-claimed every sweep
- backfill = one-time SQL clearing last_refreshed for poster-set/
  backdrop-empty manga

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(manga): reading-loop UX - continue CTA, next chapter, series-aware cards, file details

Fixes the four high-priority findings from the manga UX review plus a
file-inspector request:

- H1: series hero gets a Continue / Start Reading / Read Again CTA
  targeting the first unread chapter (firstUnreadChapter over the ordered
  list), plus an overflow menu (View Details, admin Refresh Metadata)
- H2: the reader resolves its owning manga series (chapter detail now
  carries series_id/series_title) and offers next-chapter navigation: a
  header next button and an end-of-book floating CTA at >=99.5% progress;
  back defaults to the series even without a backTo param
- H3: chapter rows show a persistent read check + muted title, and the
  mark-read mutation carries series_id so the series detail cache
  invalidates (read states no longer revert on revisit)
- H4: continue-reading cards for manga chapters present the series:
  sections payload resolves chapter->series linkage, the card heading/image
  link to the series, and meta lines launch the reader
- View Details: manga series menus (card dot menu + detail overflow) open
  a file inspector showing folder paths and per-chapter file names/sizes
  via GET /catalog/items/{id}/manga-files; paths are stripped for viewers
  without file-path visibility (item-versions policy)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(manga): UX mediums - richer detail page, smarter list, manga sort scope

Second batch from the manga UX review (M1-M7):

- M1: multi-chapter volume sections are collapsible (fully read sections
  start collapsed) with sticky headers, and long series get a 'Jump to
  <next unread>' anchor above the list
- M2: the series hero shows the author line (HeroCrewLine learns Author
  credits with person links; DetailHero now renders crewLine and genre
  chips independently) and Volumes/Chapters badges
- M3: browse-card count chip abbreviates to '12 Vol - 3 Ch' so it fits
  narrow cards without occluding covers
- M4: manga gets its own sort scope: Duration/Bitrate (meaningless for
  file-less series rows) disappear, reading labels (Date Read / Reads)
  apply, Author stays
- M5: global search labels manga results 'Manga' instead of the raw type
- M6: chapters carry the viewer's reading fraction; part-read rows show an
  inline progress bar + percent
- M7: chapter rows show the extracted cover thumbnail (presigned
  poster_url on the chapters payload) instead of a generic icon

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(manga): UX lows - volume token dedupe, comic reader chrome, empty-state hint

- buildMangaList buckets volumes by canonical numeric token so mixed
  release naming (v01 + 1) yields one Volume 1 instead of duplicates
- cbz/cbr readers start with the side panel closed and hide prose-only
  chrome (reading ruler, TTS, typography/font controls, hyphenation,
  writing mode) while keeping comic-relevant settings (theme, brightness,
  margin, right-to-left, spread, flow)
- manga empty state mentions chapters appear after the library scan

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(manga): publication status badge via new SDK status field

- vendor the unpublished plugin SDK (adds MetadataItem.status) under
  internal/compat/ with a relative go.mod replace, following the
  zishang520-webtransport-go convention; swap to the published module
  before the upstream PR
- map plugin status into MetadataResult.ShowStatus, persist it during
  manga enrichment, and show it as the hero status badge (show_status was
  already on the detail payload and MetadataBadges)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(manga): generalize backdrop pass to secondary fields (backdrop + status)

The backdrop-only claim arm becomes a secondary-fields pass: enriched items
missing a backdrop and/or publication status are claimed, fetched by stored
provider ID, and only the missing secondary fields are written. Lets the
new status field backfill across the already-enriched library instead of
applying only to future enrichments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(metadata): merge ShowStatus through MergeMetadata/MergeGlobalMetadata

The new MetadataResult.ShowStatus never reached the accumulated result the
manga enricher persists from - the field-by-field merges didn't know it, so
the status backfill pass obtained nothing. Regression-tested on both paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(manga): keep scanner identity IDs out of the metadata flow

filterMangaProviderIDs passed the scanner's manga_series identity row
through, so the search-skip-when-already-matched guard saw provider IDs on
every item and never searched: unmatched items went straight to a by-ID
fetch with no usable ID and were stamped as terminal no-match without a
single provider request (and the MangaDex fallback was never consulted).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: gitignore docker-compose.override.yml (local deployment override)

The override unpublishes the bundled redis/postgres host ports
(ports: !override []). It is a per-deployment, local-only file: ignoring it
keeps a rebase from main and git clean -fd from disturbing it, and keeps it
out of any PR. Its accidental absence once exposed Redis to the internet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(manga): code-review fixes — no-match guard, sort comparator, volume-count consistency

- enrichWithProviders: set accumulator.HasMetadata after a provider result
  merges (MergeMetadata doesn't propagate it). Without this, a confident
  match carrying only genres/authors/status/year but no cover and no overview
  failed the no-match check and was discarded + terminally stamped.
- byChapterIndex: both un-indexed chapters yield POSITIVE_INFINITY, so the
  subtraction was Infinity-Infinity=NaN (Array.sort treats NaN as 0, leaving
  order undefined). Compare explicitly for a stable order.
- MangaContent volume/chapter badges: derive counts from the rendered
  buildMangaList entries (which canonicalize v01 ≡ 1) instead of raw distinct
  volume tokens, so the badge can no longer say '2 Volumes' over one row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(manga): clarify the enrichment claim's secondary arm is admin-reset-only

The secondary arm (poster present, backdrop/status missing) requires
last_refreshed IS NULL, so it is only reachable when an operator resets
last_refreshed to backfill a newly-added field — not an automatic periodic
re-check (which would re-fetch banner-less series every sweep). Documents the
intent so it does not read as dead code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(manga): collapse continue-reading chapters per series; batch provider-id lookup

- Continue Reading now collapses multiple in-progress chapters of the same
  manga into one card (most recently read kept), mirroring the episode→series
  collapse. The reading section resolves chapter→series linkage into itemMeta
  (applyMangaChapterSeriesMeta) and runs the shared
  collapseContinueWatchingSeriesCandidates, which the reading path previously
  skipped.
- claimBatch resolves provider IDs for the whole batch in one query via the
  new ProviderIDRepository.GetByContentIDs (content_id = ANY), replacing the
  per-item GetByContentID N+1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): manga publication-status chip on browse cards + more legible chips

- Color-coded publication status pill (Ongoing/Completed/Hiatus/Cancelled/
  Upcoming) in the manga card's top-left corner, mirroring the vol/chapter
  count chip top-right. Strictly manga-gated; show_status was already on the
  browse payload.
- New .glass-chip (78% surface vs glass-subtle's 40%) for the manga count +
  status pills so the labels stay legible over busy cover art.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* build(manga): depend on published silo-plugin-sdk v0.7.0

Replace the vendored internal/compat/silo-plugin-sdk copy with a normal
dependency on the published SDK module at v0.7.0, which adds
MetadataItem.status (publication/airing status) consumed by the manga
status badge at internal/metadata/plugin_provider.go.

- go.mod: pin v0.7.0, drop the local-path replace directive
- remove the vendored internal/compat/silo-plugin-sdk tree
- Dockerfile: drop the vendored-SDK COPY
- strip the manga design docs/plans from docs/superpowers (internal)

Requires Silo-Server/silo-plugin-sdk#4 merged and tagged v0.7.0.

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

* fix(manga): exclude chapters from the matcher's unmatched-item lister

Manga chapters are type='ebook' items that stay status='pending' by
design - provider metadata lives on the type='manga' series item. The
scan-final RetryUnmatchedItemsByFolderAndPathPrefix listed all of them
and ran a rate-limited ebook-plugin search per chapter: 31,564 chapters
x ~1s = 8h46m appended to a 2-minute manga library scan (observed
live), every one a guaranteed no-match. Earlier runs never survived to
completion, so the library's last_scanned_at stayed NULL forever.

Add the same manga_chapters NOT EXISTS guard the ebook enricher's
claim query already uses. Verified live: the same library now scans in
27s with retried_items=0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(scanner): never probe-repair ebook/comic files (ebook+manga detail-page killer)

NeedsCriticalProbeRepair was always true for BaseType 'ebook' files (epub, pdf,
cbz, cbr — incl. manga chapters): buildEbookMediaFile leaves ProbeUpdatedAt nil
and they have no audio/video, so probeEnsurer.Ensure spawned ffprobe per file on
every detail/watch load and never converged (ffprobe errors on zip/rar, result
never persisted). Short-circuit probe-repair for ebook base type — they're read
directly and never use the transcode/playback probe pipeline.

SHARED fix: benefits both the ebooks and manga library types.

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

* perf+fix(ebooks): parallelize detail extension + preserve finished read-state

- buildEbookExtension ran its 3 related-content queries (series, also-by-author,
  similar) sequentially; run them concurrently like buildAudiobookExtension so
  ebook detail latency is the slowest query, not their sum.
- PGEbookReaderProgressStore.Upsert did an unconditional SET progress=EXCLUDED;
  a routine autosave (e.g. reopening a finished book) could drop it below the
  0.9 finished threshold and silently un-mark it read (and clear the manga
  chapter checkmark, which rides on the same row). Guard: once finished,
  progress only moves on an explicit unread (row delete); below threshold it
  tracks freely.

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

* perf(manga): batch chapter presign, index volume counts, quiet scan log

- fetchMangaChapters presigned each chapter poster individually; a long-running
  series has hundreds of chapters. Batch them in one PresignImageURLs call, and
  add the missing rows.Err() check (was silently returning partial lists).
- The browse manga count chip's count(DISTINCT volume) subquery wasn't covered
  by manga_chapters_series (series_content_id, chapter_index); add
  idx_manga_chapters_series_volume (series_content_id, volume) so both count
  subqueries are index-only.
- Downgrade the per-chapter "manga scan: indexed" log from Info to Debug (one
  line per .cbz; the 500-file progress log already covers operator visibility).

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

* fix(manga): address PR #138 code-review findings

Folds PR #142 into the manga branch (already done via fast-forward) and
remediates the issues surfaced in the #138 code review.

Correctness:
- Preserve the scanner's manga_series identity anchor through enrichment.
  ReplaceByContentID's DELETE was unconditional, so the first successful
  enrichment wiped the manga_series provider-id row the scanner relies on
  for idempotency, causing duplicate series + metadata loss on the next
  scan. excludedProviderIDs now also means "not deleted", and the DELETE
  preserves those rows. (internal/catalog/provider_id_repo.go)
- Fall back to the series cover when the latest chapter has no poster.
  Poster columns default to '' (not NULL), so the manga series-card poster
  override blanked cards via a plain COALESCE; wrap operands in NULLIF.
  (internal/sections/fetcher.go)
- Keep backTo a real query param on reader links when libraryId is absent.
  It was string-concatenated with '&', producing a malformed URL on
  deep-links; route it through the query helper instead.
  (web/src/lib/mediaNavigation.ts, EbookReader.tsx, MangaContent.tsx)

Quality:
- Hide manga chapters from favorites/watchlist browse, matching the
  exclusion enforced on every other listing surface.
  (internal/catalog/favorites_browse.go)
- Centralize the manga chapter exclusion predicate into a single exported
  catalog.MangaChapterExclusionWhere, removing four duplicated copies.
  (catalog, sections, ebooks)
- Skip the two manga count subqueries on browse scopes that cannot contain
  manga (non-manga type filters), substituting NULL placeholders.
  (internal/catalog/browse.go)
- Normalize provider publication status (AniList/MangaDex/SDK variants)
  into a stable label set so show_status carries one manga value-domain.
  (internal/manga/enrichment.go)

Adds unit tests for the poster NULLIF contract, browse gating, and status
normalization.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: regenerate go.sum after rebase onto main

Drops stale silo-plugin-sdk v0.6.0 and other leftover hashes from the
intermediate rebased states; go.mod is now on the published v0.7.0 tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(scanner): adapt manga scan to ebookFileShouldSkip 3-value signature

main changed ebookFileShouldSkip to also return the existing content ID;
the manga scan path only needs the unchanged flag, so discard the new
return. Resolves a silent semantic conflict from the rebase onto main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Silo Server Developer <warmasterx555@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:13:10 -04:00
63d608f6fc fix(progress): filter continue-watching by viewer access scope (#167)
* fix(progress): filter continue-watching by viewer access scope

The continue-watching list (GET /progress) only filtered by library when an
explicit library_id query param was passed. The global call passes none, so a
restricted profile received progress rows for items outside its scope (e.g. an
XXX title, or a title above the profile's content-rating cap). The web client
then fans out a per-item GET /catalog/items/{id} detail fetch for each row, and
the inaccessible ones return 404 — surfacing as a dead Continue Watching tile
and stray 404s.

Always apply the viewer's access scope to the progress list. Adds
LibraryItemRepository.FilterAccessibleContentIDs, a batched, episode-aware
mirror of the detail endpoint's access predicate (library membership +
content-rating ceiling), and wires it into HandleListProgress for restricted
profiles only (unrestricted viewers are unaffected). ExcludedMediaTypes is
omitted intentionally: the viewer access.Scope does not carry it and the
request path never sets it.

* fix(progress): gate continue-watching episode access on the parent series

FilterAccessibleContentIDs keyed episode access off episode_libraries and
required a media_item_libraries membership even for rating-only viewers, both
of which diverge from the detail/watch path that masks inaccessible items
(DetailService.GetItemDetail → EnsureAccessible(episode.SeriesID)). For shows
whose episodes span multiple library folders this reintroduced the dead tile /
out-of-scope leak this filter exists to prevent, and rating-only profiles
could lose membership-less items the detail endpoint still serves.

Rewrite the predicate to mirror EnsureAccessible exactly: base the lookup on
media_items, join media_item_libraries only when the viewer is
library-restricted, and resolve episodes through their parent series. Extract a
pure buildFilterAccessibleContentIDsSQL so the query shape (placeholder
numbering, the parent-series join, the optional rating predicate) is
unit-tested without a database, and de-duplicate the two progress filter
helpers via progressContentIDs/keepAccessibleEntries.

AI-use disclosure: implemented with AI assistance (Claude).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:32:52 -04:00
d3v1l1989andGitHub 9d00ce2089 fix(web): start autoplay countdown when entering post-roll early (#162)
When a series episode reaches 30s before its end, the player enters
post-roll and pauses the underlying video to stop HLS from buffering the
tail segment. A paused video never fires the `ended` event, but the
PlayingNextScreen countdown was gated on `postRollVideoEnded`, which only
became true on `ended`. The result: the "Up Next" overlay appears, the
video is paused, and the countdown never starts — it only resumes if the
user manually plays the video (letting it reach the end and fire `ended`).

Arm the countdown gate at the moment we enter post-roll early, since that
is the point at which we've decided playback is over, rather than waiting
for an `ended` event that an intentionally-paused video will never emit.
2026-06-16 18:30:54 -04:00
d3v1l1989andGitHub 4502c7a850 fix(jellycompat): return seasons/episodes when browsing a series via /Items?ParentId= (#160)
* fix(jellycompat): return seasons/episodes when browsing a series via /Items?ParentId=

Clients that browse a show through the generic /Items?ParentId= endpoint with no
IncludeItemTypes (e.g. the VoidTV Android-TV client's getSeasons/getEpisodes) got
the top-level library views instead of the series' seasons or the season's
episodes. HandleItems only honored an item/season ParentId when
IncludeItemTypes=Season was present and otherwise fell through to the
library-views fallback (or a global type browse), so ParentId was effectively
ignored.

Decode a season ParentId into its own parentSeasonID and route item parents:
- season ParentId -> that season's episodes
- series ParentId -> the series' seasons (or its episodes for
  IncludeItemTypes=Episode; an unsatisfiable type filter returns an empty page)

Extract the shared writeSeriesEpisodesResponse used by /Shows/{id}/Episodes and
the new browse paths. The generic /Items browse paths honor StartIndex/Limit
(matching the /Items contract and the sibling seasons listing) while
/Shows/{id}/Episodes keeps its existing whole-season response.

Adds parent_browse_test.go covering series->seasons, season->episodes, the
unsatisfiable-type-filter empty case, paging, and the parser's season/item
ParentId distinction.

* fix(jellycompat): empty result for non-episode type filter on a season ParentId

handleSeasonEpisodeChildren ignored IncludeItemTypes and always returned the
season's episodes. A season's only children are episodes, so a type filter that
excludes Episode (e.g. IncludeItemTypes=Movie) now returns an empty page,
mirroring the series-parent path's handling of unsatisfiable type filters.
Addresses a CodeRabbit review comment.
2026-06-16 18:30:25 -04:00
d3v1l1989andGitHub 623348111c fix(jellycompat): resolve smart-collection BoxSet children at read time (#161)
Browsing into a smart (live-query) collection via /Items?ParentId={boxsetId}
returned 0 children while the BoxSet's ChildCount showed a non-zero count:
handleBoxSetChildren only read materialized library_collection_items via
ListItems, which is empty for smart collections whose membership is a query
evaluated at read time. Confirmed live — smart "Directed by ..." collections
returned TotalRecordCount 0 while materialized collections returned their items.

Branch handleBoxSetChildren on catalog.IsLiveQueryType: smart collections
resolve their members through the query executor (mirroring the web API's
loadLiveCollectionItems — Normalize/Validate/ApplySmartCollectionItemLimit,
library-scope intersection, access-filtered Preview), then feed the resulting
content IDs into the same hydration path as stored collections. A malformed or
invalid query definition degrades to an empty page (never a 500).

Hoist the duplicated intersectCollectionLibraryIDs helper into
catalog.IntersectCollectionLibraryIDs so the web and compat resolvers share one
implementation.

Adds smart_boxset_test.go (smart children resolve in query order, library-scope
intersection reaches the executor, malformed-query and no-executor degrade to
empty).
2026-06-16 18:30:16 -04:00
e084cdd1d6 Add unified literary works for ebooks and audiobooks (#107)
* docs: add literary works design and plan

* feat(literary): add work link schema

* feat(literary): add work domain primitives

* feat(literary): persist work links

* feat(literary): score work matches

* feat(catalog): include literary work summary on item detail

* feat(literary): expose work detail API

* feat(literary): assemble work detail

* feat(literary): add admin work linking primitives

* feat(catalog): group literary items by work

* feat(literary): auto-link works during book scans

* fix(literary): narrow work match candidates

* fix(literary): address work merge blockers

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-16 18:17:08 -04:00
CoffeeKnyteandGitHub 9cd00a877b fix(metadata): cap un-completable episode refresh debt as terminal (#168)
Episodes a provider genuinely has no data for stay actionable forever
and, via syncRefreshDebtForItem, pin the whole series item at
RefreshDebtReasonEpisodeIncomplete -> priority 300 (the top band). Those
debt rows never drain and always cut the queue ahead of legitimately-due
content, every backoff interval, indefinitely.

Classify episode-incomplete debt as terminal once its persistent
attempt_count reaches a small cap (3, ~5 days under the existing stepped
backoff): demote it off the priority-300 band (to a still-fixable
reason's band if one is present, else a terminal floor) and re-check it
rarely (90d) instead of every 30d. The signal is purely attempt_count on
the existing row -- no schema change. Demote rather than delete: deleting
would let the next series pass recreate the row at attempt_count 0 and
restart the cycle.

Giving up fast is safe: the library "refresh incomplete" and per-item
refresh endpoints re-fetch on demand and bypass the backoff, and
media_items.episode_metadata_incomplete is left untouched so they still
find these items. Logs the terminal transition (no silent demotion).

AI-use disclosure: implemented with AI assistance (Claude Code).
2026-06-16 18:16:33 -04:00
bf54f040f9 fix(audiobooks): extract scan covers and harden dedupe (#103)
* fix(audiobooks): extract scan covers and harden dedupe

* fix(audiobooks): address scan cover review

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-16 17:58:01 -04:00
d3v1l1989andGitHub fe7eb70483 fix(jellycompat): reuse the play session for Static direct play instead of leaking one per request (#165)
Clients that direct-play via /Videos/{id}/stream?Static=true never call
PlaybackInfo, so they send their own client-generated PlaySessionId and repeat
it on every range request. resolvePlaybackRoute looked that id up, missed (the
server never minted it), and returned ErrSessionNotFound — so HandleVideoStream
fell to createStaticPlaySession and started a *new* upstream session for every
request. Each counts toward the per-user max_streams cap and only ages out after
the 45s activity grace, so a single direct play's range requests pile up orphaned
sessions and quickly trip the cap -> 429 TooManyStreams, locking the user out of
their own playback.

When the provided PlaySessionId is unknown (or owned by another caller), fall
through to the existing CompatToken-scoped FindByRoute reuse instead of erroring,
so all of a direct play's requests share one session. Reuse stays scoped to the
caller's own token, so a guessed/foreign id cannot bind another user's session.
Adds a test asserting StartSession runs once across repeated Static requests.
2026-06-16 17:51:42 -04:00
d3v1l1989andGitHub c81de3459e feat(jellycompat): add Filters2, LocalTrailers, UserImage, ClientLog and Sessions endpoints (#164)
* feat(jellycompat): add Filters2, LocalTrailers, UserImage and ClientLog endpoints

Four endpoints that real Jellyfin clients call were unregistered and fell
through to chi's default 404 (or, for Filters2, were swallowed by /Items/{id}).
All are additive and contract-faithful to the Jellyfin C# server:

- GET /Items/Filters2 -> 200 QueryFilters v2 shape (Genres NameGuidPair[],
  Tags, Audio/SubtitleLanguages), empty arrays. Fladder's filter UI 404'd before.
- GET /Items/{id}/LocalTrailers (+ /Users/{userId}/... alias) -> 200 bare
  BaseItemDto[] ([]); Silo indexes no local trailers. Infuse/Moonfin hit this
  on every item-detail load.
- GET|HEAD /UserImage?userId= -> the same anonymous palette avatar as the
  legacy /Users/{id}/Images/Primary route; HandleUserImage now reads the id
  from the query param when the path segment is absent (modern Jellyfin route).
- POST /ClientLog/Document -> 200 {FileName} after draining/discarding the
  body (Silo has no client-log store); 413 over 1 MiB, matching MaxDocumentSize.

Stops recurring 404 noise and lets clients that depend on these (filter sheets,
avatars, crash-log upload) work. Adds handler unit tests for each.

* feat(jellycompat): add GET /Sessions returning a contract-shaped session list

Wholphin and other jellyfin-sdk clients poll GET /Sessions (optionally
?deviceId=) every few seconds during playback; the route was unregistered, so
each poll hit a chi 404 the SDK could not deserialize — a ~289-per-4h 404 storm
in production. Register it under the same [Authorize] group Jellyfin uses and
return a correctly-typed SessionInfoDto[] (currently empty, consistent with the
existing compat stub handlers). This stops the storm and lets clients degrade
cleanly; populating live session/now-playing state from the playback store is a
follow-up.

* refactor(jellycompat): match Jellyfin client-log size limit exactly

Use 1,000,000 bytes (Jellyfin's ClientLogController.MaxDocumentSize, decimal)
instead of 1<<20, and fix the comment that wrongly called it 1 MiB. Behavior is
functionally identical (the body is discarded); this is contract-fidelity only.
Review follow-up.

* test(jellycompat): add router-level coverage for the new endpoints

The per-handler tests call handlers directly and never exercise NewRouter, so
route registration, chi static-vs-{id} ordering, and auth-group placement were
untested — the one thing this change is actually about. Add a full
NewRouter/ServeHTTP test asserting the session-auth-group routes (Filters2,
LocalTrailers x2, Sessions, ClientLog/Document) return 401 unauthenticated
(registered + behind auth, not 404 or accidentally anonymous), /UserImage serves
its anonymous palette avatar, and an authenticated Filters2 reaches the v2
filters handler (not shadowed by /Items/{id}) with /Sessions returning [].
2026-06-16 17:51:00 -04:00
d3v1l1989andGitHub 3f3cf55002 fix(jellycompat): return 404 for HLS segments of a failed transcode (#163)
HandleHLSSegment mapped every non-ErrSegmentNotFound error from the
segment-retrieval/recovery path to a generic 500 "Failed to load segment".
When a transcode process starts and then exits non-zero, WaitForSegment
returns a wrapped playback.ErrTranscodeFailed, which fell through to that
500 — observed in production as repeated 500s on seg_00000.ts that drove an
8x client retry storm and crash-log uploads.

The segment will never materialize once its transcode has died, so this is a
not-found condition: Jellyfin's DynamicHls handler falls through to a
PhysicalFileResult for the absent file, which ASP.NET serves as 404, never
500. Map ErrTranscodeFailed to 404 alongside ErrSegmentNotFound via a small
extracted hlsSegmentErrorResponse helper, reserving 500 for genuinely
unexpected errors. Adds a unit test pinning the mapping.
2026-06-16 11:14:14 -04:00
d3v1l1989andGitHub 190223e030 fix(jellycompat): serve item and user images anonymously to match Jellyfin (#158) 2026-06-15 18:18:10 -04:00
ea6d1d1f00 feat(jellycompat): support DELETE /Videos/ActiveEncodings transcode teardown (#159)
* feat(jellycompat): support DELETE /Videos/ActiveEncodings transcode teardown

* fix(jellycompat): guard ActiveEncodings teardown against not-yet-started sessions

HandleDeleteActiveEncodings omitted the UpstreamSessionID == "" guard that
the sibling Stopped-report path uses. A PlaybackSession is created by
PlaybackInfo with an empty UpstreamSessionID; it is only populated once the
first manifest request reaches ensureUpstreamPlayback. A DELETE
/Videos/ActiveEncodings arriving in that window (with a matching token) passed
the ownership guard and ran teardownPlaySession, which deletes the compat play
session from the store. A subsequent HandleMasterManifest then 404s, and the
teardown was not the "no-op-safe" operation its doc comment claims.

Mirror the Stopped report path by treating an unknown, not-owned, OR
not-yet-started PlaySessionId as a uniform idempotent 204 no-op. Add a
regression test that fails if the guard is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 18:17:48 -04:00
Quick ae65678ba4 fix(jellycompat): populate episode season parent metadata 2026-06-15 18:10:40 -04:00
d3v1l1989andGitHub 2e422b66df fix(jellycompat): match the Static stream query parameter case-insensitively (#157) 2026-06-15 18:03:58 -04:00
Quick 10a0635331 Increase default migration timeout
- Raise the default migration timeout from 5m to 20m
- Document SILO_MIGRATE_TIMEOUT in the example environment
2026-06-15 16:20:50 -04:00
13c5e0ba2f feat(catalog): deterministic cross-server content_id (#155)
* feat(catalog): deterministic cross-server content_id

Replace per-server Sonyflake content_id with a structured natural key
derived from provider IDs (movie:tmdb:…, series:tvdb:…, episode:…,
local:… fallback), so two servers holding the same title share one
anchor for artwork, watch history, progress, favorites and ratings.

- internal/contentid: derivation core, SeriesIDFromContentID transform,
  frozen precedence, SchemeVersion=1, embedded-series-anchor invariant.
- internal/metadata/service.go: deterministic id at every mint site.
- internal/catalog/history_source.go: resolve show via string transform
  for anchored episode ids; skip the episodes_pkey probe.
- migrations/sql/20260612130000: collision-safe value remap across the
  65-column reference graph + COLLATE "C", FK/trigger handling, audit
  map, working down.

Benchmarked against an exact-cardinality copy of cprod-postgres
(1.93M episodes, 775k history rows): 2.57x faster history page, 1.7x
throughput at 100 concurrent users, 2.7x cheaper per content_id probe.

* feat(catalog): re-ID untagged items to deterministic content_id at first match

Untagged libraries get a path-derived local: content_id at scan time and
only learn their provider IDs later, when the match worker confirms a
result. Previously that id was never folded back in, so untagged-then-matched
items kept a per-server local: placeholder forever and never converged across
servers (re-ID was deferred to a migration rerun).

mergeAndPersist now promotes a local: skeleton to its deterministic
provider-anchored id at the moment of first confirmed match, via a single
new gate (canonicalizeLocalContentID):

  - target id already taken  -> merge onto it (existing rebind machinery)
  - target id free           -> rename in place

The rename is a single SQL function (silo_rename_content_id); FK children
follow via ON UPDATE CASCADE added to the content_id family, so a fresh
skeleton moves a handful of rows rather than the full-table remap the bulk
migration does. The guard is one IsLocal prefix check, so tagged content and
all refreshes pay nothing, and the move is self-healing under retry.

Verified: gofmt/vet/build clean; migrate-validate passes; migration applies
on the real schema (up/down/up), FKs gain ON UPDATE CASCADE while keeping
ON DELETE; functional test confirms series PK move + series_id cascade +
provider-id sweep, and movie rename.

Follow-ups (noted in docs): recomposeSeriesChildIDs for a series that
accumulated episodes before matching; a lockstep test for the soft-ref list.

* fix(catalog): harden content_id parsing and merge per review

Address review feedback on the deterministic content_id work:

- history_source.go: gate the anchored-episode display-id transform on the
  full five-part episode shape (split_part parts 2-5 non-empty), not just the
  'episode:' prefix, so a malformed id can't transform to 'series:broken:' and
  vanish at the media_items join. Shared anchoredEpisodePredicate drives both
  the null-poisoned join key and the series-recovery expression.
- contentid.go: unexport the provider-precedence slices so no package can
  mutate the frozen SchemeVersion ordering at runtime.
- contentid.go: add parseAnchored to validate the exact per-kind arity and
  numeric season/episode suffixes; SeriesIDFromContentID and IsProviderAnchored
  now fail closed on truncated/malformed ids (e.g. "episode:tvdb:296762").
- canonicalize.go: distinguish catalog.ErrItemNotFound from transient lookup
  errors (a real error no longer masquerades as "target free"), and allow a
  matched local source to be consolidated onto the canonical row instead of
  orphaning a duplicate.

* refactor(contentid): URL-safe "-" separator in content_id

Use "-" instead of ":" to join content_id components
(movie-tmdb-228064, episode-tvdb-296762-1-5, local-<hex>). "-" is an RFC 3986
unreserved character, so a content_id is URL-safe verbatim: encodeURIComponent
is a no-op and the id is its own tidy path segment (/item/series-tvdb-296762)
with no %3A escaping. The stored value equals the URL value, so there is no
encode/decode boundary and an operator can grep the id straight out of a URL or
log. Every component is [a-z0-9]+ (or "tt"+digits), so "-" is unambiguous.

Pre-release format finalization: this branch is unmerged, so no deployed data
carries ":" ids — the migration mints the "-" form fresh and no re-migration is
needed. Still SchemeVersion 1.

- contentid.go: single `sep` constant drives construction and parsing so the two
  can never drift; all constructors/parsers and doc examples updated.
- history_source.go: split_part transform and the anchored-episode predicate use
  '-'; kept in lockstep with the package via a code comment.
- 20260612130000_deterministic_content_id.sql: derivation and season/episode
  composition emit '-'; LIKE filters match 'series-%'.
- docs/architecture/deterministic-content-id.md: format spec + rationale for the
  separator choice; this is the design doc the change is derived from.

Client-side: the web frontend treats content_id as an opaque string (no
splitting/regex), so no client changes are required; existing
encodeURIComponent call sites simply stop emitting %3A.

* docs(contentid): show why hash/bigint rejected in probe-cost table

Add Cross-server deterministic / Zero-join show transform / Human-readable
columns to the index-probe-cost comparison so the trade-off is legible at a
glance: the 128-bit hash and bigint surrogate are faster but each give up a
load-bearing property, and the structured key is the only all-checkmark row.

* docs(contentid): order probe-cost table to end on the structured key

* docs(contentid): label fenced blocks and drop stray EOF tags

Per CodeRabbit review: add 'text' language to three fenced code blocks
(MD040) and remove accidental </content></invoke> artifacts at EOF.

* fix(catalog): remap array-valued content_id soft references in deterministic id migration

The value-remap migration (20260612130000) enumerates the reference graph by FK
plus a scalar name+type sweep (text/varchar/bpchar). That misses
trending_discover_snapshots.content_ids: it is text[] (excluded by the type
filter), named content_ids not content_id (excluded by the name list), and
cannot carry an FK — so the bulk remap left those arrays holding stale Sonyflake
ids that resolve to nothing until the snapshot regenerates. A counterexample to
the migration's "self-protecting, cannot orphan" invariant.

Remap the array element-wise in both directions (Up old->new, Down new->old),
preserving order and leaving collision/unmatched elements untouched; a WHERE
EXISTS guard skips empty/unaffected arrays so array_agg never collapses the NOT
NULL column to NULL. Mirror the gap in silo_rename_content_id (20260614120000)
with array_replace for the single-value runtime rename so the two stay in
lockstep.

Verified on PG18: mixed/collision/empty arrays remap correctly and round-trip
clean; runtime array_replace preserves order.

Surfaced reviewing #155. The jellycompat restart-decode regression and the
atomicity-wording nit are posted as review comments, not addressed here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(jellycompat): pack content_id into compat UUID reversibly so item ids survive restarts

Addresses the restart-decode regression raised in review of #155. With
content_id now a structured string instead of a numeric Sonyflake,
EncodeStringID sent every item/season id down the one-way SHA1 path, making
decode depend on an in-memory reverse map. That map is cold after a process
restart (the codec is a process-lifetime singleton), so a client presenting a
previously-issued item UUID — resume-from-home, deep link, detail page, image,
userdata — got "unknown compat id" until the item was re-listed.

Make the encoding reversible instead of stateful:

- internal/contentid: add Pack/Unpack, a bit-packed, fixed-budget (<=15 byte)
  binary form of a structured or local content_id. digitCount preserves
  provider-id leading zeros (e.g. imdb tt0944947); structured forms are
  self-delimiting; the local form fills the budget exactly. Provider ids that
  overflow uint64 return ok=false.
- Shrink ForLocal to a 112-bit (sha256(path)[:14]) hash so a local id packs
  losslessly into the 15-byte UUID payload. 112 bits is far beyond any single
  server's local-item count. No other code assumed the old width.
- internal/jellycompat: EncodeStringID packs item/season content_ids into the
  UUID (byte 0 = kind, bytes 1..15 = packed, non-zero tag distinguishes it from
  the numeric encoding); DecodeStringID unpacks first and re-packs to confirm,
  so an opaque id whose bytes merely parse is rejected and falls through to the
  map. Numeric ids and arbitrary names (genres, studios) are unchanged.

Net: item/season ids decode by pure computation — stable across restarts and
across instances — with no lookup table. Only the rare unpackable content_id and
non-content names still use the in-memory map.

TDD: round-trip property tests in contentid (all kinds, leading zeros, reject
cases) and a cross-instance decode test in jellycompat that fails on the old
hash+map path. Full contentid + jellycompat suites green; production code
golangci-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(migrate): make the migration run timeout configurable (SILO_MIGRATE_TIMEOUT)

The boot-path migration runner hardcoded a 5-minute context timeout. The
deterministic-content-id value-remap (20260612130000) does a full-table COLLATE
rewrite + 65-column remap that needs ~20 min on a real dataset (615k items /
2M episodes), so it was cancelled at 5 min. Worse, Postgres keeps the orphaned
backend running (holding AccessExclusive locks) until it notices the dead client
at a statement boundary, while the goose session advisory lock releases on
disconnect — so each 5-min boot retry piled a new attempt behind the previous
one's locks. The migration never applied; the server boot-looped.

Make the timeout configurable via SILO_MIGRATE_TIMEOUT (a Go duration like
"60m"); 0 or negative disables the deadline for a one-off heavy migration. Default
stays 5m. All three entry points (migrate-status, --migrate-only, boot) honor it.

Required for the deterministic-content-id migration to apply on any real-sized
database, not just dev — the 5m cap made the PR undeployable at scale.

Follow-up (not here): on cancellation the runner should actively terminate its
backend so a future timeout cannot orphan a lock-holding statement.

TDD: MigrationTimeout parsing (default/override/zero/invalid) + MigrationContext
deadline behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(contentid): require exact length for local ids in Unpack

Tighten the tagLocal branch of Unpack from `len(body) < localHashLen` to an
equality check. The local form fills the compat-UUID payload exactly (no
padding), so a body of any other length is non-canonical; matching it exactly
keeps Unpack a strict fail-closed inverse of Pack for the fixed-length branch,
which decodes client-supplied UUIDs.

Not applied to the structured branch (a review suggestion proposed the same
change there): structured ids are self-delimiting and the compat layer pads them
with trailing zeros to fill the 15-byte UUID payload, so ignoring trailing bytes
is intentional and documented. Rejecting them would make every structured id
fail to decode — the jellycompat cross-instance test guards against that.

Not a live bug today (the only caller passes u[1:] from a 16-byte UUID, so body
is always exactly localHashLen, and idcodec re-packs to verify), but it is the
correct contract and zero-risk. Adds a regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:34:13 -04:00
88aa769fe6 feat(collections): surface server collections on the user Collections tab (#156)
* feat(collections): surface server collections on the user Collections tab

The user-facing Collections tab only showed personal collections, which are
usually empty — leaving most users with a confusingly blank page. Server
(admin-curated) collections were reachable only inside each individual
library's tab.

Add a new GET /collections/server endpoint that aggregates visible library
collections across every accessible library (honoring access scope, capped per
library with a total_count for a See all link), and restructure Collections.tsx
into two titled sections: Your collections (personal) and Server collections
(horizontal teaser rows per library, linking into each library's Collections
tab). Extract the shared CollectionPosterCard so the per-library grid and the
new rows share one implementation.

* fix(collections): match server-collections loading skeleton to row layout

The Server collections section renders as one horizontal teaser row per
library, but the loading skeleton showed a poster grid — so data arriving
visibly reflowed the page from a grid into rows. Mirror the final layout
(section header + per-library rows of poster cards) in the skeleton, and
drop the now-unused COLLECTION_POSTER_GRID_CLASSES import.

Addresses CodeRabbit review comment on PR #156.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Align server collections with shared carousel behavior

- Add opt-out edge padding to reusable media carousels
- Render server collection rows with shared carousel controls and spacing

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:51:12 -04:00
5afe56cfc0 feat(jellycompat): add runtime-managed Jellyfin Web compatibility (#77)
* feat(jellycompat): install web assets at runtime

* fix(jellycompat): recover stale web operation locks

* fix(jellycompat): harden web component management

* feat(admin): refine compat settings and restart status

* chore(dev): add hot-reload docker compose stack

* fix(dev): include npm in hot-reload backend

* feat(admin): refine Jellyfin compatibility settings

* feat(settings): improve jellyfin proxy summary

* feat(settings): improve jellyfin web controls

* fix(settings): update jellyfin web removal status

* fix(settings): enable jellyfin web after install

* feat(jellycompat): auto-select web ui version

* test(api): update rate limit handler setup

* feat(jellycompat): refine web ui install onboarding

* fix(jellycompat): address web ui install review issues

* fix(onboarding): mirror jellyfin api runtime status

* fix(admin): remove global restart banner

* fix(settings): gate restart required tracking

* fix(jellyfin): ignore live settings for restart status

* fix(jellyfin): avoid restart for live compat settings

* fix(subtitles): normalize AI language codes

* fix(catalog): support partial title search tokens

* feat(branding): add white-label customization

* Add push relay engineering plan

- Document relay API contracts, APNs/FCM behavior, auth, storage, and ops
- Capture implementation plan, provider references, decisions, and README

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-15 09:34:08 -04:00
3e77f39d24 ci(v1): auto-label [v1] proposals so fork/CLI filings reach the board (#146)
The issue form only applies labels for web-form submissions; contributors
filing via API/CLI or from a fork lack the triage/write needed to set
labels, so their proposals landed unlabeled and never auto-added to the
Silo v1 project. Add an issues:opened workflow that stamps v1-proposed on
any [v1]-titled issue via the repo GITHUB_TOKEN, regardless of filer
permission.

Also drop epic from the proposal template's auto-labels: a fresh proposal
is a candidate, not yet a tracked epic. epic-ness (better: the Epic issue
type) is applied at acceptance/lock alongside v1 + milestone.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:26:19 -04:00
6f9427d613 docs(process): v1 scope-lock process — proposal template, CODEOWNERS gate, agent instructions (#145)
Implements the process layer of the v1 feature-lock planner: capability
proposals arrive uniform via issue form; the lock artifact
(docs/architecture/v1-scope.md) is CODEOWNERS-gated; the shared
CLAUDE.md/AGENTS.md guidelines gain the scope gate, additive-only API
rules, and pre-push checklist for agent-driven contributions.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:43:23 -04:00
bcf0253c09 feat(notifications): notify requesters of request status changes (#143)
Requests previously only notified the community server channels for
submitted/approved/declined and the requester personally for fulfilled.
This closes the gap and makes request posts addressable:

- New request.approved / request.declined delivery types ride the
  operational dispatch path to the requesting profile: inbox, websocket
  toast, email, Discord DM, personal webhooks (gated by the existing
  notify_requests flag), and web push. Submitted stays broadcast-only
  (the requester performed the action themselves). Title/year/decline
  reason travel in reason_flags since no catalog item exists yet.
- Request status notices are transactional: digest-mode recipients get
  an off-schedule early send (watermark-durable, last_digest_at left
  alone) instead of waiting for the digest hour. Per-episode recipients
  were already immediate via the dispatch nudge.
- At-most-once per (profile, request, type) via a partial unique index
  (migration 20260612100000), mirroring the fulfilled dedupe.
- Server-channel Discord request posts can @mention the requester via
  their OAuth-linked identity (notifications.server_channels.
  mention_requesters, default off). Resolved lazily in the sweep worker
  only when a Discord destination is about to receive the event; the
  ping uses content-level mention with pinned allowed_mentions, and the
  Discord identity never leaks into generic webhook payloads.

Android/Apple clients render the new inbox types with their generic
fallback until they add them.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:20:55 -04:00
91c7eb364d fix(partman): self-heal default-partition conflicts instead of crash-looping (#139)
* fix(partman): self-heal default-partition conflicts instead of crash-looping

EnsureFuturePartitions died with log.Fatalf when CREATE ... PARTITION OF
failed with SQLSTATE 23514 (check_violation) — Postgres rejects creating a
daily/weekly partition while the default partition still holds a row that
belongs in the new partition's range. A single stray row therefore turned
every startup into a crash loop with no in-app recovery (operator had to
TRUNCATE the default partition by hand).

On 23514, drain exactly the conflicting rows out of the default partition and
attach a fresh partition for the range, in a single transaction: create a
standalone table (without copying identity so original ids re-insert),
DELETE ... RETURNING the in-range rows into it, then ATTACH. If any step
fails the transaction rolls back atomically — rows return to default
untouched, no partition is created, and the next cleanup tick retries. No row
is ever destroyed.

Both startup call sites (operational_logs, activity_log) downgraded from
log.Fatalf to a warning so a partition hiccup degrades to writing into the
default partition rather than taking the server down.

* fix(partman): lock default partition during default-conflict heal

Without the lock, live writers keep routing rows for the missing (current)
period into the default partition while the heal runs; a row committed
between the drain and the ATTACH re-triggers the same 23514 check violation
and rolls the whole heal back, so the retry tick can spin under steady write
load. Locking only the default leaf (not the parent) blocks those inserts
for the heal's short duration while the rest of the table stays readable
and writable.

Addresses CodeRabbit review on #139.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:04:31 -04:00
QuickandGitHub f4594e902d Merge pull request #140 from Silo-Server/codex/admin-settings-search
[codex] add settings search command palette
2026-06-12 14:50:47 -04:00
Quick 9508449859 feat(web): add settings search command palette 2026-06-12 12:47:55 -04:00
QuickandGitHub 4e521ed2e8 feat(notifications): notification system v1 (#136)
Merge feat/notifications-v1: a durable, profile-scoped notification
system spanning availability detection, fanout, multi-channel
delivery, and the web/admin UI. 19 commits, ~22k lines, 11 migrations.

Core pipeline
- Availability seeding with per-library seed markers so back-catalog
  imports never flood; release_events -> profile interest fanout with
  settling delay and per-series burst caps; FOR UPDATE SKIP LOCKED
  claims for multi-node safety; durable per-profile inbox with
  cross-library dedupe; interest tracked via a userstore decorator so
  every mutation path feeds one chokepoint.
- Request-fulfilled notifications delivered across all channels.
- Feature flags default ON as kill switches; seeding and backfill run
  automatically at startup.

Channels
- In-app inbox with realtime websocket delivery authenticated by
  single-use handshake tickets, unread badge, per-reason preferences.
- Web Push: self-provisioned VAPID keys (private half encrypted at
  rest), RFC 8291 end-to-end encrypted payloads, service worker and
  subscribe flow.
- Outbound webhooks (gated behind admin opt-in): generic JSON with
  HMAC signing and Discord webhooks with rich embeds (posters,
  provider links, ratings); HTTPS-only private-destination guard at
  registration and connect time; URLs/secrets encrypted at rest;
  durable outbox with backoff and consecutive-failure auto-disable.
- Email: shared SMTP core (internal/mail) with branded HTML
  templates; per-profile verified addresses with per-episode or
  digest delivery and unsubscribe links.
- Discord bot DMs with account linking flow.
- Admin server channels broadcasting new content and request
  activity to Discord.

Web/admin UI
- Notifications inbox page, settings with delivery health, sidebar
  badge; admin settings redesigned around pipeline and channel cards
  with email config and synchronous test sends.

Specs in docs/superpowers/plans/notifications/ (APNs/FCM deferred to
v2; remaining follow-ups tracked in the v1.5 roadmap).
2026-06-12 10:38:53 -04:00
QuickandClaude Fable 5 1e3780d4fb fix(notifications): address code review findings
- pin the four new sensitive setting keys (SMTP password, Discord
  secret/bot token, VAPID keypair) in the encryption audit test so a
  future drop from SensitiveSettingKeys fails CI
- bound account-channel digest drains strictly before the stamped
  digest time so consecutive digest windows partition rows exactly,
  instead of recapping rows created at or after the previous stamp
- keep the events websocket open when an event-frame snapshot fails,
  matching the writeSnapshotFrame degrade-gracefully contract
- rename the seed task to Seed Content Availability to match its
  episode+movie seeding behavior
- carry poster_source_path into realtime dispatch rows per the
  DeliveryRow contract

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:32:21 -04:00
QuickandClaude Fable 5 91e987bcda fix(web): add padding and hierarchy to notification preferences popover
The shared PopoverContent primitive ships with p-0, but the preferences
popover never added its own padding, leaving toggle rows flush against
the border. Add p-4, separate the master switch from category toggles
with a divider, and dim dependent rows while the master switch is off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:54:56 -04:00
QuickandClaude Fable 5 22af261786 feat(web): redesign admin notifications settings around pipeline and channel cards
Replace the flat wall of setting groups with three zones that mirror how
notifications actually flow: a pipeline card framing the master switches
as Record events -> Fan out -> Deliver (with paused-stage explanations),
expandable per-channel cards with collapsed status chips (digest hour,
Discord credential state, failing server channels, SSRF warning), and an
Advanced zone for fanout tuning and retention. Discord's setup wall
becomes a collapsible guide; settings stay editable while a channel is
off so admins can configure before enabling. Same setting keys and form
plumbing throughout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:47:13 -04:00
QuickandClaude Fable 5 799754dde7 feat(notifications): rich Discord embeds with posters, provider links, and ratings
Upgrade all outbound Discord surfaces (personal webhooks, bot DMs, server
channels, request events) from bare title/description embeds to rich ones:
poster thumbnail, overview teaser, TMDB/IMDb/TVDB links, rating and genre
fields, content-rating footer, and a clickable title URL.

Artwork respects the v1 privacy contract via a new admin poster mode
(notifications.discord.poster_mode): "provider" (default) only emits public
provider-CDN URLs, "server" additionally presigns locally cached posters
from this server's image storage, "off" drops images entirely. Builders
never derive artwork URLs themselves; the sender layer resolves PosterURL
through System.discordPosterURL.

To keep provider-CDN URLs derivable after image caching rewrites
poster_path to a local storage key, media_items gains poster_source_path,
captured during cacheItemImages, preserved across refreshes that keep the
cached poster, and cleared on explicit poster overrides.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:32:53 -04:00
QuickandClaude Fable 5 d2ccbcb1a6 fix(web): type server channel event field tables explicitly
tsc -b (the production build) collapses flatMap over the as-const union of
two differently shaped readonly field tuples to unknown, failing the Docker
frontend stage; a bare tsc --noEmit accepted it. Give the field entries one
explicit shared type so flatMap and channel[field.key] resolve.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:10:36 -04:00
QuickandClaude Fable 5 3d2368aed7 feat(notifications): admin server channels broadcasting new content and request activity
Add admin-owned broadcast destinations ("community channels"): Discord or
generic webhooks fed straight from release_events by a per-channel watermark
sweep, announcing newly added movies/episodes as grouped digest posts plus
configurable media request lifecycle events (submitted/approved/declined/
fulfilled).

- Extend release_events with a kind discriminator and add a movie
  availability spine (movie_availability + kind-keyed
  notification_content_seed_state; first full scan seeds silently so
  upgrades never flood the movie back catalog)
- Sweep worker reads events by (created_at, id) cursor with batch-window
  grouping, per-channel backoff, and auto-disable; request events post
  best-effort via new requests.LifecycleNotifier hooks
- Reuse the webhook stack throughout: URL encryption (new AAD namespace),
  SSRF guard, embed limits, HMAC signing; shared type/name validation
  extracted for both services
- Admin CRUD API under /admin/notifications/server-channels and a Server
  Channels section in the notifications admin settings UI

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:04:35 -04:00
QuickandClaude Fable 5 beb6b880fc fix(api): move account-level Discord routes out of RequireProfile
The Discord DM channel's prefs, link-init, and unlink endpoints are
account-level — the handlers only read the user ID — but were mounted
inside the /notifications subrouter, whose RequireProfile middleware
400s any request without an X-Profile-Id header. Register them as
static paths on the auth-only group instead, the same coexistence
pattern the public email-link routes already use (static paths win over
the mounted subrouter's wildcards; verified empirically, no middleware
leak onto profile-scoped routes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:06:09 -04:00
QuickandClaude Fable 5 b83df36636 fix(notifications): drain digest window fully and re-check kill switch under claim
Digest sends read one channelFetchLimit page and then stamped
last_digest_at, permanently dropping overflow rows from combined-mode
recaps and slipping digest-only overflow by a day per page. Digest legs
now page listSince until the window is empty before stamping; renderers
already cap displayed items, so large drains stay deliverable.
Per-episode sends keep single-page reads — their watermark-advance
semantics were already correct.

Also re-check the channel's enabled() under the claim lock so flipping
the admin kill switch stops an in-flight pass immediately instead of
after it completes; the existing errChannelUnavailable path aborts the
pass without penalizing the recipient.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:05:57 -04:00
QuickandClaude Fable 5 88ddd2a406 feat(notifications): branded HTML email templates
Replace the bare-bones inline HTML in notification, verification, and
admin test emails with a shared branded layout in internal/mail,
matching the web UI's Midnight Cinema theme (dark card shell, wordmark,
mono episode-code badges, white primary CTA). The shell is built for
email clients: tables + inline styles, explicit dark color-scheme,
Outlook-safe button, and a width:100%/max-width pattern so the card
shrinks correctly on phones.

Plain-text bodies, subjects, and the link-free-when-unconfigured
guarantee are unchanged; the admin test email gains an HTML body so the
SMTP test doubles as a design preview.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:34:19 -04:00
QuickandClaude Fable 5 ebf3352bda feat(notifications): per-profile email channel with verified addresses
Re-key the email notification channel from login accounts to profiles.
Each profile owns its mode, dispatch watermark, and destination address;
there is deliberately no fallback to the account email, so the account
holder no longer receives mail for every household profile. A profile
receives nothing until its own address is verified.

- Genericize the watermark-sweep engine over a recipient key
  (accountChannel[K]): email keys by profile_id, Discord stays on
  user_id. Delivery reads move into the channel adapters.
- Custom addresses verify via single-use SHA-256-hashed token links
  served by a public endpoint; enabling the channel requires a verified
  address, and clearing the address switches the channel off.
- Addresses are globally unique (case-insensitive): rejected when
  verified for another profile or matching another account's email or
  username. Checked at request time, re-checked at verify time
  (first-to-verify wins), backstopped by a partial unique index.
- Every email carries an RFC 8058 one-click unsubscribe link backed by
  a per-profile capability token, minted lazily under the claim tx.
- Child profiles cannot set addresses (and so receive no email in v1).
- Verification sends are rate limited (1/min, 10/day per profile);
  mail.Message gains custom header support for List-Unsubscribe.
- Migration drops the account-level prefs table without carrying
  opt-ins over, so nobody gets surprise emails post-upgrade.

Android/Apple notification settings need follow-up for the new
profile-scoped response shape and address-management endpoints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:55:30 -04:00
QuickandClaude Fable 5 5f05374a1d feat(notifications): Discord bot DM channel with account linking
Adds Discord direct messages as a notification channel. Users link their
Discord account via OAuth2 (identify scope only, one-time server-side
state rows); a bot delivers their inbox notifications as DMs.

- Extract the email channel's watermark sweep into a generic
  account-channel engine; email and Discord are now thin adapters, so
  the SKIP LOCKED claim / watermark-after-send durability logic exists
  once.
- New internal/discord REST client (token exchange, identity, open DM,
  send message) — no Gateway connection, no new dependencies.
- Opt-in master switch (notifications.discord_enabled, default off)
  gates delivery, linking, capability, and the admin settings reveal.
- Admin UI: credentials (secret + bot token encrypted at rest), dev
  portal setup checklist, bot invite link buttons, and a test button
  that bypasses the settings read cache and is disabled while
  credential edits are unsaved.
- DM failures from missing shared guild (Discord 50007) surface as link
  health in user settings and self-heal via capped backoff.
- New combined mode (per_episode_and_digest) for email and Discord:
  instant sends all day plus a daily digest recapping the whole window
  since the previous digest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:51:03 -04:00
QuickandClaude Fable 5 27b9006ef5 feat(notifications): gate user webhooks behind admin opt-in
Letting users point server-originated HTTP at arbitrary destinations is
an admin decision, so notifications.webhooks_enabled now defaults to
off instead of acting as a default-on kill switch. The flag is also
enforced at webhook creation and test sends (delivery was already gated
at enqueue and dispatch); existing webhooks stay manageable while
disabled so rows are never stranded. The admin toggle moves into the
Webhook Guards group with an off default, and the user settings page
hides the Webhooks section entirely when the capability is unavailable,
matching the other channel sections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:10:16 -04:00
QuickandClaude Fable 5 df95e3cb95 feat(notifications): email notification channel
Adds email as a notification channel built on the shared SMTP core
(mail.Sender). Email mode is a per-account preference (off, daily
digest, or per-episode) stored in notification_email_prefs; delivery is
an account-watermark sweep over notification_deliveries that dedupes
cross-profile duplicates, advancing the watermark only after a
successful send. Admin controls cover the channel kill switch, the
per-episode allowance (off coerces those accounts to the digest),
digest hour, and an external URL for deep links inside emails.
Availability is advertised through /notifications/capability and the
user settings page gains an Email section for opt-in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:09:37 -04:00
QuickandClaude Fable 5 e5b210589d fix(notifications): address PR #136 review findings
Codex + CodeRabbit review fixes, all verified against current behavior:

- Web Push: single-writer VAPID provisioning via a new conditional
  SetIfAbsent settings write (no split-brain identity across nodes), and
  read/decode failures now surface instead of silently rotating the
  keypair; the eager-provisioning goroutine joins the shutdown WaitGroup
- Web Push: endpoint reassignment purges the previous owner's pending
  attempts inside the upsert transaction, with an ownership re-check at
  send time
- Webhooks: per-profile cap enforced atomically (advisory-locked
  count+insert), typed pgconn unique-violation mapping, create-time
  type/URL mismatch rejection, send-time HTTPS re-check, and Retry-After
  HTTP-date support (shared, clamped parser also used by web push)
- Delivery workers: transient delivery-row lookup errors leave the claim
  to lease expiry instead of permanently failing the attempt
- Interest: history-only imports now feed the index (userstore history
  hooks + completed-history folding in recompute/rebuild), rebuild also
  recomputes existing interest rows so removed sources get cleaned up,
  and failed flush mutations requeue (bounded) instead of dropping
- Retention: read notifications age from read_at, not created_at
- Startup: scan queue workers start only after the availability detector
  is wired, so resumed scans cannot skip availability recording
- mail: settings-store read failures propagate instead of reading as
  "not configured"
- DB: new migration adds episode ordinal/key CHECK constraints
- Web: service worker restricts notification clicks to same-origin URLs,
  preferences popover gets an error+retry state, and the realtime
  profile-rebind backoff grows to 5 minutes to keep shared channels
  stable through notifications-only outages

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:16:27 -04:00
QuickandClaude Fable 5 4274220266 feat(web): show web push delivery health in notification settings
Surface per-subscription delivery health on the Notifications settings
page: this browser and each other device now show last-delivered /
last-failed status (failures newer than the last success render in
amber, mirroring webhook health), plus a device count and an explicit
disabled-after-failures state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:05:12 -04:00
QuickandClaude Fable 5 d9e27da59e feat(notifications): request-fulfilled notifications across all channels
Notify the requesting profile once its media request is actually present
in the catalog (roadmap 06, item 2). Completion transitions stay
notification-agnostic; a presence-gated pass at the end of each
reconcile run fires the notice, so it means "watchable in Silo", not
"download finished".

- New System.DispatchOperational: delivery insert + webhook/web-push
  outbox enqueue in one transaction, post-commit multi-dispatch. The
  webhook auto-disable notice now rides the same path (replacing its
  hand-rolled hub publish and the now-removed InsertOperational), which
  also delivers auto-disable notices over web push.
- At-most-once delivery: partial unique index on
  (profile_id, reason_flags->>'request_id') plus a fulfilled_notified_at
  marker on media_requests, backfilled for pre-existing completed
  requests so deploys never flood.
- Per-webhook notify_requests toggle (default on) through repo, service,
  API, and settings UI; gated independently of the episode reason flags.
- request.fulfilled rendering in web inbox, realtime toast, web push
  payload, and Discord/generic webhook payloads, deep-linking to the
  matched catalog item.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:04:22 -04:00
QuickandClaude Fable 5 4fd21c7aee feat(web): admin settings page for notification controls
Expose every live notifications.* server setting on a new admin
Notifications tab (next to Email): kill switches, fanout tuning,
webhook guards, and retention. Unset kill switches render as enabled
to match the backend defaults, numeric fields surface their effective
defaults, and enabling private webhook destinations shows an SSRF
warning. v1.5 roadmap item 1; no backend changes — all keys are
live-read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:14:54 -04:00
QuickandClaude Fable 5 b091f0c6c1 feat(notifications): in-app inbox, realtime, webhooks, web push + shared SMTP core
Implements the notification system foundation and all v1 delivery channels
that need no external infrastructure (specs 00/01/04/05 in
docs/superpowers/plans/notifications/):

Foundation (spec 01):
- episode_availability seeding + per-library seed markers: "newly available"
  means newly released to this server, so back-catalog imports and first
  scans never flood (verified on dev: 1.13M episodes seeded silently)
- release_events -> profile_series_interest fanout worker with settling
  delay, per-series burst caps, FOR UPDATE SKIP LOCKED multi-node claims,
  and a guarded last-notified cursor
- interest index maintained via a userstore provider decorator so every
  favorites/watchlist/progress mutation path (REST, jellycompat, imports,
  playback) feeds it; progress writes only recompute on state transitions
- durable per-profile inbox + read state, forward-sync cursor API,
  websocket channel with short-lived single-use handshake tickets
- web UI: sidebar badge, inbox page, toasts, per-profile preferences
- startup/daily tasks: availability seeding, interest rebuild, retention

Outbound webhooks (spec 04):
- Discord embeds (text-only per the v1 privacy contract) and generic
  JSON signed Stripe-style with per-webhook secrets
- HTTPS-only + private-destination guard enforced at registration and at
  connect time (DNS-rebinding mitigation); URLs/secrets encrypted at rest
- durable per-target outbox enqueued in the fanout transaction, lease-based
  claims, 24h exponential retry, 3x-consecutive-4xx auto-disable with an
  in-app notice (loop-guarded)

Web push (spec 05):
- VAPID keypair self-provisioned at startup (single atomic JSON setting,
  private half encrypted at rest) — no third-party accounts needed
- payloads E2E-encrypted (RFC 8291); 404/410 treated as unsubscribe
- service worker + subscribe flow in Settings -> Notifications

Shared SMTP core (internal/mail):
- feature-agnostic mail.Sender over live email.* settings, STARTTLS or
  implicit TLS, encrypted password, admin Email settings page with
  synchronous test send; no consumer yet by design (digest is v1.5)

APNs/FCM (specs 02/03) are deferred to v2; the capability endpoint reports
them unavailable so clients render truthfully.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:55:46 -04:00
QuickandClaude Fable 5 a24279e3e3 docs(notifications): import notification design docs, add web push + v1.5 specs
Imports the notification system design folder (architecture overview,
release-events/inbox foundation, APNs/FCM relay specs, outbound webhooks)
and adds the Web Push spec (05, implemented in this branch), the shared
outbound-email architecture note, and the v1.5 roadmap (06) covering the
remaining work after APNs/FCM were deferred to v2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:55:46 -04:00
a0f7810481 fix(web): show admin chrome only on the admin account's primary profile (#131)
* fix(web): show admin chrome only on the admin account's primary profile

The top-right ServerActivity indicator and the sidebar Admin section were
gated on the account-level role alone, so every profile on an admin
account — including child profiles — saw admin system notifications and
the indicator polled four admin endpoints on their behalf. Gate both on
the active profile being the household primary, matching the existing
is_primary idiom in SettingsLayout and the server-side quota exemption.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): resolve active profile via useCurrentProfile in admin route gates

RequireAdmin/RequirePrimaryOrAdmin read the profile from useAuth(), but the
admin chrome (AppSidebar, Layout) gates on useCurrentProfile(), which resolves
the selected profile. Use the same source in the route gates so the redirect
and the visible admin UI can never disagree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web,api): centralize acting-admin policy and enforce it server-side

Address code-review findings on the primary-profile admin gate:

- Add isActingAdmin to web/src/lib/permissions.ts as the single
  client-side definition of the policy (admin role + primary or no
  profile), with a useIsActingAdmin hook on top. Route gates, sidebar,
  Layout, and realtime channel gating all use it now, so the gate and
  the chrome can no longer disagree on null-profile handling.
- Convert the admin-gated surfaces the original change missed
  (MediaItemMenu, EditMetadataDialog images tab, AddToCollectionDialog,
  MarkerEditor, theme CatalogBrowser, PersonDetail, SettingsLayout,
  ItemDetail content pages) so an admin on a non-primary profile is a
  regular viewer everywhere, not just in the sidebar.
- Make the role-derived permission bypass (metadata curation, marker
  edit) follow the same policy on both client and server.
- Enforce the policy server-side: RequireActingAdmin middleware refuses
  admin routes when the request declares a non-primary profile via
  X-Profile-Id, and the metadata-curation middleware holds admins on
  non-primary profiles to explicitly assigned permissions.
- Stop spreading the profiles query result from useCurrentProfile so
  route gates only re-render when the resolved profile changes, and
  make it safe outside AuthProvider.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web,api): fail closed on unresolved profiles in acting-admin policy

Address review feedback on the acting-admin gate:

- Server: actingAdminAllowed now denies when the declared profile cannot
  be resolved to one of the caller's profiles, so a bogus X-Profile-Id
  can no longer restore admin powers to a non-primary session.
- Client: useIsActingAdmin returns false while a selected profile id has
  not yet resolved (e.g. hard refresh before the profiles query
  returns), instead of briefly treating it as "no profile selected".
  useCurrentProfile exposes hasSelectedProfile to make that state
  distinguishable.
- hasPermission/canCurateMetadata/canEditMarkers now require the profile
  argument (resolved profile or explicit null), so a missed call site
  fails the typecheck instead of silently restoring the admin bypass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-11 10:41:52 -04:00
QuickandClaude Fable 5 adeda3c87c docs(notifications): add notification system design plans
Imported the Continuum-era notification specs (durable inbox + websocket
foundation, APNs relay, FCM relay, outbound webhooks) and amended them for
Silo. Amendments from the 2026-06-11 review: wire contracts normalized to
Silo naming, back-catalog seeding and per-series burst suppression, durable
dispatch outbox, cross-library episode dedupe, forward-sync wake API,
ticket-based websocket handshake, relay threat-model additions (egress IP,
keyed collapse IDs), and webhook 4xx/SSRF hardening. Includes a
self-contained design-decisions.html visual overview.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:38:29 -04:00
0eb87fa0f5 fix(player): flip ASS subtitle delay sign to match VTT semantics (#130)
JASSUB renders the ASS event matching video.currentTime + timeOffset,
so an event at source time S appears at video time S - timeOffset.
Adding the user delay to the offset therefore showed ASS subtitles
EARLIER on positive delay, while the VTT path (and the subtitle menu's
"later" label) shifts cues later. At +2000ms an ASS track diverged from
an SRT track by a full 4 seconds.

Subtract the delay instead, correct the comment to state the actual
JASSUB render equation, and add tests covering the constructed
timeOffset for positive/negative delays plus the live-instance update
path.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:28:46 -04:00
fadd8ff456 feat(player): native PGS subtitle rendering via libpgs (#129)
* feat(playback): add IsPGS helper and sup streaming extract path

PGS (Blu-ray bitmap) subtitle tracks can be copied losslessly into a .sup
elementary stream for client-side rendering, so they no longer have to be
burned in. streamExtractOutput maps PGS to (copy, sup), and the seek/-t
windowing now skips PGS like ASS: both formats are fetched once and
consumed whole by their client-side renderers.

This also fixes a pre-existing truncation bug: the -t duration cap was
applied unconditionally, cutting embedded ASS extracts off at the default
600s window even though the ASS client fetches the full track.

Extract the ffmpeg argument construction into streamExtractArgs for
testability, following the buildFFmpegArgs pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(api): expose PGS subtitle tracks as .sup stream URLs

PGS tracks were filtered out of /playback/start subtitle_urls entirely,
so the web player showed no subtitles for PGS-only files (#34). Include
them with a .sup URL extension; DVD/DVB bitmap tracks stay hidden since
they still have no non-burn-in delivery path.

HandleSubtitle streams the full PGS track as application/octet-stream.
The seek/duration window is forced to zero for sup: subtitleSeekPosition
falls back to the session's last reported position even without a
?position= query, which would otherwise start the extract mid-file. The
proxy-node subtitle handler gets the same sup branch, streaming ffmpeg
output directly instead of buffering like its text paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(player): consolidate subtitle codec helpers into subtitleCodecs.ts

Rename assSubtitles.ts to subtitleCodecs.ts — the module already labeled
every codec, not just ASS — and add isPGSCodec/isBitmapCodec. Replace the
duplicated BITMAP_CODECS set in SubtitleTranslateModal with the shared
helper so codec lists live in one place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(player): native PGS subtitle rendering via libpgs

Render PGS subtitle tracks client-side instead of leaving them
unavailable (#34). usePGSSubtitles mirrors the JASSUB hook: when a PGS
track is active it lazy-loads libpgs, which fetches the .sup stream in a
worker, decodes display sets progressively as bytes arrive, and draws
them onto a canvas positioned over the video.

The renderer looks up the display set at currentTime + timeOffset, so
the HLS stream origin adds and the user-facing delay subtracts — a
positive delay shows subtitles later, matching VTT semantics. Offset
changes apply through the timeOffset setter without recreating the
renderer; track switches, PiP detach, and unmount dispose it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(player): prefer text over bitmap tracks in subtitle auto-select

With PGS tracks now listed, an earlier PGS track would win auto-select
over a later same-language SRT/ASS track. Deprioritize bitmap codecs
within the same source tier — text is lighter to render and styleable —
while a PGS track still wins when it is the only language match, and
forced-PGS auto-select now works.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:18:29 -04:00
a17529fd6f feat(config): live admin settings + truthful restart-required banner (#128)
* feat(nodeconfig): harden config watcher for integrated-mode use

- RequestReload(): non-blocking, coalescing reload nudge that runs on the
  poll goroutine, so concurrent requests can never swap a stale snapshot
  over a newer one (unlike ForceReload from request handlers)
- Skip OnChange callbacks when the reloaded config is deep-equal to the
  previous one, so the 60s poll doesn't fire rebuild/log callbacks on
  no-op reloads
- Add RedisURL to BootstrapOverrides; previously a reload clobbered an
  env-provided Redis URL in the live config
- Split reload into fetchSettings/applySettings and add unit tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(config): hot-reload config watcher in integrated mode

Start nodeconfig.Watcher in integrated/api mode (previously only proxy/
transcode worker modes hot-reloaded). Expose the live config to the API
and jellycompat routers via func-typed LiveConfig/OnConfigChange fields
with nil fallbacks to the startup snapshot, and wire the admin settings
update hook to RequestReload so same-process changes apply immediately
even without Redis.

No consumer reads the live config yet — conversions land separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(admin): truthful restart-required banner for settings saves

The settings UI showed 'restart required' after every save regardless of
the key. The backend now classifies each key via a central registry
(internal/config/restart_keys.go) and PUT /admin/settings/{key} reports
restart_required per key; useSettingsForm only raises the banner when a
saved key actually needs a restart (and keeps it raised until restart).

The registry is conservative: every currently startup-frozen key is
marked restart-required; subsequent hot-reload conversions shrink it.
Settings read live from the settings repo (branding, overlays, markers,
download.*, ...) default to no-restart. DownloadSettings/OverlaySettings
drop their hardcoded restartRequired={false} special-casing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(logging): hot-reload server.log_level and server.log_quiet

Share one slog.LevelVar across the handler chain and make
logfilter.Handler's quiet-prefix list an atomic pointer shared with
WithAttrs/WithGroup clones (New previously returned the inner handler
unwrapped when the quiet list was empty, leaving nothing to update).
The integrated-mode config watcher now applies both settings live;
their keys leave the restart-required registry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(auth): hot-reload access/refresh token expiries

JWTService stores expiries as atomics with a SetExpiries hook; all three
instances (main API, ABS compat, jellycompat) re-apply them on config
reload. Applies to newly issued tokens; outstanding tokens keep their
original expiry. The JWT secret stays fixed for the process lifetime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(playback): read transcode config live at session start

The playback and stream handlers pull ffmpeg path / hwaccel / transcode
dir from the live config when starting a transcode or extracting
subtitles, instead of values frozen at router construction. Each session
snapshots the config once so its output dir and binary stay consistent.

Also fixes a real bug: playback.hw_device was parsed into the config but
never wired into the integrated-mode handler, so local transcodes always
ran with an empty HWDevice while transcode nodes honored it.

playback.transcode_dir leaves the restart-required registry (the handler
is its only consumer); ffmpeg_path/hw_accel stay restart-required until
scanner/chapterthumbs/audiobook consumers convert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(jellycompat): read compat identity settings live per request

System/Auth handlers take a config provider instead of the startup
snapshot, so jellyfin_compat.public_url, .server_name, and
.emulated_server_version apply without restart. server_id stays
restart-required (generate-once, baked into the resource mapper), as do
the session-store TTLs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(scanner,metadata,mdblist): hot-reload worker pools and API key

scanner.workers, matcher.workers/batch_size, metadata.cache_images, and
mdblist.api_key convert to atomic fields with setters wired to the
config watcher. Worker counts apply on the next scan/match cycle (the
loops read them per cycle); the MDBList key applies to the next request.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ai): hot-reload AI connection, models, toggles, and quotas

The shared llm.Client holds its config behind an atomic pointer
(UpdateConfig; each request snapshots once), and the subtitle/metadata
AI services gain UpdateConfig plus setters on the translator (batching)
and Whisper transcriber (ffmpeg path, chunk seconds). The router derives
their configs from shared helpers used both at construction and in
OnConfigChange callbacks, re-evaluating the chat-only-gateway transcribe
guard on each reload and warning only when it newly fires.

Everything on the AI Services page now applies without restart except
ai.max_concurrent_jobs (fixed-capacity dispatch semaphore).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): wire transcode_enabled; remove dead playback/scanner knobs

playback.transcode_enabled was parsed into the config but the resolver
always received a hardcoded true — the admin toggle did nothing. It now
reads the live config per playback start, so disabling transcodes
applies without restart.

Remove settings that were wired to nothing so 'save + restart' stops
pretending: playback.allow_hevc_encoding (resolver field never
assigned), playback.transcode_ahead_segments and
playback.segment_duration (parsed, never consumed — segment duration is
per-session from the client), scanner.file_removal_grace (DeleteMissing
is never called). UI fields removed and the config struct fields pruned
so they don't resurrect; YAML import still tolerates the legacy keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:25:07 -04:00
6f716d3404 fix(scanner): skip misplaced TV episodes in movie libraries (#90)
* fix(scanner): skip misplaced TV episodes in movie libraries

A movie-type library containing a TV show laid out as
"Show/Season NN/SxxExx.mkv" (e.g. a fan "supercuts" pack) created one
bogus movie item per episode, all titled after the season folder
("Season 01"). They surfaced on Recently Added and never matched.

Add naming.IsMisplacedSeriesFile, which detects a file with an SxxExx
name inside an explicit "Season NN"/"Specials" directory, and guard
createOrFindSkeleton so such files in a strict movie library are recorded
as a skipped root (reason series_in_movie_library) instead of becoming
items. The movie match-queue worker now dequeues files the skeleton step
deliberately skips instead of erroring on the empty content id.

The guard fires on the structural signal alone, regardless of any parsed
provider id, because a "Season NN" folder otherwise yields a bogus tmdb
id (the season number). Movies whose release filename merely contains an
SxxExx substring but live in a proper "Title (Year)/" folder are
unaffected (covered by the new test).

* fix(metadata): durably exclude misplaced-series files from movie queue

Address review findings on the misplaced-TV skip:

- The skipped file's content_id is never set, so every library sync
  re-enqueued it just for the worker to skip and dequeue it again.
  Exclude files beneath a series_in_movie_library skipped root in the
  movie match queue predicates; deleting the skipped root row makes the
  files eligible again. The worker drain remains to flush rows claimed
  before the root was recorded.
- Extract the eligibility predicate (previously duplicated verbatim in
  eight queries) into movieQueueFileEligibleCond.
- Derive skipped-root file_count from media_files under the root via a
  new UpsertObservedFile instead of hardcoding 1: a 34-episode pack now
  reports 34 instead of each per-file upsert overwriting the count.
  This also fixes the pre-existing missing_folder_ids undercount.
- Fold the two near-identical Upsert+log blocks in createOrFindSkeleton
  into a recordSkippedRoot helper, normalize libraryType once, and name
  the reason strings as constants.
- Drop a no-op filepath.Base on already-split path segments in
  IsMisplacedSeriesFile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:18:41 -04:00
7958f0bbf0 feat(nodepool): node groups, per-node caps, and local transcode fallback control (#126)
* feat(nodepool): node groups, per-node caps, and local transcode fallback control

Group co-located transcode and proxy nodes so transcoded streams are
served by a proxy on the same host/LAN instead of bouncing across the
internal network (fixes #93):

- New nodepool.Planner is the single selection entry point: it picks the
  transcode node and its group's proxy together (round-robin within the
  group), replacing the independent ProxyPool.Pick/TranscodePool.Acquire
  calls scattered across the native and jellycompat handlers, and absorbs
  the duplicated soft-affinity pick logic.
- A group is only eligible while all of its enabled members are healthy;
  ungrouped nodes keep the historical behavior.
- New per-node max_jobs cap (transcodes for transcode nodes, streams for
  proxies; NULL = unlimited), enforced via health-reported job counts
  plus short-lived reservations that expire once fresher health data
  arrives. Proxy health now reports real stream counts, including HLS
  sessions via idle-expiry tracking.
- New playback.local_transcode_fallback setting (default on) lets admins
  refuse API-server transcoding when no eligible node exists.
- Health checks now publish updated node copies under the pool lock
  instead of mutating shared structs in place, fixing a data race.
- Admin UI: group + cap fields on the node form, group/cap columns, and
  the new fallback toggle in playback settings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(nodepool): proxy bandwidth measurement and egress caps

Proxy nodes now measure their stream egress (rolling 60s average over
everything under /stream) and report it via the health endpoint. A new
per-proxy max_bandwidth_kbps cap lets the planner route new streams away
from saturated proxies:

- Admission combines the measured egress with the estimated bitrate of
  the new stream (transcode target bitrate, or source bitrate for direct
  play/remux) so a stream is only admitted where it fits.
- Recently admitted streams are bridged as bandwidth reservations for the
  meter window, since the rolling average only converges on a new
  stream's rate gradually.
- A group whose proxies lack bandwidth headroom is treated as full: its
  transcode nodes are skipped, same as the job cap.
- Admin UI: per-proxy "Max Egress Bandwidth (Mbps)" field and a live
  egress column; manual health checks return the measured rate.

Active streams are never interrupted - the cap only gates new admissions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(playback): trim node-mode time-to-stream-start

Distributed playback paid several avoidable costs before the first frame
that integrated mode doesn't have. This trims the safe ones:

- Web player preconnects to the stream origin (the proxy node) as soon as
  /playback/start returns, overlapping DNS/TCP/TLS handshakes with the
  transcode dispatch instead of paying them at the first manifest fetch.
- The transcode node no longer blocks its 202 on monitoring work: the
  Redis session-track write moves off the request path, and a replaced
  session's segment directory is renamed aside and deleted in the
  background instead of synchronously (RemoveAll of a long session can
  take seconds on slow disks during quality switches).
- The proxy's node-facing HTTP client gets a tuned transport: a larger
  idle-connection pool (Go's default of 2 per host causes connection
  churn and TLS re-handshakes when many viewers stream through one
  proxy->node pair) and a response-header timeout so a hung transcode
  node can no longer hang client requests indefinitely.
- jellycompat's remote transcode dispatch gains the same 10s timeout the
  native path has had; an unreachable node previously hung the compat
  manifest request until the OS gave up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:18:18 -04:00
QuickandClaude Fable 5 52dd5dff7f perf(web): prefetch season detail and episodes from series page
Season cards now warm the react-query cache for the season's item detail
and episodes on hover/focus/touch, so navigating from a series page to a
season renders the episode grid without a request waterfall.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:06:30 -04:00
QuickandClaude Fable 5 e64b130bd3 perf(catalog): batch per-episode lookups in episodes endpoint
The item episodes endpoint issued 2-4 sequential round-trips per episode
(media files, watch progress, localization, still-image presigning),
putting season detail loads at ~500ms for typical seasons. Both the real
and synthetic season paths now share one builder that resolves each
concern in a single batched call, using the batch methods that already
existed (ListByEpisodeIDs, ListProgressByMediaItems,
PresignURLsWithExpiry) plus a new LocalizeEpisodeModels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:05:49 -04:00
QuickandClaude Fable 5 ed4cebf3ba feat(ai): per-user transcription quota for subtitle ASR jobs
Cap how many Whisper transcription jobs each user account can start per
rolling window (day/week/month), configurable from admin settings. The
player modal shows remaining usage and the server returns 429 with
details when the limit is hit.

Enforcement is atomic with the job insert (per-user advisory lock, same
pattern as media-request quotas), so concurrent requests cannot race
past the limit. Failed/cancelled jobs that never produced transcription
work are refunded. Exemption applies to the admin account's primary
profile only; other profiles on an admin account stay subject to the
quota. A partial index covers the quota count, a malformed quota
setting row degrades to "no quota" instead of blocking startup, and the
period vocabulary and admin-role predicate are each defined once
(ai.ValidQuotaPeriod, apimw.IsAdmin).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:02:42 -04:00
39ba284c9d feat(ai): shared AI core — metadata translation, Whisper ASR, per-profile language, on-view translation (#127)
* docs: design + plan for shared AI core, metadata translation, Whisper ASR

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(ai): shared LLM client, segment translator, and job runner packages

internal/ai/llm: OpenAI-compatible chat client moved out of subtitles/ai,
plus /v1/audio/transcriptions (verbose_json) for the ASR work; one shared
retry/backoff loop for both. internal/ai/translate: the batched indexed-JSON
translation protocol generalized to text segments. internal/ai/jobrunner:
dispatch/heartbeat/reaper/cancel lifecycle extracted behind a minimal store
interface, with a semaphore shareable across job services.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(subtitles): consume shared AI core

LLMTranslator becomes a thin cue<->segment adapter over aitranslate; the
service delegates dispatch/heartbeat/reaper/cancel to jobrunner; the local
OpenAI client is gone in favor of internal/ai/llm. Behavior (prompts, wire
protocol, job rows, recovery semantics) is unchanged. NewService now takes
the dispatch semaphore so all AI job services can share one bound.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(config): shared ai.* settings, metadata translation job table, localization provenance columns

ai.* connection keys (chat + optional separate ASR endpoint) load with a
fallback to the legacy subtitle_ai.* rows — those are never renamed in SQL
because encrypted values are GCM-bound to their setting key. New toggles:
subtitle_ai.transcribe_enabled, metadata_ai.enabled. Migration adds
metadata_translation_jobs, per-field provenance (provider|ai|manual) on the
localization tables, and media_folders.auto_translate_metadata.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(catalog): localization field provenance with provider/ai/manual precedence

Provider upserts keep manual values and never blank a field with an empty
incoming value; new UpsertAITranslation/UpsertAIOverview methods write AI
fields only over empty or ai-sourced values (force adds provider, never
manual) — all enforced in single-statement SQL. Serving now merges only
non-empty localized fields onto the base item, since localization rows are
legitimately partial (AI rows carry no titles/artwork).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(metadata): AI translation service, refresh auto-fallback, and admin API

internal/metadata/translation: job service over the shared AI core that
expands an item to its season/episode overviews, skips already-localized
fields (zero model calls on repeat runs), batches paragraphs through the
generic translator, and persists per batch with provenance-aware upserts.
MetadataService gains an AutoTranslator seam invoked after each refresh for
libraries with auto_translate_metadata. Admin endpoints under the metadata
curation guard: enqueue, list (poll), cancel; plus a status probe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(subtitles): Whisper ASR transcribe and transcribe_translate jobs

New WhisperTranscriber: one ffmpeg pass extracts the audio track to 10-min
16kHz mono WAV chunks (temp dir cleaned on every exit path), each chunk goes
to the OpenAI-compatible /v1/audio/transcriptions endpoint (verbose_json,
per-request timeout sized to 3x chunk duration), segment timestamps are
offset and built into wrapped cues. Chunks process playhead-first and stream
live to the requesting session. The transcript is stored as an ordinary
downloaded subtitle (provider 'transcribed'); transcribe_translate chains
the existing translator and stores the translated track as the job result.
Enqueue accepts an optional kind; status reports transcribe_enabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): AI services settings, metadata translate action, library auto-translate, generate-from-audio

New AI Services admin page hosts the shared endpoint config (reads fall back
to legacy subtitle_ai.* values, writes target ai.*) and the three feature
toggles; the AI card moves out of Subtitles settings. The metadata editor
gains a Translate-with-AI panel with job polling and force/re-translate. The
library form gains the auto-translate toggle (threaded through the libraries
API). The player translate modal gains a From-audio mode that lists audio
tracks and submits transcribe / transcribe_translate jobs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style: gofmt import grouping in router and translation tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(catalog): per-profile metadata language and viewer-triggered description translation

user_profiles.preferred_metadata_language threads through the access scope
into catalog serving: presentation language now resolves explicit param ->
profile preference -> library metadata language (native API and jellycompat).
ItemDetail gains pending_translation_language when the viewer's language is
missing a localized overview. New metadata_ai.on_view setting (off|button|
auto) gates POST /items/{id}/translate-description: any profile with item
access may request its language, with in-flight dedup and a 15-minute
failure cooldown so page views never hammer a broken endpoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): on-view description translation with per-profile metadata language

Profile playback settings gain a Metadata language picker (library default
inherit). Detail pages: when the server reports pending_translation_language
and metadata_ai.on_view is 'auto', the description translates on view with a
pulse animation until the refetched detail comes back localized (45s
timeout); in 'button' mode a small Translate chip triggers the same flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): expose metadata_ai.on_view in AI Services settings

The on-view translation mode had no UI control, so it could only ever be
'off' — viewers got neither the auto translation nor the fallback button.
Adds the off/button/auto selector to the Features card, and the config
loader now warns and falls back to 'off' on a bad row instead of refusing
to start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai): clear configuration hint when the transcription endpoint is chat-only

A blank Transcription base URL falls back to the chat endpoint; chat-only
gateways reject the multipart upload with an opaque 400 that reads like a
pipeline bug. 400/404/405 transcription failures now carry a hint to set a
Whisper-compatible endpoint in AI Services.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(subtitles): wrap ASR cue text by rune count, not bytes

Arabic/Cyrillic/Greek text is 2+ bytes per character in UTF-8, so byte-based
wrapping broke lines at roughly half the intended visual width.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(web): steer transcription base URL hint away from chat-only gateways

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ai): block chat-only gateways for transcription, add endpoint presets

llm.IsChatOnlyGateway (OpenRouter et al — no timestamped transcription API)
is enforced in three layers: the settings API rejects ai.asr_base_url values
pointing at one, the router disables ASR with a warning when the blank-URL
fallback would land on one, and llm.Transcribe refuses outright. The AI
Services page gains one-click transcription presets (Groq turbo/accurate,
OpenAI, self-hosted speaches) plus the mirrored client-side check, and the
settings API now also validates metadata_ai.on_view.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(subtitles): tighten ASR subtitle sync

Three systematic timing-error sources addressed: cue offsets now use the
segment muxer's exact per-chunk start times (segment_list CSV) instead of
assuming index*chunk_seconds; the audio stream's start delay relative to the
container timeline (common in TS remuxes) is probed via ffprobe and added to
every cue; and the chunk length is now operator-tunable via
subtitle_ai.asr_chunk_seconds (60-600s, default 600) since shorter chunks
bound Whisper's within-chunk timestamp drift. Playhead-first ordering now
pivots on real chunk starts, and a beyond-end playhead starts at the final
chunk instead of restarting from zero.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai): tolerate base URLs that already include the /v1 segment

Providers like DeepInfra expose their OpenAI-compatible API under a base
that contains the version segment (api.deepinfra.com/v1/openai); always
appending /v1/... mangled those. endpointURL now appends bare paths when
the base already carries /v1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): prefer self-hosted transcription in presets and hints

Preset order becomes self-hosted (recommended) -> Groq turbo -> Groq
large-v3 -> OpenAI, and the settings hint plus the job-error hint lead with
the self-hosted option. The self-hosted preset now fills the turbo CT2 model
to match the recommended speaches setup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(subtitles): request VAD and word timestamps for ASR cue accuracy

Without vad_filter, faster-whisper servers report wall-to-wall segment
times: cues linger on screen through silence (verified up to 91s) and
paragraph-length segments become single 400+ char cues. Request
vad_filter=true (skipped for hosted providers that reject non-OpenAI
fields and run VAD server-side) plus timestamp_granularities word+segment,
and rebuild cues from word timings: split at speech pauses, sentence ends,
text capacity, and a 7s max duration; cap word-less segments instead of
trusting their reported end; stretch sub-second cues to a readable minimum.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:58:54 -04:00
Quick c7f65bf351 fix(audiobooks): normalize ABS session token hash 2026-06-10 11:56:59 -04:00
54b20f57d7 feat(audiobooks): player QoL — shortcuts, smart rewind, per-book speed, volume, chapter nav (#125)
Brings the web audiobook player to parity with best-in-class audiobook
apps (Audiobookshelf, Audible, BookPlayer) on the quality-of-life axis,
per docs/superpowers/plans/audiobook-player-qol.md:

- Keyboard shortcuts active on every route while the player is mounted
  (space/K, arrow skips, volume, M, N/P chapters, shift+./, speed,
  E expand, Esc collapse), inert while a video session exists.
- Configurable skip intervals with asymmetric defaults (back 10s,
  forward 30s) via a new player settings popover.
- Speed control expanded to 0.5x-3x with a 0.05 stepper, presets, and
  per-book memory (LRU-capped, device-local).
- Chapter prev/next transport buttons (prev restarts the chapter when
  more than 3s in, the universal player convention).
- Volume/mute via the shared VolumeControl with a new theme-token
  "surface" tone; persistence shared with the video player.
- Smart rewind: backs up 3-30s on resume scaled by pause length, plus a
  flat 10s on cold resume from the detail page; explicit seeks are never
  second-guessed. Toggleable in player settings.
- Now Listening time label cycles total / remaining / remaining at
  current speed.

Chapter flattening is deduplicated into lib/audiobooks/chapters.ts, the
SkipIcon is shared between both player views, and new pure modules
(smartRewind, chapter nav, prefs) come with unit tests.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 09:08:18 -04:00
QuickandClaude Fable 5 35a0db7d29 fix(api): decode percent-encoded provider IDs in route params
chi matches routes against the raw (escaped) request path, so
chi.URLParam returns parameters still percent-encoded when clients
escape reserved characters. The web UI sends marker provider IDs via
encodeURIComponent, so plugin-based providers like "plugin:6:introdb"
arrived as "plugin%3A6%3Aintrodb", breaking validate (400) and update
(404) for any provider ID containing a colon.

Add a shared decodedURLParam helper and use it in the marker provider,
subtitle provider, and watch provider handlers, returning 400 on
malformed escape sequences.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 08:58:45 -04:00
Quick ef78a1c5da Merge branch 'main' of https://github.com/Silo-Server/silo-server 2026-06-10 08:43:08 -04:00
8b70357703 feat(ebooks): first-class ebook libraries, scanner, and reader (#124)
* docs: define ebook architecture matching audiobooks

* docs: plan ebook audiobook-parity implementation

* feat: add ebook scanner parser foundation

* fix: harden ebook scanner foundation

* fix: handle ebook isbn labels

* fix: guard ebook subtree scans

* feat: scan ebook libraries in core

* fix: preserve ebook scan people credits

* fix: refresh ebook scan metadata safely

* feat: persist ebook series membership

* test: cover ebook series persistence decisions

* fix: address ebook scanner PR review

* docs: clarify ebook foundation PR scope

* feat: add ebook metadata enricher

* fix: harden ebook poster cache

* feat: wire ebook metadata sync task

* feat: expose ebook library metadata setup

* feat: add ebook catalog scope support

* feat: add ebook detail view

* feat: label ebook file versions by format

* feat: use file-size copy for downloads

* feat: use file language in download dialog

* test: cover ebook detail authors and downloads

* fix: drop narrator credits from ebook scanner merges

* fix: align ebook collection filters with book media

* fix: drop asin provider ids from ebook enrichment

* fix: force ebook people refresh for stale narrators

* chore: omit ebook planning docs from branch

* feat: add ebook detail related content

* feat: add ebook reader file entrypoint

* feat: render ebooks with foliate reader

* feat: persist ebook reader progress

* feat: add ebook reader controls

* feat: extract ebook pdf metadata

* feat: favor scanner isbn during ebook enrichment

* feat: extract fbz ebook metadata

* feat: count cbz ebook pages

* feat: show ebook file page counts

* feat: show ebook download summaries

* feat: switch ebook reader files

* feat: prefer epub for ebook read action

* feat: surface ebook reader progress

* feat: sync ebook reader progress cache

* feat: hide ebook read action for unsupported files

* feat: filter ebook reader file selector

* fix: serve fbz ebook archives with reader mime type

* fix: detect fbz ebooks from compound filename

* fix: authorize fbz ebooks from compound filename

* fix: scope ebook catalog facets

* fix: reject narrator queries for ebooks

* fix: build ebook recommendation text from authors

* fix: include ebooks in embedding eligibility

* fix: include ebooks in recommendation media mix

* fix: include ebooks in recently added recommendations

* feat: include ebook progress in recommendation signals

* feat: include ebooks in continue watching sections

* feat: include ebooks in catalog progress metrics

* fix: read ebook isbn from epub metadata

* fix: filter ebook asin provider aliases

* fix: fall back from unsupported ebook reader files

* fix: sort ebook catalogs by reader progress

* fix: filter ebook catalogs by reader progress

* fix: include ebooks in last watched catalog filters

* feat: reflect ebook reader progress in item user state

* feat: share ebook progress state across item surfaces

* feat: report ebook scan progress

* fix: include ebook activity in recommendations

* fix: expose ebook reader progress on item detail

* fix: support ebook subtree scans

* fix: honor profile header for ebook item progress

* fix: add ebook library default sections

* fix: route ebook continue cards to reader

* fix: hide watched toggle for ebooks

* fix: route ebook watch tonight cards to reader

* fix: route ebook hero actions to reader

* fix: detect archive ebook reader formats by filename

* feat: cache embedded ebook covers during scan

* fix: encode ebook hero reader links

* fix: persist non-epub ebook reader progress

* fix: scope narrator catalog badges to audiobooks

* fix: merge ebook reader progress during item repair

* fix: label ebook progress filters as read

* fix: show ebook related rails as book covers

* fix: remove txt ebook reader support

* fix: reject txt ebook reader files

* fix: label ebook advanced filters as read

* fix: label ebook personalized sorts as read

* fix: remove plain text reader loader path

* test: cover ebook unread catalog rules

* fix: preserve ebook reader library context

* fix: link ebook genres with library scope

* fix: encode related rail item links

* fix: encode catalog card item links

* fix: encode hero and continue item links

* fix: encode watch tonight item links

* fix: encode recommendation and search item links

* test: cover ebook scan format set

* fix: label ebook search results clearly

* fix: make global search prompt media neutral

* fix: encode catalog read API ids

* fix: encode item API ids

* fix: include ebook reader vendor in docker build

* fix: make ebook reader build clean

* fix: clean ebook embedded descriptions

* docs: plan ebook reader shell parity

* feat: add ebook reader shell controls

* fix: widen ebook scrolled reader flow

* fix: remove scrolled reader content width cap

* docs: plan ebook reader full parity

* feat: persist ebook reader config

* feat: add ebook annotations and bookmarks

* feat: add ebook reader tools and aids

* feat: add ebook advanced reader settings

* fix: keep ebook reader panel in viewport

* fix: use foliate sizing units for ebook scroll flow

* fix: keep ebook settings controls readable

* fix: simplify ebook reader settings controls

* feat(ebooks): extract local covers during scan (#98)

* feat(ebooks): extract local covers during scan

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

* fix(catalog): coalesce nullable media artwork fields

* fix(ebooks): group sibling formats by book identity

* fix(ebooks): tolerate legacy ebook metadata encodings

* fix(ebooks): decode PDF hex metadata strings

* fix(ebooks): harden local cover extraction and format grouping

Address review findings on the local cover scan:

- Restrict generic sidecar covers (cover.jpg, folder.png, ...) to
  single-book directories, always accept images named after the book
  file, and apply exactly one cover per reconcile with sidecar taking
  precedence over the embedded cover.
- Replace the read-then-write poster update with an atomic conditional
  UPDATE (ItemRepository.SetLocalPoster) so provider/admin artwork is
  never clobbered by concurrent writers, and refresh locally owned
  posters when the extracted cover bytes change (thumbhash compare).
- Preserve UTF-8 PDF Info strings (including a UTF-8 BOM) instead of
  forcing everything through Windows-1252; the cp1252 fallback now only
  applies to non-UTF-8 bytes.
- Select EPUB covers by manifest media-type with properties="cover-image"
  outranking the EPUB2 meta name="cover" id, so XHTML cover pages no
  longer shadow the real image.
- Order CBZ pages naturally (2.jpg before 10.jpg, ch2/ before ch10/)
  when picking the cover page, via a single O(n) min-scan.
- Bump the ebook content group key scheme to version 2 and reprocess
  rows written under older versions so pre-existing libraries gain
  sibling-format grouping instead of accumulating duplicates.
- Group different formats only (a same-format sibling with colliding
  sparse metadata stays a separate item) and stop a joining sibling's
  embedded metadata from overwriting a provider-matched item.
- Decode any IANA-labelled OPF/FB2 XML charset (windows-1251, koi8-r,
  shift_jis, ...) via x/net/html/charset, and wire the charset reader
  into FB2 parsing which previously had none.
- Strip the full .fb2.zip double extension from filename-derived titles
  and group keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat(ebooks): add reader profiles and ruler (#99)

* feat(ebooks): extract local covers during scan

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

* fix(catalog): coalesce nullable media artwork fields

* fix(ebooks): group sibling formats by book identity

* fix(ebooks): tolerate legacy ebook metadata encodings

* fix(ebooks): decode PDF hex metadata strings

* feat(ebooks): add reader profiles and ruler

* fix(ebooks): address reader ruler and profile review findings

- skip renderer setStyles/render when computed styles and attributes are
  unchanged, so ruler position updates no longer re-style the book view
- drag the ruler via a local draft that commits on release, with the
  surface rect cached at pointer-down
- migrate font values persisted before the generic stacks (Inter,
  Georgia, Merriweather, legacy serif) so the font select never renders
  blank, with a Custom fallback option for unknown values
- make the ruler band click-through and move dragging to a dedicated
  keyboard-accessible slider handle so links and text selection keep
  working under the band
- share font stacks between options and profiles via READER_FONT_STACKS
- surface the active reading profile, move presets to the top of the
  settings panel, and drop the redundant profile button aria-labels

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ebooks): resolve prefer-const lint error in readest document lib

`pnpm run lint` failed on the branch because `direction` is never
reassigned in getDirection; split the destructure so only the
reassigned `writingMode` stays mutable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Merge branch 'main' into work/ebooks-reader-base

Brings the ebook integration branch up to date with main (audiobook
library redesign, continue-watching rework and card affordances,
quic-go bump, jellycompat fixes). Conflict resolutions favor main's
generalized mechanisms and register ebooks with them:

- media scope validation goes through IsValidMediaScope (now including
  "ebook" alongside main's "video" group scope), in Go and in the web
  filter/search types
- continue-watching uses main's typed rails; reading-type sections pull
  resume points from ebook_reader_progress and the ebook library default
  section is wired to ContinueTypeConfig(ContinueTypeReading)
- item_repo keeps main's derived select-list machinery (itemColumnExpr)
  and both poster accessors (GetPoster/SetLocalPoster for ebook covers,
  GetPosterPath for audiobook covers)
- web cards/hero/watch-tonight adopt main's buildMediaPlayHref helpers,
  which now route ebooks to /reader/ebook and encode content ids;
  ebook affordances (BookOpen icon, Read verb, percent-read subtitle)
  carry over onto main's reworked components
- LibraryForm ebook support ported into main's refactored
  useLibraryForm/libraryTypes modules

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(docker): copy foliate-js vendor into Dockerfile.dev frontend stage

foliate-js is a file:vendor/foliate-js dependency, so pnpm install needs
the vendor directory before the lockfile install layer. The production
Dockerfile already copies it; the dev image was missed, breaking
make dev-deploy with ENOENT on /app/web/vendor/foliate-js.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ebooks): render Continue Reading sections as upright poster cards

All-ebook continue sections previously fell through to the horizontal
16:9 wide card; include ebooks in the poster-variant check so book
covers render in their natural 2:3 framing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): stop related-rail highlight ring clipping on detail pages

Move the current-item ring onto the cover artwork with a themed
ring-offset color (matching the sidebar profile highlight) and give
the scroll container top headroom so the ring is not cut off by
overflow-x-auto. Applies to both ebook and audiobook detail rails.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(scanner): harden ebook scanning against data loss and bad metadata

- Reconcile missing ebook files like video/audio, with real per-root walk
  failure tracking (failed/unmounted roots are excluded from deletion),
  symlinked-root support via the shared logical walker, and the empty-root
  cleanup allowance before any destructive reconciliation.
- Create ebook items as 'pending' so enrichment can promote them to
  'matched' (backfill migration included), and protect matched items from
  re-scan clobbering: title/year skipped, people/series fill-empty only.
- PDF metadata: scan head + tail windows (non-linearized PDFs keep the Info
  dict at the end), require proper key delimiters, head values win.
- Cap plain .fb2 reads like .fbz entries; drop .md as an ebook format.
- gofmt internal/scanner/audiobook.go (pre-existing drift).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ebooks): make enrichment failures non-terminal with dedicated backoff state

- Provider errors now record a failure (capped retries) instead of stamping
  last_refreshed, which permanently excluded items after transient outages.
- Unconfigured metadata chains and the scan-window membership race skip the
  item without stamping or burning a retry.
- Failure tracking moves to a new ebook_enrichment_state table, decoupling
  it from media_items.refresh_failures (shared with metadata refresh debt).
- Preserve non-author people credits when persisting enrichment results.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(catalog): gate ebook progress on hidden history and centralize threshold

- Apply user_history_hidden_items gating (video semantics) to the ebook
  watched/in-progress filters, progress sort plan, and Continue Reading.
- Continue Reading pages past dismissed items via the shared collector and
  dedupes items across pages (also fixes the video path's latent exposure).
- Centralize the 0.9 finished threshold as models.EbookFinishedProgressThreshold
  with a single SQL-interpolated mirror in catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(recommendations): correct watcher counting and wire ebook taste signals

- itemWatchersQuery dedupes to distinct (watcher, item) rows so one
  binge-watcher can no longer satisfy minWatchers; the eligibility floor
  now counts distinct accounts rather than profiles.
- Hidden-history gating on GetEbookReaderProgressForUser (signal reader).
- Ebook reading produces canonical implicit taste signals (weighted like
  the equivalent movie progress ratio); ebooks join taste-seed candidates.
- Stale GetRecentlyAddedItems doc comment corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): harden ebook reader endpoints and serve a Content-Security-Policy

- Serve a CSP on all SPA HTML responses: blob/srcdoc book iframes inherit
  it, so script-src 'self' 'wasm-unsafe-eval' blocks script execution from
  malicious book content (sandbox alone is defeated by the WebKit
  allow-scripts requirement). Threat model documented on the constant.
- X-Content-Type-Options: nosniff on frontend, jellycompat, and ebook file
  responses; MIME resolution can no longer fall through to octet-stream
  for an admitted ebook file.
- Annotation PATCH: presence-aware field semantics (absent keeps, present
  sets/clears), invariant re-validation on the merged row, and an atomic
  SELECT ... FOR UPDATE read-merge-write.
- Request size caps (413) on progress/config/annotation writes;
  Content-Disposition via mime.FormatMediaType; hidden-history gating in
  the shared ebook progress lister; FK-cascade indexes for reader tables.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(api): native read-state endpoints for ebooks

- POST/DELETE /watched/{id} accepts ebook content IDs: mark read upserts
  progress 1.0 preserving the reader's file/location (or picks the
  preferred reader file for never-opened books); mark unread mirrors video
  unwatch semantics and deletes the progress row.
- /history/remove accepts ebooks: hides via user_history_hidden_items
  without touching the reading position (hidden != unread; next reading
  activity resurfaces the book, mirroring video re-watch).
- Access-filter checks match the video branch; shared logic lives in
  ebook_read_state.go. Sort metrics/user-state thresholds use the shared
  constant; profile-header fallback deduplicated.

Clients: response is {type: "ebook", affected_count: 1, played: bool};
the existing watched SSE event fires.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): harden the ebook reader UI

- Open-flow race: cancellation checked after every await with full stale-run
  teardown (no wrong-file progress saves, no leaked views/blob URLs);
  book.destroy() on cleanup.
- Progress: monotonic stale-response guard; visibilitychange flush uses the
  refresh-capable client, pagehide uses keepalive; per-book cross-format
  progress documented as deliberate.
- Settings: side effects out of the setState updater; local edits no longer
  clobbered by late server config; pending saves flushed on unmount/pagehide.
- TTS: generation token so Stop actually stops (Chromium/Firefox synthetic
  events); Media Session uninstalled on unmount.
- External book links: http(s) only, opened with noopener,noreferrer.
- apiBlob 512 MiB guard with a user-facing error; fraction bookmarks
  navigable; search-result key collisions fixed; dead e-ink code removed;
  getLibrarySortRelevanceScope deduplicated; md format dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): mark read/unread affordances for ebooks

- Item detail gets a Mark Read/Unread button; card menus drop the ebook
  gate and share type-aware labels/toasts (also dedupes audiobook wording).
- Watched-state invalidation includes the reader progress query key so the
  Continue button and percent refresh after toggling.
- Continue Reading dismiss copy for ebooks; dismissal path now URL-encodes
  item IDs (ebook content IDs can contain reserved characters).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: record the PR #124 review and hardening pass

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 08:18:35 -04:00
0bd4f8cb3b fix(jellycompat): restore CanDownload with a real Download route for Infuse (#123)
dd81a7ef set CanDownload=false to stop Wholphin's screensaver from
404ing on the nonexistent /Items/{id}/Download route — but the flag is
load-bearing for Infuse, which refuses Direct Play (Static=true
streaming) of items it believes it cannot download. With omitempty the
field vanished from the JSON entirely and Infuse playback broke, while
PlaybackInfo-negotiating clients were unaffected.

Resolve the underlying inconsistency instead of trading one client for
the other: implement GET/HEAD /Items/{id}/Download serving the original
file (range support, Content-Disposition, optional mediaSourceId for
multi-version items) under stream-group auth, and restore
CanDownload=true now that the route exists. Fixes Infuse playback and
keeps Wholphin's download callers working.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 21:09:47 -04:00
c5f21cb10d fix(jellycompat): parse repeated Fields query params (#110)
* fix(jellycompat): parse repeated Fields query params

parseItemsQuery read the Fields parameter via q.Get("Fields"), which
returns only the first value when a client sends Fields as repeated
query params (Fields=A&Fields=B&...) instead of comma-separated in a
single param (Fields=A,B,C).

The jellyfin-sdk-kotlin (used by Wholphin) sends repeated params. When
such a request listed a detail-only field like MediaSources after other
fields — e.g. the episode-playlist request
  /Shows/{id}/Episodes?Fields=PrimaryImageAspectRatio&...&Fields=MediaSources&...
silo saw only the first value (PrimaryImageAspectRatio), so
needsDetailFields stayed false, the request took the list path, and the
response came back without MediaSources. Clients then could not start
playback of the returned episodes ("no media sources").

Join all repeated Fields values before splitting on commas so field
order and delimiter style no longer matter. Comma-separated single-param
clients (e.g. VidHub) are unaffected.

* fix(jellycompat): stop advertising CanDownload and stub ThemeSongs

Wholphin (jellyfin-sdk-kotlin) audit surfaced two reachable gaps:

- mapping.go set CanDownload=true on every playable item while no
  /Items/{id}/Download route exists, sending clients that honor the flag
  (e.g. Wholphin's screensaver/slideshow) into 404s. Advertise false until
  a download route exists.
- GET /Items/{id}/ThemeSongs 404'd, so enabling theme songs in Wholphin
  silently failed on every detail page. Stub it with an empty
  ThemeMediaResult. This cannot reuse the generic item stub: the SDK
  models OwnerId as non-nullable, so the response must include it even
  when empty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(architecture): add Wholphin endpoint coverage audit

Cross-references every Jellyfin endpoint the Wholphin client can call
against the routes jellycompat serves, with gating evidence for each
missing-but-unreachable endpoint and prioritized recommendations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 20:42:46 -04:00
1a1282db0c build(deps): bump quic-go to 0.60.0 via webtransport-go compat shim (#121)
engine.io (via socket.io) depends on zishang520/webtransport-go v0.9.1,
which is pinned to old quic-go internals and breaks against newer
quic-go releases, blocking dependabot's quic-go bump (#74).

Add internal/compat/zishang520-webtransport-go, a shim module that
preserves the zishang520/webtransport-go API shape while delegating to
the maintained quic-go/webtransport-go v0.10.0, wired in with a go.mod
replace directive. The Dockerfiles COPY the shim before go mod download
so the replace resolves in container builds.

Also bumps the Go toolchain to 1.26.4 and golang.org/x/crypto, x/sys,
and x/image.

Closes #74.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 20:08:30 -04:00
79c749b9a7 Feat/continue watching card affordance (#120)
* feat(web): improve Continue Watching card affordances

Add hover/active feedback to the play button (scale, brightness,
shadow) and make the episode meta and time-left lines link to the
item page like the heading. The blank time-left placeholder stays a
plain div so card heights align without an invisible link.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(web): cover audiobook-only continue sections using poster cards

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 20:02:18 -04:00
46540dfec3 fix(progress): track resume points independently of watched state (#117)
* fix(progress): track resume points independently of watched state

Re-watching a finished item never re-entered Continue Watching: completion
latched completed = TRUE one-way, pinned position_seconds to the duration,
and the resume query filtered on completed = FALSE — so a rewatch heartbeat
could never surface the item again (and releasing the latch would have
erased the watched state clients display).

Adopt the Jellyfin invariant instead of guard heuristics:

- Completion resets position_seconds to 0 (UpdateProgress, SetProgress,
  SetProgressAt, SetProgressIfNewer, MarkWatched, MarkProgressBatch), so
  position_seconds > 0 now means "live resume point".
- completed stays a pure one-way watched latch; rewatch heartbeats re-enter
  Continue Watching through plain GREATEST/MAX while the watched flag and
  PlayCount survive (matching Plex and Jellyfin master).
- ListProgress("in_progress") keys on position_seconds > 0 in both stores;
  the SQLite store also gains the min-resume floor the Postgres store had.
- jellycompat reports Played=true with live PositionTicks during a rewatch
  (resumePositionTicks no longer zeroes played items) — the DTO shape real
  Jellyfin emits since jellyfin/jellyfin#15762.
- Web mirrors the latch (playbackProgressCache), resumes rewatches at their
  stored position, and shows progress bars on rewatched episodes.
- ABS audiobook surfaces keep today's behavior: finished books report 100%
  via the completed flag and Continue Listening still excludes them.
- Migrations reset legacy completed rows (position pinned to duration) to
  0: a Goose migration for Postgres and a user_version-gated one-time fix
  for the per-user SQLite DBs.

Replaces the guard-based approach of #109, whose restart detection
(50% fraction + 60s time gap) could never release the latch for immediate
rewatches (blocked heartbeats refreshed updated_at, re-arming the gap) and
un-watched items on position-0 heartbeats.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(progress): address review — migration gate, one-way latch, missed writers/readers

Review fixes for the position-based watch-progress model:

- The per-user SQLite data fix is now migrateToV11 in the existing
  versioned runMigrations chain (schemaVersion 11). The previous
  standalone PRAGMA gate compared against 1, but existing DBs already
  sit at user_version 10, so the reset never ran for them — and the
  gate would have rewound the version. Fresh DBs short-circuit to the
  current version as before.
- `completed` is now one-way across every playback/sync writer:
  SetProgress (the RecordPlaybackStop path — stopping a rewatch below
  the watched threshold no longer clears the watched state),
  SetProgressAt, SetProgressIfNewer (both stores), and the history
  import upsert, which also stops pinning completed imports to
  position = duration. Mark-unwatched still releases the latch via
  ClearProgress/ClearProgressBatch.
- MarkProgressBatch regains its freshness guard: a delayed batch mark
  carrying an old timestamp can no longer zero a newer rewatch resume
  point (the position-reset now rides the original updated_at check).
- Catalog read paths align with the new in-progress definition
  (position_seconds > 0, completed-agnostic): smart-collection
  in_progress filter, progress sort ratio, episode progress CTE, and
  both next-up predicates.
- jellycompat derives PlayedPercentage and PlaybackPositionTicks from
  the same clamped position; a played item at rest reports 100 (as the
  old model did) while a rewatch reports its live fraction.
- ABS audiobook UpsertProgress stores position 0 on finish so finished
  books can't surface as phantom resume entries; re-listens still move
  position forward from 0 with the latch intact.
- The web optimistic cache zeroes the resume point on completion,
  mirroring the server invariant until the refetch lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 19:44:40 -04:00
162e0cc449 feat(audiobooks): redesign audiobook library around resume and series progression (#116)
* feat(audiobooks): redesign audiobook library around resume and series progression

Audiobook libraries previously reused the video-shaped library page: a
backdrop carousel hero (audiobooks have square covers and no backdrops),
movie-style default sections, and a browse grid whose primary audiobook
axes (author, narrator, series) were buried as filters.

Backend:
- New next_in_series section type: surfaces the next unstarted book, by
  series_index, in series the profile has finished a book of, ordered by
  most recent finish. Registered as a library-staple recipe.
- New GET /api/v1/catalog/audiobook-groups endpoint: grouped browse by
  author/narrator/series with book count, total duration, per-profile
  progress counts, and poster URLs for cover stacks.
- Audiobook library defaults: continue-listening is featured (renders as
  the Now Listening hero) with next-in-series directly after it. A data
  migration upgrades existing audiobook libraries, skipping layouts where
  an admin already featured a section.

Frontend:
- NowListeningHero replaces HeroBanner for audiobook libraries: resume
  deck with chapter position, hours left, ambient color from the cover,
  and one-click resume; remaining in-progress books render as the
  Continue Listening row.
- Library tab gains Books/Series/Authors/Narrators browse axes persisted
  via the type param; selecting a group drops into the Books grid with
  the matching filter applied.
- "Recommended" tab is labeled "Home" for audiobook libraries; audiobook
  continue cards use square covers and hr/min time-left formatting.
- Shared audiobook chapter/file/duration helpers extracted to
  web/src/lib/audiobooks (deduplicated from AudiobookContent).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(audiobooks): address review feedback on library redesign

- Push library scoping into the next-in-series candidate SQL so finished
  series whose next book lives in another library can't consume the
  candidate limit and starve a library-scoped section (Codex P2).
- Paginate the audiobook groups fetch until the server-reported total is
  reached (500/page, 20-page bound) so client-side filtering sees the
  complete author/narrator/series list (Codex P2, CodeRabbit).
- Make the redesign migration rollback-safe: rows the Up touches carry
  config markers (featured_by_migration / seeded_by_migration) and the
  Down reverts only marked rows, leaving admin-set featured state and
  hand-created next_in_series sections alone (Codex P2, CodeRabbit).
- Gate NowListeningHero's detail-derived files/credits on the detail
  matching the deck item, so Resume can't start the new book with the
  previous book's files while keepPreviousData shows stale detail
  (Codex P2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 19:28:53 -04:00
7f8dc95eca feat(web): make Continue Watching posters open the item page (#118)
* feat(web): make Continue Watching posters open the item page

Clicking a Continue Watching / Next Up poster previously started
playback immediately, which made it hard to reach an item's detail
page from the home screen. Posters now navigate to the item page and
playback moves to an explicit play button layered over the artwork
(hover/focus-revealed on desktop, always visible on touch, matching
the existing card menu pattern).

The card title is now its own link: for episodes it goes to the
series page via series_id, for movies to the movie page.

Also mocks the playback context in SectionRow.test.tsx, fixing two
pre-existing failures unrelated to this change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): gate hidden play overlay on hover capability, not viewport width

Review feedback on the Continue Watching play button: md:opacity-0 hid
the button on touch tablets at desktop widths where no hover exists to
reveal it, and the visually hidden link still captured clicks at the
poster center on desktop. Gate the hidden state on pointer-fine instead
of md, and pair it with pointer-events-none so the hidden button cannot
intercept poster clicks; hover and keyboard focus restore both
visibility and interactivity together.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 19:24:59 -04:00
2933e0581f fix(web): make pre-merge frontend checks pass at HEAD (prettier drift + 15 stale tests) (#119)
* style(web): run prettier over drifted files

prettier --check was failing on 19 files at HEAD. The pinned prettier
version (3.8.1) has not changed since the initial migration, so the
drift came from unformatted commits, not a toolchain bump. Formatting
changes only, no behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(web): sync stale test harnesses with current components

15 tests across 6 files were failing at HEAD; the components were
committed already out of sync with their tests in the initial
migration. All fixes are in test files only:

- SectionRow / SeriesContent: mock the watch playback controller
  required by ContinueWatchingCard and MediaItemMenu, mock the new
  useSimilarItems hook, add useOptionalAuth to the auth mock, and
  wrap renders in QueryClientProvider for MediaItemMenu query hooks
- SeriesContent: read rating from item.user_rating (the useRating
  hook no longer exists)
- ProfilesSettings: add useHouseholdSessions to the profiles mock
  for the new HouseholdStreamsPanel
- ImpersonationBanner: match the redesigned banner ("Viewing as",
  sticky full-width bar instead of sidebar offset)
- EpisodeRow: watched indicator uses the text-success token now
- profile-management: use a valid DiceBear preset ref; "adventurer"
  no longer resolves and round-trips to ""

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 19:17:14 -04:00
074d106402 feat(jellycompat): BoxSet collections, genre-by-name, and airtight ABS media exclusion (#115)
* feat(jellycompat): exclude audiobook libraries, add BoxSets and genre-by-name

- Audiobook libraries (type 'audiobooks'/'audiobook') no longer appear in
  Views/VirtualFolders, and all browse/search/genre/detail paths are clamped
  to movie/series/episode so audiobook items cannot leak or stream through
  the Jellyfin compat surface (they are served by the ABS-compat API).
- Library collections are now exposed as Jellyfin BoxSets:
  IncludeItemTypes=BoxSet listing (optionally scoped via ParentId library),
  /Items/{id} BoxSet detail, ParentId children with curated position order
  preserved (explicit SortBy delegates to catalog ordering), poster/backdrop
  presigning, and visibility + library-access filtering.
- /Items with only unexposable IncludeItemTypes (e.g. Playlist) returns an
  empty result instead of falling through to views/browse.
- New GET /Genres/{name} endpoint resolving canonical genre casing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(jellycompat): centralize ABS media-type exclusion, fix BoxSet edge cases from review

- Add catalog.AccessFilter.ExcludedMediaTypes, enforced by applyAccessFilter
  and threaded through Search/GetByIDsWithAccess/EnsureAccessible and
  BrowseFavorites. The compat layer stamps audiobook+podcast exclusions onto
  every resolved access filter (one wrap in withDefaults), closing the
  favorites, recommendations, and item-image leak paths that per-call-site
  guards missed.
- Treat podcast libraries like audiobook libraries: hidden from Views, items
  excluded everywhere (they're served by the ABS-compat API).
- HandleItems: BoxSet listing no longer hijacks user-state-filtered queries
  (IncludeItemTypes=BoxSet&Filters=IsFavorite returns empty again),
  IncludeItemTypes=CollectionFolder returns library views as before, and
  Ids=<boxsetId> re-hydrates the BoxSet DTO instead of falling through to
  the views response.
- BoxSet artwork is now durable: stable signed tags seeded from the artwork
  key (no churn on presign rotation) plus a collections fallback in the
  images handler, so posters survive restarts and cache expiry.
- BoxSet listing filters/sorts/pages the lightweight collection rows before
  building DTOs, so a Limit=24 page over 300 collections no longer presigns
  ~600 posters per request; collection children also page before hydrating
  user state.
- Dedupe: shared loadVisibleCollection guard, shared collection-page writer,
  single scoped-types implementation, emptyQueryResult helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(jellycompat): address PR review comments

- withCompatAccessExclusions merges compat exclusions with any the base
  resolver already supplies instead of conditionally skipping them.
- Explicit type filters clamp to a closed allowlist (movie/series/episode/
  season) rather than passing unknown types through to catalog queries.
- Collection artwork on the session path applies the same visibility rules
  as the BoxSet item endpoints (hidden or inaccessible-library collections
  404 instead of serving posters).
- loadVisibleCollection propagates infrastructure errors instead of masking
  transient DB failures as 404/empty; only ErrLibraryCollectionNotFound maps
  to not-found.
- ListFavorites filters ABS-surface favorites before applying the
  limit/offset window (over-fetching the raw rows) so pages don't shrink or
  shift, and presigns artwork only for the returned page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 19:16:28 -04:00
b246f271cb fix(jellycompat): hide dismissed and superseded entries from Resume (#114)
The jellycompat /UserItems/Resume endpoint served the raw in_progress
list, so Jellyfin clients showed every half-watched episode a profile
ever abandoned, including stale entries from earlier seasons of shows
the viewer had long moved past. The first-party Continue Watching row
already hid those via dismissal filtering and the superseded-episode
check, but that logic was private to internal/sections.

Extract the shared rules into internal/catalog
(ContinueWatchingProgressFilter, HomeDismissalIndex) and apply them to
the compat Resume path through a new UserDataService method,
FilterResumeProgress. The sections fetcher now delegates to the same
code, so both surfaces agree on what "still watching" means.

Resume pagination keeps advancing by raw batch counts so filtering
cannot terminate scans early, and the raw-offset fast path is limited
to StartIndex=0 for Resume because filtered lists make raw offsets
diverge from the visible list. The watched-items view ("completed"
status) stays unfiltered, and the per-series collapse deliberately
stays first-party only to preserve Jellyfin endpoint semantics.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 18:01:36 -04:00
0d0d7bab1e fix(naming): let trailing bare IMDb ids anchor group identity (#113)
* fix(naming): let trailing bare IMDb ids anchor group identity

hasStructuredIDAnchor only consulted ParseStructuredFolderIDs, so folders
tagged with an unbracketed trailing id ("Eggs Run (2021) tt8049994") still
went ambiguous on title conflicts. ParseFolderIDs now returns only explicit
evidence (structured tags plus unambiguous tt-prefixed ids), so use it for
the anchor check. Covers the last 3 ambiguous-with-IDs groups on dev.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(naming): persist file-stem trailing IMDb ids into group identity

InferGroupIdentity anchored on file-level trailing bare IMDb ids via
hasStructuredIDAnchor but extracted file-stem IDs with
ParseStructuredFolderIDs, so the id that justified resolving a title
conflict never reached GroupIdentity/ScannedMediaGroup and downstream
matching saw a resolved group without it. Use ParseFolderIDs for the
file-stem extraction to mirror the anchor check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 17:12:05 -04:00
ce9830cd02 fix(jellycompat): report fileless episodes as LocationType=Virtual (#111)
* fix(jellycompat): report fileless episodes as LocationType=Virtual

Provider-metadata-only episodes (unaired/missing entries pulled from
TVDB/TMDB that have no underlying media file) were mapped with
LocationType=FileSystem and an empty MediaSources list. Jellyfin's
contract is that such items report LocationType=Virtual.

Because they were not marked Virtual, clients that build playback queues
from the episode list (Wholphin, Infuse, Findroid, ...) treated them as
playable, queued them, and failed on advance with "no media sources".
Wholphin specifically filters LocationType=Virtual out of its
auto-advance playlist, so marking these Virtual lets next-episode /
skip-outro jump cleanly to the next real episode, and the episode list
greys them out as expected.

itemFromDetailWithFields now stamps LocationType=Virtual on playable
items (movie/episode) that have zero file versions.

* fix(jellycompat): mark fileless episodes Virtual on list paths too

The Virtual fix only covered itemFromDetailWithFields, which clients reach
only when requesting detail-level Fields (MediaSources, MediaStreams, ...).
itemFromList and episodeFromUpstream still stamped LocationType=FileSystem
unconditionally, so the same fileless episode reported Virtual or FileSystem
depending on the endpoint/Fields combination used.

Centralize the decision in applyPlayableLocation (which also clears VideoType
on virtual items, matching Jellyfin) and plumb a HasMediaFiles signal into the
list paths:

- episode targets query gains an EXISTS check against media_files
- the pool-less fallback uses a new EpisodeRepository.HasFilesByIDs
- the /Shows/{id}/Episodes non-detail path reuses the already-fetched
  episode targets, so no extra query is needed

A nil signal preserves the historical FileSystem default for producers that
do not check file presence (movies, series-level lists).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 16:59:55 -04:00
75b476d124 fix(naming,metadata): anchor identity on provider IDs, drop bare-numeric IDs, back off match queue retries (#112)
* fix(naming,metadata): anchor identity on provider IDs, drop bare-numeric IDs, back off match queue retries

Three scanner/matching fixes validated against dev data:

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(naming): merge trailing bare IMDb id with structured folder tags

ParseFolderIDs returned early on any structured tag, so a folder like
"Show [tvdbid-81189] tt1375666" lost the trailing IMDb id. Parse both and
merge, with an explicit structured imdb tag still taking precedence over a
trailing bare id. Matches Jellyfin, which resolves each provider key
independently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs: implementation plan for requests pluginization

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

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

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

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

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

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

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

* feat(requests): plugin-backed RequestRouterProvider seam

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs: drop internal superpowers specs/plans from PR

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

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

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

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

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

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

Fixes #105

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

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

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

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

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

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

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

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

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

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

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

* fix(requests): handle router review edge cases

* fix(web): resolve schema form build casing

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

---------

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

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

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

Fixes #105

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

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

* fix(web): allow podcast continue targets

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

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

* feat(home): split continue listening sections

* fix(catalog): coalesce nullable media artwork fields

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

* fix(audiobooks): address playback review feedback

---------

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

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

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

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

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

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

* fix(jellycompat): support Wholphin season item queries

---------

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

* fix(audiobooks): enable ABS compat by default

* chore(audiobooks): remove stale feature flag setting

* fix(audiobooks): validate scoped scan roots

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
2026-06-08 18:17:08 -04:00
Quick 9a240edb17 Merge branch 'main' of https://github.com/Silo-Server/silo-server 2026-06-08 15:27:13 -04:00
9e29e7b330 feat(security): encrypt server-owned credentials at rest (#45) (#95)
* feat(security): encrypt server-owned credentials at rest

Introduce AES-256-GCM at-rest encryption (HKDF-derived from a required
SECRET_KEY) for server-owned credentials, with row-bound AAD, a versioned
enc:v1: envelope, and an idempotent startup backfill.

- internal/secret: cipher + RowAAD/SettingsAAD + the startup backfill engine.
- SECRET_KEY required at bootstrap; cipher threaded as an explicit dependency.
- server_settings: EncryptedSettingsRepo decorator over the audited
  SensitiveSettingKeys (also drives admin redaction); the config watcher and
  watch-sync settings reads decrypt too.
- Arr keys inline-encrypted; the ambiguous SecretResolver indirection removed
  from requests/autoscan.
- Per-table columns encrypted: subtitles, watch-sync, webhook-sync (not
  webhook_secret), history-import, and the jellycompat session's bridged Silo
  access/refresh tokens.
- Startup backfill (resolve-then-encrypt for arr refs) is best-effort and
  primary-node gated.

Equality-looked-up secrets and plugin_runtime_configs.config_value are out of
scope (need hashing / cross-repo design) — see
docs/architecture/secret-encryption.md.

Refs #45

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

* chore(compose): require SECRET_KEY in docker-compose

The server now fatals without SECRET_KEY, so the integrated service (and the
commented distributed proxy/transcode examples) pass it through with a
fail-fast guard matching the existing MEDIA_ROOT pattern. Distributed worker
nodes must use the SAME key as the primary to decrypt shared data.
Generate with: openssl rand -base64 48.

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

* fix(security): encrypt history import session credentials

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:25:48 -04:00
d3626fca00 feat(jellycompat): add /Users endpoint and unified sa_ admin-key auth (#91)
* fix(api): allow HEAD on /api/v1/direct-download

Firefox (and some download managers) issue a HEAD request before
starting a download. The route only registered GET, so HEAD returned
405 Method Not Allowed and the browser aborted the download.

Mirrors the pattern already used by /stream/{session_id}, which
registers both GET and HEAD on the same handler. ServeDirect is built
on http.ServeContent / ServeFile, which natively handle HEAD by
writing headers without a body, so no handler changes are needed.

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

* docs: design jellyfin autoscan scan compatibility

* refactor(scan): extract scan trigger resolver

* refactor(api): share scan target resolution

* feat(jellycompat): accept admin api keys for autoscan

* feat(jellycompat): add autoscan media update route

* docs: document jellyfin autoscan setup

* fix(jellycompat): harden autoscan auth and batch scan enqueue

- Reject nil API keys and bound last-used update with a 5s timeout
- Stop leaking internal queue errors in autoscan responses
- Batch scan enqueues via new CreateBatch and reuse folder list across path resolves

* chore: add planning docs and requests updates

- Add plans for date-named episodes and Jellyfin autoscan compat
- Update requests handlers, service, and UI hooks
- Remove Makefile.local.example

* refactor(scantrigger): drop redundant Target.LibraryID field

- Read library ID from Target.Folder.ID everywhere
- Guard scan queue enqueue against nil Folder
- Simplify admin API key auth error plumbing

* fix(catalog): gate search overview-only matches behind title FTS

- Always apply stats CTE + CROSS JOIN so single-word queries no longer flood results with description-only hits
- Require overview_rank >= 0.15 for overview-only fallback rows
- Switch title gate from contiguous LIKE to title_rank > 0 so reordered-token title matches aren't demoted

* ci(docker): build image on push to main via self-hosted runner

- Trigger Docker image builds on pushes to main instead of nightly cron
- Run on self-hosted Linux runner
- Drop the `nightly` tag

* docs(specs): add design for TMDB-backed request section in search

Adds the design for surfacing requestable TMDB results inside the main
catalog search (Cmd+K dialog and full results page) as a clearly
delimited "Request to Add" section that never blocks or displaces
library results.

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

* docs(specs): address Codex adversarial review for search request section

Splits discovery eligibility from submission eligibility so blocked
and quota-exhausted viewers still see the requestable section with
disabled per-row CTAs, matching the documented behavior. Documents
the required extensions to useRequestSearch — signal forwarding,
viewer-identity-keyed cache, and invalidation on auth/profile/
settings/limit changes — so the planned 5-minute staleTime is
safe and cancellation works as described.

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

* docs(plans): add implementation plan for search request section

Twelve TDD tasks covering: api() signal contract test, useCanRequest
hook, viewer-keyed requestKeys.search, useRequestSearch extension
(signal + viewer key + 5min staleTime + enabled override), invalidation
cascade tests, RequestPosterCard optional onRequest, RequestToAddSection
component (dialog + grid variants), GlobalSearch and Catalog wiring
with empty-state suppression for the library-0/TMDB-pending edge case,
final lint/test pass, and manual smoke. Notes a single deviation from
the spec: submitDisabledReason is null in the initial implementation,
with per-row request data driving disabled UI.

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

* docs(plans): address Codex adversarial review for search request section

Fixes the high-severity finding that RequestToAddSection's internal
useRequestSearch call was not gated on discoveryEnabled, allowing
/api/v1/requests/search and TMDB lookups to fire for users without
request access. The plan now (1) passes { enabled: discoveryEnabled }
to the section's hook, (2) gates the parent mount in GlobalSearch and
Catalog on canRequest.discoveryEnabled as defense in depth, and (3)
adds tests asserting both the enabled forwarding and the no-mount
behavior when discovery is disabled.

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

* test(api): pin AbortSignal forwarding contract on api()

* feat(hooks): add useCanRequest gating hook for discovery eligibility

* refactor(keys): add viewerKey to requestKeys.search

* feat(requests): key useRequestSearch by viewer, forward signal, raise staleTime

* test(requests): document viewer-keyed cache isolation and invalidation cascade

* feat(request-card): make onRequest optional on discover variant

* feat(search): add RequestToAddSection dialog variant

* feat(search): add RequestToAddSection grid variant for Catalog page

* feat(search): render RequestToAddSection in the Cmd+K dialog with empty-state suppression

* feat(catalog): render RequestToAddSection grid with empty-state suppression

* chore(web): format request search section

* test(web): avoid unsupported Array.at in search request tests

* commit message

{"subject":"fix(search): prevent empty-state flash before TMDB fallback renders","body":"- Add isResolving to useCanRequest and gate empty states on it across GlobalSearch and Catalog\n- Debounce TMDB query in Catalog and hide ItemGrid when the request section may rescue an empty library\n- Track per-card submit state in RequestToAddSection grid so concurrent requests don't trample each other\n- Suppress anonymous TMDB request-search fetches to avoid cross-viewer cache leakage"}

* fix(jellycompat): tolerate autoscan sidecar updates

* fix(webhooksync): skip events for unmapped external users

- Require explicit profile mapping instead of falling back to the default profile
- Update settings UI copy to reflect that unmapped users are ignored

* refactor(admin): show per-section loading and error states

- Replace page-level loading gate with skeletons per section on dashboard and stats
- Surface query errors inline instead of blocking the whole page
- Disable "Scan All Libraries" when no libraries are configured

* fix(search): address request search review feedback

* feat(subtitles): restore upload management

* feat(auth): add assignable user permissions

* feat(auth): expose user permissions

* feat(api): authorize item metadata curation

* feat(api): route metadata curation by permission

* feat(web): add permission helpers

* feat(web): assign metadata curation permission

* fix(web): keep device profile hooks unconditional

* feat(web): show metadata tools to curators

* fix(auth): address metadata curation review issues

* fix(auth): tighten curator job response review fixes

* docs: add metadata curation permission plan

* test(auth): expand session revocation coverage

* docs: add PageBack component design spec

* fix(auth): gate media file paths on metadata curation permission

- Allow curators (not just admins) to view media file paths and locations
- Apply library access filter to file-level access checks

* fix(metadata): break duplicate provider candidate ties

- Score candidate metadata completeness and auto-match the richer duplicate when title/year/type tie
- Enrich near-duplicate candidates via the provider chain before initial match selection
- Seed both movie and series match queues for mixed-type libraries and wait for TV queue settle
- Add taskmanager worker test coverage and a plan doc for the tie-breaker work

* feat(ui): add shared PageBack component for consistent back navigation

Replace the eight inconsistent back affordances across user-facing pages
with a single absolute-positioned chevron pill, so the control lives in
the same screen position regardless of title length or hero content.

DetailBreadcrumb keeps its textual hierarchy path but no longer owns the
back chevron; PageBack does. DetailHero gains a topNav slot consumed by
Movie/Series/Season/Episode/Request detail pages. Non-hero pages drop
their bespoke back buttons and add PageBack inside a relative wrapper.

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

* feat(ui): add floating variant to PageBack for sticky nav

- Add `floating` prop to pin PageBack to viewport on lg+ screens
- Switch styling from glass-subtle to glass with shadow for better contrast
- Use floating variant on SettingsLayout

* fix(ui): make PageBack destinations deterministic

* feat(episode-carousel): highlight currently viewed episode

- Add "Now Viewing" badge with pulsing indicator on current episode
- Replace border with primary-color ring for current episode card
- Set aria-current="page" on links to the current episode

* feat(jellycompat): sign image tags and accept them without session

- HMAC-sign image tags using the configured JWT secret
- Serve item/season/episode images via signed tag without requiring a session or cache hit

* fix(jellycompat): harden signed image tags

* fix(jellycompat): stabilize signed image tags across restarts

{"subject":"fix(jellycompat): stabilize signed image tags across restarts","body":"- Sign library poster and episode parent series image tags from canonical paths/thumbhashes instead of presigned URLs so tags survive restarts\n- Accept signed canonical tags in the image handler without a session and fall back to legacy URL-derived cache tags\n- Always fetch series detail for episodes to build stable parent image tags"}

* fix(metadata): accept exact cross-provider match ties

* Optimize episode added_at sorting

* fix(libraryingest): treat drainer shutdown cancel as clean stop

TV/series full scans (libraries with new or updated items) were recorded as
"cancelled" with an empty error message and never completed matching.

When the file-walk finishes, the ingest executor waits out a settle window and
then calls stopDrainers() to shut down the concurrent match goroutines. That
cancels the drainer context while a ProcessBatchByFolderAndPathPrefix call may
still be in flight. The drainer treated the resulting context.Canceled as a
fatal error: it pushed the error to drainerErrCh and called cancel() on the
whole scan context, so scanqueue.process() mapped it to cancelRun().

Large/slow libraries (many series, slow provider lookups) keep a batch in
flight continuously, so stopDrainers() almost always landed mid-call and the
scan was cancelled; small/fast libraries were usually idle at that instant and
completed normally.

Treat a cancelled drainer context as a deliberate shutdown: return cleanly
without escalating. Genuine external cancellation still reaches the run via the
main goroutine's scanCtx checks, so real cancels are not swallowed.

Adds a regression test (settle window made injectable) that fails against the
old handler with 'concurrent match scope ...: context canceled' and passes
with the fix.

* perf(catalog): add episode browse index fast path

* fix(catalog): address episode catalog review feedback

* Fix settle-window drainer cancellation in library ingest

* fix(catalog): support relative date filters

* fix(collections): cap smart collection results

* fix(library): preserve episode browse url

* fix(catalog): use season posters for episode cards

* feat(sections): show episode context in cards

* feat(calendar): show local episode airtimes

* Enable DRI passthrough in docker compose

Co-authored-by: Codex <noreply@openai.com>

* feat(ui): paginate the Ambiguous Roots table

Match the sibling tables on the Admin Libraries page (Troubleshooting/unmatched):
use the existing usePagination hook + PaginationBar (10/page, auto-hidden when
<=10 rows), render pag.rows, and reset to page 0 when the library selector or
search filter changes.

* feat(admin): enlarge match-candidate posters + hover-to-enlarge

Unmatched-item match dialog rendered candidate posters at 44x64px, too small
to identify a film. Bump to 64x96 (2:3) and add a portaled Tooltip hover preview
(192x288) using the existing poster image, so operators can tell candidates apart.

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

* fix(admin): search unmatched items across the whole table, not just the page

The unmatched-items search filtered only the current page's rows client-side.
Push the query server-side: HandleListUnmatchedItems takes an optional 'q' param
and filters title/library/type/status with parameterized ILIKE across all rows,
paginating the filtered set. Frontend hook takes a debounced search, resets to
page 1 on change, keeps the section mounted while searching. Also fixes stale
test mocks that returned the pre-pagination array shape instead of {items,total}.

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

* style(admin): prettier-format match dialog; reset unmatched page in onChange

Run prettier over the Tooltip-wrapped poster JSX, and reset the unmatched-items
page in the search input's onChange rather than a useEffect (avoids the
react-hooks/set-state-in-effect warning / cascading renders).

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

* test(admin): wire QueryClientProvider + missing hook mocks so the suite runs

The AdminLibraries test file failed all 6 tests with 'No QueryClient set' on
this branch and at the parent commit -- pre-existing infrastructure gap. With
that fixed, several hooks that the page imports (useCancelLibraryScans,
useLibraryRoots, useUpsertLibraryRootOverride, useDeleteLibraryRootOverride,
useActiveScans) and the UNMATCHED_PAGE_SIZE constant also needed mocking. One
stale assertion on the renamed 'Root path' header is updated; the deeper
troubleshooting test, which mocked useSkippedLibraryRoots but the section was
refactored to useLibraryRoots(_, 'ambiguous'), is skipped with a TODO -- a real
rewrite is needed and is out of scope for this MR.

5 of 6 tests now run and pass; the 6th is properly flagged.

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

* fix(admin): search all unmatched item library memberships

* fix(naming): strip unsubstituted Sonarr tokens ({TvdbId}/{imdb-}) from titles

These tokens survived the provider-tag regex ([\w]+ doesn't match braces),
polluting parsed titles (e.g. 'A Girl & Her Guard Dog [tvdb-{TvdbId}]') so
they could not score-match. Broaden the regex to drop {...} and empty tokens.

* fix(naming): numeric-only titles are not bare provider IDs

'86' / '22 7' were parsed as trailing tvdb ids, tripping the trusted-ID gate
so the correct title match was rejected. Require a letter in the name before
treating a trailing number as a bare id.

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

* test(naming): document bare-id trade-off; cover CJK title + movies numeric

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

* feat(matcher): auto-accept a year-corroborated single distinct show

A search that resolves to one distinct show (one candidate, or the same
title+year returned once per source as unmerged TVDB/TMDB rows) whose year
matches the parsed year is now auto-accepted via the existing top-ranked
candidate, even when the fuzzy title score is in the 55-69 band. The 55/70/15
thresholds are unchanged; this only adds a year-gated acceptance for
effectively-unique results (recovers lone-correct-result items like 1201 (1993)).

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

* test(matcher): exercise the single-distinct-show guard properly + conflicting-ID case; doc notes

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

* fix(matcher): tolerate concurrent-merge ErrItemNotFound in series episode-link ensure

A scan drainer and the background MatchWorker can process the same folder
concurrently. When a provider-ID merge moves a series' episodes to the survivor
and deletes the source, an in-flight ensureSeriesEpisodeLinks(sourceID) hits
catalog.ErrItemNotFound and was failing the whole scan. The episodes are already
reattached, so this is benign: log and continue (matching the lenient call sites)
instead of failing. Genuine errors still abort.

* diag(matcher): debug-log per-candidate match scores

Adds a DEBUG-gated log in selectInitialMatchCandidate printing each scored
candidate (title/year/type/sources/provider_ids/score) against the hint, so
operators can see why an item did or didn't auto-match. Zero-cost when debug
logging is off; no change to matching behavior.

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

* feat(matcher): resolve cross-source ties by library provider priority

Accept a year-corroborated single distinct show when the TOP tie-group (within
15 pts of best) is one show across providers, ignoring low-score noise below it,
and pick the winner by the library's metadata-provider chain order (providerPriority,
highest-first; falls back to top-scored). Recovers items like '100 Days Wild'
that are returned identically by TVDB and TMDB. Thresholds (55/70/15) unchanged.

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

* feat(matcher): accept cross-source-corroborated ties without a hint year

When the top tie-group is one distinct show returned by 2+ distinct providers
(candidatesAreSingleDistinctShow already verifies matching title+year), accept it
even if the hint has no parsed year (year-less folders like '100 Deeds for Eddie
McDowd'). Multi-source agreement substitutes for the year guard; lone single-source
no-year results stay subject to the single-candidate >=70 gate. Thresholds unchanged.

* diag(matcher): debug-log provider search query + per-provider result counts

Adds DEBUG logs in the ModeInitialMatch search path: each provider's result
count for the query, and the assembled raw/candidate totals. Lets us see when a
provider search returns zero ('no metadata found') vs a scoring/tie issue.
Zero behavior change.

* fix(naming): parse bare bracketed IMDb IDs ([tt10011226]/{tt...})

Folders tagged with a bare IMDb id in brackets (Plex/Kodi style, e.g.
'17 Blocks (2021) [tt10011226]') had the id silently dropped — folderIDPattern
needs an 'imdb-' prefix and trailingImdbIDPattern needs an un-bracketed trailing
tt-id. Recognize bracketed bare tt-ids so these items get the trusted-ID match
path instead of falling to title+year scoring.

* feat(metadata): match sole exact-title candidate despite year off by <=2

Folder years routinely differ from provider release years by a year or two
(festival vs wide release, regional dates), zeroing the year bonus and leaving
a lone exact-title candidate at 63-68 — just under the single-candidate >=70
gate (e.g. Dead Reckoning 1947 vs 1946, 17 Blocks 2021 vs 2019, Stasi FC). Add
title corroboration to the existing lone-result rule: a sole distinct show whose
normalized title exactly matches and whose year is within +/-2 is accepted. The
55 floor still rejects low-similarity titles (e.g. Hotel Transylvania Puppy! vs
Puppy!). No 55/70/15 threshold change.

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

* chore(lint): gofmt single-space alignment in root_inference.go var block

When inferProviderTagRe was broadened to handle unsubstituted Sonarr token
placeholders ({TvdbId}/{imdb-}), the regex grew long enough that gofmt prefers
single-space rather than column-aligned spacing across the var block.

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

* fix(metadata): tighten matcher and bare IMDb parsing

* docs: add resilient library deletion design spec

Batched, deadlock-retrying rewrite of delete_library to replace the
single multi-minute transaction that deadlocks on large libraries.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: add implementation plan for resilient library deletion

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(catalog): add deadlock-retry helper for batched deletes

* test(catalog): clarify cancel-path expectation in retry test

* feat(catalog): add deleteInBatches loop helper

* refactor(catalog): make image-dir helpers querier-agnostic

Add rowQuerier interface satisfied by both *pgxpool.Pool and pgx.Tx.
Split collectImageDirs into collectRawImageDirs (raw collection) and a
thin wrapper that filters via filterUnreferencedImageDirs. Both helpers
now accept rowQuerier so a later task can call them from pool-level
batch deletes without an open transaction.

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

* fix(catalog): delete libraries in deadlock-retrying batches

Replaces the single multi-minute delete transaction with phased, batched
autocommit deletes (orphan items, media files, memberships, folder row),
each retried on deadlock. Holds only short locks, survives concurrent
writers, and is resumable on failure.

* refactor(catalog): wrap orphan-batch iteration error

* fix(catalog): clamp still/poster/logo backdrops to largest cached variant

Episode stills used as backdrops only exist at w500/w300 in the cache, so
requesting a w1280/w1920 backdrop width 404s. Add catalog.BackdropVariantPath
+ imageTypeFromCachedPath and route featured (w1920) and Continue Watching /
Next Up (w1280) backdrops through it; still/poster/logo paths clamp to their
type's largest cached variant while real backdrops keep the requested width.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* perf(startup): defer non-critical init off the HTTP listener path

Collect catalog-size-dependent seeding (metadata match queues, legacy
series-group cleanup) and the watch-provider scrobble sweep into a
backgroundInit slice that runs sequentially in a background goroutine after
the server is ready, instead of blocking startup before the listener accepts
connections. Steps log failures and stop early on shutdown.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(migrations): make air_timezone column add idempotent

Use ADD COLUMN IF NOT EXISTS so re-running 162 on a database that already has
the column is a no-op.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* build: stamp git revision via Makefile ldflags

`make build` did not inject buildinfo's `revisionOverride`/`dirtyOverride`
ldflags (the Dockerfile already does), so binaries built via make report
their version as "unavailable" in the admin Build panel whenever Go's VCS
metadata isn't embedded. Mirror the Dockerfile by computing the git
revision + dirty state and passing them through `-ldflags -X`.

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

* fix(web): accessibility & UX fixes from visual QA pass

Accessibility (WCAG AA):
- Lighten the Standard-theme `--muted-foreground` (#6e6e78 -> #9696a0,
  ~3.4:1 -> >=5.3:1) and darken the light-theme equivalent so secondary
  text meets 1.4.3 contrast app-wide; the opt-in High Contrast mode is no
  longer the only conformant path.
- Give icon-only controls accessible names (4.1.2): the password show/hide
  toggle (also drop tabIndex={-1} so it's keyboard reachable), and the
  Edit/Delete/health/copy/refresh actions across the Users, Libraries,
  Nodes, API Keys, Catalog Maintenance and Job History admin tables.
- Fix the Switch off-state (invisible track -> visible border + fill) and
  the PlaybackSettings SettingRow label association (the <label htmlFor>
  pointed at a wrapping <div>; the id now lands on the Switch/SelectTrigger).
- Login: wrap the card in <main> and add an <h1>; Profiles: add an
  accessible PIN-protected label and a corner lock badge.
- Player + catalog: role="status" on the initial loading overlay; scope the
  catalog count ("0 in library" for search) and announce it via aria-live;
  trim the verbose poster-link name to the title.

UX / consistency:
- Emphasize overdue scheduled tasks (warning colour + icon + word, not
  colour alone).
- Per-source catalog subtitles instead of one shared string.
- Add a Reconnect affordance when the admin log stream drops (it does not
  auto-retry).
- Page titles for Watch Party + all admin sub-pages (incl. plugins); admin
  heading capitalisation normalised to Title Case.
- Show "dev build" instead of "unavailable" when no build revision is
  stamped.

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

* fix(catalog): re-check orphan status when deleting library items

Orphan detection moved outside the media_items delete in the batched
library-delete rewrite, opening a TOCTOU race: a concurrent scan/import
could attach one of the collected content IDs to another library between
collectOrphanBatch and the delete, after which the unconditional
`DELETE FROM media_items WHERE content_id = ANY($1)` would still remove the
shared row and cascade away the newly-added membership — dropping the item
from the other library. Re-check the orphan invariant inside the delete
(NOT EXISTS a membership in another folder) and count rows actually deleted.

Addresses PR #21 review (P1).

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

* fix(metadata): persist a cleared air_timezone instead of skipping it

Clearing a previously-set air timezone sent JSON null, which decodes to a
nil *string that UpdateMetadata treats as "skip this column", so the old
value remained. The dialog now sends "" (accepted by ValidateAirTimezone),
and UpdateMetadata maps air_timezone through NULLIF so an empty value
persists as SQL NULL (matching the nullable column) rather than "".

Addresses PR #21 review (P2).

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

* fix(startup): sweep open scrobbles before accepting playback

The open-scrobble sweep was queued in the deferred background-init list,
which runs concurrently with the HTTP listener; a resume immediately after
restart could start new scrobbles before the previous process's open
sessions were stopped, leaving overlapping/stale scrobbles on remote
providers. Run the sweep synchronously before the listener starts, bounded
by a 30s timeout so an unreachable provider can't hang startup (the heavier
non-critical init stays deferred).

Addresses PR #21 review (P2).

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

* fix(catalog): scan air_timezone in paginated item queries

scanItemsWithTotal was not updated for the new air_timezone column, yet the
shared column lists it reads (itemColumns, qualifiedListItemColumns) include
it. Search and BrowseFavorites build their SELECTs from those lists with
COUNT(*) OVER (), so each row carried one more column than the scan had
destinations and every call failed at scan time with a pgx mismatch. Add the
missing &item.AirTimezone target between AirTime and ShowStatus.

Found during PR #21 review (critical: Search/Favorites runtime regression).

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

* revert(startup): keep open-scrobble sweep deferred for fast startup

Reverts 62563f5c. Running the sweep synchronously before the listener could
add up to 30s to restart-before-playback when a watch provider is
unreachable, which regresses the deliberate startup-deferral from a1a6c6cf.
Prefer the fast-startup behavior and accept the small window where a resume
immediately after restart may create a duplicate scrobble; the sweep returns
to the deferred background-init list. (Panic-safety for that list is added in
a follow-up commit.)

Per PR #21 review decision.

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

* fix(startup): recover from panics in deferred background init

The deferred background-init steps run in a detached goroutine after the
HTTP listener is already accepting connections. An unrecovered panic in any
step (queue seeding, legacy cleanup, scrobble sweep) would crash the entire
live server. Wrap each step in a recover that logs the panic with a stack
and continues to the next step.

Found during PR #21 review.

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

* feat(auth): make usernames and emails case-insensitive

Login identifiers were compared case-sensitively, so "John" and "john"
were distinct accounts and a user could not log in unless they matched the
exact casing used at registration.

Convert users.username and users.email to the citext type (migration 165).
citext compares case-insensitively while preserving the originally stored
casing for display, so the existing unique constraints become
case-insensitive and `WHERE username = $1` / `email = $1` lookups match
regardless of case with no change to the query code itself.

Also add auth.NormalizeUsername/NormalizeEmail (trim-only; case preserved),
applied at the repository chokepoints (Create, Update, GetByUsername,
GetByEmail) and before validation in the create paths, so surrounding
whitespace no longer defeats matching or creates lookalike accounts.

Verified non-destructively against the dev DB: mixed-case lookups resolve
to the same row, case-variant inserts are rejected by the unique
constraint, and the down migration cleanly reverts to text.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(sections): add trending_discover home section

A library-agnostic home section that surfaces external global trending
(TMDB or Trakt, admin-selectable) mixing movies + series, matched to
titles in the viewer's enabled libraries. TMDB uses /trending/all/{window}
(natively mixed); Trakt merges trending movies + shows. Fetched live with
a 1h in-process cache, so no background job or stored collection — and no
per-library duplication.

Appears in the admin section gallery via its recipe presets (TMDB Trending
Today/This Week, Trakt Trending); featured -> hero via the existing flag.

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

* docs: add trending_discover persistent snapshot design

Replace the in-process 1h trending cache with a background-refreshed,
persisted snapshot for reliability under upstream failure and sync-run
observability.

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

* docs: add trending_discover persistent snapshot implementation plan

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

* refactor(sections): tidy trending_discover fetch and cache helpers

Extract newTrendingEntry, reuse orderMediaItems, and collapse concurrent
cache-miss loads with singleflight. Baseline for the persistent snapshot work.

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

* feat(sections): add trending_discover_snapshots table

* feat(sections): add trending snapshot model and repository

* feat(sections): list enabled trending_discover section configs

* feat(sections): add trending refresher with persisted snapshots

* feat(tasks): add refresh_trending_discover task

* refactor(sections): read trending_discover from persisted snapshot

* feat: wire trending refresh task and snapshot reader

* chore(sections): satisfy lint (wrap trakt errors, lift source/window constants)

* fix(migrations): renumber trending_discover_snapshots 166 -> 167

The shared dev DB already recorded version 166 (166_trending_blend_collection_type
from another branch), so the integer-version migration runner silently skipped our
166 and the table was never created — the trending section errored out empty.
167 is the next free version.

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

* fix(sections): harden trending refresher per PR review

- Interleave Trakt movies/shows by rank so the mixed row shows both types
  instead of burying all series past the display limit.
- Treat any Trakt sub-fetch failure as fatal (errors.Join) so a partial
  result never overwrites the last-good snapshot with a media type missing.
- Skip non-title entries (TMDB trending/all returns media_type "person") in
  both ID batching and ordering so they can't match an unrelated library title.
- Guard the refresh task against a nil refresher.
- Tests: person skip, Trakt interleave, Trakt partial-failure preserves
  last-good, snapshot read error propagation.

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

* fix(catalog): project air_timezone in episode catalog subquery

episodeCatalogSelectBody is the derived "mi" relation that episode catalog
hydration and preview read qualifiedListItemColumns("mi") from. The local
episode airtimes feature (808f265f) 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>

* fix(calendar): order events by viewer-local wall-clock time

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>

* docs(calendar): add presets design spec and implementation plan

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

* feat(calendar): generalize personal filter to an id-set restriction

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

* feat(calendar): add per-profile followed/favorites/watchlist/watched resolvers

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

* feat(calendar): resolve presets to id-sets and overlay watched status

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>

* feat(calendar): wire popular and trending sources into calendar handler

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

* feat(calendar): add watched field to CalendarEvent type

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

* feat(calendar): preset selector with responsive pills, persistence, empty-state nudge

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

* feat(calendar): dim and check-mark already-watched event cards

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

* feat(calendar): hide server-wide Popular preset in web UI for now

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>

* refactor(calendar): simplify preset handler and reuse storage util

- 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>

* feat(jellycompat): add /Users endpoint and unified sa_ admin-key auth

A user could not connect Tunarr to the Jellyfin-compat API. Tunarr's media-source
health check probes both /Users/Me and /Users; for API-key connections (its
recommended method) there is no "me", so it relies on GET /Users, which we did
not implement. Adding it alone was insufficient: a Silo sa_ admin key only
authorized two autoscan routes, so an API-key source would pass the ping but 401
on every browse/stream call.

This adds GET /Users and unifies auth so an sa_ admin key authorizes the same
browse/stream routes a session token does — matching Jellyfin, where an API key
authorizes every endpoint:

- GET /Users returns the caller's own user as a single-element list
  (current-profile-only; Silo is multi-account, so listing all users would leak
  across households). Behavior verified against real Jellyfin 10.11.8.
- An sa_ admin key synthesizes a compat session bound to the account's primary
  profile, injected into request context so existing handlers work unchanged.
  Applied to the browse group (RequireSessionOrAPIKeySession) and the stream
  group (PlaybackSessionAuth); /Library/VirtualFolders keeps its admin-bool path.
- The key + owning user are re-validated on every request (revocation is
  immediate); only the primary-profile lookup is cached. HLS follow-ups that
  carry only PlaySessionId resolve the negotiated session's sa_ CompatToken.

Validated end-to-end on dev: connect -> list user -> libraries -> browse ->
PlaybackInfo -> HLS stream, including PlaySessionId-only follow-ups.

Security notes: an admin key acts as the primary (parent) profile, so it bypasses
child-profile parental/PIN restrictions and can mutate the primary profile's
watch state — acceptable for an admin-trust key.

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

---------

Co-authored-by: Claude Code <rxwatcher@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Silo Server Migration <noreply@silo-server.invalid>
Co-authored-by: zZebrahz <zzebrahz@gmail.com>
Co-authored-by: CoffeeKnyte <67730400+CoffeeKnyte@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Silo Server Developer <warmasterx555@gmail.com>
2026-06-08 14:25:10 -04:00
QuickandClaude Opus 4.8 ed8f63c886 docs(plans): add credential encryption at-rest plan (#45)
Plan for issue #45: a reusable internal/secret AES-256-GCM package keyed by a required SECRET_KEY (HKDF), inline-encrypted api_key_ref columns that replace the SecretResolver indirection, an EncryptedSettings decorator for sensitive server_settings, and an idempotent startup backfill with resolve-then-encrypt for legacy arr references. Plan doc only; no code changes.

Refs #45

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:29:34 -04:00
d3v1l1989andGitHub 65ea9b651c fix(web): drop backdrop-blur from Pill card overlay preset (#89)
The Pill overlay preset was the only one of the five to carry
`backdrop-blur-sm` (backdrop-filter: blur(4px)) on its badge class.
Each enabled badge renders its own element, so a typical library grid
produces hundreds of backdrop-filter layers the browser must re-sample
and blur on every repaint — including every scroll frame, since the
poster art behind each badge moves while scrolling. This caused severe
scroll/interaction jank, most visibly on Chrome on Windows.

The blur is also effectively invisible: the Pill background is already
rgba(20,20,30,0.7) (70% opaque), so removing it has no meaningful visual
impact while eliminating the per-frame GPU cost. The other four presets
are unaffected.
2026-06-08 09:57:47 -04:00
Quick 3704b262d6 Merge branch 'main' of https://github.com/Silo-Server/silo-server
# Conflicts:
#	cmd/silo/main.go
#	go.sum
#	internal/api/handlers/admin.go
#	internal/api/handlers/admin_jobs.go
#	internal/api/handlers/admin_jobs_test.go
#	internal/api/handlers/admin_test.go
#	internal/api/handlers/subtitle_search.go
#	internal/api/router.go
#	internal/auth/permissions.go
#	internal/auth/permissions_test.go
#	internal/auth/repository.go
#	internal/catalog/folder_delete_test.go
#	internal/catalog/folder_repo.go
#	internal/catalog/window_count_test.go
#	internal/metadata/worker.go
#	internal/requests/service.go
#	internal/scanner/file_repo.go
#	internal/scanqueue/repository.go
#	internal/scanqueue/service.go
#	internal/scantrigger/scantrigger.go
#	internal/scantrigger/scantrigger_test.go
#	internal/sections/defaults.go
#	internal/sections/defaults_test.go
#	web/src/api/client.ts
#	web/src/components/ContinueWatchingCard.tsx
#	web/src/components/ItemCard.tsx
#	web/src/lib/documentTitle.ts
#	web/src/lib/permissions.ts
#	web/src/pages/AdminDashboard.tsx
#	web/src/pages/AdminLibraries.test.tsx
#	web/src/pages/AdminLibraries.tsx
#	web/src/pages/AdminTasks.tsx
#	web/src/pages/AdminUserDetail.tsx
#	web/src/pages/AdminUsers.tsx
#	web/src/pages/ItemDetail/EpisodeContent.tsx
#	web/src/pages/ItemDetail/MovieContent.tsx
#	web/src/pages/ItemDetail/components/ActionBar.tsx
#	web/src/pages/LibraryBrowse.tsx
#	web/src/pages/LibraryPage.tsx
#	web/src/pages/RequestDetail.tsx
2026-06-08 09:38:21 -04:00
QuickandGitHub f5b3cba3cb fix(catalog): use semi-joins for discovery library scope (#88)
* fix(catalog): use semi-joins for discovery library scope

* fix(catalog): require membership for deny-only discovery
2026-06-08 09:33:55 -04:00
zZebrahzandGitHub 96bae2f346 fix(library): clean orphaned provisional items (#76)
* fix(library): clean orphaned provisional items

* test(metadata): use item not found sentinel in fake repo

* fix(library): preserve abs references during orphan cleanup

* fix(library): avoid dropped abs cleanup tables

* fix(library): harden provisional orphan cleanup

* fix(library): preserve home dismissal series orphans

* fix(library): Delete matched items created after the first orphan sweep
2026-06-07 21:32:40 -04:00
978e1b4954 feat(playback): NVENC support for transcoding (#79)
* feat(playback): NVENC support for transcoding

* fix(playback): probe nvenc before auto-selecting

* fix(playback): use safe NVENC smoke probe dimensions

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-07 21:27:56 -04:00
zZebrahzandGitHub c4e67effc3 fix(admin): correct dashboard library counts and scans (#78)
* fix(admin): correct dashboard library counts and scans

* fix(admin): handle mixed library dashboard counts

* fix(admin): refine dashboard count affordances
2026-06-07 19:59:10 -04:00
6b1554a86c Fix scoped catalog refresh invalidation (#51)
* Fix scoped catalog refresh invalidation

* Remove duplicate catalog refetch

* fix(web): keep library lists fresh on scoped catalog events

---------

Co-authored-by: RXWatcher <14085001+RXWatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-07 18:15:34 -04:00
Quick 480ca44306 fix(recommendations): improve taste seed cold start ranking
Closes #66
2026-06-07 17:42:52 -04:00
d3v1l1989andGitHub ecd85f9ca1 fix(jellycompat): enforce account-level library restrictions (#72)
* fix(jellycompat): enforce account-level library restrictions

The compat AccessFilterFn re-implemented a subset of the access policy
and never loaded users.library_ids, so admin-assigned account library
restrictions did not apply to the Jellyfin-compat API: restricted users
could list, browse, search, and play items from every enabled library.

Replace the hand-rolled closure with the shared access.Resolver via a
new jellycompat.NewScopeAccessFilter adapter so account restrictions,
profile restrictions, user-disabled libraries, and rating/quality
ceilings apply exactly as on the native API. PIN verification is
skipped because compat login already verifies profile PINs.

Also: fail closed (empty allowlist) when scope resolution errors
instead of returning an unrestricted filter; treat an empty allowlist
as restricted-to-nothing in ListUserLibraries instead of unrestricted;
and hide user-disabled libraries from the compat views list.

* test(jellycompat): cover library list filtering via folder source stub

Extract a folderListSource interface for the two FolderRepository
methods directContentService uses (matching the existing browseSource/
itemAccessSource pattern) so ListUserLibraries is unit-testable, and
add coverage for the restricted-allowlist path and the user-disabled
library filtering in the views list.
2026-06-07 17:19:40 -04:00
QuickandGitHub f9bb94a299 [codex] Fix autoscan plugin bindings and poll status (#75)
* fix(autoscan): bind sources by plugin id

* fix(autoscan): skip overlapping source polls
2026-06-07 17:18:55 -04:00
eb6024573e feat(audiobooks): make audiobook libraries first-class catalog items (#73)
* docs(audiobooks): design spec for plugin absorption

Plan to absorb silo-plugin-audiobooks into silo-server as a first-party
feature. Audiobooks land in silo's existing SPA; ABS clients connect
directly. Hard constraints: reuse existing tables (media_items,
media_files, user_watch_progress, user_playback_sessions, people,
item_people, library_collections); only two new tables (abs_sessions,
podcast_feeds) and at most one column add (media_libraries.kind);
silo's main :8080 listener handles ABS Socket.io natively. Out of
scope: audiobook requests flow, smart collections, share links,
external recommender, custom metadata providers, separate audiobook
SPA.

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

* docs(audiobooks): implementation plan sub-plan 1 (discovery + schema)

First of six sub-plans for the absorption. Six tasks: a discovery
audit that resolves the spec's Risk questions, four idempotent SQL
migrations (abs_sessions, podcast_feeds, media_libraries.kind,
audiobooks.enabled feature flag), and an empty-but-compiling
internal/audiobooks package scaffolded into cmd/silo. Lands as a
strict no-op for users (feature flag defaults to false).

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

* docs(audiobooks): discovery findings for absorption sub-plan 1

Locks schema/code decisions for migrations 139-142 and downstream
sub-plans. Resolves open Risk questions from the absorption design spec.

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

* feat(audiobooks): migration 139 add abs_sessions table

Parallel of jellycompat_sessions for Audiobookshelf-compatible clients.
Lets ABS mobile/desktop apps maintain a device-bound session that
silo's audiobooks/abs handlers will validate.

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

* style(audiobooks): match codebase conventions in migration 139

Lowercases type keywords in the abs_sessions CREATE TABLE body to
match neighboring migrations, fixes the client_version column
alignment, and replaces the misleading "parallel to
jellycompat_sessions" header comment with a more accurate
description of the table's role.

Cosmetic only — the running schema is unchanged.

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

* feat(audiobooks): migration 140 add podcast_feeds table

Side table on media_items for RSS-subscribed podcasts. Holds feed URL,
ETag/Last-Modified for conditional fetches, last-refresh timestamp, and
the per-feed refresh interval consumed by the upcoming
podcastfeed.Refresher scheduled task.

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

* style(audiobooks): uppercase PRIMARY KEY in migration 140

Aligns with the codebase convention (type keywords lowercase,
constraint keywords uppercase) established in migration 139's
post-style-fix form. Cosmetic only — running schema is unchanged.

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

* chore(audiobooks): migration 141 no-op for media_folders.type

Sub-plan 1 originally reserved migration 141 to add a 'kind' column to
media_libraries discriminating audiobook/podcast libraries. Discovery
audit (sub-plan 1 Task 1) found that the actual table is media_folders
and it already has a type text NOT NULL column with no CHECK constraint
or enum, so 'audiobooks' and 'podcasts' can be added as future values
without DDL.

Landing this migration as a documented no-op preserves the version
numbering audit trail and pins the decision in git history. The
matching down migration is also a no-op.

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

* feat(audiobooks): migration 142 add audiobooks.enabled flag

Server-settings row that gates the absorbed audiobooks feature.
Defaults to 'false' so sub-plan 1 lands as a strict no-op; subsequent
sub-plans branch on this flag and operators flip it to 'true' at
cutover.

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

* feat(audiobooks): scaffold internal/audiobooks package

Empty-but-compiling Service that reads the audiobooks.enabled feature
flag from server_settings. Wired into cmd/silo so the package is
referenced from the binary; no routes mounted, no scheduled tasks
registered, no DB writes. Subsequent sub-plans hang scanner branches,
ABS handlers, Socket.io, podcast refresher, and SPA pages off this
Service.

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

* style(audiobooks): cosmetic cleanups in scaffolded package

Two pre-emptive cleanups flagged by code review before sub-plan 2
copies the patterns:

  1. Sort the internal/audiobooks import after internal/adminjob in
     cmd/silo/main.go (alphabetical).
  2. Drop the redundant "audiobooks: " prefix from the Enabled() error
     wrap; matches how every other top-level service package
     (watchstate, scanqueue, metadata, etc.) formats errors.

No behavior change.

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

* docs(audiobooks): implementation plan sub-plan 2 (scanner)

Second of six sub-plans. 10 tasks: PersonKind constants for Author and
Narrator, audio-extension recognizer, library-type helpers, a
walkLogicalTree refactor (movieLibrary bool -> typed walkMode), chapter
extraction via ffprobe, single-file and multi-file audiobook parsers,
scanner write path producing media_items.type='audiobook', and a
filesystem podcast parser (RSS deferred to sub-plan 5).

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

* feat(audiobooks): add Author and Narrator PersonKind constants

Discovery audit confirmed item_people.kind is unconstrained smallint
with values 1-6 in use. Reserve 7 = Author, 8 = Narrator for audiobook
people-links written by the upcoming scanner branches.

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

* feat(audiobooks): add audio-extension recognizer for scanner

Mirrors the existing videoExtensions/SupportsVideoFile pair. Used by
upcoming audiobook and podcast scanner branches to filter directory
walks.

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

* feat(audiobooks): library-type recognizers for scanner dispatch

isAudiobookLibraryType and isPodcastLibraryType match singular and
plural forms case-insensitively, mirroring isMovieLibraryType. Used by
upcoming scanner walk branches (Task 4) that filter audio files into
audiobook and podcast libraries.

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

* refactor(scanner): replace movieLibrary bool with typed walkMode

Lets walkLogicalTree dispatch on multiple library shapes (video, movie,
audiobook, podcast) without proliferating boolean flags. Behavior for
existing video and movie libraries is unchanged; audiobook and podcast
modes will be consumed by the upcoming audiobook.go and podcast.go
parsers in later tasks of this sub-plan.

walkModeFor() derives the mode from a media_folders.type string;
unknown types default to walkModeVideo to preserve prior behavior for
any caller still passing a raw type.

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

* feat(audiobooks): expose ffprobe format tags on ProbeData

The audiobook scanner needs format-level tags (title, artist, album,
date) for media_items metadata; ffprobe already parses them in
ffprobeFormat.Tags but ProbeData previously discarded them. Add
FormatTags map[string]string to ProbeData, populate it in
convertProbeData via a new normalizeFormatTags helper that lowercases
keys and trims values.

Adds a fixture audiobook .m4b with embedded chapters (Intro/Outro) and
format tags, and a test that verifies ProbeFile() returns both
correctly.

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

* feat(audiobooks): parser for single-file audiobook folders

parseAudiobookFolder reads tags + chapters via the existing ProbeFile
(now that Task 5 exposes FormatTags on ProbeData) and produces a
parsedAudiobook struct. Title falls back from "title" tag to "album";
author from "artist" -> "album_artist" -> "composer"; series from
"album" -> "series" -> "mvnm" (Movement Name, used by some MP4 tools).
Year parsed from "date" or "year" tags, tolerating ISO dates and
parenthesized forms.

Single-file case only; multi-file folders (one audio file per chapter)
return a placeholder error and arrive in Task 7.

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

* feat(audiobooks): multi-file audiobook folder support

Folders containing N audio files (one per chapter/part) get one
parsedAudiobookFile per file; each file's chapter list is synthesized
as a single chapter with title = filename stem. Title/author/series/
year come from the first file's tags.

Also drops the duplicate pickFirstNonEmpty helper added in Task 6 in
favor of the existing firstNonEmpty already in probe.go.

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

* feat(audiobooks): scanner write path produces audiobook media_items

ScanAudiobookFolder walks an audiobooks-typed media folder and treats
each immediate subdirectory as one audiobook. For each parsed audiobook
it upserts:
  - one media_items row with type='audiobook'
  - one media_files row per audio file (with chapters JSONB)
  - author/narrator links in item_people (kind=7, kind=8)

Adds itemRepo and personRepo to the Scanner struct, wired from
fileRepo.Pool() in NewScanner — no constructor signature change needed.

ScanFolder dispatches to this path when folder.Type='audiobooks',
bypassing the per-file movie/TV pipeline because audiobooks are
folder-scoped entities.

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

* feat(audiobooks): filesystem podcast scanner

ScanPodcastFolder walks a podcasts-typed media folder, treating each
subdirectory as a podcast show and each audio file inside as an
episode. Writes media_items.type='podcast' + episodes rows + media_files
rows. RSS-subscribed feeds (podcast_feeds table) arrive in sub-plan 5;
this task covers filesystem-only ingestion.

ScanFolder dispatches to this path when folder.Type='podcasts'.

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

* docs(audiobooks): implementation plan sub-plan 5 (podcasts)

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

* feat(audiobooks): expose audiobooks/podcasts library types in admin UI

Adds 'Audiobooks' and 'Podcasts' options to the library-type dropdown
in the admin libraries page so operators can flag a folder as an
audiobook or podcast library. Extends contentLevelsForType() so the
admin UI's downstream filtering treats those types correctly
(audiobook -> ['audiobook'], podcasts -> ['podcast',
'podcast_episode']).

Backend scanner branches for these types were already wired in
sub-plan 2.

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

* chore(migrations): renumber 139_abs_sessions to 147 for origin/main merge

origin/main adds 139_media_requests at the same number our local
audiobook branch had used for abs_sessions. Renumber ours to 147 to
free up 139 for the upstream migration. The schema_versions row is
updated in lockstep on the running database so the migrator sees the
abs_sessions migration as already applied at its new version.

Migrations 140-146 (podcast feeds, media_folders kind noop, audiobook
feature flag, abs playback sessions, podcast episode guid, audiobook
series, audiobook title cleanup) stay where they are — they don't
collide with anything on origin/main.

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

* chore(migrations): renumber 140_podcast_feeds to 157 for origin/main merge

origin/main added 140_user_permissions at the same version this branch
had used for podcast_feeds. Renumber ours to 157 (next free above the
collections-unify migration at 156) so 140 is free for the upstream
migration. schema_versions on the running database is updated in lockstep
so the migrator sees podcast_feeds as already applied at its new version.

Same pattern as d59c1cb (renumber 139_abs_sessions to 147 for the prior
main merge). Pending migrations after this rename: 132 (downloaded
subtitles admin index, main), 140 (user_permissions, main), and 156
(unify_user_collections, this branch).

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

* chore(migrations): renumber 141_media_folders_kind_noop to 159 for origin/main merge

Same shape as eb8f67d (the 140→157 renumber from the previous main
merge). origin/main added 141_episode_title_sort_index at the same
version this branch had used for media_folders_kind_noop. Renumber
ours to 159 (next free above the audiobook_series truncate at 158) so
141 is open for the upstream migration. schema_versions on the
running database is updated in lockstep so the migrator sees
media_folders_kind_noop as already applied at its new version.

Pending migrations on silo-prod after this rename: 141
(episode_title_sort_index, main) and any other newer ones from main
that the branch hasn't picked up yet.

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

* chore(migrations): renumber 142_audiobooks_feature_flag to 160 for origin/main merge

Companion to 3c6f062's 141 renumber — origin/main also added
142_episode_catalog_entries (alongside 141_episode_title_sort_index)
at a version this branch had used for the audiobooks feature flag.
Renumber ours to 160 so 142 is open for the upstream migration;
schema_versions on silo-prod is updated in lockstep so the migrator
sees audiobooks_feature_flag as already applied at its new version.

This was the only remaining collision (verified by checking for
duplicate version prefixes across migrations/).

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

* fix(audiobooks): address foundation review comments

* fix(audiobooks): tighten scanner identity handling

* fix(audiobooks): propagate scanner cancellation

* chore(audiobooks): adopt goose migration layout

* docs(audiobooks): implementation plan sub-plan 3 (API + frontend MVP)

Third of six sub-plans. 9 tasks: three REST endpoints (list/detail/
progress), TanStack Query hooks + types, three React pages
(Library/Detail/Player), and navigation integration. Scoped to MVP —
author/series indices, smart collections, share links, and other
nice-to-haves from the spec are deferred. Streaming reuses silo's
existing /api/v1/stream/{session_id}; no new transcode code.

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

* feat(audiobooks): list endpoint at GET /api/v1/audiobooks

Paginated list of media_items with type='audiobook' scoped to the
caller's accessible libraries via the existing access filter.
Mirrors silo's existing list-style handlers for movies and series.

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

* feat(audiobooks): detail endpoint at GET /api/v1/audiobooks/{id}

Returns the media_items row, its media_files (with chapters JSONB),
author/narrator extracted from item_people (kinds 7/8), and the
caller's per-profile listening progress from user_watch_progress.

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

* feat(audiobooks): progress endpoint at POST /api/v1/audiobooks/{id}/progress

UPSERTs user_watch_progress for the caller's (user_id, profile_id,
content_id). Body carries position_seconds; clients are expected to
post every 5-10s during playback plus on pause/seek (matching silo's
existing video progress cadence).

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

* feat(audiobooks): frontend types and TanStack Query hooks

TypeScript types match the JSON shapes from the new
/api/v1/audiobooks endpoints (list, detail, progress). Three hooks:
useAudiobookLibrary (list), useAudiobook (detail), and
useReportAudiobookProgress (mutation that invalidates the detail
query on success so progress updates reflect immediately).

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

* feat(audiobooks): library grid page at /audiobooks

Renders a paginated grid of audiobook cards using the
useAudiobookLibrary hook. Each card links to /audiobooks/book/{id}.
Cards show poster, title, and year; falls back to a "No cover"
placeholder when the audiobook has no poster_url. Empty state hints
to operators that they need to set a library's type to 'audiobooks'.

Routes themselves are wired in Task 8 (navigation integration).

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

* feat(audiobooks): detail page with chapter list

Renders cover, title, author, narrator, year, and overview alongside a
chapter list. Clicking a chapter opens an inline sticky
AudiobookPlayer at that chapter's start. A "Resume" button restarts
playback at the saved progress position if present.

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

* feat(audiobooks): HTML5 audio player with chapter navigation

Single-file audiobook playback for MVP. Multi-file queuing arrives in
a follow-up. Streams via the existing /api/v1/direct-download GET
endpoint. Position is reported to /api/v1/audiobooks/{id}/progress
every 10s while playing plus on pause/seek/end. Skip-30s, playback
rate select, chapter list panel.

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

* feat(audiobooks): wire navigation and routes

Adds an Audiobooks entry to the sidebar and registers the two new
routes (/audiobooks for the library grid, /audiobooks/book/:id for
detail). The player renders inline inside the detail page; no
dedicated player route is required for MVP.

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

* fix(audiobooks): address native API review comments

* feat(audiobooks): add ABS compatibility and polish

* fix(audiobooks): stabilize ABS playback progress reporting

* fix(audiobooks): clean up ABS branch review fixes

* chore(audiobooks): adopt goose layout for ABS migrations

* fix(audiobooks): align player seek bar props

* feat(audiobooks): make libraries first-class catalog items

* feat(admin): add server restart endpoint

* fix(audiobooks): address review comment findings

---------

Co-authored-by: RXWatcher <14085001+RXWatcher@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 15:57:05 -04:00
zZebrahzandGitHub c469550fa0 fix(libraries): collapse admin library diagnostics (#61)
* Fix troubleshooting visibility on admin libraries

* Remove metadata matcher badge from admin libraries

* test(libraries): cover hidden matcher queue badge
2026-06-07 12:51:45 -04:00
d3v1l1989andGitHub 3353456d46 fix(jellycompat): aggregate series watch state from episode progress (#70)
A series never has a user_watch_progress row of its own, so series items
always rendered Played:false with no UnplayedItemCount — every show looked
unwatched in compat clients' library views, and the IsPlayed browse filter
treated all series as unplayed. Roll episode progress up to series rows in
the browse/search/detail paths, mirroring applySeasonUserData and the
native API's rollup semantics (WatchedCount/UnplayedCount/InProgressCount).
Progress lookups are chunked at 500 ids (per-user stores may be
SQLite-backed) and the rollup is capped at 250 series per page so
max-limit browses of series-only libraries stay bounded.
2026-06-07 11:42:44 -04:00
d3v1l1989andGitHub 826f0d0b9a fix(jellycompat): serve real MediaSources on Resume/NextUp pages (#69)
Infuse and SenPlayer build Continue Watching rows from the listing's
MediaSources and discard responses that carry only the detail-field stub,
leaving the row empty. Keep the progress scan list-level (the per-entry
GetItemDetail fanout removed in the error-report-2026-05-08 §6 fix stays
removed), but re-map the returned page — bounded by the request limit,
capped at 100 — through the detail path when the client requests
detail-level Fields, matching HandleLatest's existing pattern. The stub
remains as the fallback past the cap and is made honest: playability
flags set, collections serialized empty instead of null.
2026-06-07 11:10:15 -04:00
0b3723c7f2 fix(jellycompat): enforce allow_4k_transcode (#65)
* fix(jellycompat): enforce allow_4k_transcode in playback negotiation and transcode start

The compat layer never read the setting, so clients that couldn't direct
play a 4K file got a full video encode regardless. Suppress
SupportsTranscoding for 4K sources during negotiation and guard the local
and remote transcode starts; audio-only transcodes (video copy) stay
allowed, matching the native handler.

Fixes #64

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

* test(jellycompat): cover settings read error in 4K transcode default-deny

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:47:59 -04:00
QuickandGitHub cdb5b70a8d Merge pull request #25 from Silo-Server/feat/calendar-presets
feat(calendar): personalized presets (Following default) with watched overlay
2026-05-29 14:57:57 -04:00
QuickandClaude Opus 4.8 4fea42488a refactor(calendar): simplify preset handler and reuse storage util
- 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>
2026-05-29 14:56:16 -04:00
QuickandClaude Opus 4.8 dc194c4e54 feat(calendar): hide server-wide Popular preset in web UI for now
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>
2026-05-29 14:24:18 -04:00
QuickandClaude Opus 4.8 93b835e1fd feat(calendar): dim and check-mark already-watched event cards
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:13:51 -04:00
QuickandClaude Opus 4.8 6cebbb3cd9 feat(calendar): preset selector with responsive pills, persistence, empty-state nudge
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:13:51 -04:00
QuickandClaude Opus 4.8 f859cbfd7a feat(calendar): add watched field to CalendarEvent type
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:13:51 -04:00
QuickandClaude Opus 4.8 e9847a4424 feat(calendar): wire popular and trending sources into calendar handler
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:56:14 -04:00
QuickandClaude Opus 4.8 6560dfc470 feat(calendar): resolve presets to id-sets and overlay watched status
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>
2026-05-29 13:55:39 -04:00
QuickandClaude Opus 4.8 134ebf399e feat(calendar): add per-profile followed/favorites/watchlist/watched resolvers
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:50:48 -04:00
QuickandClaude Opus 4.8 a119b60ca6 feat(calendar): generalize personal filter to an id-set restriction
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:49:34 -04:00
QuickandClaude Opus 4.8 1f729c4764 docs(calendar): add presets design spec and implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:47:45 -04:00
QuickandClaude Opus 4.8 cade7f37aa fix(calendar): order events by viewer-local wall-clock time
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>
2026-05-29 12:16:18 -04:00
QuickandGitHub 57bcae38e9 Merge pull request #24 from Silo-Server/claude/amazing-thompson-b2843d
fix(catalog): project air_timezone in episode catalog subquery
2026-05-29 11:55:56 -04:00
QuickandClaude Opus 4.8 aa1a0bd38b fix(catalog): project air_timezone in episode catalog subquery
episodeCatalogSelectBody is the derived "mi" relation that episode catalog
hydration and preview read qualifiedListItemColumns("mi") from. The local
episode airtimes feature (808f265f) 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>
2026-05-29 11:53:32 -04:00
QuickandGitHub 3c338e855a Merge pull request #23 from Silo-Server/feat/trending-section
feat(sections): persist trending_discover via background-refreshed snapshot
2026-05-29 11:35:05 -04:00
QuickandClaude Opus 4.8 f105361658 fix(sections): harden trending refresher per PR review
- Interleave Trakt movies/shows by rank so the mixed row shows both types
  instead of burying all series past the display limit.
- Treat any Trakt sub-fetch failure as fatal (errors.Join) so a partial
  result never overwrites the last-good snapshot with a media type missing.
- Skip non-title entries (TMDB trending/all returns media_type "person") in
  both ID batching and ordering so they can't match an unrelated library title.
- Guard the refresh task against a nil refresher.
- Tests: person skip, Trakt interleave, Trakt partial-failure preserves
  last-good, snapshot read error propagation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:28:59 -04:00
QuickandClaude Opus 4.8 c98f828c25 fix(migrations): renumber trending_discover_snapshots 166 -> 167
The shared dev DB already recorded version 166 (166_trending_blend_collection_type
from another branch), so the integer-version migration runner silently skipped our
166 and the table was never created — the trending section errored out empty.
167 is the next free version.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 10:57:57 -04:00
Quick 4455c2cd40 chore(sections): satisfy lint (wrap trakt errors, lift source/window constants) 2026-05-29 10:51:54 -04:00
Quick 9d03ad0b37 feat: wire trending refresh task and snapshot reader 2026-05-29 10:49:01 -04:00
Quick 38723c8b81 refactor(sections): read trending_discover from persisted snapshot 2026-05-29 10:46:01 -04:00
Quick 3c3ef82ae8 feat(tasks): add refresh_trending_discover task 2026-05-29 10:44:15 -04:00
Quick ff5f399e2c feat(sections): add trending refresher with persisted snapshots 2026-05-29 10:43:54 -04:00
Quick 7d70bf0cf4 feat(sections): list enabled trending_discover section configs 2026-05-29 10:42:09 -04:00
Quick 94a588c7a3 feat(sections): add trending snapshot model and repository 2026-05-29 10:41:47 -04:00
Quick ca4cd230a4 feat(sections): add trending_discover_snapshots table 2026-05-29 10:40:58 -04:00
QuickandClaude Opus 4.8 1a74bdbb6a refactor(sections): tidy trending_discover fetch and cache helpers
Extract newTrendingEntry, reuse orderMediaItems, and collapse concurrent
cache-miss loads with singleflight. Baseline for the persistent snapshot work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 10:40:13 -04:00
QuickandClaude Opus 4.8 cc5fee0956 docs: add trending_discover persistent snapshot implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 10:37:27 -04:00
QuickandClaude Opus 4.8 7992470cc3 docs: add trending_discover persistent snapshot design
Replace the in-process 1h trending cache with a background-refreshed,
persisted snapshot for reliability under upstream failure and sync-run
observability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 10:25:31 -04:00
QuickandClaude Opus 4.8 ba4fd9d9fd feat(sections): add trending_discover home section
A library-agnostic home section that surfaces external global trending
(TMDB or Trakt, admin-selectable) mixing movies + series, matched to
titles in the viewer's enabled libraries. TMDB uses /trending/all/{window}
(natively mixed); Trakt merges trending movies + shows. Fetched live with
a 1h in-process cache, so no background job or stored collection — and no
per-library duplication.

Appears in the admin section gallery via its recipe presets (TMDB Trending
Today/This Week, Trakt Trending); featured -> hero via the existing flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 09:25:17 -04:00
QuickandGitHub c216899c2f Merge pull request #22 from Silo-Server/feat/case-insensitive-usernames
feat(auth): make usernames and emails case-insensitive
2026-05-28 21:46:07 -04:00
QuickandClaude Opus 4.7 c61381dc79 feat(auth): make usernames and emails case-insensitive
Login identifiers were compared case-sensitively, so "John" and "john"
were distinct accounts and a user could not log in unless they matched the
exact casing used at registration.

Convert users.username and users.email to the citext type (migration 165).
citext compares case-insensitively while preserving the originally stored
casing for display, so the existing unique constraints become
case-insensitive and `WHERE username = $1` / `email = $1` lookups match
regardless of case with no change to the query code itself.

Also add auth.NormalizeUsername/NormalizeEmail (trim-only; case preserved),
applied at the repository chokepoints (Create, Update, GetByUsername,
GetByEmail) and before validation in the create paths, so surrounding
whitespace no longer defeats matching or creates lookalike accounts.

Verified non-destructively against the dev DB: mixed-case lookups resolve
to the same row, case-variant inserts are rejected by the unique
constraint, and the down migration cleanly reverts to text.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:44:51 -04:00
QuickandGitHub 834f3e5b84 Merge pull request #21 from Silo-Server/fix/web-a11y-visual-qa
fix(web): accessibility & UX fixes from visual QA pass
2026-05-28 21:09:43 -04:00
QuickandClaude Opus 4.8 9ac181dbf4 fix(startup): recover from panics in deferred background init
The deferred background-init steps run in a detached goroutine after the
HTTP listener is already accepting connections. An unrecovered panic in any
step (queue seeding, legacy cleanup, scrobble sweep) would crash the entire
live server. Wrap each step in a recover that logs the panic with a stack
and continues to the next step.

Found during PR #21 review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 20:39:08 -04:00
QuickandClaude Opus 4.8 d34e292e54 revert(startup): keep open-scrobble sweep deferred for fast startup
Reverts 62563f5c. Running the sweep synchronously before the listener could
add up to 30s to restart-before-playback when a watch provider is
unreachable, which regresses the deliberate startup-deferral from a1a6c6cf.
Prefer the fast-startup behavior and accept the small window where a resume
immediately after restart may create a duplicate scrobble; the sweep returns
to the deferred background-init list. (Panic-safety for that list is added in
a follow-up commit.)

Per PR #21 review decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 20:37:33 -04:00
QuickandClaude Opus 4.8 b10ae3eabc fix(catalog): scan air_timezone in paginated item queries
scanItemsWithTotal was not updated for the new air_timezone column, yet the
shared column lists it reads (itemColumns, qualifiedListItemColumns) include
it. Search and BrowseFavorites build their SELECTs from those lists with
COUNT(*) OVER (), so each row carried one more column than the scan had
destinations and every call failed at scan time with a pgx mismatch. Add the
missing &item.AirTimezone target between AirTime and ShowStatus.

Found during PR #21 review (critical: Search/Favorites runtime regression).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 20:37:18 -04:00
QuickandClaude Opus 4.8 62563f5c80 fix(startup): sweep open scrobbles before accepting playback
The open-scrobble sweep was queued in the deferred background-init list,
which runs concurrently with the HTTP listener; a resume immediately after
restart could start new scrobbles before the previous process's open
sessions were stopped, leaving overlapping/stale scrobbles on remote
providers. Run the sweep synchronously before the listener starts, bounded
by a 30s timeout so an unreachable provider can't hang startup (the heavier
non-critical init stays deferred).

Addresses PR #21 review (P2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 20:26:15 -04:00
QuickandClaude Opus 4.8 9fceae63fc fix(metadata): persist a cleared air_timezone instead of skipping it
Clearing a previously-set air timezone sent JSON null, which decodes to a
nil *string that UpdateMetadata treats as "skip this column", so the old
value remained. The dialog now sends "" (accepted by ValidateAirTimezone),
and UpdateMetadata maps air_timezone through NULLIF so an empty value
persists as SQL NULL (matching the nullable column) rather than "".

Addresses PR #21 review (P2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 20:26:15 -04:00
QuickandClaude Opus 4.8 a4c394b827 fix(catalog): re-check orphan status when deleting library items
Orphan detection moved outside the media_items delete in the batched
library-delete rewrite, opening a TOCTOU race: a concurrent scan/import
could attach one of the collected content IDs to another library between
collectOrphanBatch and the delete, after which the unconditional
`DELETE FROM media_items WHERE content_id = ANY($1)` would still remove the
shared row and cascade away the newly-added membership — dropping the item
from the other library. Re-check the orphan invariant inside the delete
(NOT EXISTS a membership in another folder) and count rows actually deleted.

Addresses PR #21 review (P1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 20:26:15 -04:00
QuickandClaude Opus 4.8 234869d41e fix(web): accessibility & UX fixes from visual QA pass
Accessibility (WCAG AA):
- Lighten the Standard-theme `--muted-foreground` (#6e6e78 -> #9696a0,
  ~3.4:1 -> >=5.3:1) and darken the light-theme equivalent so secondary
  text meets 1.4.3 contrast app-wide; the opt-in High Contrast mode is no
  longer the only conformant path.
- Give icon-only controls accessible names (4.1.2): the password show/hide
  toggle (also drop tabIndex={-1} so it's keyboard reachable), and the
  Edit/Delete/health/copy/refresh actions across the Users, Libraries,
  Nodes, API Keys, Catalog Maintenance and Job History admin tables.
- Fix the Switch off-state (invisible track -> visible border + fill) and
  the PlaybackSettings SettingRow label association (the <label htmlFor>
  pointed at a wrapping <div>; the id now lands on the Switch/SelectTrigger).
- Login: wrap the card in <main> and add an <h1>; Profiles: add an
  accessible PIN-protected label and a corner lock badge.
- Player + catalog: role="status" on the initial loading overlay; scope the
  catalog count ("0 in library" for search) and announce it via aria-live;
  trim the verbose poster-link name to the title.

UX / consistency:
- Emphasize overdue scheduled tasks (warning colour + icon + word, not
  colour alone).
- Per-source catalog subtitles instead of one shared string.
- Add a Reconnect affordance when the admin log stream drops (it does not
  auto-retry).
- Page titles for Watch Party + all admin sub-pages (incl. plugins); admin
  heading capitalisation normalised to Title Case.
- Show "dev build" instead of "unavailable" when no build revision is
  stamped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 18:47:11 -04:00
QuickandClaude Opus 4.8 c30b38d220 build: stamp git revision via Makefile ldflags
`make build` did not inject buildinfo's `revisionOverride`/`dirtyOverride`
ldflags (the Dockerfile already does), so binaries built via make report
their version as "unavailable" in the admin Build panel whenever Go's VCS
metadata isn't embedded. Mirror the Dockerfile by computing the git
revision + dirty state and passing them through `-ldflags -X`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 18:46:56 -04:00
QuickandClaude Opus 4.7 ee3a6d1776 fix(migrations): make air_timezone column add idempotent
Use ADD COLUMN IF NOT EXISTS so re-running 162 on a database that already has
the column is a no-op.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:11:11 -04:00
QuickandClaude Opus 4.7 a1a6c6cfac perf(startup): defer non-critical init off the HTTP listener path
Collect catalog-size-dependent seeding (metadata match queues, legacy
series-group cleanup) and the watch-provider scrobble sweep into a
backgroundInit slice that runs sequentially in a background goroutine after
the server is ready, instead of blocking startup before the listener accepts
connections. Steps log failures and stop early on shutdown.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:11:11 -04:00
QuickandClaude Opus 4.7 772569cefe fix(catalog): clamp still/poster/logo backdrops to largest cached variant
Episode stills used as backdrops only exist at w500/w300 in the cache, so
requesting a w1280/w1920 backdrop width 404s. Add catalog.BackdropVariantPath
+ imageTypeFromCachedPath and route featured (w1920) and Continue Watching /
Next Up (w1280) backdrops through it; still/poster/logo paths clamp to their
type's largest cached variant while real backdrops keep the requested width.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:11:11 -04:00
Quick 0d651cc947 refactor(catalog): wrap orphan-batch iteration error 2026-05-28 17:02:53 -04:00
Quick c1f12d1b1b fix(catalog): delete libraries in deadlock-retrying batches
Replaces the single multi-minute delete transaction with phased, batched
autocommit deletes (orphan items, media files, memberships, folder row),
each retried on deadlock. Holds only short locks, survives concurrent
writers, and is resumable on failure.
2026-05-28 17:02:53 -04:00
QuickandClaude Sonnet 4.6 d4520677cc refactor(catalog): make image-dir helpers querier-agnostic
Add rowQuerier interface satisfied by both *pgxpool.Pool and pgx.Tx.
Split collectImageDirs into collectRawImageDirs (raw collection) and a
thin wrapper that filters via filterUnreferencedImageDirs. Both helpers
now accept rowQuerier so a later task can call them from pool-level
batch deletes without an open transaction.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 17:02:53 -04:00
Quick 518cc7eac7 feat(catalog): add deleteInBatches loop helper 2026-05-28 17:02:53 -04:00
Quick fdfa1c16b8 test(catalog): clarify cancel-path expectation in retry test 2026-05-28 17:02:53 -04:00
Quick 5a366ec42b feat(catalog): add deadlock-retry helper for batched deletes 2026-05-28 17:02:53 -04:00
QuickandClaude Opus 4.7 be06b32c82 docs: add implementation plan for resilient library deletion
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:02:53 -04:00
QuickandClaude Opus 4.7 3f1254d374 docs: add resilient library deletion design spec
Batched, deadlock-retrying rewrite of delete_library to replace the
single multi-minute transaction that deadlocks on large libraries.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:02:53 -04:00
Quick c19f0d6c6f Merge branch 'main' of https://github.com/Silo-Server/silo-server 2026-05-28 13:11:49 -04:00
QuickandGitHub 481a577937 Merge pull request #19 from fluxis/mr/a-matcher-reliability
fix(metadata): matcher reliability — Sonarr token cleanup, bare-bracket IMDb IDs, year-corroboration, provider-priority tie resolution
2026-05-28 13:11:43 -04:00
Quick 9f62c6977c fix(metadata): tighten matcher and bare IMDb parsing 2026-05-28 13:09:56 -04:00
Silo Server DeveloperandClaude Opus 4.7 311fbd05dd chore(lint): gofmt single-space alignment in root_inference.go var block
When inferProviderTagRe was broadened to handle unsubstituted Sonarr token
placeholders ({TvdbId}/{imdb-}), the regex grew long enough that gofmt prefers
single-space rather than column-aligned spacing across the var block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:58:06 +02:00
Silo Server DeveloperandClaude Opus 4.7 fa839b6e37 feat(metadata): match sole exact-title candidate despite year off by <=2
Folder years routinely differ from provider release years by a year or two
(festival vs wide release, regional dates), zeroing the year bonus and leaving
a lone exact-title candidate at 63-68 — just under the single-candidate >=70
gate (e.g. Dead Reckoning 1947 vs 1946, 17 Blocks 2021 vs 2019, Stasi FC). Add
title corroboration to the existing lone-result rule: a sole distinct show whose
normalized title exactly matches and whose year is within +/-2 is accepted. The
55 floor still rejects low-similarity titles (e.g. Hotel Transylvania Puppy! vs
Puppy!). No 55/70/15 threshold change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:58:06 +02:00
Silo Server Developer edb7051508 fix(naming): parse bare bracketed IMDb IDs ([tt10011226]/{tt...})
Folders tagged with a bare IMDb id in brackets (Plex/Kodi style, e.g.
'17 Blocks (2021) [tt10011226]') had the id silently dropped — folderIDPattern
needs an 'imdb-' prefix and trailingImdbIDPattern needs an un-bracketed trailing
tt-id. Recognize bracketed bare tt-ids so these items get the trusted-ID match
path instead of falling to title+year scoring.
2026-05-28 17:58:06 +02:00
Silo Server Developer 7406023c93 diag(matcher): debug-log provider search query + per-provider result counts
Adds DEBUG logs in the ModeInitialMatch search path: each provider's result
count for the query, and the assembled raw/candidate totals. Lets us see when a
provider search returns zero ('no metadata found') vs a scoring/tie issue.
Zero behavior change.
2026-05-28 17:58:06 +02:00
Silo Server Developer f4ea8a6d75 feat(matcher): accept cross-source-corroborated ties without a hint year
When the top tie-group is one distinct show returned by 2+ distinct providers
(candidatesAreSingleDistinctShow already verifies matching title+year), accept it
even if the hint has no parsed year (year-less folders like '100 Deeds for Eddie
McDowd'). Multi-source agreement substitutes for the year guard; lone single-source
no-year results stay subject to the single-candidate >=70 gate. Thresholds unchanged.
2026-05-28 17:58:06 +02:00
Silo Server DeveloperandClaude Opus 4.7 db34df0a35 feat(matcher): resolve cross-source ties by library provider priority
Accept a year-corroborated single distinct show when the TOP tie-group (within
15 pts of best) is one show across providers, ignoring low-score noise below it,
and pick the winner by the library's metadata-provider chain order (providerPriority,
highest-first; falls back to top-scored). Recovers items like '100 Days Wild'
that are returned identically by TVDB and TMDB. Thresholds (55/70/15) unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:58:06 +02:00
Silo Server DeveloperandClaude Opus 4.7 f4ba5faddc diag(matcher): debug-log per-candidate match scores
Adds a DEBUG-gated log in selectInitialMatchCandidate printing each scored
candidate (title/year/type/sources/provider_ids/score) against the hint, so
operators can see why an item did or didn't auto-match. Zero-cost when debug
logging is off; no change to matching behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:58:06 +02:00
Silo Server Developer aab0a804b9 fix(matcher): tolerate concurrent-merge ErrItemNotFound in series episode-link ensure
A scan drainer and the background MatchWorker can process the same folder
concurrently. When a provider-ID merge moves a series' episodes to the survivor
and deletes the source, an in-flight ensureSeriesEpisodeLinks(sourceID) hits
catalog.ErrItemNotFound and was failing the whole scan. The episodes are already
reattached, so this is benign: log and continue (matching the lenient call sites)
instead of failing. Genuine errors still abort.
2026-05-28 17:45:38 +02:00
Silo Server DeveloperandClaude Opus 4.7 aff528358e test(matcher): exercise the single-distinct-show guard properly + conflicting-ID case; doc notes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:45:38 +02:00
Silo Server DeveloperandClaude Opus 4.7 8ff06ed008 feat(matcher): auto-accept a year-corroborated single distinct show
A search that resolves to one distinct show (one candidate, or the same
title+year returned once per source as unmerged TVDB/TMDB rows) whose year
matches the parsed year is now auto-accepted via the existing top-ranked
candidate, even when the fuzzy title score is in the 55-69 band. The 55/70/15
thresholds are unchanged; this only adds a year-gated acceptance for
effectively-unique results (recovers lone-correct-result items like 1201 (1993)).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:45:38 +02:00
Silo Server DeveloperandClaude Opus 4.7 a6da4a4a98 test(naming): document bare-id trade-off; cover CJK title + movies numeric
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:45:38 +02:00
Silo Server DeveloperandClaude Opus 4.7 cc7cd5cd39 fix(naming): numeric-only titles are not bare provider IDs
'86' / '22 7' were parsed as trailing tvdb ids, tripping the trusted-ID gate
so the correct title match was rejected. Require a letter in the name before
treating a trailing number as a bare id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:45:38 +02:00
Silo Server Developer e074925327 fix(naming): strip unsubstituted Sonarr tokens ({TvdbId}/{imdb-}) from titles
These tokens survived the provider-tag regex ([\w]+ doesn't match braces),
polluting parsed titles (e.g. 'A Girl & Her Guard Dog [tvdb-{TvdbId}]') so
they could not score-match. Broaden the regex to drop {...} and empty tokens.
2026-05-28 17:45:38 +02:00
Quick 30b9194e43 Merge branch 'main' of https://github.com/Silo-Server/silo-server 2026-05-28 11:41:12 -04:00
QuickandGitHub 7ce164fba9 Merge pull request #20 from fluxis/mr/b-admin-ux
feat(admin): admin libraries UX polish — full-table unmatched search, larger match-candidate posters with hover preview, paginated Ambiguous Roots
2026-05-28 11:41:03 -04:00
Quick b26e8b753c fix(admin): search all unmatched item library memberships 2026-05-28 11:31:50 -04:00
Silo Server DeveloperandClaude Opus 4.7 8fee2370f8 test(admin): wire QueryClientProvider + missing hook mocks so the suite runs
The AdminLibraries test file failed all 6 tests with 'No QueryClient set' on
this branch and at the parent commit -- pre-existing infrastructure gap. With
that fixed, several hooks that the page imports (useCancelLibraryScans,
useLibraryRoots, useUpsertLibraryRootOverride, useDeleteLibraryRootOverride,
useActiveScans) and the UNMATCHED_PAGE_SIZE constant also needed mocking. One
stale assertion on the renamed 'Root path' header is updated; the deeper
troubleshooting test, which mocked useSkippedLibraryRoots but the section was
refactored to useLibraryRoots(_, 'ambiguous'), is skipped with a TODO -- a real
rewrite is needed and is out of scope for this MR.

5 of 6 tests now run and pass; the 6th is properly flagged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:25:36 +02:00
Silo Server DeveloperandClaude Opus 4.7 387a79962f style(admin): prettier-format match dialog; reset unmatched page in onChange
Run prettier over the Tooltip-wrapped poster JSX, and reset the unmatched-items
page in the search input's onChange rather than a useEffect (avoids the
react-hooks/set-state-in-effect warning / cascading renders).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:25:36 +02:00
Silo Server DeveloperandClaude Opus 4.7 c99406e360 fix(admin): search unmatched items across the whole table, not just the page
The unmatched-items search filtered only the current page's rows client-side.
Push the query server-side: HandleListUnmatchedItems takes an optional 'q' param
and filters title/library/type/status with parameterized ILIKE across all rows,
paginating the filtered set. Frontend hook takes a debounced search, resets to
page 1 on change, keeps the section mounted while searching. Also fixes stale
test mocks that returned the pre-pagination array shape instead of {items,total}.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:25:36 +02:00
Silo Server DeveloperandClaude Opus 4.7 690db33147 feat(admin): enlarge match-candidate posters + hover-to-enlarge
Unmatched-item match dialog rendered candidate posters at 44x64px, too small
to identify a film. Bump to 64x96 (2:3) and add a portaled Tooltip hover preview
(192x288) using the existing poster image, so operators can tell candidates apart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 16:59:48 +02:00
Silo Server Developer 1d20deb51f feat(ui): paginate the Ambiguous Roots table
Match the sibling tables on the Admin Libraries page (Troubleshooting/unmatched):
use the existing usePagination hook + PaginationBar (10/page, auto-hidden when
<=10 rows), render pag.rows, and reset to page 0 when the library selector or
search filter changes.
2026-05-28 16:59:48 +02:00
QuickandCodex 1054b96e46 Enable DRI passthrough in docker compose
Co-authored-by: Codex <noreply@openai.com>
2026-05-27 21:40:07 -04:00
Quick 808f265fab feat(calendar): show local episode airtimes 2026-05-27 21:32:18 -04:00
QuickandGitHub 0f3f548952 Merge pull request #18 from Silo-Server/codex/episode-section-labels
feat(sections): show episode context in cards
2026-05-27 15:49:35 -04:00
Quick e4814dea76 feat(sections): show episode context in cards 2026-05-27 15:48:00 -04:00
Quick 87986d88d0 fix(catalog): use season posters for episode cards 2026-05-27 14:54:58 -04:00
Quick f08ae2572d fix(library): preserve episode browse url 2026-05-27 14:54:51 -04:00
Quick d27b177817 fix(collections): cap smart collection results 2026-05-27 14:27:14 -04:00
Quick 0b01c2c413 fix(catalog): support relative date filters 2026-05-27 13:59:29 -04:00
QuickandGitHub bfe73a4dc9 Merge pull request #15 from Silo-Server/fix/tv-scan-settle-window-cancel
fix(libraryingest): TV full scans cancelled at settle-window boundary
2026-05-27 13:44:57 -04:00
Quick 01b0926748 Fix settle-window drainer cancellation in library ingest 2026-05-27 13:37:33 -04:00
QuickandGitHub d9e6059079 Merge pull request #16 from Silo-Server/codex/episode-catalog-performance
perf(catalog): add episode browse index fast path
2026-05-27 12:52:48 -04:00
Quick b4d4ad218a fix(catalog): address episode catalog review feedback 2026-05-27 12:48:59 -04:00
Quick 3a23798094 perf(catalog): add episode browse index fast path 2026-05-27 11:57:49 -04:00
CoffeeKnyte a3397be489 fix(libraryingest): treat drainer shutdown cancel as clean stop
TV/series full scans (libraries with new or updated items) were recorded as
"cancelled" with an empty error message and never completed matching.

When the file-walk finishes, the ingest executor waits out a settle window and
then calls stopDrainers() to shut down the concurrent match goroutines. That
cancels the drainer context while a ProcessBatchByFolderAndPathPrefix call may
still be in flight. The drainer treated the resulting context.Canceled as a
fatal error: it pushed the error to drainerErrCh and called cancel() on the
whole scan context, so scanqueue.process() mapped it to cancelRun().

Large/slow libraries (many series, slow provider lookups) keep a batch in
flight continuously, so stopDrainers() almost always landed mid-call and the
scan was cancelled; small/fast libraries were usually idle at that instant and
completed normally.

Treat a cancelled drainer context as a deliberate shutdown: return cleanly
without escalating. Genuine external cancellation still reaches the run via the
main goroutine's scanCtx checks, so real cancels are not swallowed.

Adds a regression test (settle window made injectable) that fails against the
old handler with 'concurrent match scope ...: context canceled' and passes
with the fix.
2026-05-27 15:46:24 +00:00
Quick a720467d3b Optimize episode added_at sorting 2026-05-27 10:11:58 -04:00
Quick cba9e7a65a fix(metadata): accept exact cross-provider match ties 2026-05-27 09:13:30 -04:00
QuickandGitHub e8dda26b01 Merge pull request #14 from Silo-Server/t3code/116da365
feat(jellycompat): sign image tags and accept them without session
2026-05-26 22:31:23 -04:00
Quick 578100f5ef fix(jellycompat): stabilize signed image tags across restarts
{"subject":"fix(jellycompat): stabilize signed image tags across restarts","body":"- Sign library poster and episode parent series image tags from canonical paths/thumbhashes instead of presigned URLs so tags survive restarts\n- Accept signed canonical tags in the image handler without a session and fall back to legacy URL-derived cache tags\n- Always fetch series detail for episodes to build stable parent image tags"}
2026-05-26 22:29:49 -04:00
Quick deb8305a1d fix(jellycompat): harden signed image tags 2026-05-26 21:50:10 -04:00
Quick 0788e7c211 feat(jellycompat): sign image tags and accept them without session
- HMAC-sign image tags using the configured JWT secret
- Serve item/season/episode images via signed tag without requiring a session or cache hit
2026-05-26 21:34:44 -04:00
QuickandGitHub 74dd8bc6fc Merge pull request #13 from Silo-Server/t3code/2f7d918d
feat(episode-carousel): highlight currently viewed episode
2026-05-26 21:29:37 -04:00
QuickandGitHub 3875e36ecf Merge pull request #12 from Silo-Server/t3code/5155230b
feat(ui): shared PageBack component for consistent back navigation
2026-05-26 21:29:27 -04:00
Quick 7641c016d8 feat(episode-carousel): highlight currently viewed episode
- Add "Now Viewing" badge with pulsing indicator on current episode
- Replace border with primary-color ring for current episode card
- Set aria-current="page" on links to the current episode
2026-05-26 21:29:11 -04:00
Quick 8f580a420c fix(ui): make PageBack destinations deterministic 2026-05-26 21:18:39 -04:00
Quick 65fe0a7f51 feat(ui): add floating variant to PageBack for sticky nav
- Add `floating` prop to pin PageBack to viewport on lg+ screens
- Switch styling from glass-subtle to glass with shadow for better contrast
- Use floating variant on SettingsLayout
2026-05-26 20:51:52 -04:00
QuickandClaude Opus 4.7 932f3c3908 feat(ui): add shared PageBack component for consistent back navigation
Replace the eight inconsistent back affordances across user-facing pages
with a single absolute-positioned chevron pill, so the control lives in
the same screen position regardless of title length or hero content.

DetailBreadcrumb keeps its textual hierarchy path but no longer owns the
back chevron; PageBack does. DetailHero gains a topNav slot consumed by
Movie/Series/Season/Episode/Request detail pages. Non-hero pages drop
their bespoke back buttons and add PageBack inside a relative wrapper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:18:05 -04:00
Quick ee54942b01 fix(metadata): break duplicate provider candidate ties
- Score candidate metadata completeness and auto-match the richer duplicate when title/year/type tie
- Enrich near-duplicate candidates via the provider chain before initial match selection
- Seed both movie and series match queues for mixed-type libraries and wait for TV queue settle
- Add taskmanager worker test coverage and a plan doc for the tie-breaker work
2026-05-26 20:07:43 -04:00
Quick 3c28e9b7f0 Merge branch 'main' of https://github.com/Silo-Server/silo-server 2026-05-26 20:05:38 -04:00
QuickandGitHub ea13ecb998 Merge pull request #11 from Silo-Server/t3code/eb3dedf1
feat(auth): add metadata curation permission
2026-05-26 20:05:29 -04:00
Quick c152298ad3 fix(auth): gate media file paths on metadata curation permission
- Allow curators (not just admins) to view media file paths and locations
- Apply library access filter to file-level access checks
2026-05-26 20:04:54 -04:00
Quick 0b6070c6e1 docs: add PageBack component design spec 2026-05-26 19:56:45 -04:00
Quick 9f48ae2d8b test(auth): expand session revocation coverage 2026-05-26 19:41:55 -04:00
Quick 353a7fd61d docs: add metadata curation permission plan 2026-05-26 19:40:00 -04:00
Quick 2e550c3d35 fix(auth): tighten curator job response review fixes 2026-05-26 19:34:16 -04:00
Quick 60959f5812 fix(auth): address metadata curation review issues 2026-05-26 19:27:56 -04:00
Quick ed8b992c80 feat(web): show metadata tools to curators 2026-05-26 18:24:39 -04:00
Quick b784f86249 fix(web): keep device profile hooks unconditional 2026-05-26 18:24:13 -04:00
Quick d637ae7dbf feat(web): assign metadata curation permission 2026-05-26 18:21:16 -04:00
Quick 1a72e59254 feat(web): add permission helpers 2026-05-26 18:18:55 -04:00
Quick 99f10fb2d4 feat(api): route metadata curation by permission 2026-05-26 18:18:19 -04:00
Quick 5ba7ecdca7 feat(api): authorize item metadata curation 2026-05-26 18:15:05 -04:00
Quick c95c40c3fe feat(auth): expose user permissions 2026-05-26 18:13:32 -04:00
Quick 24d48c5e6e feat(auth): add assignable user permissions 2026-05-26 18:12:20 -04:00
QuickandGitHub e2e2b632ff Merge pull request #10 from Silo-Server/codex/restore-subtitle-uploads
feat(subtitles): restore upload management
2026-05-26 18:02:27 -04:00
Quick 2728d2f50a feat(subtitles): restore upload management 2026-05-26 17:57:26 -04:00
QuickandGitHub f969da014d Merge pull request #9 from Silo-Server/t3code/search-tmdb-fallback-results
feat(search): add TMDB-backed Request to Add section to search surfaces
2026-05-26 15:17:34 -04:00
Quick 107a0d3c85 fix(search): address request search review feedback 2026-05-26 15:11:10 -04:00
Quick 44feb2f08a refactor(admin): show per-section loading and error states
- Replace page-level loading gate with skeletons per section on dashboard and stats
- Surface query errors inline instead of blocking the whole page
- Disable "Scan All Libraries" when no libraries are configured
2026-05-26 14:33:25 -04:00
Silo Server Migration 86afa748de Merge branch 'main' of https://github.com/Silo-Server/silo-server 2026-05-26 08:35:16 -04:00
Silo Server Migration ef1c6accb6 fix(webhooksync): skip events for unmapped external users
- Require explicit profile mapping instead of falling back to the default profile
- Update settings UI copy to reflect that unmapped users are ignored
2026-05-26 08:34:17 -04:00
QuickandGitHub 291e7a382b Merge pull request #8 from Silo-Server/t3code/jellyfin-autoscan-scan-compat
feat(jellycompat): support Autoscan via Jellyfin media-update endpoint
2026-05-26 08:33:48 -04:00
zZebrahz fd39824c0e fix(jellycompat): tolerate autoscan sidecar updates 2026-05-25 19:37:25 -07:00
Silo Server Migration 77c8309a4c commit message
{"subject":"fix(search): prevent empty-state flash before TMDB fallback renders","body":"- Add isResolving to useCanRequest and gate empty states on it across GlobalSearch and Catalog\n- Debounce TMDB query in Catalog and hide ItemGrid when the request section may rescue an empty library\n- Track per-card submit state in RequestToAddSection grid so concurrent requests don't trample each other\n- Suppress anonymous TMDB request-search fetches to avoid cross-viewer cache leakage"}
2026-05-25 22:27:46 -04:00
Silo Server Migration ac1e0ad6a3 test(web): avoid unsupported Array.at in search request tests 2026-05-25 18:22:27 -04:00
Silo Server Migration 7926d25c79 chore(web): format request search section 2026-05-25 18:20:39 -04:00
Silo Server Migration 09b97c0d32 feat(catalog): render RequestToAddSection grid with empty-state suppression 2026-05-25 18:19:42 -04:00
Silo Server Migration bcc28795e4 feat(search): render RequestToAddSection in the Cmd+K dialog with empty-state suppression 2026-05-25 18:17:31 -04:00
Silo Server Migration c5e2770bab feat(search): add RequestToAddSection grid variant for Catalog page 2026-05-25 18:15:52 -04:00
Silo Server Migration eda8b7938d feat(search): add RequestToAddSection dialog variant 2026-05-25 18:15:10 -04:00
Silo Server Migration b3cdf438f9 feat(request-card): make onRequest optional on discover variant 2026-05-25 18:13:31 -04:00
Silo Server Migration 4d51608118 test(requests): document viewer-keyed cache isolation and invalidation cascade 2026-05-25 18:12:45 -04:00
Silo Server Migration e97286da01 feat(requests): key useRequestSearch by viewer, forward signal, raise staleTime 2026-05-25 18:12:19 -04:00
Silo Server Migration 4f0ecbe3c0 refactor(keys): add viewerKey to requestKeys.search 2026-05-25 18:11:11 -04:00
Silo Server Migration 5fef177e36 feat(hooks): add useCanRequest gating hook for discovery eligibility 2026-05-25 18:10:58 -04:00
Silo Server Migration e1f711dea7 test(api): pin AbortSignal forwarding contract on api() 2026-05-25 18:10:06 -04:00
Silo Server MigrationandClaude Opus 4.7 08ba09c955 docs(plans): address Codex adversarial review for search request section
Fixes the high-severity finding that RequestToAddSection's internal
useRequestSearch call was not gated on discoveryEnabled, allowing
/api/v1/requests/search and TMDB lookups to fire for users without
request access. The plan now (1) passes { enabled: discoveryEnabled }
to the section's hook, (2) gates the parent mount in GlobalSearch and
Catalog on canRequest.discoveryEnabled as defense in depth, and (3)
adds tests asserting both the enabled forwarding and the no-mount
behavior when discovery is disabled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 18:02:58 -04:00
Silo Server MigrationandClaude Opus 4.7 f2a6b1081e docs(plans): add implementation plan for search request section
Twelve TDD tasks covering: api() signal contract test, useCanRequest
hook, viewer-keyed requestKeys.search, useRequestSearch extension
(signal + viewer key + 5min staleTime + enabled override), invalidation
cascade tests, RequestPosterCard optional onRequest, RequestToAddSection
component (dialog + grid variants), GlobalSearch and Catalog wiring
with empty-state suppression for the library-0/TMDB-pending edge case,
final lint/test pass, and manual smoke. Notes a single deviation from
the spec: submitDisabledReason is null in the initial implementation,
with per-row request data driving disabled UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:35:10 -04:00
Silo Server MigrationandClaude Opus 4.7 9fc70631a8 docs(specs): address Codex adversarial review for search request section
Splits discovery eligibility from submission eligibility so blocked
and quota-exhausted viewers still see the requestable section with
disabled per-row CTAs, matching the documented behavior. Documents
the required extensions to useRequestSearch — signal forwarding,
viewer-identity-keyed cache, and invalidation on auth/profile/
settings/limit changes — so the planned 5-minute staleTime is
safe and cancellation works as described.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:19:37 -04:00
Silo Server MigrationandClaude Opus 4.7 c205da8b2b docs(specs): add design for TMDB-backed request section in search
Adds the design for surfacing requestable TMDB results inside the main
catalog search (Cmd+K dialog and full results page) as a clearly
delimited "Request to Add" section that never blocks or displaces
library results.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 13:53:03 -04:00
Silo Server Migration 8f818ac0c9 ci(docker): build image on push to main via self-hosted runner
- Trigger Docker image builds on pushes to main instead of nightly cron
- Run on self-hosted Linux runner
- Drop the `nightly` tag
2026-05-25 13:03:56 -04:00
Silo Server Migration a639cf60de fix(catalog): gate search overview-only matches behind title FTS
- Always apply stats CTE + CROSS JOIN so single-word queries no longer flood results with description-only hits
- Require overview_rank >= 0.15 for overview-only fallback rows
- Switch title gate from contiguous LIKE to title_rank > 0 so reordered-token title matches aren't demoted
2026-05-25 12:49:38 -04:00
Silo Server Migration b2fae0aa70 refactor(scantrigger): drop redundant Target.LibraryID field
- Read library ID from Target.Folder.ID everywhere
- Guard scan queue enqueue against nil Folder
- Simplify admin API key auth error plumbing
2026-05-25 12:11:56 -04:00
Silo Server Migration c3cf485cd9 chore: add planning docs and requests updates
- Add plans for date-named episodes and Jellyfin autoscan compat
- Update requests handlers, service, and UI hooks
- Remove Makefile.local.example
2026-05-25 12:07:50 -04:00
Silo Server Migration 4a5ca0b9eb fix(jellycompat): harden autoscan auth and batch scan enqueue
- Reject nil API keys and bound last-used update with a 5s timeout
- Stop leaking internal queue errors in autoscan responses
- Batch scan enqueues via new CreateBatch and reuse folder list across path resolves
2026-05-25 11:39:35 -04:00
Silo Server Migration 03f97e63d6 docs: document jellyfin autoscan setup 2026-05-25 11:18:02 -04:00
Silo Server Migration f73febbd55 feat(jellycompat): add autoscan media update route 2026-05-25 11:18:02 -04:00
Silo Server Migration 6ceb75e717 feat(jellycompat): accept admin api keys for autoscan 2026-05-25 11:18:02 -04:00
Silo Server Migration bf0aefa050 refactor(api): share scan target resolution 2026-05-25 11:18:02 -04:00
Silo Server Migration 4f46580210 refactor(scan): extract scan trigger resolver 2026-05-25 11:18:02 -04:00
Silo Server Migration 77510cd12a docs: design jellyfin autoscan scan compatibility 2026-05-25 11:18:02 -04:00
QuickandGitHub a75d232ceb Merge pull request #7 from Silo-Server/t3code/discover-studios-networks-genres-clean
feat(requests): add media request system
2026-05-25 10:56:57 -04:00
QuickandGitHub dae57776fc Merge pull request #5 from Silo-Server/fix/direct-download-head
fix(api): allow HEAD on /api/v1/direct-download
2026-05-24 11:58:26 -04:00
rxwatcherandClaude Opus 4.7 e4485c9eb6 fix(api): allow HEAD on /api/v1/direct-download
Firefox (and some download managers) issue a HEAD request before
starting a download. The route only registered GET, so HEAD returned
405 Method Not Allowed and the browser aborted the download.

Mirrors the pattern already used by /stream/{session_id}, which
registers both GET and HEAD on the same handler. ServeDirect is built
on http.ServeContent / ServeFile, which natively handle HEAD by
writing headers without a body, so no handler changes are needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:57:42 +02:00
3847 changed files with 190960 additions and 20292 deletions
+264
View File
@@ -0,0 +1,264 @@
---
name: issue-to-pr
disable-model-invocation: true
description: >-
End-to-end implement a GitHub issue and open a ready pull request. Invoke
explicitly as `/issue-to-pr <number|#ref|url>` (this is a side-effecting
workflow — it creates branches and opens PRs — so it does not auto-trigger).
It creates an isolated git worktree off the latest main, reproduces the bug
(or scopes the feature), finds the root cause, implements the smallest correct
change following repo conventions, verifies it (build/lint/tests), runs a Codex
adversarial review and addresses the material findings, then commits and opens
a ready PR. Not for pure triage (use triage-issue), pure review of an existing
PR (use review-pr-or-mr / code-review), or analysis without code changes.
---
# Issue → PR
Turn a single GitHub issue into a verified, reviewed, ready-to-merge pull
request — autonomously — while staying honest about uncertainty and stopping
when a human decision is genuinely required.
The goal is not "produce a diff." It is: **confirm the real problem exists,
make the smallest change that correctly solves it, prove it works by execution,
have an adversary try to break it, and only then ship a PR.** Speed comes from
doing these in order, not from skipping them.
## Inputs
- The issue reference is passed as `$ARGUMENTS` — a number (`123`), a `#123`
reference, or a full issue URL. If none is given, ask; do not guess.
- The repo is the current git repository; PRs target `origin`
(`Silo-Server/silo-server`) and base `main`.
## Operating principles
- **Default to "maybe nothing needs fixing."** Coding agents "fix" already-correct
or stale code in a large fraction of cases because they don't stop to confirm the
problem is real. Confirm the issue reproduces on current `main` before changing
anything — it may already be fixed.
- **Root cause over symptom.** A fix you can't explain is a fix you can't trust.
- **Verify by execution, not by reading.** Build/lint/tests are the primary
correctness signal. The adversarial review is a *second* layer on top — never a
substitute for green checks. If you can't verify it, don't ship it.
- **Smallest correct change.** Match surrounding code's idioms, naming, and comment
density. Put code in the package that owns the behavior (CLAUDE.md); don't create
catch-all helpers or duplicate logic — search for an existing helper before
writing a new one, and extract shared logic instead of copying.
- **Don't trust recall for APIs.** Verify every symbol, function, and signature you
call actually exists by grepping the codebase — agents hallucinate plausible-looking
APIs.
- **Don't gold-plate.** A reviewer told to find gaps will always find some. Address
what affects correctness or the issue's stated requirements; treat the rest as
optional. Extra abstraction "just in case" is a defect, not diligence.
- **Know when to stop.** See [Stop and ask](#stop-and-ask). Autonomy is for
execution, not for inventing product decisions or shipping unverifiable guesses.
Track the phases below with a task list (TaskCreate/TaskUpdate) so progress is
visible and nothing is skipped on long runs.
## Workflow
### 1. Understand the issue
Read everything before writing anything.
```bash
gh issue view <N> --json number,title,body,labels,state,author,url,comments
```
Determine: **bug vs. feature** (labels + body → branch prefix `fix/` vs `feat/`);
**acceptance criteria** (what does "done" mean?); **linked scope** (does it serve
an epic / sub-issue to reference as `Part of #NNN`?); and **client surface** (does
it touch API/auth/playback/session/library/metadata behavior consumed by
`silo-android` / `silo-apple`? — if so, coordinated client follow-up may be needed).
If the issue is vague, underspecified, or needs a product call, stop and ask now —
before spending a worktree on the wrong problem.
### 2. Create a worktree off the latest main
Work in isolation so the user's checkout is untouched. Create it at the **main
repository root**, not inside the current worktree.
```bash
# Resolve the primary repo root (works from inside any worktree)
MAIN_ROOT="$(cd "$(dirname "$(git rev-parse --git-common-dir)")" && pwd)"
git -C "$MAIN_ROOT" fetch origin # always branch off fresh main
SLUG=<short-kebab-slug-from-issue-title>
BRANCH=fix/issue-<N>-$SLUG # use feat/ for features
WT="$MAIN_ROOT/.claude/worktrees/issue-<N>"
git -C "$MAIN_ROOT" worktree add -b "$BRANCH" "$WT" origin/main
cd "$WT"
```
If the branch/worktree already exists from a prior run, reuse it (cd in) rather
than failing — but confirm it's based on current `origin/main`; if it's stale, tell
the user before continuing. From here, **all work happens in `$WT`** (the Bash tool
keeps the working directory between calls); use absolute paths when in doubt.
### 3. Reproduce / confirm — a hard gate
This is the highest-leverage step. Do not skip it.
- **Bug:** reproduce it concretely — ideally a **test that fails on current `main`**
(red), or a precise script/trace through the faulty path. Pin down the exact code
path and the invariant it violates.
- **If you cannot reproduce it, STOP and report** — do not patch. The issue may
already be fixed, be environment-specific, or be a misunderstanding. Shipping a
speculative fix for a non-reproducing bug is the most common way agents make
things worse.
- **Feature:** confirm the capability is actually missing, then map where it fits —
the owning package, patterns to follow, the data flow, the smallest set of files.
**Plan proportionally.** If you could describe the diff in one sentence (a typo, a
log line, a one-line guard), skip formal planning and make the change. Plan first
when the work is multi-file, unfamiliar, or architecturally uncertain — name the
files/interfaces involved, state what's out of scope, and end the plan with the
verification step. For anything large, confirm direction with the user before a big
build-out.
### 4. Implement
Make the change, scoped to this one concern (one concern per PR). Respect the hard
rules:
- **API:** additive-only within `/api/v1` — never rename/remove a response field,
change a field's type, or repurpose a status code. New behavior = new
fields/endpoints; expose capability endpoints for feature detection.
- **Migrations:** new DB changes are Goose SQL migrations via
`make migrate-create NAME=...` (timestamped). Never `goose fix`, never paired
`.up.sql`/`.down.sql`.
- **Maintainability:** extract shared logic instead of duplicating; change existing
code rather than bolting on local workarounds.
For a bug, the failing test from step 3 should now pass.
### 5. Verify — the primary correctness gate
Run the checks and make them genuinely pass. The exact commands and the worktree
quirks (`GOWORK=off`, stubbing `web/dist`, the v2 golangci invocation, the flaky
GPU tests) are in **[references/verification.md](references/verification.md)** —
read it first, because plain `go build ./...` / `make lint` fail inside
`.claude/worktrees`.
At minimum: Go build + the relevant package tests pass; if `web/` changed,
`pnpm run lint` + `pnpm run format:check` + build pass; `make verify-local-paths` is
clean. Run the **full relevant suite**, not just your new test — catch regressions
in code you didn't touch. Show the command output as evidence; don't assert success.
**Never reach green by weakening the checks.** Deleting/skipping/`xfail`-ing tests,
loosening assertions, lowering coverage thresholds, or relaxing lint/CI to make a
red check pass is a blocker, full stop — if a test genuinely must change, that needs
explicit human sign-off, not a quiet edit. If you can't make the checks pass on the
merits, stop and report.
### 6. Adversarial review (Codex)
Commit your work first (so the review sees exactly what will ship), then run a Codex
adversarial review of the branch against `main`. Full invocation, output handling,
the iteration cap, and the fallback are in
**[references/codex-review.md](references/codex-review.md)** — follow it exactly.
The loop, in short:
1. Commit the implementation on the branch.
2. Run the adversarial review (`--base main`, foreground/`--wait`, extended timeout).
3. For each finding: **first confirm it's real and grounded in the actual code**
(reviewers over-flag — don't fix phantom nits). Then **fix** material findings
that affect correctness or the stated requirements (and re-run the review), or
**rebut** them with a defensible, written reason. Note non-material findings as
optional.
4. **Cap the loop at 2 iterations (3 absolute max).** Debugging effectiveness decays
sharply after ~2–3 attempts, so thrashing won't help — if material findings still
stand after the cap, stop and escalate to the user instead of looping.
Do not open the PR while a material, un-rebutted adversarial finding stands.
### 7. Commit, push, open the PR
Conventional Commit subjects scoped to the domain, e.g.
`fix(playback): guard against nil session on reconnect`. End commit messages with:
```
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
```
Push and open a **ready** (non-draft) PR against `main`:
```bash
git push -u origin "$BRANCH"
gh pr create --base main --head "$BRANCH" --title "<conventional subject>" --body "$(cat <<'EOF'
## Problem
<what's broken / missing, grounded in the issue — and how you reproduced it>
## Approach
<what you changed and *why this approach* — the tradeoff, not a file list>
## Verification
<commands run and their result; the failing→passing test; manual checks>
## Adversarial review
<clean, or the material findings and how each was fixed/rebutted>
## Risks & follow-up
<rollout/migration/compatibility risks; any silo-android / silo-apple
follow-up; anything deferred>
Closes #<N>
<add "Part of #<epic>" if the issue serves a capability epic/sub-issue>
## AI-use disclosure
Implemented by Claude Code (issue-to-pr skill) with human oversight.
EOF
)"
```
End the PR body with:
```
🤖 Generated with [Claude Code](https://claude.com/claude-code)
```
For **UI changes**, attach before/after screenshots or a recording (the repo
requires this); if you can't capture them, explicitly flag in the PR that they're
still needed.
### 8. Report back
Give the user the PR URL, a 2–3 line summary, the verification result, the
adversarial-review outcome, any flagged risks or required client follow-up, and the
worktree path so they can inspect it.
## Long or multi-iteration runs
Performance degrades on long sessions, and context compaction can silently drop
constraints. So: keep a short progress note and a "failed approaches" list in the
scratchpad so you don't repeat dead ends; and after any compaction, re-read CLAUDE.md
and re-assert the hard guardrails (additive-only API, migration rules, no weakening
of tests) before continuing.
## Stop and ask
Pause and ask the user (don't push a PR) when:
- The issue is ambiguous, underspecified, or needs a product/UX decision.
- **The bug doesn't reproduce** on current `main`.
- The fix would require a non-additive API change, or a risky/irreversible migration.
- The change balloons into something large or architectural — propose a plan first.
- Checks can't be made to pass on the merits, or material adversarial findings remain
after the iteration cap.
- A test/CI change would be needed to "pass" — get sign-off, don't do it silently.
- The right fix clearly belongs in a sibling repo (`silo-android`, `silo-apple`, a
plugin/SDK repo) — see CLAUDE.md's multi-repo guidance.
In these cases, summarize what you found and what you'd do, and let the user decide.
## Notes
- Authorized to open ready PRs autonomously once checks pass and the adversarial
review is clean — that's the durable instruction from setup. Not authorized to
merge, force-push shared branches, or close issues by hand.
- Never invent a fix you can't verify or explain. An honest "here's what's uncertain"
beats a confident wrong PR.
@@ -0,0 +1,88 @@
# Codex adversarial review
The `/codex:adversarial-review` command is marked `disable-model-invocation: true`,
so you cannot trigger it as a tool/skill. Call the same underlying runtime
directly via Bash: the `codex-companion.mjs` helper, subcommand
`adversarial-review`. It challenges the *approach and design*, not just defects —
it tries to find the strongest reasons the change should not ship.
## 1. Locate the companion script
```bash
CODEX_COMPANION="${CLAUDE_PLUGIN_ROOT:+$CLAUDE_PLUGIN_ROOT/scripts/codex-companion.mjs}"
if [ -z "$CODEX_COMPANION" ] || [ ! -f "$CODEX_COMPANION" ]; then
CODEX_COMPANION="$(find "$HOME/.claude/plugins/marketplaces" "$HOME/.claude/plugins/cache" \
-name codex-companion.mjs 2>/dev/null | head -n1)"
fi
echo "$CODEX_COMPANION"
```
If nothing is found, the Codex plugin isn't installed/set up. Don't silently skip
the review — tell the user, point them at `/codex:setup`, and ask whether to
proceed without it (or fall back to a `feature-dev:code-reviewer` agent pass).
The user asked specifically for the Codex adversarial review, so treat its absence
as a real blocker, not a detail.
## 2. Commit first, then review the branch against main
Review what will actually ship. Commit the implementation on the branch, then
diff against `main`:
```bash
git add -A && git commit -m "<conventional subject>"
```
Run it in the **foreground** so results come back this turn, and set an extended
Bash timeout (reviews can take several minutes) — pass `timeout: 600000` on the
Bash call:
```bash
node "$CODEX_COMPANION" adversarial-review --wait --base main "optional focus text"
```
- `--wait` runs foreground and prints the result to stdout.
- `--base main` reviews the whole branch diff (`main...HEAD`).
- Append focus text to steer it (e.g. the specific failure mode, the risky
concurrency path, the migration). Keep the adversarial framing — don't soften it.
- Do **not** use `--background` here; you need the findings in-hand to act on them.
## 3. Interpret the output
The review returns a verdict plus findings:
- **`approve` / no material findings** → green light for the PR.
- **`needs-attention`** → there is at least one material risk. Each finding names
a file, a line range, an impact, and a concrete recommendation.
LLM reviewers systematically **over-flag** — they report gaps even when the work is
sound. So for every finding, in order:
1. **Confirm it's real.** Open the cited file/lines and verify the finding is
grounded in the actual code and actually reachable. Drop findings that reference
code that doesn't exist, lines outside the diff, or behavior the code doesn't have
— don't fix phantom nits.
2. For a confirmed finding, do one of two things — never silently ignore it:
- **Fix it** if it affects correctness or the issue's stated requirements, then
re-run the review (go back to step 2 after committing the fix); or
- **Rebut it** with a defensible, written reason it doesn't apply, and capture
that reasoning in the PR's "Adversarial review" section.
3. Treat style/naming/speculative/low-value findings as **optional** — don't
gold-plate. The adversarial prompt is told not to emit these, but if any slip
through, note and move on; don't add abstraction or defensive code for cases that
can't happen.
## 4. Loop — but cap it
Repeat implement → verify → commit → review until the review is `approve` or every
material finding is fixed or defensibly rebutted.
**Cap the loop at 2 iterations (3 absolute max).** Debugging effectiveness decays
sharply after ~2–3 attempts — if material findings still stand after the cap, stop
and escalate to the user with a summary rather than thrashing or shipping over a
standing objection. Only open the PR once the review is clean within the cap.
## 5. Record it in the PR
Summarize the outcome in the PR body's **Adversarial review** section: either
"clean — no material findings" or a terse list of what was raised and how each was
fixed or rebutted. This gives reviewers the adversary's perspective for free.
@@ -0,0 +1,77 @@
# Verification inside a worktree
Commands assume the repository root is the cwd (the new worktree, `$WT`).
## Why the obvious commands fail here
A `.claude/worktrees/*` checkout lives under the parent Silo workspace, whose
`go.work` only lists the primary `./silo-server`, not worktree checkouts. So:
- `go env GOWORK` resolves to the parent workspace from anywhere under `SiloServer/`.
- Plain `go build ./...` / `go test ./...` fail with
*"directory prefix . does not contain modules listed in go.work"*.
- `go build ./...` also needs `web/dist` to exist (the embed directive).
- `golangci-lint` is not installed locally.
## Go
Prefix every Go command with `GOWORK=off`.
```bash
# Build the parts that matter without needing web/dist:
GOWORK=off go build ./internal/... ./cmd/...
# Run the tests for the package(s) you touched (fast, targeted):
GOWORK=off go test ./internal/<pkg>/...
# If you need a full ./... build, stub the embed target first:
mkdir -p web/dist && touch web/dist/.gitkeep
GOWORK=off go build ./...
```
Lint Go without installing golangci-lint — must be the **v2** module path (the
repo's `.golangci` config rejects v1):
```bash
GOWORK=off go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run ./internal/...
```
**Known flake, not a real failure:** `internal/playback` GPU-probe tests
(`TestResolveHWAccel*`, the NVENC smoke test) can fail with `signal: killed`
under parallel load on macOS, but pass in isolation. If they're the only
failures, re-run them alone before treating them as real:
```bash
GOWORK=off go test ./internal/playback/ -run TestResolveHWAccel -count=1
```
## Frontend (only if `web/` changed)
```bash
cd web
pnpm install # if deps changed or node_modules is absent
pnpm run lint
pnpm run format:check
pnpm run build # catches type/build errors before the PR
cd ..
```
The repo requires `pnpm run lint` and `pnpm run format:check` to be clean before
a merge request.
## Repo-wide
```bash
make verify-local-paths # must be clean; CLAUDE.md forbids absolute paths in docs/specs
```
## Migrations
If the change needs schema work, create a timestamped Goose migration — never
hand-number, never `goose fix`, never paired up/down files:
```bash
make migrate-create NAME=add_thing
make migrate-validate
make migrate-status
```
+24
View File
@@ -1,6 +1,12 @@
# Silo Docker deployment
# For the default docker compose setup, MEDIA_ROOT is the main value you need to set.
# Optional: layer compose files through env. Leave unset for CPU-only defaults.
# Linux/macOS example for enabling NVIDIA override:
# COMPOSE_FILE=docker-compose.yml:docker-compose.nvidia.yml
# Windows example:
# COMPOSE_FILE=docker-compose.yml;docker-compose.nvidia.yml
# Required for docker compose: host path to your media library.
# Example: MEDIA_ROOT=/srv/media
MEDIA_ROOT=/srv/media
@@ -36,10 +42,27 @@ POSTGRES_SHM_SIZE=8gb
# Optional host port overrides for docker-compose.yml.
# PORT=8090
# Used only after Jellyfin-compatible app support is enabled by an admin.
# JF_PORT=8096
# PROXY_PORT=8083
# TRANSCODE_PORT=8082
# Optional NVIDIA override controls (used by docker-compose.nvidia.yml).
# NVIDIA_GPU_COUNT=1
# REQUIRED: master key for at-rest credential encryption. Silo refuses to start
# without it. Integration API keys, S3 keys, and other server-owned secrets are
# encrypted under a key derived from this value, so it must be at least 32
# characters and kept secret. Generate one with:
# openssl rand -base64 48
# Back it up SEPARATELY from your database dumps (treat it like a CA private
# key): losing it makes every encrypted secret unrecoverable. See
# docs/architecture/secret-encryption.md.
# SECRET_KEY=replace-with-output-of-openssl-rand-base64-48
# Optional public URL for Silo. If not set, the server will use the IP address of the container.
# SILO_PUBLIC_URL=https://silo.example.com
# Run from source / advanced overrides
# Only DATABASE_URL is required when running Silo outside the default docker compose stack.
# DATABASE_URL=postgres://silo:password@localhost:5432/silo
@@ -53,3 +76,4 @@ POSTGRES_SHM_SIZE=8gb
# Optional server mode override for source runs or standalone worker nodes.
# MODE=integrated
#SILO_MIGRATE_TIMEOUT=60m
+2
View File
@@ -0,0 +1,2 @@
# v1 scope lock — changes to the lock artifact require maintainer review
/docs/architecture/v1-scope.md @Quick104
+1
View File
@@ -0,0 +1 @@
github: quick104
@@ -0,0 +1,56 @@
name: v1 capability proposal
description: Propose a user-facing capability for the Silo v1 scope lock
title: "[v1] <capability name>"
labels: ["v1-proposed"]
body:
- type: markdown
attributes:
value: |
One proposal per **user-facing capability** (e.g. "Manga library type", "Transcoded playback"),
not per PR or per endpoint. Locked capabilities are what the client teams plan against —
the **API surface** section is mandatory for a capability to be lockable.
Scope state and lock rules: `docs/architecture/v1-scope.md`.
- type: textarea
id: summary
attributes:
label: Summary
description: One paragraph, user-facing language. What can a user do when this ships?
validations:
required: true
- type: textarea
id: value
attributes:
label: User value / why v1
description: Why does v1 need this rather than v1.1?
validations:
required: true
- type: textarea
id: api-surface
attributes:
label: API surface
description: >
Endpoints/payloads clients will call (method + path + notable request/response fields).
Plain list now; OpenAPI pointers once the #135 tooling lands. A capability cannot be
Locked without this section filled in.
placeholder: |
GET /api/v1/manga/{seriesId}/chapters — list chapters (id, index, volume, read_state)
POST /api/v1/watch-progress — records reading progress (existing endpoint, new item type)
validations:
required: true
- type: textarea
id: client-impact
attributes:
label: Client impact (android / apple / web)
description: What must each client build to surface this capability? "None" is a valid answer per client.
validations:
required: true
- type: dropdown
id: size
attributes:
label: Rough size (server-side)
options:
- S
- M
- L
validations:
required: true
+26
View File
@@ -0,0 +1,26 @@
name: v1 proposal labeler
# The issue form applies `v1-proposed` automatically, but only for submissions
# through the web form. Contributors filing via the API/CLI (or from a fork)
# cannot apply labels without triage/write access, so their proposals would land
# unlabeled and never auto-add to the Silo v1 project. This stamps `v1-proposed`
# on any issue whose title starts with `[v1]`, regardless of who filed it.
on:
issues:
types: [opened]
permissions:
issues: write
jobs:
label:
if: startsWith(github.event.issue.title, '[v1]')
runs-on: ubuntu-latest
steps:
- name: Apply v1-proposed
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
run: gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --add-label v1-proposed
+15 -1
View File
@@ -20,6 +20,7 @@ coverage.html
# Temp
/tmp/
*.tmp
.local/
# Worktrees
.worktrees/
@@ -40,6 +41,7 @@ go.work.sum
# Frontend
web/node_modules/
.pnpm-store/
web/dist/
plugins/metadb/dist/
web/*.tsbuildinfo
@@ -48,7 +50,11 @@ docs/inspiration
.agents/
/.claude/
# Ignore everything in .claude/ except committed, shared skills.
/.claude/*
!/.claude/skills/
/.claude/skills/*
!/.claude/skills/issue-to-pr/
.cursor/
.superpowers/
.codex/
@@ -58,5 +64,13 @@ logs/
.playwright-mcp/
demo/
docker-compose.dev.yml
docker-compose.local.yml
# Local-only deployment override (unpublishes bundled redis/postgres host
# ports via `ports: !override []`). Kept out of git so a rebase from main
# never disturbs it and it never lands in a PR; its absence once exposed
# Redis to the internet, so it must persist on the box.
docker-compose.override.yml
# Local compose customization layered onto docker-compose.yml by make dev-deploy.
docker-compose.local.yml
.playwright-cli/
output/
+12
View File
@@ -88,3 +88,15 @@ Recent history follows Conventional Commit-style subjects such as `feat(playback
- Silo separates login accounts (`users`) from household profiles; multiple profiles on one account share the same `user_id`.
- Profile `is_primary` marks the household parent for that account; it is not the same as server-wide `admin` role on the user account.
- Point the Vite dev frontend at a remote backend with `VITE_API_PROXY_TARGET` in `web/.env.local` (or inline) when running `make dev-frontend`; the frontend calls relative `/api` URLs proxied by Vite.
## Silo v1 Process (scope lock & API rules)
**API rules — additive-only within `/api/v1`.**
- Never rename or remove a response field, change a field's type, or repurpose a status code on an existing endpoint.
- New functionality adds new fields/endpoints; removals go through the existing Deprecation/Sunset header flow only.
- New features expose capability endpoints (feature detection) rather than relying on version sniffing.
- Contract strategy and tooling: issue #135.
**PR requirements.**
- Link the capability epic or sub-issue the PR serves (`Part of #NNN`). PRs with no linked scope item will be questioned at review.
- One concern per PR; Conventional Commit subject; AI-use disclosure in the PR body.
+112
View File
@@ -0,0 +1,112 @@
# Developing Silo
This document covers building, running, and contributing to the Silo server. If you just want to run Silo, see the [README](README.md).
See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution expectations, merge request guidance, and the policy for AI-assisted submissions.
## Prerequisites
- **Go** 1.24+
- **Bun** 1.0+
- **PostgreSQL** 18+
- **FFmpeg** (for transcoding support)
## Local Development
Local development remains intentionally separate from the deploy-oriented compose setup. Use [docker-compose.yml](docker-compose.yml) for local services and the source-build workflow below.
```sh
# Start local PostgreSQL and Redis
docker compose up -d postgres redis
# Run the frontend dev server (hot reload, proxies API to :8090)
make dev-frontend
# Run the Go backend
make dev-backend
```
The main compose file expects `MEDIA_ROOT` to be set even if you only want the bundled PostgreSQL and Redis services, so set that in `.env` first (`cp .env.example .env`).
If you are developing `Silo` and `silo-plugin-sdk` together, keep using the local [`go.work`](go.work) workspace. That workspace is a developer convenience only. CI and release builds run with `GOWORK=off`, so any new SDK helper used here must be pushed and tagged in `silo-plugin-sdk` before this repo can merge or release the change.
Plugin authors should start with [docs/architecture/plugin-development.md](docs/architecture/plugin-development.md), which covers the RPC plugin package format, generated proto workflow, SDK import paths, route and asset exposure, and auth or user-config integration points.
## Make Targets
| Target | Description |
|---|---|
| `make build` | Build frontend + Go binary |
| `make frontend` | Build frontend only |
| `make dev-frontend` | Vite dev server with HMR |
| `make dev-backend` | Run Go backend (integrated mode) |
| `make dev-proxy` | Run a standalone proxy node |
| `make dev-transcode` | Run a standalone transcode node |
| `make migrate-create NAME=add_thing` | Create a timestamped Goose SQL migration |
| `make migrate-validate` | Validate Goose migration files without touching a database |
| `make migrate-status` | Show Goose migration status using Silo's bootstrapping runner |
| `make migrate-up` | Apply pending Goose migrations using Silo's bootstrapping runner |
| `make clean` | Remove build artifacts |
## Database Migrations
PostgreSQL schema migrations are managed by Goose. Migration SQL files live in
`migrations/sql/` and use Goose annotations. Converted legacy migrations keep
their original numeric versions so existing `schema_versions` rows can bootstrap
cleanly into Goose without replaying old SQL. New migrations should be created
with timestamped filenames:
```sh
make migrate-create NAME=add_thing
make migrate-validate
```
Do not run `goose fix`; timestamped migrations are the repository policy because
they avoid version collisions across parallel PRs. The existing `001`-style
files are historical compatibility records, not the naming pattern for new work.
Runtime migrations are applied by the integrated/API server only. Proxy and
transcode modes never mutate schema.
For existing installs, use `make migrate-status` and `make migrate-up` rather
than invoking the Goose CLI directly; those targets copy legacy
`schema_versions` rows into `public.goose_db_version` under the migration lock
before reading or applying migrations. Set `ENV_FILE=path/to/.env` when the
database URL should be read from a non-default env file.
## Running Tests
```sh
# Go tests (uses testcontainers — Docker must be running)
go test ./...
# Frontend tests
cd web && bun test
```
## Linting
```sh
# Go
golangci-lint run
# Frontend
cd web && bun run lint
cd web && bun run format:check
```
## Project Structure
```
cmd/silo/ Entry point
internal/
api/ HTTP router, handlers, middleware
auth/ JWT authentication and sessions
catalog/ Media item, episode, season repositories
config/ YAML + env var configuration
jellycompat/ Jellyfin/Emby protocol compatibility
metadata/ Plugin-driven metadata matching and enrichment
playback/ Direct play, remux, transcode session management
scanner/ Media file discovery and FFProbe
worker/ Background jobs (scan, match, reconcile)
web/ React + TypeScript frontend (Vite, Tailwind, shadcn/ui)
migrations/sql/ Goose-managed PostgreSQL schema migrations
```
+8 -3
View File
@@ -3,6 +3,7 @@ FROM node:22-slim AS frontend
RUN corepack enable && corepack prepare pnpm@10.32.1 --activate
WORKDIR /app/web
COPY web/package.json web/pnpm-lock.yaml ./
COPY web/vendor/foliate-js ./vendor/foliate-js
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
COPY web/ .
@@ -22,6 +23,7 @@ ENV GONOSUMDB=github.com/Silo-Server/*
RUN apt-get update && apt-get install -y --no-install-recommends libvips-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY go.mod go.sum ./
COPY internal/compat/zishang520-webtransport-go/ internal/compat/zishang520-webtransport-go/
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
go mod download
@@ -48,12 +50,15 @@ RUN apt-get update && \
echo "deb [signed-by=/usr/share/keyrings/jellyfin.gpg arch=${TARGETARCH}] https://repo.jellyfin.org/debian bookworm main" \
> /etc/apt/sources.list.d/jellyfin.list && \
apt-get update && \
apt-get install -y --no-install-recommends jellyfin-ffmpeg7 libvips42 fonts-noto-core fonts-noto-cjk && \
apt-get install -y --no-install-recommends jellyfin-ffmpeg7 git libvips42 fonts-noto-core fonts-noto-cjk && \
apt-get purge -y gnupg && apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*
RUN mkdir -p /tmp/silo-transcode
RUN mkdir -p /tmp/silo-transcode /var/lib/silo/compat/jellyfin-web
COPY --from=frontend /usr/local/bin/node /usr/local/bin/node
COPY --from=frontend /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm
RUN ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
COPY --from=build /silo /usr/local/bin/silo
COPY third_party/jellyfin-web/ /srv/jellyfin-web/
EXPOSE 8080 8096 13378
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:${PORT:-8080}/api/v1/health || exit 1
+9 -4
View File
@@ -5,6 +5,7 @@ FROM node:22-slim AS frontend
RUN corepack enable && corepack prepare pnpm@10.32.1 --activate
WORKDIR /app/web
COPY web/package.json web/pnpm-lock.yaml ./
COPY web/vendor/foliate-js ./vendor/foliate-js
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
COPY web/ .
@@ -19,6 +20,7 @@ ENV GONOSUMDB=github.com/Silo-Server/*
RUN apt-get update && apt-get install -y --no-install-recommends libvips-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY go.mod go.sum ./
COPY internal/compat/zishang520-webtransport-go/ internal/compat/zishang520-webtransport-go/
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
go mod download
@@ -53,13 +55,16 @@ RUN apt-get update && \
echo "deb [signed-by=/usr/share/keyrings/jellyfin.gpg arch=${TARGETARCH}] https://repo.jellyfin.org/debian bookworm main" \
> /etc/apt/sources.list.d/jellyfin.list && \
apt-get update && \
apt-get install -y --no-install-recommends jellyfin-ffmpeg7 libvips42 fonts-noto-core fonts-noto-cjk && \
apt-get install -y --no-install-recommends jellyfin-ffmpeg7 git libvips42 fonts-noto-core fonts-noto-cjk && \
apt-get purge -y gnupg && apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*
RUN mkdir -p /tmp/silo-transcode
RUN mkdir -p /tmp/silo-transcode /var/lib/silo/compat/jellyfin-web
COPY --from=frontend /usr/local/bin/node /usr/local/bin/node
COPY --from=frontend /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm
RUN ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
COPY --from=build /silo /usr/local/bin/silo
COPY third_party/jellyfin-web/ /srv/jellyfin-web/
EXPOSE 8080 8080 8096
EXPOSE 8080 8096 13378
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:${PORT:-8080}/api/v1/health || exit 1
ENTRYPOINT ["silo"]
+6 -5
View File
@@ -1,4 +1,4 @@
.PHONY: frontend build dev-frontend dev-backend dev-proxy dev-transcode lint clean jellyfin-web-bundle migrate-continuum-check verify-local-paths install-hooks migrate-create migrate-validate migrate-status migrate-up
.PHONY: frontend build dev-frontend dev-backend dev-proxy dev-transcode lint clean jellyfin-web migrate-continuum-check verify-local-paths install-hooks migrate-create migrate-validate migrate-status migrate-up
GIT_COMMON_DIR := $(strip $(shell git rev-parse --git-common-dir 2>/dev/null))
MAIN_CHECKOUT_ROOT := $(if $(GIT_COMMON_DIR),$(abspath $(GIT_COMMON_DIR)/..))
@@ -15,7 +15,8 @@ else ifneq ($(wildcard $(SHARED_PLUGIN_SDK_DIR)),)
DEV_PLUGIN_SDK_DIR ?= $(SHARED_PLUGIN_SDK_DIR)
endif
JELLYFIN_WEB_OUTPUT_DIR ?= third_party/jellyfin-web
JELLYFIN_WEB_INSTALL_DIR ?= .local/compat/jellyfin-web
JELLYFIN_WEB_VERSION ?= 10.11.6
# Build version stamping: inject the git revision so the admin Build panel shows a
# version even when Go's VCS metadata isn't embedded (mirrors the Dockerfile ldflags).
@@ -82,9 +83,9 @@ install-hooks:
fi
git config core.hooksPath .githooks
# Fetch and build the pinned Jellyfin Web bundle
jellyfin-web-bundle:
JELLYFIN_WEB_OUTPUT_DIR=$(JELLYFIN_WEB_OUTPUT_DIR) scripts/fetch-jellyfin-web.sh
# Fetch and build the pinned Jellyfin Web component into a gitignored local cache.
jellyfin-web:
go run ./cmd/silo/ compat-web install --dir "$(JELLYFIN_WEB_INSTALL_DIR)" --version "$(JELLYFIN_WEB_VERSION)"
# Read-only preflight for Continuum Docker installs moving to Silo.
migrate-continuum-check:
+124 -170
View File
@@ -1,8 +1,17 @@
# Silo
A self-hosted media streaming server with a React frontend and Go backend. Supports direct play, remuxing, and hardware-accelerated transcoding. Compatible with Jellyfin/Emby clients (VidHub, Findroid) via a built-in compatibility layer.
Silo is a self-hosted media streaming server for your movies, shows, music, and books. Point it at your media folders and stream to your devices — at home or away — with direct play, remuxing, and hardware-accelerated transcoding handled automatically.
Join the community on [Discord](https://discord.gg/4RxuUQAEnW).
Join the community on [Discord](https://discord.gg/4RxuUQAEnW). If Silo is useful to you, consider [sponsoring the project](https://github.com/sponsors/quick104) — see [Supporting Silo](#supporting-silo).
## Highlights
- **Plays your media, your way** — direct play when the device supports it, remux or hardware-accelerated transcode (including NVENC) when it doesn't.
- **Web app included** — a full-featured web client and admin interface ship with the server.
- **Works with apps you already use** — optional Jellyfin/Emby-compatible API supports clients such as VidHub, Findroid, and Infuse.
- **Household profiles** — multiple profiles per account, with per-profile watch state and parental controls.
- **Plugin-driven metadata** — match and enrich your libraries with providers like TMDB and TVDB, installed as plugins.
- **Fast setup** — one `docker compose up -d` brings up the whole stack; everything else is configured in the admin UI.
## Deploy with Docker (recommended)
@@ -30,10 +39,33 @@ The easiest way to run Silo is with Docker Compose. The default stack assumes yo
docker compose up -d
```
This starts PostgreSQL, Redis, and the integrated Silo server. The app is available at `http://localhost:8090` and the Jellyfin-compatible endpoint at `http://localhost:8096`.
This starts PostgreSQL, Redis, and the integrated Silo server. The app is available at `http://localhost:8090`. Jellyfin-compatible app support is disabled until an administrator enables it in onboarding or admin settings.
If you already have PostgreSQL and Redis available, omit those bundled service examples from compose and point Silo at your existing `DATABASE_URL` and `REDIS_URL` instead.
### Optional NVIDIA/NVENC
GPU support is kept out of the default compose file so hosts without NVIDIA drivers work unchanged.
Install the NVIDIA Container Toolkit and use a Docker Compose version with GPU reservation support before enabling this override.
Use the optional override file when you want NVENC:
```sh
docker compose -f docker-compose.yml -f docker-compose.nvidia.yml up -d
```
If you want this controlled from `.env`, set `COMPOSE_FILE`:
```dotenv
COMPOSE_FILE=docker-compose.yml:docker-compose.nvidia.yml
NVIDIA_GPU_COUNT=1
```
Windows uses `;` instead of `:` between compose files.
Then `docker compose up -d` will include the NVIDIA override automatically.
4. **Configure through the admin UI**
Add libraries, users, metadata providers, and playback settings from the web interface.
@@ -82,157 +114,6 @@ Silo is externally stateful by default rather than fully stateless. Durable appl
Migrating an existing Continuum Docker install should be done with the preflight
helper and cutover guide in [docs/continuum-to-silo-docker-migration.md](docs/continuum-to-silo-docker-migration.md).
## Build from Source
### Prerequisites
- **Go** 1.24+
- **Bun** 1.0+
- **PostgreSQL** 18+
- **FFmpeg** (for transcoding support)
### Quick Start
1. **Start PostgreSQL** (skip if you already have one running)
```sh
docker compose up -d postgres redis
```
The main compose file still expects `MEDIA_ROOT` to be set even if you only want the bundled PostgreSQL and Redis services, so set that in `.env` first.
2. **Configure the database connection**
```sh
cp .env.example .env
```
Edit `.env` and set `DATABASE_URL` to point to your PostgreSQL instance.
3. **Build and run**
```sh
make build
./silo
```
The server starts at `http://localhost:8080` by default. All other settings are configured through the admin UI.
## Development
Local development remains intentionally separate from the deploy-oriented compose setup. Use [docker-compose.yml](docker-compose.yml) and the existing source-build workflow for local development.
```sh
# Run the frontend dev server (hot reload, proxies API to :8090)
make dev-frontend
# Run the Go backend
make dev-backend
```
If you are developing `Silo` and `silo-plugin-sdk` together, keep using the local [`go.work`](go.work) workspace. That workspace is a developer convenience only. CI and release builds run with `GOWORK=off`, so any new SDK helper used here must be pushed and tagged in `silo-plugin-sdk` before this repo can merge or release the change.
See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution expectations, merge request guidance, and the policy for AI-assisted submissions.
Plugin authors should start with [docs/architecture/plugin-development.md](docs/architecture/plugin-development.md), which covers the RPC plugin package format, generated proto workflow, SDK import paths, route and asset exposure, and auth or user-config integration points.
## Reporting Issues
If you are reporting a bug, install problem, or performance issue, start with the admin workflow and reproduction steps, not Claude/Codex analysis.
Please include:
- What you were trying to do
- Exact steps you took
- What you expected to happen
- What actually happened
- What exact action is slow or broken (`save`, `scan`, `browse`, `import`, `playback`, etc.)
- Whether it happens every time or only sometimes
- The library, media type, filter, setting, or value involved
- Version, branch, commit, and deployment details if you know them
- Screenshots, recordings, or log snippets if relevant
If you used Claude/Codex for debugging, put that under `Technical notes` at the end. Suspected files, SQL output, stack traces, and root-cause theories can be helpful, but only after the workflow and repro steps are clear.
Use this template:
```text
Goal:
Steps:
Expected:
Actual:
What is slow/broken:
Scope:
Version/branch:
Deployment:
Technical notes:
```
### Make Targets
| Target | Description |
|---|---|
| `make build` | Build frontend + Go binary |
| `make frontend` | Build frontend only |
| `make dev-frontend` | Vite dev server with HMR |
| `make dev-backend` | Run Go backend (integrated mode) |
| `make dev-proxy` | Run a standalone proxy node |
| `make dev-transcode` | Run a standalone transcode node |
| `make migrate-create NAME=add_thing` | Create a timestamped Goose SQL migration |
| `make migrate-validate` | Validate Goose migration files without touching a database |
| `make migrate-status` | Show Goose migration status using Silo's bootstrapping runner |
| `make migrate-up` | Apply pending Goose migrations using Silo's bootstrapping runner |
| `make clean` | Remove build artifacts |
### Database Migrations
PostgreSQL schema migrations are managed by Goose. Migration SQL files live in
`migrations/sql/` and use Goose annotations. Converted legacy migrations keep
their original numeric versions so existing `schema_versions` rows can bootstrap
cleanly into Goose without replaying old SQL. New migrations should be created
with timestamped filenames:
```sh
make migrate-create NAME=add_thing
make migrate-validate
```
Do not run `goose fix`; timestamped migrations are the repository policy because
they avoid version collisions across parallel PRs. The existing `001`-style
files are historical compatibility records, not the naming pattern for new work.
Runtime migrations are applied by the integrated/API server only. Proxy and
transcode modes never mutate schema.
For existing installs, use `make migrate-status` and `make migrate-up` rather
than invoking the Goose CLI directly; those targets copy legacy
`schema_versions` rows into `public.goose_db_version` under the migration lock
before reading or applying migrations. Set `ENV_FILE=path/to/.env` when the
database URL should be read from a non-default env file.
### Running Tests
```sh
# Go tests (uses testcontainers — Docker must be running)
go test ./...
# Frontend tests
cd web && bun test
```
### Linting
```sh
# Go
golangci-lint run
# Frontend
cd web && bun run lint
cd web && bun run format:check
```
## License
Silo is licensed under `AGPL-3.0-or-later`. See [LICENSE](LICENSE).
## Configuration
Silo requires only a `DATABASE_URL` when running from source or against external infrastructure. In the default Docker Compose path, the stack wires the database and Redis URLs for you. All other settings — libraries, metadata providers, transcoding, users — are managed through the admin UI after first launch.
@@ -246,7 +127,7 @@ Silo requires only a `DATABASE_URL` when running from source or against external
| `proxy` | Stream proxy node that connects to the shared deployment database and Redis |
| `transcode` | HLS transcode worker node that connects to the shared deployment database and Redis |
## PostgreSQL Auto-Tuning
### PostgreSQL Auto-Tuning
The default Docker Compose stack does not require a checked-in `postgresql.conf`.
It enables Silo's [pgtune](https://github.com/le0pard/pgtune)-style OLTP tuning
@@ -298,20 +179,93 @@ change PostgreSQL server settings. Settings already written with `ALTER SYSTEM`
remain in `postgresql.auto.conf`; reset those PostgreSQL parameters if you later
move fully to a custom `postgresql.conf`.
## Project Structure
## Build from Source
If you prefer running Silo without Docker:
1. **Install prerequisites**: Go 1.24+, Bun 1.0+, PostgreSQL 18+, and FFmpeg.
2. **Start PostgreSQL and Redis** (skip if you already have them running)
```sh
docker compose up -d postgres redis
```
The main compose file still expects `MEDIA_ROOT` to be set even if you only want the bundled PostgreSQL and Redis services, so set that in `.env` first.
3. **Configure the database connection**
```sh
cp .env.example .env
```
Edit `.env` and set `DATABASE_URL` to point to your PostgreSQL instance.
4. **Build and run**
```sh
make build
./silo
```
The server starts at `http://localhost:8080` by default. All other settings are configured through the admin UI.
## Reporting Issues
If you are reporting a bug, install problem, or performance issue, start with the admin workflow and reproduction steps, not Claude/Codex analysis.
Please include:
- What you were trying to do
- Exact steps you took
- What you expected to happen
- What actually happened
- What exact action is slow or broken (`save`, `scan`, `browse`, `import`, `playback`, etc.)
- Whether it happens every time or only sometimes
- The library, media type, filter, setting, or value involved
- Version, branch, commit, and deployment details if you know them
- Screenshots, recordings, or log snippets if relevant
If you used Claude/Codex for debugging, put that under `Technical notes` at the end. Suspected files, SQL output, stack traces, and root-cause theories can be helpful, but only after the workflow and repro steps are clear.
Use this template:
```text
Goal:
Steps:
Expected:
Actual:
What is slow/broken:
Scope:
Version/branch:
Deployment:
Technical notes:
```
cmd/silo/ Entry point
internal/
api/ HTTP router, handlers, middleware
auth/ JWT authentication and sessions
catalog/ Media item, episode, season repositories
config/ YAML + env var configuration
jellycompat/ Jellyfin/Emby protocol compatibility
metadata/ Plugin-driven metadata matching and enrichment
playback/ Direct play, remux, transcode session management
scanner/ Media file discovery and FFProbe
worker/ Background jobs (scan, match, reconcile)
web/ React + TypeScript frontend (Vite, Tailwind, shadcn/ui)
migrations/sql/ Goose-managed PostgreSQL schema migrations
```
## Contributing & Development
Silo is open source and contributions are welcome. See [DEVELOPMENT.md](DEVELOPMENT.md) for building from source in a dev workflow, running tests, database migrations, and project layout, and [CONTRIBUTING.md](CONTRIBUTING.md) for contribution expectations, merge request guidance, and the policy for AI-assisted submissions.
## Supporting Silo
Silo is an open-source hobby project, developed in spare time and funded out of pocket. If you'd like to support development, you can sponsor via [GitHub Sponsors](https://github.com/sponsors/quick104).
Donations go directly toward the costs of building and running the project:
- AI development tooling subscriptions (Claude, Codex) used to build and maintain Silo
- Push notification relay infrastructure
- Future development costs
Sponsoring is entirely optional — Silo is and will remain free and open source. Bug reports, contributions, and feedback are just as valuable.
## License & Trademarks
Silo's source code is licensed under the **GNU Affero General Public License
v3.0 or later** (`AGPL-3.0-or-later`) — see [LICENSE](LICENSE).
The **Silo name, logo, and wordmark are trademarks of Silo Media L.L.C.** and
are **not** covered by the AGPL. You're free to fork and redistribute the code,
but forks and redistributions must not use the Silo brand as their identity and
must remove or replace the brand assets. Publishing a Silo-branded app to an app
store requires written permission. See [TRADEMARK.md](TRADEMARK.md) for what's
permitted — including referential use like "compatible with Silo."
+5 -6
View File
@@ -5,11 +5,11 @@ material needed for Silo server builds.
## Jellyfin Web
`third_party/jellyfin-web/10.11.6/` contains a vendored Jellyfin Web build used
for Jellyfin compatibility surfaces. Upstream package notices generated by that
build are preserved next to the corresponding bundles as `*.LICENSE.txt` files.
Keep those notices with the vendored files when updating or redistributing this
directory.
Silo does not bundle Jellyfin Web in this repository or in the default runtime
image. Administrators can explicitly install Jellyfin Web as a separate
compatibility component with `silo compat-web install` or the admin settings UI.
The installer records the upstream source URL, tag, commit SHA, checksum,
license, and source/provenance metadata beside the installed assets.
## Collection Template Posters
@@ -17,4 +17,3 @@ directory.
poster artwork. The artwork is intended to use generic original scenes and
avoid copyrighted movie/show posters, recognizable actors, franchise
characters, provider logos, and readable in-image third-party branding.
+85
View File
@@ -0,0 +1,85 @@
# Silo Trademark & Brand Policy
Silo's source code is free and open source under the **GNU Affero General Public
License v3.0 or later** (`AGPL-3.0-or-later`). You are welcome to use, study,
modify, and redistribute it under those terms.
**That freedom applies to the code — not to the Silo name and brand.** The Silo
trademarks identify the official project and its official builds. This policy
explains how they may and may not be used. It exists for a simple reason:
anyone should be free to fork the software, while users can still trust that
something called "Silo" actually comes from us.
## Marks covered
The following are trademarks of **Silo Media L.L.C.** ("Silo Media"), whether or
not registered:
- the names **Silo** and **Silo Server**
- the **Silo logo** and **wordmark**
- Silo **product and component names**, including **SiloRemote**, **SiloMac**,
and other "Silo…" names
- confusingly similar variations of the above
The AGPL license over the source code **does not grant any license to these
marks.** This reservation is expressly permitted by AGPL-3.0 §7(e).
## Uses that are always fine — no permission needed
- **Referring to Silo truthfully** — "compatible with Silo," "a plugin for
Silo," "built on Silo," "a fork of Silo." Use the word to refer to *our*
project, not to brand *yours*.
- **Redistributing official, unmodified builds** under the Silo name, with all
notices kept intact.
- **Running, modifying, and self-hosting** Silo for yourself or your
organization.
- **Editorial and educational** use — articles, tutorials, talks, reviews.
## Uses that require our written permission
- Distributing a **modified version under the Silo name** (see *Forks* below).
- **Publishing an app to any app store** — Apple App Store, Google Play,
F-Droid, or similar — under the Silo name, logo, or a confusingly similar
identity.
- Using the marks in a **company, product, domain, or app name**, or in any way
that implies Silo Media operates or endorses your offering.
- **Merchandise** or other commercial use of the logo or wordmark.
## Forks must rebrand
You may fork Silo — that is your right under the AGPL. But a **distributed** fork
must not use the Silo name or logo as its identity. Pick your own name and logo,
remove the Silo brand assets (below), and you may state factually that your
project "is based on Silo" or "is a fork of Silo." This is the same model used by
Mozilla, Rust, and many other open-source projects: **open code, protected
identity.**
## Brand assets are not under the AGPL
The Silo logo, wordmark, and icon files are **© 2026 Silo Media L.L.C., all
rights reserved**, and are **not** licensed under the AGPL or any other
open-source license. Any redistribution or fork must **remove or replace** them.
See the `NOTICE` file in the brand-asset directories.
## Official sources
The only official Silo software comes from:
- the **Silo-Server** organization on GitHub — <https://github.com/Silo-Server>
- the official website — <https://siloserver.org>
- official app-store listings published by **Silo Media L.L.C.**
Any app-store listing, download, or "Silo" app that does not originate from the
above is **not** official and is **not** authorized to use the Silo marks.
## Requesting permission / reporting misuse
Email **trademark@siloserver.org** with the details. We're generally glad to
grant reasonable requests — the goal of this policy is only to prevent user
confusion and unauthorized "official-looking" releases.
---
*Silo and the Silo logo are trademarks of Silo Media L.L.C. The **™** symbol is
used with the Silo wordmark until federal registration issues, after which **®**
applies.*
+655 -120
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -23,6 +23,9 @@ func buildLiveSessionSync(s *playback.Session, reportingNode string) worker.Sess
PlayMethod: string(s.PlayMethod),
ReportingNode: reportingNode,
ClientIP: s.ClientIP,
ClientName: s.ClientName,
ClientVersion: s.ClientVersion,
ClientUserAgent: s.ClientUserAgent,
AudioTrackIndex: s.AudioTrackIndex,
TranscodeAudio: s.TranscodeAudio,
StreamBitrateKbps: s.StreamBitrateKbps,
+181
View File
@@ -0,0 +1,181 @@
# Development stack:
# docker compose -f docker-compose.dev.yml up --build
#
# Vite serves the UI at http://localhost:${PORT:-8090} and proxies API
# requests to the Air-reloaded Go backend on the compose network.
name: silo-dev
services:
postgres:
image: pgvector/pgvector:pg18
restart: unless-stopped
shm_size: ${POSTGRES_SHM_SIZE:-2gb}
environment:
POSTGRES_USER: ${POSTGRES_USER:-silo}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-silo}
POSTGRES_DB: ${POSTGRES_DB:-silo}
ports:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- silo-dev-postgres:/var/lib/postgresql
command: ["postgres", "-c", "listen_addresses=*"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-silo}"]
interval: 5s
timeout: 3s
retries: 5
redis:
image: redis:alpine
restart: unless-stopped
ports:
- "${REDIS_PORT:-6379}:6379"
volumes:
- silo-dev-redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
silo:
build:
context: .
dockerfile_inline: |
FROM node:22-slim AS node_runtime
FROM golang:1.26
ENV CGO_ENABLED=1
ENV GOPROXY=direct
ENV GOPRIVATE=github.com/Silo-Server/*
ENV GONOSUMDB=github.com/Silo-Server/*
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
ffmpeg \
git \
libvips-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
COPY --from=node_runtime /usr/local/bin/node /usr/local/bin/node
COPY --from=node_runtime /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm
RUN ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
RUN go install github.com/air-verse/air@latest
WORKDIR /app
restart: unless-stopped
working_dir: /app
command:
- sh
- -c
- |
mkdir -p tmp web/dist
if [ ! -f web/dist/index.html ]; then
printf '%s\n' '<!doctype html><html><body><div id="root"></div></body></html>' > web/dist/index.html
fi
cat >/tmp/air.docker.toml <<'AIR'
root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/silo ./cmd/silo/"
bin = "./tmp/silo"
args_bin = []
include_ext = ["go", "sql", "json"]
include_dir = ["cmd", "internal", "migrations"]
exclude_dir = ["tmp", "vendor", "third_party", "docs", "scripts", "web"]
exclude_regex = ["_test\\.go$"]
delay = 500
stop_on_error = true
kill_delay = 500
send_interrupt = true
[log]
time = true
[misc]
clean_on_exit = true
AIR
air -c /tmp/air.docker.toml
environment:
MODE: integrated
PORT: "8080"
DATABASE_URL: postgres://${POSTGRES_USER:-silo}:${POSTGRES_PASSWORD:-silo}@postgres:5432/${POSTGRES_DB:-silo}?sslmode=disable
REDIS_URL: redis://redis:6379
SILO_PLUGIN_CACHE_DIR: /var/lib/silo/plugins
POSTGRES_TUNE: "off"
ports:
- "${BACKEND_PORT:-18080}:8080"
- "${JF_PORT:-8096}:8096"
- "${ABS_PORT:-13378}:13378"
volumes:
- .:/app
- silo-dev-air-tmp:/app/tmp
- silo-dev-go-build:/root/.cache/go-build
- silo-dev-go-mod:/go/pkg/mod
- silo-dev-plugins:/var/lib/silo/plugins
- silo-dev-compat:/var/lib/silo/compat
- silo-dev-transcode:/tmp/silo-transcode
- silo-dev-audiobook-covers:/var/lib/silo/audiobook-covers
- ${MEDIA_ROOT:-/tmp/silo-dev-media}:${MEDIA_CONTAINER_ROOT:-/mnt/media}:ro
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/api/v1/health"]
interval: 5s
timeout: 3s
retries: 30
start_period: 10s
env_file:
- path: .env
required: false
web:
build:
context: .
dockerfile_inline: |
FROM node:22-slim
RUN corepack enable && corepack prepare pnpm@10.32.1 --activate
WORKDIR /app/web
restart: unless-stopped
working_dir: /app/web
command: sh -c "pnpm install --frozen-lockfile --store-dir /pnpm/store && pnpm run dev -- --host 0.0.0.0"
environment:
VITE_API_PROXY_TARGET: http://silo:8080
VITE_HMR_CLIENT_PORT: "${PORT:-8090}"
PNPM_STORE_DIR: /pnpm/store
CI: "true"
ports:
- "${PORT:-8090}:5173"
volumes:
- .:/app
- silo-dev-web-node-modules:/app/web/node_modules
- silo-dev-pnpm-store:/pnpm/store
depends_on:
silo:
condition: service_healthy
volumes:
silo-dev-air-tmp:
silo-dev-audiobook-covers:
silo-dev-compat:
silo-dev-go-build:
silo-dev-go-mod:
silo-dev-plugins:
silo-dev-pnpm-store:
silo-dev-postgres:
silo-dev-redis:
silo-dev-transcode:
silo-dev-web-node-modules:
+20
View File
@@ -0,0 +1,20 @@
services:
silo:
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: ${NVIDIA_GPU_COUNT:-1}
capabilities: [gpu]
# Optional distributed-mode example. Most single-host installs should
# just use the integrated service above.
# silo-transcode:
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: ${NVIDIA_GPU_COUNT:-1}
# capabilities: [gpu]
+31 -1
View File
@@ -31,11 +31,36 @@ services:
timeout: 3s
retries: 5
meilisearch:
image: ${MEILISEARCH_IMAGE:-getmeili/meilisearch:latest}
restart: unless-stopped
profiles: [search]
entrypoint:
- /bin/sh
- -euc
- |
if [ -z "$${MEILI_MASTER_KEY:-}" ]; then
echo "MEILI_MASTER_KEY is required when enabling the search profile" >&2
exit 1
fi
exec /bin/meilisearch
environment:
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:-}
ports:
- "${MEILISEARCH_HOST:-127.0.0.1}:${MEILISEARCH_PORT:-7700}:7700"
volumes:
- ${SILO_DATA_ROOT:-/opt/silo}/meilisearch:/meili_data
silo:
image: ${SILO_IMAGE:-ghcr.io/silo-server/silo-server:latest}
restart: unless-stopped
environment:
MODE: integrated
# Master key for at-rest credential encryption. Required — the server
# refuses to start without it. Generate with: openssl rand -base64 48
# Back it up SEPARATELY from your database dumps; losing it makes encrypted
# secrets unrecoverable. See docs/architecture/secret-encryption.md.
SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env — generate one with openssl rand -base64 48}
DATABASE_URL: postgres://${POSTGRES_USER:-silo}:${POSTGRES_PASSWORD:-silo}@postgres:5432/${POSTGRES_DB:-silo}?sslmode=disable
REDIS_URL: redis://redis:6379
SILO_PLUGIN_CACHE_DIR: /var/lib/silo/plugins
@@ -46,8 +71,9 @@ services:
- "${ABS_PORT:-13378}:13378"
volumes:
- ${MEDIA_ROOT:?Set MEDIA_ROOT in .env to the host media path}:${MEDIA_CONTAINER_ROOT:-/mnt/media}:ro
- ${MEDIA_BOOKS_ROOT:-${MEDIA_ROOT}}:${MEDIA_BOOKS_CONTAINER_ROOT:-${MEDIA_CONTAINER_ROOT:-/mnt/media}}:ro
- ${MEDIA_BOOKS_ROOT:-${MEDIA_ROOT}}:${MEDIA_BOOKS_CONTAINER_ROOT:-${MEDIA_CONTAINER_ROOT:-/mnt/media}/books}:ro
- ${SILO_DATA_ROOT:-/opt/silo}/plugins:/var/lib/silo/plugins
- ${SILO_DATA_ROOT:-/opt/silo}/compat:/var/lib/silo/compat
- ${SILO_DATA_ROOT:-/opt/silo}/transcode:/tmp/silo-transcode
- ${SILO_DATA_ROOT:-/opt/silo}/catalog-seeds:/catalog-seeds:ro
- ${SILO_DATA_ROOT:-/opt/silo}/audiobook-covers:/var/lib/silo/audiobook-covers
@@ -71,6 +97,8 @@ services:
# profiles: [proxy]
# environment:
# MODE: proxy
# # Must be the SAME SECRET_KEY as the primary node (shared encrypted data).
# SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env — generate one with openssl rand -base64 48}
# DATABASE_URL: postgres://${POSTGRES_USER:-silo}:${POSTGRES_PASSWORD:-silo}@postgres:5432/${POSTGRES_DB:-silo}?sslmode=disable
# REDIS_URL: redis://redis:6379
# PORT: "8080"
@@ -95,6 +123,8 @@ services:
# profiles: [transcode]
# environment:
# MODE: transcode
# # Must be the SAME SECRET_KEY as the primary node (shared encrypted data).
# SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env — generate one with openssl rand -base64 48}
# DATABASE_URL: postgres://${POSTGRES_USER:-silo}:${POSTGRES_PASSWORD:-silo}@postgres:5432/${POSTGRES_DB:-silo}?sslmode=disable
# REDIS_URL: redis://redis:6379
# SILO_PLUGIN_CACHE_DIR: /var/lib/silo/plugins
@@ -0,0 +1,376 @@
# Deterministic, Cross-Server `content_id`
**Status:** Implemented (branch `feat/deterministic-content-id`)
**Scope:** catalog / scanner / metadata
**Key code:** `internal/contentid`, `internal/metadata/service.go`,
`internal/catalog/history_source.go`,
`migrations/sql/20260612130000_deterministic_content_id.sql`,
`migrations/sql/20260614120000_content_id_online_reid.sql`
This is the dev-facing reference for *why* `content_id` is what it is and *how*
it works. It merges the original design spec, the implementation notes, and the
PR write-up into one place.
---
## 1. The problem
`content_id` is the anchor almost everything hangs off: artwork, metadata, watch
history, progress, favorites, ratings, collections, credits. Historically it was
a **Sonyflake** — a locally generated, time-ordered random number minted at scan
time.
That means the *same* movie matched on two servers (or re-imported into a fresh
instance) gets a **different** id each time. Consequence: two servers holding the
identical title share *none* of that state. Run more than one Silo, or try to
sync a profile's history between servers, and the same film shows up as a
stranger on the other side — no resume point, no watched flags, no ratings. There
was no stable identity to build any cross-server feature on.
We want: **the same logical title produces the same `content_id` on every server,
every time**, derived from a globally stable anchor (the provider IDs already in
the library), and **stable across file renames/re-encodes** so linked state
survives.
---
## 2. The decision
Make `content_id` a **deterministic, provider-derived, structured natural key**,
stored as **`text COLLATE "C"`**:
| Item | `content_id` |
| ---- | ------------ |
| Movie | `movie-tmdb-228064` (fallback `movie-imdb-tt2413338`) |
| Series | `series-tvdb-296762` |
| Season | `season-tvdb-296762-1` |
| Episode | `episode-tvdb-296762-1-5` |
| Unmatched / local | `local-<128-bit path hash, hex>` |
Why this shape:
- **Collision-free with no hashing.** Provider IDs are unique by construction, so
this is a natural key, not a surrogate — there is no "how many bits" question
and no birthday bound.
- **No column-type migration.** It stays `text`, so the wide soft-reference graph
keeps working; only the *values* are remapped, not the type.
- **Debuggable.** A row's identity is legible in logs and queries, and the
scanner's match step becomes a direct PK lookup ("do I already have
`movie-tmdb-228064`?") instead of a join through `media_item_provider_ids`.
- **Stable** across renames/re-encodes — a movie keeps its tmdb id.
### Why `content_id`, not a file-level id
Two distinct identities at different granularities, not to be conflated:
- **`content_id`** — the *logical* item (movie/series/season/episode). One id,
*many* files (1080p + 4K + cuts; a series owns thousands of episode files).
- **`media_files.id`** — one *physical* file; a per-server serial that only
answers "which file on disk."
`content_id` is the high-value target (35 FKs + ~25 unconstrained soft references
point at it). Its stability requirement is the *opposite* of a file id's: a file
id *should* change on rename; a `content_id` must *not*, or every server would
drop a title's watch history on a routine rename. That's why it's anchored on
rename-invariant provider IDs, never a path or file hash. File-level dedup is a
separate, deferred concern.
---
## 3. How the id is derived
### Format (SchemeVersion 1)
```text
movie-<provider>-<id> movie-tmdb-228064
series-<provider>-<id> series-tvdb-296762
season-<provider>-<seriesId>-<n> season-tvdb-296762-1
episode-<provider>-<seriesId>-<s>-<e> episode-tvdb-296762-1-5
local-<hex112> local-<sha256(path)[:14]>
```
The component separator is `-`, an [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986#section-2.3)
*unreserved* character. Every component is `[a-z0-9]+` (or `tt` + digits for
IMDb), so `-` is an unambiguous delimiter, and because it never needs
percent-encoding the id doubles as its own tidy URL path segment
(`/item/series-tvdb-296762`) — identical to what is stored in the database, so
there is no encode/decode boundary and an operator can grep the value straight
out of a URL or log. (`:` would be legal in a path too, but `encodeURIComponent`
escapes it to `%3A`; an unreserved separator sidesteps that entirely.)
- The leading entity-type token domain-separates namespaces, so a movie and an
episode can never alias on a coincidental number.
- IDs are normalized: lowercase scheme tokens, whitespace trimmed, IMDb keeps its
`tt` prefix verbatim.
### Frozen provider precedence
Each item anchors on **one** canonical provider, chosen by fixed precedence so
two servers that see the same tags pick the same anchor:
- **Movies:** `tmdb → imdb → tvdb`
- **Series / Season / Episode:** `tvdb → tmdb → imdb` (TVDB is episode-canonical)
Precedence **and** exact string format are frozen behind a code constant
(`contentid.SchemeVersion = 1`), *not* a DB column — any scheme change forces a
full remap that normalizes every row at once, so there is never a mixed-version
population. Staleness is recoverable by recomputing the expected id from the
provider columns and comparing.
### The load-bearing invariant: the show falls out of the episode id
Season/episode ids compose from the **series anchor + season/episode numbers**
(both present in filenames and universally available), *not* their own provider
episode IDs (often missing). Because `episode-<provider>-<seriesId>-<s>-<e>`
embeds the series anchor, the series id is a pure **string transform** of the
episode id — no catalog lookup:
```text
episode-tvdb-296762-1-5 → series-tvdb-296762
movie-tmdb-228064 → movie-tmdb-228064 (movies/series: unchanged)
```
This is relied on by the hot watch-history query (§5). The format must never
break it. (`contentid.SeriesIDFromContentID` is the canonical transform.)
### Where the anchor comes from
For tagged libraries the IDs are in the folder/file name
(`{imdb-tt2413338} {tmdb-228064}`), so both servers derive the same id **from the
path string at scan time, with no API call**. The denormalized
`media_items.{tmdb,imdb,tvdb}_id` columns and the `media_item_provider_ids` link
table are the other sources. `content_id` is *derived from* the link table, not a
replacement — keep the full provider-ID set indexed.
---
## 4. What was built
| Area | File(s) | Summary |
| ---- | ------- | ------- |
| Derivation core | `internal/contentid/contentid.go` | `ForMovie`/`ForSeries`/`ForSeason`/`ForEpisode`/`ForLocal`, `SeriesIDFromContentID`, `IsProviderAnchored`/`IsLocal`, `SchemeVersion`, frozen precedence, normalization. |
| Generation wiring | `internal/metadata/service.go` | `deriveLogicalContentID`/`deriveSeasonContentID`/`deriveEpisodeContentID` replace `idgen.NextID()` at every mint site (skeleton, provider-match, explicit/implicit seasons, episodes, scanner fallback). |
| Re-ID on match | `internal/metadata/canonicalize.go` | Promotes a `local-` placeholder to its deterministic id at first confirmed match. |
| Hot query | `internal/catalog/history_source.go` | Watch-history `display_id` resolves the show via the string transform for anchored episodes; skips the `episodes_pkey` probe. |
| Value-remap migration | `migrations/sql/20260612130000_deterministic_content_id.sql` | Collision-safe Sonyflake → structured remap across the whole reference graph + `COLLATE "C"`, with rollback. |
| Online re-ID migration | `migrations/sql/20260614120000_content_id_online_reid.sql` | Adds `silo_rename_content_id` + `ON UPDATE CASCADE` to the content-id FK family. |
### Generation: determinism at scan time
The scanner already parses provider tags from names, so `createOrFindSkeleton`
derives `movie-tmdb-…`/`series-tvdb-…` immediately — two servers seeing the same
tags mint the same id with no provider API call. This is the primary target
(Radarr/Sonarr/Plex-tagged libraries). Untagged new items get a path-derived
`local-` id keyed on the **file path** (not the folder), so distinct pre-merge
skeletons in a shared folder stay separate while being stable across rescans.
Same-server convergence is preserved by the existing dedup
(`findExistingByProviderIDs`) plus `UpsertTx`'s
`ON CONFLICT (content_id) DO UPDATE` backstop.
Non-movie/series items (audiobook/ebook/podcast) and anchorless items **stay on
Sonyflake** — there's no stable cross-server anchor to derive from, so changing
them gains nothing.
### Re-ID on match: untagged items converge later
A tagged file is born with the right id; an *untagged* file gets a `local-` id
and only learns its provider IDs later when the match worker confirms a result. A
single gate in `mergeAndPersist` (`canonicalizeLocalContentID`) promotes the
placeholder at first confirmed match:
```text
SCAN a new file ← unchanged, still no network call
▼
createOrFindSkeleton ──► tag? YES ► movie-tmdb-123 NO ► local-<hash>
▼
(later) match worker → mergeAndPersist → canonicalizeLocalContentID
│ Is the id still local- AND do we now have provider IDs?
│ no → do nothing
│ yes → Does a row already sit at movie-tmdb-123?
│ yes → MERGE this row into it (rebindItemToExistingItem)
│ no → RENAME local-<hash> → movie-tmdb-123
▼ └─ DB auto-moves child rows (ON UPDATE CASCADE)
rest of mergeAndPersist runs with the corrected id
```
The guard is a single `IsLocal` prefix check, so tagged content and all refreshes
pay nothing; the promotion is self-healing (a partial run re-runs on the next
match). The rename primitive `silo_rename_content_id` reuses the migration's
catalog-driven column enumeration so the two stay in lockstep.
`ON UPDATE CASCADE` only fires when a `content_id` actually changes (essentially
never outside this promotion), so there is no steady-state cost.
### The migration
The column **type does not change** (`text` → `text COLLATE "C"`); the **values**
are remapped via add-then-swap, not in-place PK mutation. It dynamically
enumerates the reference graph (the three PKs, every FK child from
`pg_constraint`, and soft-reference columns by name — **65 columns** on the real
schema), drops/recreates FKs and triggers, and retains a `content_id_migration_map`
audit table for rollback.
**Collisions are never merged.** If two items derive to the same key, or a key is
already taken, those rows keep their Sonyflake id and are flagged `collision` in
the map — the migration can never violate a PK or silently drop a row. Collision
status **cascades** from a series to its seasons/episodes (otherwise a uniquely
numbered episode under a colliding series would get an `episode-…` key whose
embedded series anchor points at a non-existent row).
`COLLATE "C"` is applied to every referencing column in the same migration so
join keys match on both type and collation.
---
## 5. Why `COLLATE "C"` is mandatory
Provider-derived IDs are pure ASCII, but `content_id` inherits the DB default
`en_US.utf8` collation. The design **regresses without `COLLATE "C"`**:
- For **equality / hash joins** (the watch-history join), `en_US.utf8` is
deterministic so PostgreSQL already compares raw bytes — collation is
irrelevant.
- For **ordering** comparisons (B-tree descent on PK probes, `ORDER BY`, range
scans), `en_US.utf8` invokes libc `strcoll` while `C` is a plain `memcmp`.
- Structured keys share long prefixes (`episode-tvdb-296762-…`), which makes
`strcoll` *worse* than on 18-digit Sonyflake strings — so a structured key
under `en_US.utf8` is *slower* than the status quo. Under `C` it's ~3–5×
faster.
So `COLLATE "C"` is load-bearing, not a free upgrade.
### The hot query
The watch-history page query (`history_source.go`) resolves each history row's
episode key to its series, then inner-joins `media_items`, deduped over the
profile's *entire* history. Two facts:
- `DISTINCT ON` can't short-circuit on `LIMIT` — cost is **O(history size)** with
PK probes per row, not O(page).
- The structured id lets us **delete the `LEFT JOIN episodes`** that existed only
to fetch `series_id`: for anchored episode ids the show is a string transform.
The join is null-poisoned for anchored ids (`= NULL` is an unsatisfiable b-tree
scan key) so the planner never descends `episodes_pkey` for them. Legacy/`local-`
ids still fall through the join.
The remaining `JOIN media_items` stays — it's for access/library filtering, not
the show id. Reducing full-history scans to O(page) for the heaviest profiles is
follow-up work (a per-`(profile, display_id)` summary table), not part of this
change.
---
## 6. Performance — measured at production scale
Measured on an isolated, prod-tuned PostgreSQL 18 instance (`shared_buffers=4GB`)
loaded with an **exact-cardinality copy of production**: 184k items, 1.93M
episodes, 775k watch-history rows / 1,622 profiles. "Before" reproduces
production today (Sonyflake, `en_US.utf8`); "after" applies the structured-key
remap + `COLLATE "C"`. **Every metric improves.**
**`content_id` index-probe cost** — 300k forced point lookups over 1.9M keys:
The last three columns are *why* the faster schemes were rejected: speed alone
isn't the goal — the scheme has to carry the properties the whole feature exists
for. ✓ = has the property, ✗ = lacks it.
| Key scheme | Index size | µs / probe | vs today | Cross-server deterministic | Zero-join show transform | Human-readable |
| ---------- | ---------- | ---------- | -------- | :------------------------: | :----------------------: | :------------: |
| Sonyflake text, `en_US.utf8` *(today)* | 74 MB | 5.05 | 1.00× | ✗ | ✗ | ✗ |
| 128-bit hash `bytea(16)` | 74 MB | 1.80 | 2.81× | ✓ | ✗ | ✗ |
| `bigint` surrogate | 41 MB | 1.23 | 4.11× | ✗ | ✗ | ✗ |
| **Structured + `COLLATE "C"` (this PR)** | 84 MB | **1.86** | **2.71×** | ✓ | ✓ | ✓ |
The structured key lands within 3% of a 128-bit hash and is the **only all-✓
row**. The two faster schemes each give up something load-bearing:
- **128-bit hash** is cross-server deterministic (it hashes the same natural
key), but an opaque digest can't be string-transformed, so episode→series
needs a catalog join (no zero-join hot path), and it isn't legible in logs,
URLs, or `psql`.
- **`bigint` surrogate** is just the status quo's narrow integer: fast because
it's 8 bytes, but per-server (not reproducible elsewhere), no embedded anchor
to transform, and not meaningful to a human.
Index grows ~14% (84 vs 74 MB), immaterial.
**Real history-page query** — heaviest profile (25,384 rows):
| State | Latency | `episodes_pkey` probes | Speedup |
| ----- | ------- | ---------------------- | ------- |
| Before | 385 ms | 25,348 | 1.00× |
| **This PR** | **150 ms** | **2,775** | **2.57×** |
**Concurrency** (`pgbench`, 150 real profiles): at 100 concurrent users,
**311 ms / 321 tps → 181 ms / 551 tps** — 1.7× throughput, 42% lower tail
latency. Comparison cost is pure CPU, so cheaper `COLLATE "C"` comparisons raise
the throughput ceiling under load.
*Caveat: warmed (dataset fits in `shared_buffers`); the before/after **ratios**
are the robust result, not absolute tps.*
---
## 7. Known limitations / follow-ups
- **Untagged series children.** A series that accumulated season/episode rows
*before* it matched keeps those children on Sonyflake until a follow-up
`recomposeSeriesChildIDs` pass re-derives them. Rare at first match (children
usually don't exist yet); not yet wired.
- **Cross-provider reconciliation.** A series tagged tmdb-only on one server and
tvdb-only on another still diverges — both paths derive from the denormalized
provider columns. Proper fix: consult `media_item_provider_ids` before walking
the precedence list.
- **O(history) query shape.** The watch-history `DISTINCT ON` over full history is
the real liability at concurrency; the string transform removes a probe but not
the full scan. The O(page) summary table is the fix, deferred.
- **Migration at scale.** Runs in one transaction holding `AccessExclusive` locks
for the COLLATE rewrite + remap — run off-peak; very large installs (10–50M
rows) should use the batched procedure noted in the migration header.
- **`local-` uses the absolute path,** so a same-server remount re-IDs local
items. Acceptable for the best-effort local namespace. The hash is 112 bits
(`sha256(path)[:14]`) so a `local-` id packs losslessly into a compat UUID
(see below).
- **Client-visible id churn.** `jellycompat` encodes `content_id` into the
client-facing UUID, so item ids seen by Android/Apple/Jellyfin clients change at
migration (caches/resume keyed on item id reset once). **silo-android** and
**silo-apple** should be made aware; server-side state is fully remapped. The
encoding is *reversible* — a structured or local `content_id` is bit-packed
into the UUID (`contentid.Pack`/`Unpack`), not hashed into a lookup table — so
a compat item id decodes by pure computation and is stable across server
restarts and across instances. Only arbitrary names (genres, studios) and the
rare `content_id` too large to pack still use the in-memory hash map.
---
## 8. Prior art
- **Plex:** logical identity is a provider-namespaced GUID (`plex://movie/…`,
`com.plexapp.agents.imdb://tt…`) — exactly this shape. (Its per-server integer
`ratingKey` is the non-portable counterexample we avoid.)
- **Jellyfin / Emby:** store namespaced provider IDs and match on them; item GUIDs
are deterministic. Same principle — anchor identity on the provider, not a
per-server sequence.
---
## Appendix — alternatives rejected
Retained so the decision isn't re-litigated:
- **Keep Sonyflake.** Non-deterministic per server — the entire problem.
- **Hash provider tuple → `uuid` (128-bit).** Collision resistance is unnecessary
(input is already unique), the id becomes opaque, and it forces a `text → uuid`
migration across 35 FKs + ~25 soft refs whose failure mode is a seq-scan cliff
(miss one column → `uuid = text` per-row cast → index disabled). Pays a large
migration cost for an ~8-byte width win that only matters at a 50M-row ceiling.
- **Hash provider tuple → `text` hex.** Opaque like the uuid, large like the
natural key — no reason to pick it over the legible structured key.
- **Path or content hash.** A title spans many files at many paths (no single path
to hash), and the hash changes on rename — the opposite of the stability
requirement. Only appropriate for *file-level* identity.
- **Pack provider + numeric id into `bigint`.** Variable-width provider numbers
and S/E packing make the encoding brittle; not worth it over a legible key.
- **File-level deterministic id (`media_files`).** Deferred, not rejected — lower
value (no metadata/watch state hangs off it), with open problems (server-local
mount anchors, same-file-in-two-libraries). A complement, out of scope here.
+49
View File
@@ -0,0 +1,49 @@
# Outbound Email (`internal/mail`)
**Status:** Implemented 2026-06-11
Silo's shared outbound email facility. It is deliberately feature-agnostic: any
feature that sends mail (notification emails, account flows, invites) composes
a `mail.Message` and hands it to the shared `mail.Sender`, so SMTP
configuration, security policy, and diagnostics live in exactly one place.
## Abstraction
```go
type Sender interface {
Enabled(ctx context.Context) bool
Send(ctx context.Context, msg Message) error
}
```
`Message` carries recipients, subject, and text and/or HTML bodies (both set →
multipart/alternative). `Send` returns `mail.ErrNotConfigured` when email is
disabled or incomplete, so features treat email as an optional transport and
degrade gracefully. The SMTP implementation (`mail.NewSMTPSender`) is backed by
`github.com/wneessen/go-mail`.
## Configuration
Live server settings (no restart required; read on every send — volume is
low):
| Key | Default | Notes |
|---|---|---|
| `email.enabled` | `false` | master switch |
| `email.smtp_host` | — | required |
| `email.smtp_port` | `587` | |
| `email.smtp_security` | `starttls` | `starttls` \| `tls` (implicit, port 465) \| `none` |
| `email.smtp_username` | — | empty = no auth |
| `email.smtp_password` | — | encrypted at rest (`SensitiveSettingKeys`) |
| `email.from_address` | — | required |
| `email.from_name` | `Silo` | |
Admin UI: Admin Settings → Connections → Email, including a synchronous test
send (`POST /api/v1/admin/email/test`).
## Adding a consumer
Construct messages in the feature package and send through a `mail.Sender`
dependency. Do not read `email.*` settings from feature code, and check
`Enabled` (or branch on `ErrNotConfigured`) rather than treating a missing
SMTP configuration as an error.
@@ -0,0 +1,787 @@
# Restart-resilient playback (TR-lease)
How Silo keeps playback alive across a server restart, reconnect, or partial
stream — and how the design arrived at **TR-lease** (token-carried
reconstruction + a server-side session-deny marker for revocation).
> **Status: the revocation half is deferred to a future PR.** Only the
> token-carried **reconstruction** core shipped in this PR. The **session-deny /
> stream-revocation** mechanism described throughout (the `silo:streamauth:<sid>`
> deny marker, the proxy's `Allowed()` enforcement, and the admin
> Stop/Terminate deny write) is **not present in the current implementation**.
> Admin Terminate and user Stop tear down the live in-memory session and the
> ffmpeg producer, but they do **not** prevent a still-valid stream token from
> reconstructing the session until its 24h TTL expires. Sections describing that
> mechanism are kept for design continuity and are individually flagged as
> deferred below.
This document is the consolidated design record. It folds together four earlier
working notes so a future engineer can see the full evolution, the options
weighed, the issues raised across review rounds, the variables traded off, and
why the final shape was chosen. The superseded notes were:
- *unified-playback-reconstruct* — the reconstruct core (PR #174), "RC".
- *token-carried-playback-reconstruct* — the storage evolution, "TR".
- *async-recipe-card-playback* — a monitoring-preserving middle path, "RC-async".
- *playback-reconstruct-options-comparison* — the goals/options matrix.
Paths are repository-relative; assume the repository root is the cwd.
---
## 1. The problem
Before this work, a missing in-memory playback session was a `404`. A server
restart (deploy, crash, OOM) wiped the in-memory session map, so every in-flight
stream died: the client's next manifest/segment/byte-range request hit a session
that no longer existed and playback stopped hard. The same gap appeared on any
front-end that had never held the session (horizontal scale) and on a dedicated
transcode node reboot.
Core priorities for the fix (from the repo guidelines): **performance first,
reliability first, predictable behavior under load and during failures**. The
hot path (per-segment serve) must stay off central/Postgres; bytes must stay
offloaded to nodes; correctness and robustness win over convenience.
---
## 2. The core insight: reconstruct, don't rehydrate
A missing session is a **reconstruct trigger, not a 404**. The client always
re-requests the next chunk after an outage, and its request already states where
it is (an HTTP `Range`, a `?seek=`, or a `seg_NNNNN` number). So the server
rebuilds the session just-in-time from two things:
1. a small **durable descriptor** (identity, ownership, and the byte-affecting
encode parameters — the "recipe"), and
2. the **position the client supplies** on the triggering request.
Nothing live is serialized (no ffmpeg process, context, channels, or log sink).
This is the contribution of PR #174 ("RC"), which also unified the native and
Jellyfin-compat paths behind one `playback.TranscodeManager` so the
card-lifetime rules, the reconstruct concurrency cap, and the node-affinity rule
live in exactly one place. The reconstruct skeleton —
`LoadOrReconstructSession`, `ReconstructSession` / `ReconstructTranscode`, the
single-flight + `reconstructSem` cap that paces the post-restart thundering
herd, `RegisterReconstructed`, the `CloseProcess`/`Close` split — is the
foundation every later option reused. **It was kept; only the descriptor storage
and the revocation model evolved.**
The six routes that must survive a restart:
| # | Route | Tier | How it is rebuilt |
|---|-------|:----:|-------------------|
| 1 | native direct | 1 | client `Range` re-serve; descriptor = identity |
| 2 | native remux | 1 | re-spawn ffmpeg at `?seek`; descriptor = id + audio |
| 3 | native transcode | 2 | rebuild `Session` + ffmpeg seeked to the requested segment; descriptor = full encode opts |
| 4 | jellycompat direct | 1 | compat store + `Range` re-serve |
| 5 | jellycompat remux | 1 | compat store + `?seek` |
| 6 | jellycompat transcode | 2 | compat store + shared-manager reconstruct |
Tier 1 = stateless re-serve (client re-supplies position). Tier 2 = rebuild
ffmpeg. Routes 4–6 always retain a durable central compat store
(`jellycompat_playback_sessions`): third-party Jellyfin clients cannot
round-trip a native descriptor, so the compat layer keeps the full
`PlaybackSession` (route + media source resolved before native reconstruct
runs).
---
## 3. The two axes the options differ on
Every option is a point on a 2-D grid: **where the descriptor lives** (vertical)
× **how revocation is bounded** (horizontal).
```text
AUTHORITY / REVOCATION MODEL ----------------------->
re-resolve at long token short TTL +
reconstruct (24h, expire) refresh checkpoint
D central +----------------+----------------+----------------+
E Postgres row | RC (#174) | (n/a) | RC-async |
S +----------------+----------------+----------------+
C client/node- | (n/a) | TR-24h | TR + re-mint |
R carried token | | | |
I +----------------+----------------+----------------+
P
T top row = descriptor stays central (node serves bytes but cannot
| self-reconstruct → node-restart gap)
| bottom = recipe travels to the node (node self-sufficient, no central record)
```
**TR-lease is a fourth point off this grid:** bottom row (token-carried, 24h),
but the revocation column is replaced by a *server-side deny marker* — on an
admin kill, central would write a per-session deny to Redis that the node
enforces by withholding bytes. Revocation is decoupled from the token TTL and
needs no client refresh. (An earlier iteration polled and re-resolved every
stream on a timer; that was cut to the event-driven session-deny — see §7 and
the §8 design note.)
> **Status: deferred to a future PR.** The deny-marker column described above is
> the design target, not the current shipped behavior. As shipped, the
> token-carried row offers no revocation tighter than the 24h token TTL on the
> node path; admin Terminate and user Stop only tear down the live session and
> ffmpeg producer.
---
## 4. Goals weighed
| ID | Goal |
|----|------|
| G1 | Hot path (per-segment serve) stays off central/Postgres |
| G2 | Bytes offloaded to nodes (served from disk, signature-verified, never central) |
| G3 | Restart resiliency across all 6 routes |
| G4 | Two-factor ownership (auth caller + ownership rebind; refuse `userID==0`/mismatch) |
| G5 | Revocation latency (how fast a banned user / pulled access stops playing) |
| G6 | Single-box: zero external store required for reconstruction |
| G7 | Restart runway (descriptor stays valid longer than the outage) |
| G8 | Multi-front-end / verify-anywhere (no integrated-transcode split-brain) |
| G9 | Observability — durable, identity-rich active-stream record |
| G10 | Low operational surface (tables, migrations, stores, endpoints) |
| G11 | Node-restart recovery |
| G12 | No new client coordination required |
| G13 | Small signing-key blast radius |
| G14 | Cleanup correctness (never wipe a live session's segment dir) |
A note on G9 (observability): the *live* "who is streaming now" view does **not**
depend on the descriptor strategy. Every offload node calls the Redis-backed
node-session tracker (`internal/nodesessions/tracker.go`) on every serve, tied to
actual byte-serving (so a non-cooperative client or replayed token still shows
up) and surviving a central restart. G9 scores only the *durable, identity-rich*
(who + what + recipe) slice on top of that.
---
## 5. The options, and the comparison
Legend: ✓ meets · ✓\* minor asterisk · ~ partial · ✗ fails · ✓✓ best-in-class
| Goal | `main` | RC #174 | TR-24h | TR + re-mint | RC-async | **TR-lease** |
|------|:------:|:-------:|:------:|:------------:|:--------:|:------------:|
| G1 hot path free of central DB | ✓ | ✓\* | ✓ | ✓ | ✓ | ✓ |
| G2 bytes offloaded | ✓ | ✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ |
| G3 restart resiliency (6/6) | ✗ | ✓ | ✓ | ✓ | ✓ | ✓ |
| G4 two-factor ownership | ✓ | ✓ | ~ | ~ | ~ | ✓~ |
| G5 revocation latency | ~ | ~ | ✗ (≤24h) | ✓ (≤TTL) | ✓✓ | ✓✓ |
| G6 single-box, no external store | ✓ | ✗ | ✓✓ | ~ | ~ | ~ |
| G7 restart runway | n/a | ✓ (30m) | ✓✓ (24h) | ~ | ~ | ✓✓ (24h) |
| G8 multi-front-end | ✗ | ~ | ✓~ | ✓~ | ✓~ | ✓~ |
| G9 durable identity-rich record | ✗ | ✓✓ | ✗ | ✗ | ✓ | ~ |
| G10 low operational surface | ✓✓ | ~ | ✓ | ~ | ✗ | ~ |
| G11 node-restart recovery | ✗ | ✗ | ✓ | ✓ | ✓ | ✓ |
| G12 no new client coordination | ✓ | ✓ | ✓ | ✗ | ✗ | ✓ |
| G13 small signing-key blast radius | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ |
| G14 cleanup correctness | ✓ | ✓✓ | ~ | ~ | ~ | ~ |
The TTL/revocation strategies head-to-head. **Note: the session-deny column is
the deferred design target — see §7; as shipped, the node path has no revocation
tighter than the 24h token TTL.**
| Strategy | Revocation latency | Restart runway | Client refresh? | Central on refresh? |
|----------|--------------------|----------------|-----------------|---------------------|
| Re-resolve at reconstruct (RC) | on reconstruct; node hop ≤24h | 30 min | no | only on reconstruct |
| Leave 24h, expire only (TR-24h) | up to 24h | 24h | no | never |
| Short TTL + refresh + denylist (RC-async / TR item B) | ≤~5 min | ~90 s grace | **yes** | every ~3.5 min/session |
| 24h token + session-deny marker (TR-lease — **deferred**) | *design target:* admin kill cuts node bytes on next serve; passive ban = next-play / ≤24h. *As shipped:* node path bounded by ≤24h token only | 24h | **no** | nothing on the serve path; one write per admin-kill event |
---
## 6. Why each non-final option was set aside
- **RC (PR #174, the recipe card in Postgres).** Correct and the basis of
everything here, but it couples reconstruction to a shared per-session Postgres
store: a write on every start, per audio/quality change, and a ≤1/min refresh,
with resilience tied to the DB being reachable. It does not give the single-box
zero-dependency property, and the node hop's 24h token stays unrevocable
anyway. RC's reconstruct *core* was kept; its *storage* was the thing to evolve.
- **TR-24h (token-carried, leave the token to expire).** Wins the single-box case
(zero external dependency) and removes the per-start DB write, but revocation is
bounded by *nothing tighter than the 24h token TTL* on the node path — a banned
user keeps node-served direct/remux for up to 24h. This is actually the
*status quo* for the node path even pre-branch (the proxy verifies by signature
alone, no DB), so TR-24h only adds identity claims to the token without
improving revocation. Not acceptable as the endpoint.
- **TR + re-mint (short TTL + authenticated playlist refresh + denylist), and
RC-async (the same revocation idea but keeping an async-written Postgres mirror
for observability).** Both bound revocation to ~5 min by having the client
refetch a stable `RequireAuth` playlist that re-checks access and re-mints
short-lived segment URLs. The cost: a **hard client-coordination requirement**
(silo-android / silo-apple / Jellyfin clients must implement the refresh flow),
a shrunken restart runway (~90 s grace instead of 30 min — if central is down
past the grace, playback stalls), and a new authenticated re-mint transport
that does not exist on the supported proxy/node topology. RC-async additionally
keeps a write pipeline + denylist + the central row (the highest operational
surface of all). They solved revocation by pushing work onto the client and
shortening the runway — the opposite of the reliability priorities.
The open item across TR and RC-async was always **revocation enforcement** ("item
B"): bounding revocation needs *either* a per-segment token check on the hot path
*or* an authenticated re-mint checkpoint the client drives. TR-lease removes that
dilemma.
---
## 7. TR-lease: the chosen design
TR-lease keeps TR's 24h token (so the restart runway is untouched and no client
refresh flow is needed) and adds a server-side **session-deny marker** for the
one revocation case the node path cannot otherwise cover — no client
participation, no steady-state cost.
**Reconstruction (token-carried).** The signed stream token *is* the durable
descriptor. Its claims carry the full byte-affecting recipe plus `uid/pid/mfid`
ownership lookup keys. A front-end that lost its in-memory session rebuilds
ffmpeg from the token the client re-presents — no shared per-session store, zero
external dependency for reconstruction on a single box. `HWAccel`/`HWDevice` are
deliberately *not* carried; they are re-resolved from live config so an operator
config change applies to reconstructed sessions too. Ownership re-binds to the
live authenticated caller and refuses `userID==0` / mismatch — the token's `uid`
is never trusted alone (a leaked URL is useless without the owner's auth session
on the native path).
**Revocation (session-deny marker).**
> **Status: deferred to a future PR.** Everything in this subsection (the
> `silo:streamauth:<sid>` deny marker, the admin Stop/Terminate deny write, and
> the node-side `Allowed()` enforcement) describes the design target and is **not
> present in the current implementation**. As shipped, admin Terminate and user
> Stop tear down the live in-memory session and the ffmpeg producer, but a
> still-valid stream token can reconstruct the session until its 24h TTL expires;
> there is no node-side byte-withholding and no guaranteed/instant revocation on
> the node path. The design below is retained for when revocation lands.
The revocation surface was deliberately
kept to the *one* case the offloaded topology cannot otherwise cover, rather than
a periodic re-check of every stream. An admin Stop/Terminate would write
`silo:streamauth:<sid> = deny` (TTL ≥ the 24h token lifetime); the offload node
would read it (a node-local Redis GET, sub-ms) before serving any bytes and
return `403` with no bytes on a hit. Enforcement is **byte-withholding, not
client cooperation** — a revoked client that ignores the 403 keeps hitting a
wall.
Why this would be sufficient, and what it deliberately does *not* do:
- **New playback is already blocked at central** — `/playback/start` is
`RequireAuth` + access check, so a ban/scope change always takes effect on the
next play, everywhere, with no marker needed.
- **Cooperative clients already stop** — central holds the realtime WebSocket and
can drop it + send a stop on a ban.
- **The only thing needing a hard node-side cut** is the *explicit, session-scoped
admin kill* of a *non-cooperative* client holding a valid token on a
node-served direct/remux stream (no producer to kill). That is exactly what the
(deferred) session-deny marker would cover; until it lands this case is bounded
by the ≤24h token like the passive bans below.
- **Not covered (accepted limitation):** a *passive* ban or *partial* access
change (lost one library, lowered rating) does NOT hard-revoke an
already-running, non-cooperative node stream. It is enforced at next play and
otherwise bounded by the ≤24h token. Closing this would require either a
periodic re-resolver (a poll over every active stream — rejected as constant
DB load for a rare case) or per-content deny enumeration; both were judged not
worth the machinery for a self-hosted server. A user-scoped deny was rejected
too: it over-blocks content the viewer still legitimately has access to.
**Fail-open on absence (deferred).** In the design, the normal case is *no*
marker, which serves; a Redis error also serves; only a present deny withholds
bytes. This is a deliberate reliability-first choice: availability over
revocation latency.
**Why this shape.** It keeps token-carried reconstruction's single-box and
verify-anywhere properties and would add revocation with no client coordination
(G12) and essentially no steady-state cost — the marker is written only on the
rare admin-kill event, never on a timer. It is, in effect, **TR-24 plus a single
session-scoped kill switch**. The kill switch is **deferred**; what shipped is
TR-24 (token-carried reconstruction, ≤24h token, no node-side revocation).
---
## 8. What was implemented, and where
This PR ships **Commit 1 only** (token-carried reconstruction). **Commit 2 (the
node session-deny marker) is deferred to a future PR** — the `internal/streamauth`
package, the proxy `Allowed()` guard, and the admin deny write described below
are **not present in the current implementation**.
**Commit 1 — token-carried reconstruction; retire `transcode_recipes`. (shipped)**
- `streamtoken.Claims` gains the recipe + `uid/pid/mfid`
(`internal/streamtoken/token.go`).
- `playback.RecipeCard` projects to/from claims (`ToClaims` /
`RecipeCardFromClaims`); the `Reconstruct*` front door takes the decoded recipe
instead of reading Postgres (`internal/playback/recipecard.go`,
`transcode_manager.go`).
- Native serve URLs carry the token as `?st=`; the manifest rewriter already
appends `RawQuery` to segment URIs, so segments inherit it with no route change
(`internal/api/handlers/playback.go`, `stream.go`). The integrated server is
hit directly, so the query-stripping-proxy concern that motivates a path
segment does not apply there; the proxy/node path keeps the token in the URL
path as before.
- Jellycompat folds the recipe into its durable compat store
(`PlaybackSession.Recipe`) since Jellyfin clients cannot round-trip a native
token (`internal/jellycompat/...`).
- Segment-dir cleanup switches from the card index to in-memory liveness (live
map + in-flight reconstruct set + age past the max token TTL); a dedicated node
keeps its boot-time full wipe (`internal/playback/transcode_cleanup.go`,
`transcode_manager.go`).
- `PostgresRecipeStore`, the `RecipeStore` interface, and the unmerged
`transcode_recipes` migration are deleted.
**Commit 2 — node session-deny marker. (DEFERRED — not in this PR)**
> The items below describe the planned revocation commit. They were removed from
> this PR and deferred to a future one; none of these symbols exist in the
> current implementation. The admin Stop/Terminate path still tears down the live
> session and ffmpeg producer, but writes no deny marker, so a valid token can
> reconstruct until its 24h TTL expires.
- `internal/streamauth`: a deny-only Redis `Store` — `Deny(sid)` (TTL ≥ token
lifetime) and a fail-open `Allowed(sid)` reader. No poller, no allow-leases, no
access re-resolver.
- The node guard in `internal/proxy/server.go` (`verifyToken` consults
`Allowed`).
- An admin Stop/Terminate writes the deny marker
(`internal/api/handlers/admin_playback_control.go` → `PlaybackHandler.LeaseDenier`).
- `nodesessions.SessionInfo` carries the numeric ownership keys
(`auth_user_id`/`profile_id`/`media_file_id`), populated by the node from the
verified token, to enrich the live admin "active streams" view (who/what, not
just session id) — see §10 monitoring.
- A single integrated box reads no marker and needs none — the producer-kill +
session removal on Terminate is the stop there.
> Design note (about the deferred commit 2): an earlier iteration added a central
> *revalidator* that re-resolved access for every active stream every ~2 min and
> wrote allow/deny leases. It was dropped in favour of the event-driven
> session-deny above: the poll re-derived, at a constant all-streams DB cost,
> signals the
> system already emits (new playback is gated at `/playback/start`; admin kill is
> an explicit event), and its only unique coverage — sub-24h *passive* revocation
> of an in-flight node stream — was judged not worth the machinery (see §7).
---
## 9. Issues raised across review, and their disposition
From two adversarial review rounds on the reconstruct core and the storage
evolution:
- **Integrated-transcode split-brain across front-ends (P-1 / A).** The
`SessionManager` + reconstruct single-flight are per-process and the descriptor
carries no owning-node id, so without sticky affinity two front-ends can run
divergent ffmpeg. **Disposition: deferred (owner-identity claim).** Supported
topology is dedicated `--mode=transcode` nodes (routed via `tnode`, no
split-brain) + single-box / LB-session-affinity integrated transcode. The
caveat is documented, not silently assumed.
- **24h node-path token is unrevocable (P-2).** The (deferred, see §7–§8)
session-deny marker would let the explicit admin kill cut node bytes on the next
serve. **Until that ships, this remains open:** the node-path token is
unrevocable, so admin kill and passive bans / partial access changes are all
bounded by next-play + the ≤24h token.
- **Dedicated transcode-node restart (P-3 / tr-10). Fixed for both paths.**
*Native:* the proxy forwards the verified stream token to the transcode node
(`X-Silo-Stream-Token`); on a manifest/segment miss the node re-verifies the
token, decodes the recipe, and self-reconstructs ffmpeg seeked to the requested
segment — single-flighted and concurrency-capped exactly like the integrated
reconstruct. *Jellycompat:* the node-hop token is server-minted and *could* carry the
recipe like native, but it deliberately does not — the recipe is mutated in
place under a stable session id (a Jellyfin audio/subtitle switch can arrive as
a `/Sessions/Playing/Progress` report that restarts ffmpeg without re-minting
the client's token), and a third-party Jellyfin client cannot be driven to
refresh a stale token, so a token *snapshot* could reconstruct a stale
rendition. Central instead writes the recipe to a shared Redis recipe store
(`internal/noderecipe`) keyed by upstream session id and overwrites it on every
switch, and the node (which cannot reach Postgres) reads the *current* recipe on
the reconstruct miss — over the same Redis the session tracker already uses (and
the deferred deny-lease would use), so no central URL is needed. The boot-time
full segment-dir wipe
stands; reconstruct re-transcodes from the requested segment. See §10 for the
full rationale (and why this is *not* a token-carryable case).
- **`userID==0` tolerance / "auth optional" (P-5).** Reconstruct hard-rejects
`userID==0` and a card whose session id does not match the URL.
- **Mutable recipe under a stable id (D).** A parameter change re-mints a new
manifest + token and the client reloads, so the common case self-heals. The
only residual is a restart *during* the brief switch transition with a prior
token in flight: it reconstructs the prior *rendition* (access is re-resolved,
only the rendition can be stale). TTL-bounded, self-healing on reload; minor,
accepted.
- **Cleanup liveness (F / tr-9).** Each process owns its `TranscodeDir`, so the
liveness signal is the in-process live map + the in-flight reconstruct set +
age-past-max-TTL; a dir is reaped only when absent from both and older than any
surviving token. No cross-process enumeration-failure mode for the supported
topology.
- **Signing-key blast radius (E / tr-7, G13).** A single `JWTSecret` signs native
auth, the stream token, and the node bearer, so a node-secret leak can forge
native logins. **Accepted risk** on the trusted-node precondition. **Deferred
hardening:** split the stream-token secret from the native-auth secret, then
asymmetric stream-token signing before any untrusted/edge node.
---
## 10. Residuals and follow-ups
- **Revocation of an in-flight node stream (all cases, until deny-lease ships).**
Because the session-deny marker is deferred (§7–§8), *no* ban — admin kill,
passive ban, or partial access change — hard-cuts an already-running,
non-cooperative node-served stream; all are enforced at next play, bounded by
the ≤24h token. The future deny-lease closes the *admin-kill* case; passive
bans would still need a per-content deny enumeration on the access-change event,
or accepting it. See §7.
- **Monitoring.** Two distinct questions:
- *Live "who is watching what right now"* — covered. Every node serve writes a
record to the Redis tracker (`silo:sessions:*`), now enriched with
`auth_user_id` / `media_file_id` from the verified token, so the admin
active-streams view answers who/what/where, tied to real byte-serving and
surviving a central restart. Integrated (no-node) sessions show in central's
in-memory session list.
- *Durable historical/audit ("who watched what last week")* — NOT provided by
this subsystem (there is no longer a durable per-session recipe record, by
design — G9 was the deliberate trade for token-carried reconstruction). That
history lives in the separate watch-progress / scrobble system.
- **Multiple viewers on one stream token.** The token is per playback session, so
a shared/leaked stream URL lets several clients pull bytes under one session id.
They **collapse to a single entry** in the tracker (one `sid`) — monitoring
cannot distinguish or count them — and the (deferred) session-deny would cut
them as a group, not individually. Detecting/limiting this needs a per-account
concurrency cap or
per-device session ids (device binding); deferred. Do not bind to client IP
(mobile / CGNAT).
- **Token in logs (tr-1).** Scrub the `?st=` token / path token from request
loggers (the jellycompat logger logs `RawQuery`; the native logger omits it).
Short TTL + signature bound the exposure.
- **Owner-identity (A).** Required before multi-front-end *integrated* transcode
without sticky affinity; defines owner election, self-URL discovery, a
peer-forward endpoint, and failover.
- **Node-side reconstruct (P-3). Done** for native (token-forwarded) and
jellycompat (Redis recipe handoff via `internal/noderecipe`: central writes the
recipe at remote-transcode start, the node reads it on a reconstruct miss).
Precondition: central and the nodes share the same Redis (already required for
the session tracker, and for the deferred deny-lease). Residual: the recipe
store is consulted
only for a jellycompat token, so a node restart still depends on the recipe key
surviving (24h TTL, ≥ token lifetime).
- *Why a server-side store and not the token here (the real rationale).* It is
tempting to delete `internal/noderecipe` and let the jellycompat node-hop
token carry the recipe like native — the token is server-minted and opaque to
the Jellyfin client (it follows it only as a redirect target), so it *could*.
That does **not** work, and the reason is **not** the often-stated "a Jellyfin
client can't round-trip a token." The reason is that the recipe is **mutated
in place under a stable session id**: a Jellyfin audio/subtitle switch can
arrive as a `/Sessions/Playing/Progress` report
(`restartCompatTranscodeForAudioSelection`) that restarts ffmpeg in place
**without re-minting the client's token**. A third-party Jellyfin client
cannot be driven to refresh the now-stale token — its VOD/`ENDLIST` manifest
is fetched once, and the compat WebSocket (`HandleSocket`) is a receive-only
keep-alive stub with no server→client command to force a manifest re-fetch. A
token is a snapshot at mint time, so after such a switch it would reconstruct
the **stale** rendition (old audio/subtitle) until the client happens to
re-fetch the manifest (e.g. on a seek), which for a VOD stream may be never.
Because the node also cannot reach Postgres (where the authoritative compat
recipe lives), the only way the node rebuilds the *current* recipe is a
node-reachable, server-authoritative store that central overwrites on every
switch — i.e. `internal/noderecipe`. Native has no equivalent path: every
native audio switch re-mints the manifest + token, so the native client's
token is never stale, which is exactly why native needs no such store. The
store is **load-bearing, not redundant**.
- **Key split / asymmetric signing (E).** Closes the account-takeover path; a
precondition for untrusted edge nodes.
---
## 11. Preconditions
- **Persistent `TranscodeDir` (recommended).** With persistence, reconstruct
serves surviving segments; with an ephemeral dir it re-transcodes from the
descriptor (which survives a wiped dir). A node *restart* still loses the
session regardless (P-3).
- **Re-transcode of one segment beats the buffer runway** on worst-case hardware
(software decode, subtitle burn-in). Measure.
- **Client retry/backoff exists.** Clients retry segment/manifest GETs on
5xx/connection error and reconnect the WebSocket. The token rides the URL, so no
extra client logic is needed to carry it; a client that does not retry sees the
failed request as fatal.
---
## 12. Diagrams
The `mermaid` blocks render in GitHub / VS Code / most Markdown viewers; the
ASCII blocks render anywhere.
### 12.1 Topology — where state lives
> **Deferred:** the `silo:streamauth:<sid> = deny` marker, the "Admin
> Stop/Terminate → write session-deny marker" arrow, and the node's "deny GET"
> are the planned revocation path and are **not** in the current implementation.
> The tracker (`silo:sessions:*`) and the recipe handoff are shipped.
```text
┌─────────────────────────── CENTRAL (mode=server) ───────────────────────────┐
Client │ RequireAuth /playback/start /playback/transcode/start (mint token) │
(web / android / │ native serve: /playback/transcode/{sid}/master.m3u8?st= /stream/{sid}?st= │
apple / jellyfin) │ Admin Stop/Terminate → write session-deny marker (event, not a timer) │
└───────────┬──────────────────────────┬───────────────────────┬──────────────┘
│ Postgres │ Redis │
▼ ▼ │ (multi-node only)
┌───────────────────────┐ ┌──────────────────────────┐ ▼
│ catalog / access │ │ silo:sessions:* (tracker)│ ┌────────────────────────┐
│ jellycompat_playback_ │ │ silo:streamauth:<sid> │ │ Proxy node (mode=proxy) │
│ sessions (compat │ │ = deny (session kill) │◀─│ verifyToken + deny GET │
│ store, carries Recipe)│ └──────────────────────────┘ │ serves direct/remux │
└───────────────────────┘ ▲ │ proxies transcode ──────┼──▶ Transcode node
✗ NO transcode_recipes table (deleted this branch) │ node-local GET └────────────────────────┘ (mode=transcode)
└── sub-ms, off Postgres ffmpeg /transcode/{sid}/…
```
Descriptor location by route (the core change). The "lease (node)" revocation
column is the **deferred** design; as shipped, the node path has no revocation
tighter than the ≤24h token:
| route family | descriptor (how it reconstructs) | revocation |
|--------------|----------------------------------|------------|
| native (1–3) | the signed token the client re-presents | *deferred:* lease (node) / shipped: producer-kill (integrated), ≤24h token (node) |
| jellycompat (4–6) | `jellycompat_playback_sessions.data.Recipe` (token can't round-trip) | *deferred:* lease (node) / shipped: producer-kill, ≤24h token (node) |
| ~~all~~ | ~~`transcode_recipes` Postgres row~~ — removed | — |
### 12.2 Where the token rides
```text
NATIVE (integrated server is hit directly → query param; segments inherit it)
manifest : GET /playback/transcode/{sid}/master.m3u8?st=<JWT>
segment : GET /playback/transcode/{sid}/segment/seg_00042.m4s?st=<JWT> ← RawQuery propagated by the manifest rewriter
direct : GET /stream/{sid}?st=<JWT>
PROXY/NODE (capability URL → path segment; survives a query-stripping hop)
GET {proxy}/stream/transcode/{JWT}/master.m3u8 → relative segment URIs inherit {JWT}
GET {proxy}/stream/direct/{JWT} GET {proxy}/stream/remux/{JWT}
JELLYCOMPAT → recipe is NOT in the token; it lives in the compat store.
the path token is used only for the node hop.
```
### 12.3 Normal playback — native, integrated box (no nodes)
```mermaid
sequenceDiagram
participant C as Client
participant S as Central server (integrated)
participant PG as Postgres (access)
Note over C,S: transcode start
C->>S: POST /playback/transcode/start (RequireAuth)
S->>PG: resolve file + access
S->>S: StartTranscode (ffmpeg) + RegisterTranscodeSession
S->>S: card = NewRecipeCard(opts); token = Sign(card.ToClaims(), 24h)
S-->>C: 202 manifestURL = /playback/transcode/{sid}/master.m3u8?st=TOKEN
Note over C,S: hot path — NO Postgres, NO lease (live session in memory)
C->>S: GET .../master.m3u8?st=TOKEN
S->>S: LoadOrReconstructSession → live hit → BuildPlaybackManifest (segments carry ?st)
S-->>C: manifest
loop every segment
C->>S: GET .../segment/seg_N.m4s?st=TOKEN
S->>S: live session → GetSegment → ServeFile
S-->>C: bytes
end
```
The hot path touches neither Postgres nor the lease — it is an in-memory map
hit. `?st` is verified only to decode a card if reconstruct is needed; a live
session ignores it.
### 12.4 Normal playback — native, multi-node (offloaded)
> **Deferred:** the `GET silo:streamauth:{sid}` lease guard and its `deny → 403`
> branch are the planned revocation path, **not** in the current implementation.
> As shipped the node verifies the token, tracks the session, and serves — there
> is no deny check.
```mermaid
sequenceDiagram
participant C as Client
participant S as Central server
participant N as Proxy node
participant T as Transcode node
participant R as Redis
C->>S: POST /playback/transcode/start (RequireAuth)
S->>S: card(full recipe, tnode=T); token = Sign(card.ToClaims())
S-->>C: manifestURL = {N}/stream/transcode/TOKEN/master.m3u8
loop manifest + segments
C->>N: GET /stream/transcode/TOKEN/...
N->>N: verifyToken(TOKEN)
N->>R: GET silo:streamauth:{sid} (lease guard)
alt deny
N-->>C: 403 (no bytes)
else allow / absent
N->>R: Track silo:sessions:{node}:{sid} (uid, mfid from claims)
N->>T: proxy /transcode/{sid}/...
T-->>N: bytes
N-->>C: bytes
end
end
```
The node copies `uid`/`mfid` from the verified token into the Redis tracker —
that is what enriches the live admin "active streams" view (who/what per node).
### 12.5 Restart → reconstruct (native transcode)
```mermaid
sequenceDiagram
participant C as Client
participant S as Central server (just restarted — session map empty)
Note over S: in-memory session GONE; NO transcode_recipes table to read
C->>S: GET /playback/transcode/{sid}/segment/seg_42.m4s?st=TOKEN
S->>S: card = streamCardFromQuery(?st) → Verify + decode recipe
S->>S: LoadOrReconstructSession → GetSession = NotFound, card != nil
S->>S: ReconstructSession (re-bind: refuse userID==0 / != card.uid; card.sid == {sid})
S->>S: ReconstructTranscode(seg=42) → single-flight, mark in-flight, respawn ffmpeg SEEKED to seg 42
S-->>C: bytes (resumes near where the client was)
```
- direct/remux restart is the same but Tier-1: reconstruct the `Session` from the
identity token, then `Range` / `?seek` re-serve — no ffmpeg rebuild.
- multi-node restart: the integrated front-end reconstructs only the `Session`
(to learn `tnode`) and re-proxies; the transcode node keeps serving. If the
transcode *node* itself reboots, the proxy forwards the stream token
(`X-Silo-Stream-Token`) and the node self-reconstructs ffmpeg seeked to the
requested segment (native path, P-3). A jellycompat session on a rebooted node
reconstructs too: the node fetches the recipe central wrote to the shared Redis
recipe store (`internal/noderecipe`) at transcode start, since the Jellyfin
client's token cannot carry it.
- cleanup race: a segment dir is reaped only if absent from the live map AND the
in-flight set AND older than 24h (max token TTL), so a token that can still
reconstruct never gets its dir wiped.
### 12.6 Auth deny — the session-deny marker
> **Status: deferred to a future PR.** The entire "Admin Stop/Terminate → write
> deny marker" and "Offload node → GET deny → 403" flow below is the planned
> revocation design and is **not** in the current implementation. As shipped,
> admin Stop/Terminate does the "realtime WS stop + producer teardown" half only
> (the right branch); it writes no marker, and the node performs no deny check, so
> a valid token reconstructs until its ≤24h TTL. New playback is still gated at
> `/playback/start` (the top subgraph is accurate).
```mermaid
flowchart TD
subgraph New["New playback (any topology)"]
P["/playback/start — RequireAuth + access check"] --> PB{allowed?}
PB -- no --> PR[refused — ban takes effect at next play]
PB -- yes --> PM[mint token]
end
subgraph Admin["Admin Stop/Terminate (event, not a timer)"]
K[denyStreamLease] --> WD[SET silo:streamauth:sid = deny<br/>TTL = 24h]
K --> WS[realtime WS stop + producer teardown]
end
subgraph Node["Offload node — every serve"]
G[verifyToken OK] --> L{GET silo:streamauth:sid}
L -- present deny --> F[403, no bytes]
L -- absent / redis-err --> SV[serve bytes fail-open]
end
```
Invariants:
- No steady-state writes: the marker is set only on an admin kill, never on a
timer. The normal serve path finds *no* key and serves.
- Fail-open: absent key or Redis error → serve. The only hard stop is a present
deny.
- The integrated box reads no marker — the producer-kill + session removal on
Terminate is the stop there; the marker only matters where nodes serve.
- Not covered here (by design): a *passive* ban / partial access change of an
in-flight non-cooperative node stream — enforced at next play, bounded by the
≤24h token (§7).
### 12.7 Jellycompat — what differs
```mermaid
sequenceDiagram
participant J as Jellyfin client
participant S as Central (jellycompat)
participant PG as Postgres compat store
J->>S: negotiate → PlaybackSession{UpstreamSessionID, MediaSources}
S->>PG: Put (jellycompat_playback_sessions)
Note over S: transcode start
S->>S: StartTranscode; recipe = NewRecipeCard(opts)
S->>PG: Update(ps): TranscodeStarted=true, Recipe=recipe (same write)
Note over J,S: restart → reconstruct from the COMPAT STORE, not a token
J->>S: GET HLS segment
S->>PG: Get(playSessionID) → ps.Recipe
S->>S: LoadOrReconstructSession(UpstreamSessionID, card = ps.Recipe) → ReconstructTranscode(*ps.Recipe)
S-->>J: bytes
```
Jellyfin clients cannot carry a native `?st` token, so the recipe is folded into
the durable compat row (`PlaybackSession.Recipe`, persisted in the `data` JSONB)
and written in the same `Update` that flips `TranscodeStarted`. The node hop
(multi-node jellycompat) still uses a path token exactly like native (the lease
guard is deferred along with the rest of the revocation path).
### 12.8 Hot-path cost per request
The "lease" / "SET deny" Redis costs below are **deferred** (the revocation path
is not shipped); as shipped the node does only the tracker write, and admin
Stop/Terminate writes no Redis key:
| request | Postgres | Redis | central CPU | reconstruct |
|---------|----------|-------|-------------|-------------|
| native segment, live, integrated | — | — | map hit | — |
| native segment, live, node | — | 1 track (+ deferred: GET lease) | — | — |
| native segment, after restart, integrated | — | — | verify JWT + ffmpeg respawn (once, single-flight) | yes |
| jellycompat segment, live | — | track (+ deferred: lease) | map hit | — |
| jellycompat segment, after restart | 1 Get (compat row) | track (+ deferred: lease) | respawn | yes |
| admin Stop/Terminate (event, not per-request) | — | deferred: 1 SET deny | one write | — |
---
## 13. Restart-resiliency behavior matrix (role × path)
The observable reconstruction behavior of every playback path under a restart of
each server role. **✅** = the path reconstructs and resumes on the client's next
request; **—** = the path never executes on that role (nothing to reconstruct);
**⚠️** = reconstructs only while a stated precondition holds.
| path | integrated restart | proxy-node restart | transcode-node restart |
|------|--------------------|--------------------|------------------------|
| native · direct | ✅ rebuild `Session` from `?st` token → `Range` re-serve | ✅ stateless re-serve from `claims.MediaPath` | — not served on a transcode node |
| native · remux | ✅ token + `?seek` → re-spawn pipe | ✅ stateless per-request pipe re-spawn | — |
| native · transcode | ✅ token (full recipe) → respawn ffmpeg seeked to segment | ✅ transparent re-route + token forward | ✅ self-contained — token is recipe-complete |
| jellycompat · direct | ✅ re-resolve from durable compat row → `Range` re-serve | ✅ stateless re-serve from token `MediaPath` | — |
| jellycompat · remux | ✅ compat row + `?seek` | ✅ stateless per-request pipe re-spawn | — |
| jellycompat · transcode | ✅ `ReconstructTranscode(*ps.Recipe)` from compat row | ✅ transparent re-route + token forward | ⚠️ reconstructs **iff** the shared Redis recipe (`internal/noderecipe`, ≤24h TTL) still exists; fails closed (404) otherwise |
**What carries the recovery state, per role**
| role | native carrier | jellycompat carrier |
|------|----------------|---------------------|
| integrated | `?st` stream token — identity for direct/remux, full recipe for transcode | durable compat row `PlaybackSession.Recipe` |
| proxy node | self-describing path token, re-derived per request — the proxy holds no per-session state | same path token (identity); the recipe is not needed at the proxy |
| transcode node | forwarded `X-Silo-Stream-Token`, recipe-complete | identity token + Redis `noderecipe` recipe (the Jellyfin token cannot carry it) |
**Reading the matrix**
- **Integrated and proxy-node restarts are unconditional** for every applicable
path. The proxy holds no authoritative per-session state — direct/remux are
re-served from the token's `MediaPath`, transcode is reverse-proxied — so a
proxy bounce, or a reroute to any in-group proxy (`pickProxy` is round-robin
with no session affinity), is transparent.
- **The four `—` cells are structural, not gaps.** Direct and remux never run on
a transcode node — the proxy (or the integrated box) serves them from source
media — so a transcode-node restart has nothing to reconstruct on those paths.
- **The one conditional (⚠️)** is jellycompat transcode after a *transcode-node*
restart. The node-hop token is identity-only — not because a Jellyfin client
cannot round-trip it (the token is server-minted and opaque to the client), but
because the recipe is mutated in place under a stable session id and a
third-party Jellyfin client cannot be driven to refresh a stale token, so a
token snapshot could reconstruct a stale rendition (see §10). The node therefore
rebuilds from the Redis `noderecipe` entry central overwrites on every switch
rather than from the token. Native transcode has no such dependency — every
native audio switch re-mints the token, so it is always recipe-complete and
current. This is the only native-vs-jellycompat asymmetry in reconstruction
(see §10).
**Out of scope of this matrix** (accepted behaviors, not restart-reconstruction
holes — see §7, §10):
- Passive ban / partial access change of an in-flight node stream — enforced at
next play, bounded by the ≤24h token, not by reconstruction.
- Integrated transcode behind a non-sticky load balancer can split-brain into
divergent segment dirs (P-1); safe single-front-end or with LB affinity. Remote
(node) sessions are immune — node affinity routes every front-end to the same
transcode node.
+142
View File
@@ -0,0 +1,142 @@
# At-rest credential encryption
Silo encrypts third-party integration credentials and other server-owned secrets
at rest. Sonarr/Radarr API keys, S3 keys, watch-sync OAuth tokens, the
Audiobookshelf-compat signing key, and the sensitive `server_settings` entries
are stored as ciphertext in PostgreSQL and decrypted only in memory. The key that
protects them lives **outside** the database, so a full DB dump or compromise
does not expose the secrets.
## How it works
- **Cipher:** AES-256-GCM with a random 12-byte nonce per value. Implemented in
`internal/secret`.
- **Key derivation:** the data key is HKDF-SHA256–derived from the `SECRET_KEY`
environment variable with a versioned domain label. `SECRET_KEY` is the
encryption root; it is never stored in the database.
- **Envelope:** every ciphertext is stored as `enc:v1:<base64url(nonce‖sealed)>`.
The `enc:v1:` prefix is the version marker (see *Key rotation* below).
- **Row binding (AAD):** each ciphertext is GCM-bound to its logical row via
additional-authenticated data — `table:column:<pk>` for per-row columns,
`server_settings:<key>` for settings. A DB-write attacker therefore cannot move
a credential blob from one row/column to another: decrypting under a different
binding fails authentication.
- **Read path:** an empty value stays empty; a non-`enc:v1:` value is treated as
legacy plaintext and passed through unchanged (so a not-yet-migrated row keeps
working); an `enc:v1:` value is decrypted and **any** failure (wrong key,
tampering, truncation) is surfaced as an error — it is never silently used as a
credential.
## SECRET_KEY
`SECRET_KEY` is **required**. The server refuses to start without it (it fatals
in `config.LoadBootstrap`), and it must be at least 32 characters.
Generate one with:
```bash
openssl rand -base64 48
```
Set it in the deployment environment (or in `.env` for source/dev runs, which
`godotenv` loads at startup). See `.env.example`.
### Back it up — separately from the database
Treat `SECRET_KEY` like a CA private key:
- **Store it outside your database backups.** A DB dump contains only ciphertext;
the value of at-rest encryption is entirely lost if the key travels with the
dump.
- Keep it in a secrets manager / sealed secret / password manager, not in the
repo or in the same bucket as your Postgres backups.
- Rotating the *deployment* (new host, restored DB) requires the **same**
`SECRET_KEY`. Restoring a database backup onto a node with a different
`SECRET_KEY` makes every encrypted secret unreadable.
### Key loss
If `SECRET_KEY` is lost, the encrypted secrets are **unrecoverable** — that is the
point of keeping the key out of the database. Recovery means re-entering the
affected credentials:
- Re-enter Sonarr/Radarr and Autoscan API keys, S3 keys, watch-sync connections,
history-import tokens, and subtitle provider credentials.
- `auth.jwt_secret` becomes unreadable, so all existing sessions are invalid and
users must log in again (a new secret is generated on next boot if the row is
cleared — see below).
## Rollout (automatic backfill)
On the first boot after deploying this change, the primary (migration-running)
node runs an idempotent, best-effort startup backfill that encrypts any
remaining plaintext in place:
1. sensitive `server_settings` keys,
2. the per-table credential columns (subtitles, watch-sync, webhook-sync,
history-import, including temporary server-list credentials stored in
session JSON),
3. the two arr `api_key_ref` columns — these are **resolved-then-encrypted**: a
legacy row that held a `server_settings` reference (e.g.
`requests.radarr.api_key`) is collapsed to the real credential before being
encrypted.
The backfill is safe to run repeatedly: already-encrypted values are skipped, and
a per-row guard makes concurrent multi-node boots converge without
double-encrypting. A failed row is logged and left as plaintext (no new exposure)
rather than blocking boot. Proxy/transcode nodes skip the backfill; they read
whatever the primary encrypted. No manual steps are required.
## Rollback / downgrade hazard
Downgrading to a binary that predates this change is **not** safe while secrets
are encrypted, because the old binary has no read path: it would read
`enc:v1:auth.jwt_secret` as a literal JWT secret (invalidating all sessions) and
read `enc:v1:`-prefixed integration keys as garbage credentials.
To downgrade safely you must first return the affected values to plaintext, for
example:
- Decrypt the sensitive `server_settings` back to plaintext (run a one-off that
reads each via the encrypting repo and writes the plaintext via the raw repo),
**or**
- At minimum, clear `auth.jwt_secret` so the older binary regenerates a fresh
plaintext one (this still logs everyone out), and re-enter any integration
credentials as plaintext.
There is no automatic "decrypt everything" downgrade path in this release; plan
downgrades accordingly.
## Key rotation (future)
The `enc:v1:` envelope and the HKDF domain label are versioned. A future rotation
would introduce `enc:v2:` with a new derived key (and `Decrypt` already dispatches
on the version, returning an explicit error for an unknown version). Rotation is
out of scope for this release; the versioning exists so it can be added without a
data migration of the envelope format.
## Scope and known gaps
Covered: arr (Requests + Autoscan) API keys, S3 keys, all sensitive
`server_settings`, watch-sync tokens, webhook-sync `access_token`,
history-import admin/session tokens and temporary server-list credentials,
subtitle provider `api_key`/`password`,
the Jellyfin-compat session's bridged Silo access/refresh tokens
(`jellycompat_sessions.streamapp_access_token` / `streamapp_refresh_token`), and
the ABS signing key.
Deliberately **not** encrypted (tracked as follow-ups):
- **Equality-looked-up secrets** — these are matched by exact value
(`WHERE … = $1`), and AES-GCM is randomized, so encrypting them would break the
lookup. They need a deterministic **blind-index hash** column instead:
`api_keys.api_key`, `webhook_sync_connections.webhook_secret`,
`jellycompat_sessions.token`, and `watch_together_rooms.join_token`.
- **Plugin runtime config** — `plugin_runtime_configs.config_value` is opaque
plugin-defined JSONB whose secret fields are manifest-declared and whose runtime
lives in a separate repo; encrypting it correctly needs a coordinated,
manifest-aware design.
Excluded (not a gap): `plex_sync_connections.*` is a dead table (zero Go
references); `oauth_completion.token_ciphertext` is already AES-GCM;
`users.password_hash` and the `*_hash` columns are already hashed.
+19
View File
@@ -0,0 +1,19 @@
# Silo v1 Scope
**Status: NOT LOCKED — proposal window open.**
Propose capabilities with the **v1 capability proposal** issue template; triage happens on the
[Silo v1 project](https://github.com/orgs/Silo-Server/projects/5).
When the scope locks, this file becomes the source of truth and will contain:
1. **Locked capabilities** — a table of capability epics (issue links) with one-line scope statements.
2. **API policy** — additive-only within `/api/v1` (no field renames/removals, no type changes,
no status-code repurposing; removals only via the Deprecation/Sunset header flow; capability
endpoints for feature detection). Contract tooling: #135.
3. **Amendment rules** — after lock, this file changes only via PR with code-owner review.
An amendment PR is the exception process: it must say what changes, why it cannot wait
for v1.1, and what it displaces.
Until lock: treat any capability not tracked as `Proposed`/`Locked` on the project as out of scope
for feature PRs (see the scope gate in `CLAUDE.md`).
@@ -0,0 +1,98 @@
# Wholphin → jellycompat Endpoint Coverage Report
Date: 2026-06-09
Scope: every Jellyfin server endpoint the Wholphin Android TV client (jellyfin-sdk-kotlin based) can call, cross-referenced against the routes registered in `internal/jellycompat/router.go`. Wholphin source analyzed from a local clone of `damontecres/Wholphin` (`app/src/main`). Commands assume the repository root is the cwd.
Wholphin's Seerr integration (`/api/v1/...` discover/request endpoints) targets a separate Seerr server, not Jellyfin, and is out of scope here.
## Methodology
1. Grepped Wholphin for every `*Api.method()` SDK call (`itemsApi`, `playStateApi`, etc.), websocket subscriptions, and raw URL construction; mapped each to its HTTP endpoint per the Jellyfin OpenAPI spec.
2. Inventoried every route registered in `internal/jellycompat/router.go`, including path normalization (`path_normalization.go` handles casing and `/emby`//`jellyfin` prefixes).
3. For each endpoint Wholphin uses but silo does not serve, checked whether a server-side gate (user policy flag, DTO field) prevents the client from ever calling it.
## Summary
- **Covered (full):** 22 endpoint families — everything on the core path: auth, browse, item detail, episodes/next-up, resume, playback info, direct/HLS streaming, subtitles, progress reporting, favorites/played, images, genres, persons, suggestions, media segments (skip intro), display preferences.
- **Covered (partial/stub):** 4 — websocket (keep-alive only, no remote control), `/Studios`, `/Users/Public`, `/QuickConnect/Enabled` (deliberate `false`).
- **Missing but safely gated:** 8 — the client never calls them because we don't advertise the capability (delete, subtitle download, trickplay, local trailers, additional parts, quick connect auth, live TV, playlists).
- **Missing and reachable:** 4 — theme songs, universal audio stream, item download, client log upload. These return 404 when the client calls them. None block video playback.
**Bottom line: the entire core video path is covered.** The reachable gaps are quality-of-life features (theme songs, photo screensaver, debug log upload) plus one advertised-but-broken inconsistency (`CanDownload=true` with no `/Items/{id}/Download` route).
## Coverage matrix
### Fully covered
| Wholphin call | Endpoint | silo handler |
|---|---|---|
| `userApi.authenticateUserByName` | `POST /Users/AuthenticateByName` | `handlers_auth.go` |
| `userApi.getCurrentUser` | `GET /Users/Me` | `handlers_auth.go` |
| `userApi.getUserById` | `GET /Users/{id}` | `handlers_auth.go` |
| `systemApi.getPublicSystemInfo` | `GET /System/Info/Public` | `handlers_system.go` |
| `sessionApi.postCapabilities` | `POST /Sessions/Capabilities` | `handlers_playback.go` |
| `itemsApi.getItems` | `GET /Items` | `handlers_items.go` |
| `itemsApi.getResumeItems` | `GET /UserItems/Resume` (+ legacy `/Users/{id}/Items/Resume`) | `handlers_items.go` |
| `userLibraryApi.getItem` | `GET /Items/{id}` (+ legacy user-scoped form) | `handlers_items.go` |
| `tvShowsApi.getEpisodes` | `GET /Shows/{id}/Episodes` | `handlers_items.go` |
| `tvShowsApi.getNextUp` | `GET /Shows/NextUp` | `handlers_items.go` |
| `suggestionsApi.getSuggestions` | `GET /Items/Suggestions` | `handlers_items.go` |
| `genresApi.getGenres` | `GET /Genres` | `handlers_items.go` |
| `personsApi.getPersons` | `GET /Persons` | `handlers_persons.go` (full when PersonRepo is wired; empty stub otherwise) |
| `searchApi.getSearchHints` | `GET /Search/Hints` | `handlers_items.go` (Wholphin's call is currently commented out upstream) |
| `mediaInfoApi.getPostedPlaybackInfo` | `POST /Items/{id}/PlaybackInfo` | `handlers_playback.go` |
| `videosApi.getVideoStreamUrl` | `GET/HEAD /Videos/{id}/stream(.{container})` | `streams.go` |
| (transcode path from PlaybackInfo) | `GET /Videos/{id}/master.m3u8` + HLS playlist/segments | `streams.go` |
| (subtitle delivery URLs) | `GET /Videos/{id}/{source}/Subtitles/{idx}/stream.{fmt}` | `streams.go` |
| `playStateApi.reportPlaybackStart/Progress/Stopped` | `POST /Sessions/Playing[/Progress|/Stopped]` | `streams.go` |
| `playStateApi.markPlayedItem/markUnplayedItem` | `POST/DELETE /UserPlayedItems/{id}` (+ legacy) | `handlers_userdata.go` |
| `userLibraryApi.markFavoriteItem/unmarkFavoriteItem` | `POST/DELETE /UserFavoriteItems/{id}` (+ legacy) | `handlers_userdata.go` |
| `mediaSegmentsApi.getItemSegments` | `GET /MediaSegments/{id}` | `handlers_items.go` (skip intro/credits) |
| `displayPreferencesApi.updateDisplayPreferences` | `GET/POST /DisplayPreferences/{id}` | `handlers_displayprefs.go` |
| `imageApi.getItemImageUrl` | `GET /Items/{id}/Images/{type}[/{index}]` | `handlers_images.go` |
| `imageApi.getUserImageUrl` | `GET /Users/{id}/Images/Primary` | `handlers_images.go` (placeholder avatar) |
### Partial / stub (graceful degradation)
| Endpoint | Status | Client impact |
|---|---|---|
| `GET /socket` (websocket) | Accepts upgrade, answers KeepAlive only (`handlers_websocket.go`) | Wholphin subscribes to `GeneralCommandMessage` and `PlaystateMessage` for server-initiated remote control. Connection stays healthy; remote-control commands are simply never delivered. No errors, feature inert. |
| `GET /Studios` | Empty-array stub | Studio browse rows render empty. |
| `GET /Users/Public` | Empty-array stub | No user tiles on the login screen; manual login works. |
| `GET /QuickConnect/Enabled` | Hardcoded `false` | Deliberate: Wholphin hides Quick Connect UI, so the unimplemented `POST /QuickConnect/Authorize` and `POST /Users/AuthenticateWithQuickConnect` are never called. |
### Missing but safely gated (client never calls them)
| Endpoint | Why it's unreachable |
|---|---|
| `DELETE /Items/{id}` | We return `CanDelete=false` per item (`mapping.go`) and `EnableContentDeletion=false` in user policy (`handlers_auth.go`), so Wholphin hides delete UI. |
| `POST /Items/{id}/RemoteSearch/Subtitles/{subtitleId}` | `EnableSubtitleManagement=false` in user policy hides subtitle search/download. |
| `GET /Videos/{id}/Trickplay/...` | We never emit trickplay metadata on item DTOs, so the scrubber never requests tiles. |
| `GET /Items/{id}/LocalTrailers` | Wholphin gates on `localTrailerCount > 0` (`TrailerService.kt`); we never set `LocalTrailerCount`. |
| `GET /Videos/{id}/AdditionalParts` | Gated on `partCount > 1` (`PlaylistCreator.kt`); we never set `PartCount`. |
| Quick Connect auth endpoints | Gated by `/QuickConnect/Enabled` = false. |
| Live TV suite (`/LiveTv/Channels`, `/Programs`, `/Recordings`, timers) | We expose no live-TV views or channels, so the feature never activates. |
| Playlists (`GET/POST/DELETE /Playlists/{id}/Items`) | We expose no playlist items in the catalog, so playlist screens are unreachable. Becomes a real gap the day silo grows playlists. |
### Missing and reachable (return 404 today)
| Endpoint | Wholphin caller | Impact | Severity |
|---|---|---|---|
| `GET /Items/{id}/ThemeSongs` | `ThemeSongPlayer.kt` | ~~404 → feature silently dead.~~ **Resolved 2026-06-09:** now stubbed with an empty `ThemeMediaResult` (including the `OwnerId` field jellyfin-sdk-kotlin requires). | Resolved |
| `GET /Audio/{id}/universal` | `ThemeSongPlayer.kt`, `MusicService.kt` | Audio streaming for theme songs and music playback. No music libraries are exposed today, but this is the second half of the theme-song path and the blocker for any future audio support in jellycompat. | Low (today) |
| `GET /Items/{id}/Download` | `SlideshowViewModel.kt`, `ScreensaverService.kt` | ~~Inconsistency: `CanDownload=true` while the route 404s.~~ **Resolved 2026-06-09:** a real `/Items/{id}/Download` route now serves the original file (`streams.go`), and `CanDownload=true` again. The earlier `CanDownload=false` workaround broke Infuse, which requires the flag for Direct Play. | Resolved |
| `POST /ClientLog/Document` | `MediaReportService.kt`, `DebugPage.kt` | "Upload logs to server" debug action fails. | Low |
| `GET /Audio/{id}/Lyrics` | `NowPlayingViewModel.kt` | Music-only; unreachable until audio libraries exist. | Low |
## Recommendations (priority order)
1. **Merge the repeated-`Fields` fix** (PR #110). `parseItemsQuery` still reads `q.Get("Fields")` (`internal/jellycompat/query.go`), which truncates jellyfin-sdk-kotlin's repeated query params and breaks Wholphin episode auto-advance. This is the only *covered* endpoint with a known correctness bug for this client. Follow up by sweeping the remaining single-value reads (`Ids`, `GenreIds`, `PersonIds`, `Filters`, `SortBy`, `SortOrder`) to `q.Values`, matching how `IncludeItemTypes`/`MediaTypes`/`ImageTypes` are already handled.
2. ~~Resolve the `CanDownload` inconsistency~~ — **done 2026-06-09**: real `/Items/{id}/Download` route added; `CanDownload` stays `true` (Infuse requires it for Direct Play).
3. ~~Stub `GET /Items/{id}/ThemeSongs`~~ — **done 2026-06-09**: empty `ThemeMediaResult` stub registered.
4. **Websocket server-push** (remote control: server-initiated pause/stop, display messages) — tracked in issue #122.
5. **Optional, later:** `POST /ClientLog/Document` accept-and-discard stub and real `/Studios` data. Playlists, lyrics, and universal audio only matter once silo exposes those content types through jellycompat.
## Notes
- Path normalization (`path_normalization.go`) already handles Wholphin's casing and any `/emby`//`jellyfin` prefixes, so none of the covered routes are at risk from URL-shape differences.
- Unmatched jellycompat paths fall through to chi's default 404 with no body; jellyfin-sdk-kotlin surfaces these as `InvalidStatusException`, which Wholphin generally catches per-feature.
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
# PR #124 (ebooks) — Review & Hardening Pass
Date: 2026-06-10
Scope: full review of the ebook feature branch (`work/ebooks-reader-base`) followed by
fixes for every finding. Two review rounds were performed (a full-PR review, then an
adversarial edge-case/security pass over both the original code and the first round of
fixes), with all changes verified against the full Go test suite, the full web test
suite, production frontend build, ESLint/Prettier, and `make verify-local-paths`.
Commands assume the repository root is the cwd.
## Security fixes
- **Stored XSS via book content (high):** book sections render in same-origin blob
iframes with `allow-scripts` (WebKit requirement). A Content-Security-Policy is now
served with all SPA HTML responses (`internal/server/frontend.go`) — blob/srcdoc
documents inherit it, so `script-src 'self' 'wasm-unsafe-eval'` blocks script
execution from book content on all browsers. Threat model documented on the policy
constant. `X-Content-Type-Options: nosniff` added to frontend, jellycompat, and
ebook file responses.
- Ebook file serving can no longer fall through to `application/octet-stream` for an
admitted file (extension/container whitelist and MIME resolution share one resolver),
closing a browser-sniff escape combined with the `?token=` query-auth fallback.
- External links in books are intercepted app-side: http(s) only, opened with
`noopener,noreferrer` (blocks reverse tabnabbing and `javascript:` URLs).
- Request size caps (`http.MaxBytesReader`, 413) on progress/config/annotation writes;
`Content-Disposition` built with `mime.FormatMediaType` (RFC 5987, no header
injection); annotation PATCH is atomic (`SELECT ... FOR UPDATE`) with
presence-aware field semantics and invariant re-validation.
- Verified safe under adversarial review: no IDOR (all reader-state SQL scoped to
user+profile+content), no path traversal, range serving via stdlib, progress input
validation. Accepted residual: CSS-only loads inside book iframes can leak the
book's own content but cannot reach tokens, DOM, or navigation.
## Reliability fixes
- **Scanner missing-file reconciliation (critical class):** ebook scans now mark
missing files like video/audio, with real walk-failure tracking (failed roots are
excluded from deletion), symlinked-root support via the shared logical walker, and
the empty-root cleanup allowance — an unmounted share can no longer wipe a library.
- **Enrichment:** provider errors record failures (dedicated `ebook_enrichment_state`
backoff table, capped retries) instead of permanently stamping items done;
unconfigured chains and scan-window races skip without stamping. No longer shares
`media_items.refresh_failures` with the metadata refresh-debt system.
- Ebook items are created `pending` and promoted to `matched` on enrichment (backfill
migration included), making curated-metadata protection real: matched items keep
provider titles/people/series on re-scan (fill-empty only).
- PDF metadata: head+tail window scan (non-linearized PDFs keep the Info dict at the
end), delimiter-aware key matching, head-wins merge. `.md` dropped as an ebook
format; plain `.fb2` reads capped like `.fbz`.
- FK-cascade indexes added for the reader-state tables.
## Consistency & correctness fixes
- Hidden-history (`user_history_hidden_items`) gating applied to every ebook progress
surface (watched/in-progress filters, all sort plans, Continue Reading, sort
metrics, `Played` state, recommendation signals) with the exact video semantics.
- `GetItemWatchers` counts distinct watchers (no binge inflation) and the
`minWatchers` floor counts distinct accounts, not profiles; ebook reading now feeds
implicit taste signals and taste-seed candidates; Continue Reading pages past
dismissals and dedupes across pages.
- The 0.9 finished threshold is centralized (`models.EbookFinishedProgressThreshold`).
- Reader frontend: open-flow race teardown (no wrong-file progress saves), ordered
progress writes with `pagehide`/`visibilitychange` flush, settings no longer
clobbered by late server config, TTS stop actually stops, Media Session cleanup,
512 MiB download guard, fraction bookmarks navigable.
## Feature completion
- Native read-state endpoints: `POST/DELETE /watched/{id}` and `/history/remove`
accept ebook content IDs (mark read = progress 1.0 preserving location; mark unread
mirrors video unwatch and clears position; history removal hides without losing
position). Web UI exposes Mark Read/Unread on the item page and card menus, with
Continue Reading dismiss copy and dismissal-path ID encoding fixed.
## Client follow-ups (Android / Apple)
- Ebook leaf user data encodes the reading ratio as `position_seconds` (0..1) with
`duration_seconds = 1`; do not render absolute times for ebooks.
- `/watched/{id}` returns `{type: "ebook", affected_count: 1, played: bool}` and the
existing watched SSE event fires.
- Reading progress is deliberately per-book (cross-format): one position per content
ID.
@@ -0,0 +1,301 @@
# Ebook Reader Full Parity Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Bring the core ebook reader up to the personal reader feature set with server-persisted config, annotations/bookmarks, selection tools, reading aids, and advanced settings.
**Architecture:** Keep ebook reading in core, matching audiobooks: core owns the ebook media type and reader behavior; metadata remains plugin-provided. Extend the existing `/ebooks/{content_id}` reader API and `EbookReaderHandler` rather than creating a separate plugin-style reader subsystem. Persist per-user/per-profile/per-book state in Postgres so settings, annotations, and bookmarks roam across devices.
**Tech Stack:** Go/Chi handlers, pgx/Postgres migrations, React 19, TanStack Query, Foliate/readest reader wrapper, Vitest, Go unit tests.
---
## File Structure
- `migrations/sql/20260608000300_ebook_reader_state.sql`
- Creates `ebook_reader_config` and `ebook_reader_annotations`.
- `internal/api/handlers/ebook_reader.go`
- Adds reader config and annotation types, store interfaces, handlers, validation, and Postgres store methods.
- `internal/api/handlers/ebook_reader_test.go`
- Adds handler tests for config, annotation CRUD, access scoping, and validation.
- `internal/api/router.go`
- Wires config/annotation stores and routes under `/ebooks/{content_id}`.
- `web/src/reader/FoliateBookReader.tsx`
- Adds selection/annotation/readable-text/content-popup reader handles and applies saved config.
- `web/src/pages/EbookReader.tsx`
- Adds server-backed settings, annotation/bookmark UI, selection tools, TTS controls, wake-lock/e-ink controls, and advanced reader controls.
- `web/src/hooks/useTTS.ts`, `web/src/hooks/useScreenWakeLock.ts`, `web/src/hooks/useEinkMode.ts`
- Port focused personal-reader hooks into core web.
- `web/src/reader/ebookReaderApi.ts`
- Centralizes reader config and annotation API calls for tests and page usage.
- `web/src/pages/EbookReader.test.tsx`, `web/src/reader/FoliateBookReader.test.ts`
- Adds UI and wrapper tests.
## Task 1: Server-Persisted Reader Config
**Files:**
- Create: `migrations/sql/20260608000300_ebook_reader_state.sql`
- Modify: `internal/api/handlers/ebook_reader.go`
- Modify: `internal/api/handlers/ebook_reader_test.go`
- Modify: `internal/api/router.go`
- Create: `web/src/reader/ebookReaderApi.ts`
- Modify: `web/src/pages/EbookReader.tsx`
- Modify: `web/src/pages/EbookReader.test.tsx`
- [ ] **Step 1: Write failing Go handler tests**
Add tests that:
- `GET /ebooks/{content_id}/reader-config` returns `{ "config": {} }` when no row exists.
- `PUT /ebooks/{content_id}/reader-config` saves JSON config for the authenticated user/profile/content.
- inaccessible content returns 404 through the existing `ItemAccess` path.
- invalid non-object config returns 400.
Run:
```bash
go test ./internal/api/handlers -run 'TestEbookReader.*Config' -count=1
```
Expected: fail because config store/handlers do not exist.
- [ ] **Step 2: Add migration**
Create `ebook_reader_config`:
```sql
CREATE TABLE ebook_reader_config (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
profile_id TEXT NOT NULL,
content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, profile_id, content_id)
);
CREATE INDEX ebook_reader_config_profile_updated
ON ebook_reader_config (user_id, profile_id, updated_at DESC);
```
- [ ] **Step 3: Implement config store and handlers**
Add `EbookReaderConfig`, `EbookReaderConfigStore`, `HandleGetConfig`, `HandleSaveConfig`, and `PGEbookReaderConfigStore` methods in `ebook_reader.go`.
Validation:
- authenticated user required
- `content_id` required
- `config` must decode to a JSON object
- content access checked with `FileAuthorizer.ItemAccess.EnsureAccessible`
- [ ] **Step 4: Wire routes and store**
In `internal/api/router.go`, create the store when `deps.DB != nil`, assign it to `ebookReaderHandler`, and add:
```go
r.Get("/{content_id}/reader-config", ebookReaderHandler.HandleGetConfig)
r.Put("/{content_id}/reader-config", ebookReaderHandler.HandleSaveConfig)
```
- [ ] **Step 5: Add web API wrapper**
Create `web/src/reader/ebookReaderApi.ts` with:
```ts
export function ebookReaderConfigPath(contentID: string): string
export async function fetchEbookReaderConfig(contentID: string): Promise<Record<string, unknown>>
export async function saveEbookReaderConfig(contentID: string, config: Record<string, unknown>): Promise<Record<string, unknown>>
```
- [ ] **Step 6: Move settings persistence from localStorage to server**
Update `EbookReader.tsx` to fetch reader config on load and save settings through the config endpoint. Keep a local fallback only until the server value arrives.
- [ ] **Step 7: Verify and commit**
Run:
```bash
go test ./internal/api/handlers -run 'TestEbookReader.*Config' -count=1
cd web && pnpm test src/pages/EbookReader.test.tsx src/reader/FoliateBookReader.test.ts --run
cd web && pnpm build
```
Commit:
```bash
git add migrations/sql/20260608000300_ebook_reader_state.sql internal/api/handlers/ebook_reader.go internal/api/handlers/ebook_reader_test.go internal/api/router.go web/src/reader/ebookReaderApi.ts web/src/pages/EbookReader.tsx web/src/pages/EbookReader.test.tsx
git commit -m "feat: persist ebook reader config"
```
## Task 2: Server-Persisted Annotations And Bookmarks
**Files:**
- Modify: `migrations/sql/20260608000300_ebook_reader_state.sql`
- Modify: `internal/api/handlers/ebook_reader.go`
- Modify: `internal/api/handlers/ebook_reader_test.go`
- Modify: `internal/api/router.go`
- Modify: `web/src/reader/ebookReaderApi.ts`
- Modify: `web/src/reader/FoliateBookReader.tsx`
- Modify: `web/src/pages/EbookReader.tsx`
- [ ] **Step 1: Write failing annotation handler tests**
Cover list, create, update, delete. Test annotation fields:
```json
{
"kind": "highlight",
"cfi_range": "epubcfi(/6/4,/1:0,/1:12)",
"selected_text": "sample text",
"note": "note text",
"style": "highlight",
"color": "#facc15"
}
```
Run:
```bash
go test ./internal/api/handlers -run 'TestEbookReader.*Annotation|TestEbookReader.*Bookmark' -count=1
```
Expected: fail because annotation APIs do not exist.
- [ ] **Step 2: Add annotation table**
Extend migration with:
```sql
CREATE TABLE ebook_reader_annotations (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
profile_id TEXT NOT NULL,
content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('highlight', 'note', 'bookmark')),
cfi_range TEXT,
location TEXT,
selected_text TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
style TEXT NOT NULL DEFAULT 'highlight',
color TEXT NOT NULL DEFAULT '#facc15',
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK ((kind = 'bookmark' AND location IS NOT NULL) OR (kind <> 'bookmark' AND cfi_range IS NOT NULL))
);
CREATE INDEX ebook_reader_annotations_book
ON ebook_reader_annotations (user_id, profile_id, content_id, updated_at DESC);
```
- [ ] **Step 3: Implement annotation store and handlers**
Add handlers:
- `GET /ebooks/{content_id}/annotations`
- `POST /ebooks/{content_id}/annotations`
- `PATCH /ebooks/{content_id}/annotations/{annotation_id}`
- `DELETE /ebooks/{content_id}/annotations/{annotation_id}`
- [ ] **Step 4: Wire Foliate annotation drawing and selection**
Extend `FoliateBookReaderHandle` with:
- `createSelectionAnnotation()`
- `clearSelection()`
Draw stored annotations using Foliate `addAnnotation`.
- [ ] **Step 5: Add reader UI**
Add:
- selection popover
- highlight button
- note button
- bookmark button
- annotation list in the side panel
- delete/update note controls
- [ ] **Step 6: Verify and commit**
Run handler tests, web tests, and build. Commit:
```bash
git commit -m "feat: add ebook annotations and bookmarks"
```
## Task 3: Reader Tools, TTS, And Reading Aids
**Files:**
- Create: `web/src/hooks/useTTS.ts`
- Create: `web/src/hooks/useScreenWakeLock.ts`
- Create: `web/src/hooks/useEinkMode.ts`
- Modify: `web/src/reader/FoliateBookReader.tsx`
- Modify: `web/src/pages/EbookReader.tsx`
- Modify: `web/src/pages/EbookReader.test.tsx`
- [ ] **Step 1: Port focused hooks from personal reader**
Bring over:
- Web Speech TTS controller
- screen wake lock hook
- e-ink body-class hook
- [ ] **Step 2: Add readable text handle**
Expose `getReadableText()` from `FoliateBookReader` for TTS.
- [ ] **Step 3: Add UI controls**
Add side-panel controls for:
- Speak current text
- Pause/resume/stop
- voice/rate/pitch
- wake lock
- e-ink mode
- [ ] **Step 4: Add content popups and helpers**
Add selection popover actions:
- define
- translate
Add reader content popup handling for footnotes/images/tables when Foliate emits them.
- [ ] **Step 5: Verify and commit**
Run focused web tests and build. Commit:
```bash
git commit -m "feat: add ebook reader tools and aids"
```
## Task 4: Advanced Reader Settings
**Files:**
- Modify: `web/src/reader/FoliateBookReader.tsx`
- Modify: `web/src/pages/EbookReader.tsx`
- Modify: `web/src/pages/EbookReader.test.tsx`
- [ ] **Step 1: Add advanced reader settings**
Add server-persisted settings for:
- RTL
- writing mode
- zoom/scale
- brightness
- hyphenation toggle
- custom font selection/upload if API support is added in a later task
- [ ] **Step 2: Verify and commit**
Run focused tests and build. Commit:
```bash
git commit -m "feat: add ebook advanced reader settings"
```
## Self-Review
- The plan covers server persistence first, matching the user's explicit preference.
- The plan keeps ebook features in core and metadata in plugins, matching the audiobook pattern.
- The plan avoids extra ebook catalog tables; added tables are reader-user-state only.
- The biggest implementation risk is annotation rendering against Foliate selection APIs; Task 2 isolates it after backend persistence exists.
- The migration filename is intentionally after existing ebook migrations and can be adjusted if another migration is added before implementation.
@@ -0,0 +1,68 @@
# Ebook Reader Shell Parity Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Bring the core ebook reader up to the first useful parity slice from `personal/silo-plugin-ebooks`: TOC, search, progress scrub, reader settings, and keyboard navigation.
**Architecture:** Keep the current core reader API and persistence model. Extend `FoliateBookReader` with imperative methods for TOC, search, href/fraction navigation, and runtime style updates; keep `EbookReader` responsible for the chrome/panels/settings state. Store reader preferences in localStorage for this first local slice so no backend schema is added.
**Tech Stack:** React 19, React Router, TanStack Query, foliate-js custom element, existing Silo UI components, Vitest.
---
### Task 1: Reader Handle Capabilities
**Files:**
- Modify: `web/src/reader/FoliateBookReader.tsx`
- Test: `web/src/reader/FoliateBookReader.test.ts`
- [ ] Add exported types `ReaderSettings`, `ReaderSearchOptions`, `ReaderSearchResult`, and `ReaderReadyState`.
- [ ] Extend `FoliateBookReaderHandle` with `goToFraction`, `goTo`, `search`, `clearSearch`, and `applySettings`.
- [ ] Add a `settings?: ReaderSettings` prop and convert `readerStyles()` to accept settings.
- [ ] Preserve existing progress save/restore behavior.
- [ ] Verify with `pnpm vitest run src/reader/FoliateBookReader.test.ts`.
### Task 2: TOC and Search Panel
**Files:**
- Modify: `web/src/pages/EbookReader.tsx`
- Test: `web/src/pages/EbookReader.test.tsx`
- [ ] Add panel state for `toc`, `searchResults`, `searchTerm`, and current tab.
- [ ] Render side panel tabs for Contents, Search, and Settings.
- [ ] Wire TOC items to `readerRef.current.goTo(href)`.
- [ ] Wire search form to `readerRef.current.search(term)` and result clicks to `goTo(cfi)`.
- [ ] Verify with `pnpm vitest run src/pages/EbookReader.test.tsx`.
### Task 3: Reader Settings
**Files:**
- Modify: `web/src/pages/EbookReader.tsx`
- Modify: `web/src/reader/FoliateBookReader.tsx`
- Test: `web/src/pages/EbookReader.test.tsx`
- [ ] Add localStorage-backed settings for theme, font size, font family, line height, margin, max width, spread, and flow.
- [ ] Render compact controls in the Settings tab.
- [ ] Apply settings live through `FoliateBookReader`.
- [ ] Verify settings persist across remount in tests.
### Task 4: Progress Scrub and Keyboard Navigation
**Files:**
- Modify: `web/src/pages/EbookReader.tsx`
- Modify: `web/src/reader/FoliateBookReader.tsx`
- Test: `web/src/pages/EbookReader.test.tsx`
- [ ] Add a progress range input in the header.
- [ ] Call `goToFraction` on commit/change and update display from relocate events.
- [ ] Add ArrowLeft/ArrowRight keyboard navigation on the reader page.
- [ ] Verify with page-level tests.
### Task 5: Local Verification
**Files:**
- No source changes.
- [ ] Run `cd web && pnpm run build`.
- [ ] Run `GOWORK=off go test ./internal/api/handlers ./internal/scanner`.
- [ ] Keep changes local; do not push.
@@ -0,0 +1,518 @@
# Encrypt integration credentials at rest (issue #45) — implementation plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Do not edit this plan file while implementing.
**Issue:** [#45 — Encrypt arr integration credentials at rest (Requests + Autoscan)](https://github.com/Silo-Server/silo-server/issues/45)
> **Revision note (post-review):** This plan was revised after a code review. Key corrections baked in:
> the arr backfill now **resolves the legacy `server_settings` reference before encrypting** (otherwise
> rows holding a setting-name like `requests.radarr.api_key` would be encrypted as the *name*, not the
> credential); the sensitive-settings allowlist is **audited from the config loader's real inputs**
> (incl. `redis.sentinel_password` and legacy `s3.operational_*` aliases) rather than copied from the
> admin redaction map; AAD binds to **row identity** (`table:column:pk`); read paths **pass through
> legacy plaintext** and hard-fail only on corrupt ciphertext; `settingsRepo` is **re-wrapped after the
> max-connections pool recreation**; history-import session tokens are **included**; and
> `plugin_runtime_configs.config_value` is an **explicit, documented gap** (see Out of scope).
**Goal:** Stop storing third-party integration credentials in plaintext at rest. Today Sonarr/Radarr
API keys, S3 keys, and a dozen other secrets sit in the database as naked `text` — `request_integrations.api_key_ref`
and `autoscan_connections.api_key_ref` are resolved through a plaintext `server_settings` key/value
store, and both resolvers contain a **ref-vs-literal fallback**: when the secret lookup returns empty,
the stored string is used verbatim as the API key. This plan introduces a real symmetric-encryption
layer (AES-256-GCM, `SECRET_KEY`-derived via HKDF), encrypts every recoverable server-owned credential
**inline** in its owning row, deletes the ambiguous resolver fallback, and backfills existing rows
automatically on startup.
**Decisions (locked with the requester):**
- **Master key:** a **required** `SECRET_KEY` environment variable. The server fatals at startup if it
is missing. The key lives outside Postgres, so encrypted secrets survive a full DB compromise/dump.
- **Scope:** **all silo-server-owned credential storage** (was framed "full sweep"). Every recoverable
server-owned secret — arr keys, S3 keys, all sensitive `server_settings`, and the per-table token
columns — is covered. Two categories are deliberately *not* in this PR because they are a different
*shape* of problem, not because they are unimportant: equality-looked-up secrets (need **hashing**,
not encryption) and plugin runtime config JSONB (cross-repo + manifest-driven). Both are called out in
Out of scope with follow-ups.
- **Data model (best practice):** **inline-encrypted**. Store the ciphertext directly in the owning
column and decrypt on read. Drop the `server_settings` "ref" indirection and the `SecretResolver`
literal fallback entirely — this kills the ambiguity at its root and matches the repo's one existing
good example (`oauth_completion.token_ciphertext` lives inline in its own row).
- **Rollout:** idempotent Go startup **backfill** encrypts any plaintext (no-envelope-prefix) value in
place on first boot after deploy. No manual steps.
**Architecture:** A new `internal/secret` package owns the one cryptographic primitive everything reuses:
AES-256-GCM with a random 12-byte nonce, `base64.RawURLEncoding`, and a versioned `enc:v1:` envelope
prefix; the data key is HKDF-SHA256–derived from `SECRET_KEY` with a domain label. Each ciphertext is
GCM-bound to its logical **row** via additional-authenticated-data (`"table:column:<pk>"`, or
`"server_settings:<key>"`) so a DB-write attacker cannot transplant a credential blob into another row
or column. Three wiring seams consume the cipher: (1) an `EncryptedSettingsRepo` **decorator** around
`catalog.ServerSettingsRepo` that transparently encrypts/decrypts the sensitive `server_settings` keys,
so `config.LoadFromDB` and every downstream consumer keep seeing plaintext; (2) the two `api_key_ref`
columns become inline ciphertext and the entire `SecretResolver` indirection is deleted from
`internal/requests` and `internal/autoscan`; (3) a handful of per-table repos gain a `*secret.Cipher`
and encrypt-on-write / decrypt-on-read. A startup backfill sweeps all groups idempotently — with a
**special resolve-then-encrypt path for the two arr columns** so legacy `server_settings` references are
collapsed to the real credential before encryption.
**Out of scope (each a deliberate, documented decision — not an oversight):**
- **Equality-looked-up secrets → need hashing, not encryption.** `api_keys.api_key`
(`WHERE api_key = $1`) and `webhook_sync_connections.webhook_secret` (`WHERE webhook_secret = $1`,
[repo.go:101](internal/webhooksync/repo.go:101)) are matched by exact value. AES-GCM uses a fresh
random nonce, so the same plaintext encrypts differently each time — encrypting these would silently
break API-key auth and inbound-webhook routing. The correct fix is a deterministic **hash** column +
lookup-by-hash, a distinct lookup-semantics + migration change. **Follow-up issue.**
- **`plugin_runtime_configs.config_value` → cross-repo + manifest-driven.** This is opaque plugin-defined
JSONB ([runtime_config.go:55](internal/plugins/runtime_config.go:55)); some plugins store secrets in it
(e.g. the legacy one-time copy of `introdb.api_key` at [main.go:2341](cmd/silo/main.go:2341)). It is
read/written only through `RuntimeConfigStore` *in this repo*, so whole-blob encrypt-on-write /
decrypt-on-read at that store is technically feasible — **but** host-side plugin runtime lives in the
separate **`Silo`** repo (per CLAUDE.md) and may read this table directly, and which fields are secret
is declared in each plugin's manifest. Doing this correctly needs a coordinated, manifest-aware design
across repos. **Explicitly deferred — known gap.** Note: while deferred, the legacy `introdb.api_key`
copy means that secret still exists in plaintext inside plugin config even after the `server_settings`
copy is encrypted; the follow-up must re-encrypt or purge it.
- **`plex_sync_connections`** — dead table, zero Go references (superseded by `webhook_sync_connections`
in migration `060`). Not touched.
- **Already-protected / non-secret:** `users.password_hash` (bcrypt), `oauth_completion.token_ciphertext`
(already AES-GCM), `abs_sessions.token_hash` / `oauth_completion.code_hash` (hashed), `oauth_session.state`
(equality CSRF), `subtitle_provider_config.username` (a username, not a secret),
`history_import_plex_sessions.pin_id`/`pin_code` (transient OAuth pairing handshake, not a stored
access credential).
- `internal/auth/oauth_store.go` is left as-is (its key is jwt-derived and its data is ephemeral).
Optionally a shared low-level seal/open helper can be extracted later; not required here.
**Tech Stack:** Go 1.26.3 (module declares `go 1.26.3`; stdlib `crypto/hkdf` is available — no new
dependency). PostgreSQL via `pgxpool`. **No schema migration** — every target column is already `text`
(or `jsonb` left untouched) and now simply holds ciphertext; the backfill is pure Go. No frontend or
client (silo-android / silo-apple) changes — API responses already mask these values to `has_api_key` /
`has_credentials` booleans, so no contract changes.
Commands assume the repository root is the cwd.
---
## Running tests
The `internal/secret` package is pure Go (no libvips/CGO). If a Go toolchain is on the host:
```bash
GOWORK=off go test ./internal/secret/... -v
```
`GOWORK=off` is required when running from a `.claude/worktrees/` checkout — the parent `go.work`
otherwise pins the module to `main` and the build fails with "main module does not contain package".
If the host has no Go toolchain, run in a throwaway container (a named volume caches module downloads):
```bash
docker run --rm -v "$PWD":/app -w /app -v silo-gomod:/go/pkg/mod \
-e GOFLAGS=-mod=mod golang:1.26 \
go test ./internal/secret/... -v
```
Full backend build/vet before opening the MR: `GOWORK=off go build ./...` then `make lint` and
`make verify-local-paths`.
---
## File structure
**New files:**
- `internal/secret/cipher.go` — **create**. `Cipher` type; `New`, `Encrypt`, `Decrypt`, `IsEncrypted`,
`EncryptIfPlaintext`; envelope + HKDF + version dispatch. Row-bound AAD passed by callers.
- `internal/secret/cipher_test.go` — **create**. Round-trip, tamper/AAD-mismatch rejection, wrong-key,
`IsEncrypted` edge cases, version dispatch, idempotency.
- `internal/secret/backfill.go` — **create**. Generic in-place backfill for literal-credential columns +
the special resolve-then-encrypt backfill for the two arr columns.
- `internal/secret/backfill_test.go` — **create**. Idempotency, mixed plaintext/ciphertext rows, empty
values skipped, failure isolation, arr resolve-then-encrypt.
**Modified files:**
- `internal/config/bootstrap.go` — add `SecretKey []byte` to `BootstrapConfig`; read `SECRET_KEY`; fatal
if missing or `< 32` chars.
- `cmd/silo/main.go` — construct `*secret.Cipher` after bootstrap; build the `EncryptedSettingsRepo`;
run the settings + column backfills after migrations and before settings are read; **re-wrap
`settingsRepo` after the max-connections pool recreation (line ~399)**; thread the cipher into the
affected repo constructors; delete the `SetSecretResolver` wiring.
- `internal/catalog/encrypted_settings_repo.go` — **create**. `EncryptedSettingsRepo` decorator +
exported, **audited** `SensitiveSettingKeys` (single source of truth) + `BackfillSensitiveSettings`.
- `internal/catalog/server_settings_repo.go` — unchanged behavior; remains the raw inner store.
- `internal/api/handlers/admin.go` — replace the local `sensitiveSettingKeys` map with
`catalog.SensitiveSettingKeys` (redaction now shares the audited allowlist; closes the prior gap where
`redis.sentinel_password` and `s3.operational_*` were returned in plaintext).
- `internal/requests/repository.go` — `Repository` gains `*secret.Cipher`; encrypt `api_key_ref` on
insert/update (AAD bound to integration id); decrypt (pass-through legacy plaintext) in `scanIntegration`.
- `internal/requests/service.go` — delete `SecretResolver` interface, `secrets` field,
`SetSecretResolver`, and `resolveAPIKey`; call sites use the already-decrypted `Integration.APIKeyRef`.
- `internal/autoscan/repository.go` — `Repository` gains `*secret.Cipher`; encrypt `api_key_ref` on
create/update (AAD bound to connection id); decrypt (pass-through legacy) in `scanConnection`.
- `internal/autoscan/connection.go` — delete `SecretResolver` interface, `secrets` field, and the
secret-resolution branch in `Resolve`; linked-integration path uses the requests repo's decrypted key.
- `internal/api/autoscan_wiring.go` — drop the `AutoscanSecretResolver` param from `BuildAutoscanService`.
- `internal/api/router.go` — drop the `SetSecretResolver(settingsRepo)` calls; pass the
`EncryptedSettingsRepo` where `settingsRepo` is used; thread the cipher into repo constructors.
- `internal/subtitles/pgrepo.go` — `*secret.Cipher`; encrypt `api_key` + `password` on upsert; decrypt on
read. PK for AAD = `provider_name`.
- `internal/watchsync/repository.go` — `*secret.Cipher`; encrypt `access_token` + `refresh_token` on
upsert; decrypt in a `decryptConnection` helper called after every scan. PK for AAD = connection `id`.
- `internal/webhooksync/repo.go` — `*secret.Cipher`; encrypt `access_token` on create; decrypt on scan.
**`webhook_secret` is NOT encrypted** (equality lookup — carved out). PK for AAD = connection `id`.
- `internal/historyimport/repo_admin.go` — `*secret.Cipher`; encrypt in `SetSourceAdminToken`; decrypt in
`GetSourceWithAdminToken`. PK for AAD = source `id`.
- `internal/historyimport/repo.go` — `*secret.Cipher`; encrypt `connect_access_token`
(`history_import_connect_sessions`) + `auth_token` (`history_import_plex_sessions`) on insert/update;
decrypt on scan. PK for AAD = session `id`.
- `docs/architecture/secret-encryption.md` — **create**. Operator runbook (SECRET_KEY generation, backup,
key-loss, rollback/downgrade).
**No migration files.** All target columns are already `text`; the backfill is Go-side.
---
## Reference tables
### What is encrypted vs hashed vs excluded
AAD column shows the GCM additional-authenticated-data string; `<pk>` is the row's stable primary key.
| Group | Table.column | AAD binding | Action |
| --- | --- | --- | --- |
| Arr keys | `request_integrations.api_key_ref` | `request_integrations:api_key_ref:<id>` | **Resolve-then-encrypt** (backfill) / encrypt inline (writes) |
| Arr keys | `autoscan_connections.api_key_ref` | `autoscan_connections:api_key_ref:<id>` | **Resolve-then-encrypt** (backfill) / encrypt inline (writes) |
| Settings | every key in `SensitiveSettingKeys` (see audited list below) | `server_settings:<key>` | **Encrypt via decorator** |
| Subtitles | `subtitle_provider_config.api_key`, `.password` | `subtitle_provider_config:<col>:<provider_name>` | **Encrypt inline** |
| Watch sync | `watch_provider_connections.access_token`, `.refresh_token` | `watch_provider_connections:<col>:<id>` | **Encrypt inline** |
| Webhook sync | `webhook_sync_connections.access_token` | `webhook_sync_connections:access_token:<id>` | **Encrypt inline** |
| History import | `history_import_sources.admin_token` | `history_import_sources:admin_token:<id>` | **Encrypt inline** |
| History import | `history_import_connect_sessions.connect_access_token` | `history_import_connect_sessions:connect_access_token:<id>` | **Encrypt inline** |
| History import | `history_import_plex_sessions.auth_token` | `history_import_plex_sessions:auth_token:<id>` | **Encrypt inline** |
| — | `api_keys.api_key` | — | **Carved out → hash** (follow-up) |
| — | `webhook_sync_connections.webhook_secret` | — | **Carved out → hash** (follow-up) |
| — | `plugin_runtime_configs.config_value` | — | **Deferred → cross-repo/manifest design** (follow-up) |
| — | `plex_sync_connections.*` | — | **Excluded** (dead table) |
### Audited `SensitiveSettingKeys` (single source of truth)
Built from the config loader's actual secret inputs ([internal/config/db_loader.go](internal/config/db_loader.go))
plus the admin redaction map plus other known consumers — **not** just the current redaction map. The
union is intentional and also fixes a pre-existing redaction leak. Implementers must re-audit
`db_loader.go` and `grep` for any `*secret*`/`*token*`/`*password*`/`*api_key*` settings keys before
finalizing.
```
auth.jwt_secret
s3.public_access_key s3.public_secret_key s3.public_token_secret
s3.private_access_key s3.private_secret_key
s3.user_db_access_key s3.user_db_secret_key
s3.operational_access_key s3.operational_secret_key s3.operational_token_secret # legacy aliases still read as fallbacks (db_loader L165-192); migration 086 copied but did not delete them
redis.url # may embed credentials (redis://:pass@host)
redis.sentinel_password # read at db_loader L334 — MISSING from the old redaction map
tmdb.api_key
mdblist.api_key
introdb.api_key # read in main.go (legacy → plugin copy)
subtitle_ai.api_key
recommendations.embedding_auth_token recommendations.openai_api_key # legacy alias (db_loader L409)
watchsync.trakt.client_id watchsync.trakt.client_secret
watchsync.simkl.client_id watchsync.simkl.client_secret
requests.radarr.api_key requests.sonarr.api_key # legacy single-instance arr keys
```
`auth.jwt_secret` IS encrypted at rest under `SECRET_KEY` (it is no longer the encryption root — `SECRET_KEY`
is). `redis.url`/client-id entries are not high-value secrets but are kept in the set as a harmless safe
superset and to match existing redaction.
### Envelope format
```
enc:v1:<base64.RawURLEncoding( nonce[12] ‖ gcm.Seal(...) )>
```
- `enc:v1:` — exactly 7 ASCII chars; not valid base64url, not a UUID/JWT/URL/integer prefix. Collision-proof
against any real plaintext credential in practice.
- `IsEncrypted(s)` ≡ `strings.HasPrefix(s, "enc:v1:")`.
- Decrypt parses the version (`strings.SplitN(s, ":", 3)`), dispatches to the v1 opener; an unknown
version returns an explicit `ErrUnknownVersion`. This is the forward-compat hook for a future `enc:v2:`
key rotation — implement the dispatch now even though only `v1` exists.
### Read-path contract (applies to every decrypt site — decorator and repos)
1. `value == ""` → return `""`.
2. `!IsEncrypted(value)` → **return the value unchanged** (legacy plaintext pass-through during the
backfill window; the value is no worse than today). This is unambiguous — there is no longer any
"ref vs literal" question because the `_ref` indirection is gone.
3. `IsEncrypted(value)` → `Decrypt`; **any error (wrong key, tamper, truncation) is propagated, never
swallowed and never returned as the ciphertext string.** A corrupt ciphertext fails the operation.
This reconciles the best-effort backfill (a skipped/failed row stays readable as plaintext) with the
hard guarantee that a real `enc:v1:` value never silently degrades to using ciphertext as a credential.
---
## Task 1: The `internal/secret` cipher package
**Files:** Create `internal/secret/cipher.go`, `internal/secret/cipher_test.go`.
This is the foundation; it has no dependencies on `catalog`/`config` and is independently testable.
- [ ] **Step 1: `Cipher` type + `New`.** Derive a 32-byte AES key with stdlib
`hkdf.Key(sha256.New, masterKey, nil /*salt*/, "silo/data-encryption/v1", 32)`. Return an error if
`len(masterKey) < 32`. Store the derived key as `[32]byte`. The type is safe for concurrent use.
- [ ] **Step 2: `Encrypt(plaintext, aad string) (string, error)`.** Empty plaintext returns `"", nil`
(do not produce an `enc:v1:` blob for an empty secret). Otherwise: `aes.NewCipher` → `cipher.NewGCM` →
12-byte `rand` nonce → `gcm.Seal(nonce, nonce, []byte(plaintext), []byte(aad))` →
`envelopePrefix + base64.RawURLEncoding.EncodeToString(sealed)`. `aad` is the row-bound context string.
- [ ] **Step 3: `Decrypt(ciphertext, aad string) (string, error)`.** Strip + validate the version prefix;
unknown version → `ErrUnknownVersion`. base64-decode; reject if shorter than the nonce size; split
nonce/body; `gcm.Open(nil, nonce, body, []byte(aad))`. **Any failure returns an error — never fall back
to returning the ciphertext or an empty string.** (Pass-through of *non-prefixed* plaintext is the
caller's job per the read-path contract, not `Decrypt`'s.)
- [ ] **Step 4: `IsEncrypted(s string) bool`** and `EncryptIfPlaintext(s, aad string) (string, bool, error)`.
`EncryptIfPlaintext` returns `(s, false, nil)` when already encrypted or empty; otherwise encrypts and
returns `(ct, true, nil)`. This is the idempotent primitive the backfill uses.
- [ ] **Step 5: Tests** (`cipher_test.go`):
- round-trip with AAD recovers plaintext; ciphertext has the `enc:v1:` prefix.
- two encryptions of the same plaintext+AAD differ (random nonce works).
- tamper: flip one byte of the decoded body → `Decrypt` errors.
- AAD mismatch: encrypt with `"t:c:1"`, decrypt with `"t:c:2"` → errors (proves row binding).
- wrong key: encrypt with key A, decrypt with key B → errors.
- `New` with a `< 32`-byte key → error.
- `IsEncrypted`: `"enc:v1:x"`→true, `"sa_abc"`→false, `""`→false, `"ENC:V1:x"`→false.
- unknown version `"enc:v99:..."` → `ErrUnknownVersion`.
- `EncryptIfPlaintext` is idempotent (second call is a no-op, `changed=false`).
- [ ] **Step 6:** `GOWORK=off go test ./internal/secret/... -v` passes.
---
## Task 2: `SECRET_KEY` bootstrap + cipher construction
**Files:** Modify `internal/config/bootstrap.go`, `cmd/silo/main.go`.
- [ ] **Step 1:** Add `SecretKey []byte` to `BootstrapConfig`. In `LoadBootstrap`, after the
`DATABASE_URL` check, read `os.Getenv("SECRET_KEY")`; return an error (which the existing
`log.Fatalf` path surfaces) if it is empty or `< 32` characters. Message must be actionable, e.g.
`SECRET_KEY is required (>=32 chars); generate one with: openssl rand -base64 48`. `godotenv` already
loads `.env`, so dev sets it there.
- [ ] **Step 2:** In `cmd/silo/main.go`, immediately after `LoadBootstrap` returns, construct the cipher:
`dataCipher, err := secret.New(bc.SecretKey)` and `log.Fatalf` on error. The cipher is threaded
explicitly as a dependency — **never** a package-level global.
- [ ] **Step 3:** Add `SECRET_KEY=...` to `.env.example` (or the documented dev env) and note it in the
operator runbook (Task 7).
- [ ] **Step 4:** `GOWORK=off go build ./cmd/silo` compiles; starting without `SECRET_KEY` fatals with the
actionable message.
---
## Task 3: `server_settings` encryption decorator
**Files:** Create `internal/catalog/encrypted_settings_repo.go`; modify `internal/api/handlers/admin.go`,
`cmd/silo/main.go`, `internal/api/router.go`.
- [ ] **Step 1: Build the audited allowlist.** Create exported `var SensitiveSettingKeys map[string]bool`
in `internal/catalog` populated from the "Audited `SensitiveSettingKeys`" list above. **Do not** simply
move the admin redaction map — re-audit `internal/config/db_loader.go` (and other `server_settings`
consumers) for every secret-bearing key, and include the legacy aliases (`s3.operational_*`,
`recommendations.openai_api_key`) and `redis.sentinel_password`. Update `admin.go`
(`HandleGetSettings` redaction, `HandleGetSensitiveStatus`, the `PUT` response branch) to reference
`catalog.SensitiveSettingKeys`. One allowlist now drives **both** redaction and encryption.
- [ ] **Step 2:** Implement `EncryptedSettingsRepo` wrapping `*ServerSettingsRepo` + `*secret.Cipher`:
- `Set(key, value)`: if `SensitiveSettingKeys[key]` and `value != ""`, encrypt with AAD
`"server_settings:"+key` before delegating; else delegate raw.
- `Get(key)` / `GetAll()`: delegate, then apply the **read-path contract** to each sensitive key
(pass through legacy plaintext; decrypt prefixed values; propagate decrypt errors).
- [ ] **Step 3:** Add `BackfillSensitiveSettings(ctx)` on the decorator: for each key in
`SensitiveSettingKeys`, read the raw inner value; if non-empty and not `IsEncrypted`, `inner.Set` the
encrypted value (AAD `"server_settings:"+key`). Idempotent. Returns a count + aggregated errors.
- [ ] **Step 4:** In `cmd/silo/main.go`, build `settingsRepo` as the `EncryptedSettingsRepo` wrapping the
raw `catalog.NewServerSettingsRepo(pool)`. Confirm the auto-generate-`jwt_secret` step still works: the
`GetAll` map carries plaintext, the `== ""` check is unchanged, and `Set("auth.jwt_secret", encoded)`
now writes **encrypted** while the in-memory map keeps the plaintext for `LoadFromDB`. Any function
currently typed to the concrete `*catalog.ServerSettingsRepo` changes to the `ServerSettingsStore`
interface that both already satisfy.
- [ ] **Step 5 (re-wrap after pool recreation):** `cmd/silo/main.go` rebuilds a **raw**
`catalog.NewServerSettingsRepo(pool)` after recreating the pool for `max_connections` (line ~399).
Re-wrap it there too (`settingsRepo = catalog.NewEncryptedSettingsRepo(catalog.NewServerSettingsRepo(pool), dataCipher)`)
so every later consumer gets the encrypting repo, not a raw one. Audit for any other raw construction
of the settings repo and wrap each.
- [ ] **Step 6:** Tests: decorator round-trip (raw store holds `enc:v1:`, decorator `Get` returns
plaintext); non-sensitive keys pass through unencrypted; legacy-plaintext pass-through on read;
`BackfillSensitiveSettings` idempotency.
- [ ] **Step 7:** `GOWORK=off go build ./...` compiles; admin settings GET still redacts (now including
`redis.sentinel_password`); a sensitive key written via the admin API lands as `enc:v1:` in the DB.
---
## Task 4: Inline-encrypt arr keys + delete the `SecretResolver` indirection
**Files:** Modify `internal/requests/repository.go`, `internal/requests/service.go`,
`internal/autoscan/repository.go`, `internal/autoscan/connection.go`,
`internal/api/autoscan_wiring.go`, `internal/api/router.go`, `cmd/silo/main.go`.
> The legacy-data resolution for these two columns is handled by the **resolve-then-encrypt backfill** in
> Task 6 (a row may currently hold a `server_settings` key name, not a literal key). The repo write path
> below only ever encrypts the literal credential an admin submits going forward.
- [ ] **Step 1:** `requests.NewRepository` gains a `*secret.Cipher` parameter. In `insertIntegration` and
`updateIntegration`, encrypt `i.APIKeyRef` (AAD `"request_integrations:api_key_ref:"+i.ID`) before
binding — preserve the existing `CASE WHEN $5 = '' THEN api_key_ref ELSE $5 END` keep-existing
semantics (encrypt only the non-empty incoming value). In `scanIntegration`, apply the read-path
contract to the scanned `api_key_ref`.
- [ ] **Step 2:** Delete `requests.SecretResolver` (interface), the `Service.secrets` field,
`Service.SetSecretResolver`, and `Service.resolveAPIKey`. Every former `resolveAPIKey(ctx, integration)`
call site uses `strings.TrimSpace(integration.APIKeyRef)` directly (the repo already decrypted it).
- [ ] **Step 3:** `autoscan.NewRepository` gains a `*secret.Cipher`. Encrypt `api_key_ref` in
`CreateConnection` / `UpdateConnection` (AAD `"autoscan_connections:api_key_ref:"+id`, same
keep-existing CASE semantics); apply the read-path contract in `scanConnection`.
- [ ] **Step 4:** In `internal/autoscan/connection.go`, delete the `SecretResolver` interface, the
`ConnectionResolver.secrets` field, and the secret-resolution branch in `Resolve`. For a linked Requests
integration, `RequestIntegrationLookup.Get` returns the requests repo's **already-decrypted** key — use
it directly. For a standalone connection, the autoscan repo already decrypted it. `Resolve` now only
resolves base URL + linkage and returns plaintext.
- [ ] **Step 5:** Remove the `AutoscanSecretResolver` parameter from `BuildAutoscanService`
(`internal/api/autoscan_wiring.go`) and drop the `SetSecretResolver(settingsRepo)` calls in
`internal/api/router.go` and `cmd/silo/main.go` (including the reconcile-worker service). Thread
`dataCipher` into the requests/autoscan repo constructors at all wiring sites.
- [ ] **Step 6:** Verify no API response serializes the raw key: the integration/connection response DTOs
still emit only `has_api_key`. Add/extend a test asserting the create→store→read round-trip yields the
original key, and that the stored column value is `enc:v1:`-prefixed.
- [ ] **Step 7:** `GOWORK=off go build ./...`; `GOWORK=off go test ./internal/requests/... ./internal/autoscan/...`.
---
## Task 5: Per-table credential columns
**Files:** Modify `internal/subtitles/pgrepo.go`, `internal/watchsync/repository.go`,
`internal/webhooksync/repo.go`, `internal/historyimport/repo_admin.go`, `internal/historyimport/repo.go`,
plus their constructor call sites.
Each repo gains a `*secret.Cipher` constructor arg and follows encrypt-on-write / decrypt-on-read with a
row-bound AAD. Preserve existing keep-existing CASE/COALESCE semantics — encrypt only non-empty incoming
values. All read paths use the read-path contract (pass through legacy plaintext, hard-fail corrupt
ciphertext). These columns **never** had the resolver indirection — they always stored literal
credentials — so no resolve-then-encrypt is needed (unlike the arr columns).
- [ ] **Step 1: subtitles** — encrypt `api_key` + `password` in `UpsertProviderConfig`; decrypt in
`ListProviderConfigs` and `GetProviderConfig`. Keep the existing `HasAPIKey`/`HasCredentials` flags
(set them from the decrypted values). AAD PK = `provider_name`.
- [ ] **Step 2: watchsync** — encrypt `access_token` + `refresh_token` in `UpsertConnection`. Add a
`decryptConnection(*Connection)` helper and call it after **every** scan path (list/get/by-provider/etc.
— there are several). AAD PK = connection `id`.
- [ ] **Step 3: webhooksync** — encrypt `access_token` in `CreateConnection`; decrypt in `scanConnection`.
**Do NOT touch `webhook_secret`** — it is equality-looked-up in `GetConnectionBySecret`
(`WHERE webhook_secret = $1`) and is carved out for the hashing follow-up. AAD PK = connection `id`.
- [ ] **Step 4: historyimport admin token** — encrypt in `SetSourceAdminToken`; decrypt in
`GetSourceWithAdminToken`; `ClearSourceAdminToken` (writes NULL) is unchanged. AAD PK = source `id`.
- [ ] **Step 5: historyimport session tokens** — in `internal/historyimport/repo.go`, encrypt
`connect_access_token` (`history_import_connect_sessions`) and `auth_token`
(`history_import_plex_sessions`) on insert/update; decrypt on scan (note the existing
`COALESCE(auth_token, '')` read). These are read by session `id`, not by token value. `pin_id`/`pin_code`
are transient pairing artifacts — leave them. AAD PK = session `id`.
- [ ] **Step 6:** Thread `dataCipher` into each constructor at its wiring site (`router.go` / `main.go`).
- [ ] **Step 7:** `GOWORK=off go build ./...`; package tests for each modified repo pass.
---
## Task 6: Idempotent startup backfill
**Files:** Create `internal/secret/backfill.go`, `internal/secret/backfill_test.go`; modify `cmd/silo/main.go`.
- [ ] **Step 1: Generic in-place backfill** for the literal-credential columns (subtitle ×2, watchsync ×2,
webhook `access_token`, historyimport `admin_token`, historyimport session `connect_access_token` +
`auth_token`). Per `BackfillTarget {table, column, idColumn, aadPrefix}`: select rows where the column
is non-null, non-empty, and `NOT LIKE 'enc:v1:%'`; for each, compute AAD = `aadPrefix + ":" + id`,
`EncryptIfPlaintext`, and `UPDATE ... SET col = $1 WHERE id = $2 AND col = $3` (the `AND col = $3` guard
makes concurrent multi-node boots converge without double-encrypting). Empty values are skipped (never
encrypt `""`).
- [ ] **Step 2: Resolve-then-encrypt backfill for the two arr columns** (`request_integrations.api_key_ref`,
`autoscan_connections.api_key_ref`). This is **separate** from Step 1 because legacy rows may hold a
`server_settings` key name rather than a literal key. For each row whose value is not already
`enc:v1:`-prefixed, replicate the old `resolveAPIKey` semantics **once**:
1. `resolved := encryptedSettingsRepo.Get(ctx, value)` (the decrypting decorator — handles both a
sensitive-and-encrypted target like `requests.radarr.api_key` and a plaintext custom key).
2. If `resolved` is non-empty, the row held a *reference* → encrypt `resolved` inline.
Else the row held a *literal* key → encrypt `value` inline.
3. `UPDATE ... SET api_key_ref = <enc> WHERE id = $2 AND api_key_ref = <original>`, AAD bound to the row id.
Run this **after** `BackfillSensitiveSettings` so the referenced settings are consistent (the decorator
decrypts regardless, but ordering keeps it deterministic). Optionally log/flag now-orphaned
`requests.{radarr,sonarr}.api_key` settings rows for a later cleanup (do not delete in this PR).
- [ ] **Step 3: Failure posture (decided):** best-effort. Collect per-row/per-target errors, log each at
ERROR, and emit a single startup summary line (`secret backfill: encrypted N, failed M`). A failed
encrypt leaves the pre-existing plaintext (no *new* exposure vs. today) and reads still succeed via the
read-path pass-through, so it must **not** block boot — reliability over a hard stop. (Read-path decrypt
failures on an `enc:v1:` value still hard-fail the individual operation.)
- [ ] **Step 4: Ordering in `cmd/silo/main.go`** — gate on migration-running modes only (mirror the
existing migration guard; proxy/transcode nodes skip the backfill). Sequence:
`LoadBootstrap (read SECRET_KEY) → open pool → RunMigrations → construct dataCipher → wrap settingsRepo →
BackfillSensitiveSettings → generic column backfill → arr resolve-then-encrypt backfill → GetAll(decrypts)
→ auto-gen jwt_secret (writes encrypted) → [pool recreate? re-wrap settingsRepo] → LoadFromDB(plaintext) → validate`.
The settings backfill + `GetAll` must precede `LoadFromDB` so config consumers read decrypted values.
- [ ] **Step 5: Concurrency note** — the `AND col = $3` idempotency guard is sufficient for the common
single-primary deploy. For multi-primary races, optionally wrap the backfill in a dedicated Postgres
advisory lock with a **fresh** lock id (do **not** reuse the migration `legacyBootstrapLocker` id — that
would extend the migration lock's held duration unpredictably).
- [ ] **Step 6: Tests** (`backfill_test.go`, against a test DB): seed plaintext rows → backfill →
all become `enc:v1:` and decrypt to the originals; second run is a no-op (no double-encrypt); mixed
plaintext/ciphertext rows handled; empty values skipped; one failing row does not abort the rest;
**arr resolve-then-encrypt:** a row holding `requests.radarr.api_key` (with that settings key present)
ends up encrypting the *resolved* credential, and a row holding a literal key encrypts the literal.
- [ ] **Step 7:** Boot against a DB pre-seeded with plaintext credentials **and** a legacy arr row that
references a settings key; confirm the summary log, that every target column is `enc:v1:` afterward, that
the arr row decrypts to the real Radarr key (not the setting name), and that a second boot reports
`encrypted 0`.
---
## Task 7: Verification, docs, follow-up
- [ ] **Step 1: Regression guard tests:**
- API key auth still works — `api_keys.api_key` is stored as the literal `sa_`-prefixed token and
`GetByKey` (`WHERE api_key = $1`) still resolves (proves it was *not* routed through encryption).
- `auth.jwt_secret` is consistent across two simulated boots (encrypted at rest, plaintext to
`LoadFromDB`), and the stored `server_settings` row is `enc:v1:`-prefixed.
- Inbound webhook routing still works (`webhook_secret` untouched).
- A legacy arr integration that referenced `requests.sonarr.api_key` still fulfills after backfill.
- [ ] **Step 2:** `GOWORK=off go build ./...`, `make lint`, `make verify-local-paths`,
`cd web && pnpm run lint && pnpm run format:check` (frontend unaffected — confirm green).
- [ ] **Step 3:** Write `docs/architecture/secret-encryption.md`: how `SECRET_KEY` is generated, that it
must be backed up **separately** from DB dumps (treat like a CA private key), what happens on key loss
(encrypted secrets unrecoverable → re-enter integrations), the rollback/downgrade hazard (an old binary
reads `enc:v1:auth.jwt_secret` as a literal JWT secret → all sessions invalid; document that downgrade
requires decrypting settings back to plaintext or clearing `auth.jwt_secret`), and the envelope/version
scheme for future rotation.
- [ ] **Step 4: File the follow-up issues:**
1. **Hash equality-lookup secrets** (`api_keys.api_key`, `webhook_sync_connections.webhook_secret`) —
store a deterministic hash, look up by hash.
2. **Encrypt plugin runtime config** (`plugin_runtime_configs.config_value`) — coordinated with the
`Silo` host repo and plugin-manifest secret-field metadata; must also re-encrypt/purge the legacy
plaintext `introdb.api_key` copy. Note the dead `plex_sync_connections` table as a drop candidate.
- [ ] **Step 5:** Update the issue #45 acceptance check: no plaintext arr keys at rest (Requests +
Autoscan); `api_key_ref` is unambiguous (inline ciphertext, no literal fallback); existing rows
encrypted by the backfill (arr rows resolved-then-encrypted); S3 + the broader server-owned credential
set covered; remaining gaps (equality-lookup hashing, plugin JSONB) tracked as follow-ups.
---
## Safety checklist (the implementer must not skip)
1. `SECRET_KEY` is read in `LoadBootstrap` and **fatals** if absent/short — never defaults to a zero key
or a hash of `DATABASE_URL`.
2. The cipher is constructed once, right after bootstrap, before any `settingsRepo` call — threaded as a
dependency, never a global.
3. The settings backfill + decryption happen **before** `config.LoadFromDB` consumes the map.
4. The auto-generated `auth.jwt_secret` is written **encrypted**, while the in-memory map keeps plaintext
for `LoadFromDB`.
5. **`settingsRepo` is re-wrapped as the `EncryptedSettingsRepo` after the max-connections pool recreation
(main.go ~399)** and at every other construction site — no raw settings repo escapes into later wiring.
6. The two **arr columns** use the **resolve-then-encrypt** backfill (not in-place) so legacy
`server_settings` references become the real credential before encryption; the other columns use the
plain in-place backfill.
7. `SensitiveSettingKeys` is **audited from the config loader's real inputs** (incl.
`redis.sentinel_password` and the legacy `s3.operational_*` / `recommendations.openai_api_key`
aliases), not copied from the old redaction map.
8. `api_keys.api_key` and `webhook_sync_connections.webhook_secret` are **never** routed through
encryption — they are equality-looked-up and would break auth/routing (separate hashing task).
9. Every decrypt site obeys the **read-path contract**: pass through non-prefixed plaintext; **error
(never fall back)** on a corrupt `enc:v1:` value. Audit every old `if resolved == ""` site removed in Task 4.
10. AAD binds to **row identity** (`table:column:<pk>`); PKs in scope (integration/connection ids, uuids,
`provider_name`) are stable. server_settings binds to its key.
11. The backfill is prefix-gated and idempotent (`AND col = $3` guard); proxy/transcode nodes skip it.
12. `plex_sync_connections` is not a target (dead table); `plugin_runtime_configs.config_value` is a
documented deferred gap, not silently ignored; `subtitle_provider_config.username` stays plaintext.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,377 @@
# AI Services Core, Metadata Translation & Whisper ASR — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Design:** `docs/superpowers/specs/2026-06-10-ai-translation-and-asr-design.md` (read it first; this plan does not repeat the rationale).
**Goal:** Extract the subtitle-AI LLM plumbing into shared packages (`internal/ai/llm`, `internal/ai/translate`, `internal/ai/jobrunner`), add AI translation of overviews/taglines into the existing localization tables with field provenance, and add Whisper ASR subtitle generation (`transcribe`, `transcribe_translate`). Single PR.
**Architecture:** New `internal/ai/*` packages consumed by a refactored `internal/subtitles/ai` and a new `internal/metadata/translation` service. One new Goose migration (jobs table, provenance columns, library auto-translate flag). New `ai.*` settings keys with loader fallback to legacy `subtitle_ai.*` rows (encrypted values are GCM-bound to their key — never rename rows in SQL). Frontend: AI Services settings page, metadata-editor translate action, library toggle, player "generate from audio" mode.
**Tech stack:** Go (chi, pgx), PostgreSQL via Goose SQL migrations, React + TypeScript (TanStack Query), Vitest.
Commands assume the repository root is the cwd.
---
## Ordering and verification
Tasks 1–4 are a pure refactor and must leave subtitle translation behaviorally unchanged — run `go build ./... && go test ./internal/...` after each. Tasks 5–11 are backend features; 12–15 frontend; 16 final verification.
Before opening the MR: `make lint`, `cd web && pnpm run lint && pnpm run format:check`, `make verify-local-paths`.
---
## File structure
**Create**
- `internal/ai/llm/client.go`, `client_test.go`, `config.go` — shared OpenAI-compatible client (chat + transcription)
- `internal/ai/llm/transcribe.go` — multipart `/v1/audio/transcriptions`, `verbose_json` types
- `internal/ai/translate/translate.go`, `translate_test.go` — generic segment batch translator (package `aitranslate`)
- `internal/ai/jobrunner/runner.go`, `runner_test.go` — dispatch/semaphore/heartbeat/reaper/cancel registry
- `internal/metadata/translation/{job.go,pgrepo.go,service.go,prompt.go,service_test.go}`
- `internal/subtitles/ai/transcriber.go`, `transcriber_test.go` — ASR pipeline
- `internal/playback/audio_extract.go` — ffmpeg chunked audio extraction helper
- `internal/api/handlers/metadata_ai.go`
- `migrations/sql/<timestamp>_ai_metadata_translation_and_asr.sql` (via `make migrate-create`)
- `web/src/pages/admin-settings/AIServicesSettings.tsx`
**Modify**
- `internal/subtitles/ai/{client.go→deleted,translator.go,service.go,engine.go,job.go,config.go,pgrepo.go}`
- `internal/config/{config.go,db_loader.go}`; `internal/catalog/encrypted_settings_repo.go`
- `internal/catalog/{localization_repo.go,detail.go}`; `internal/models/media.go`
- `internal/metadata/service.go` (provider provenance + auto-enqueue hook)
- `internal/api/handlers/subtitle_ai.go`; router registration; `cmd/silo/main.go` wiring
- `web/src/pages/admin-settings/SubtitlesSettings.tsx`, `web/src/components/EditMetadataDialog.tsx`, `web/src/player/components/SubtitleTranslateModal.tsx`, library settings form, `web/src/api/` types/client, admin settings nav/route registration
---
## Task 1: `internal/ai/llm` — shared client
- [ ] **Step 1: Move the chat client.** Create `internal/ai/llm` (package `llm`). Move `Client`, `chat` (export as `Chat`), `chatMessage` (export as `Message`), retry helpers (`sleepCtx`, `rateLimitBackoff`, `truncate`) from `internal/subtitles/ai/client.go`. New `llm.Config`:
```go
type Config struct {
BaseURL string // chat endpoint, no trailing /v1
APIKey string
ChatModel string
ASRBaseURL string // empty = BaseURL
ASRAPIKey string // empty = APIKey
ASRModel string
MaxConcurrentJobs int
}
```
Log lines lose the "subtitle" wording (they are shared now).
- [ ] **Step 2: Add `Transcribe`.** In `transcribe.go`:
```go
type TranscribeRequest struct {
Filename string
Audio io.Reader
Language string // optional ISO-639-1 hint
Timeout time.Duration // per-request; sized to chunk length by the caller
}
type TranscriptionSegment struct{ Start, End float64; Text string }
type Transcription struct{ Language string; Segments []TranscriptionSegment }
func (c *Client) Transcribe(ctx context.Context, req TranscribeRequest) (*Transcription, error)
```
Multipart fields: `file`, `model` (=`ASRModel`), `response_format=verbose_json`, `temperature=0`, optional `language`. Reuse the same 429/5xx/transport retry-with-backoff loop as `Chat` (extract a shared `doWithRetry` rather than duplicating it). Empty `segments` in the response is an error (`transcription returned no segments`), not a silent fallback.
- [ ] **Step 3: Tests** (`client_test.go`, httptest): chat retries on 429 (honors `Retry-After`), 5xx, 200-with-error-object, empty choices; transcribe happy path (multipart fields present, segments parsed), missing-segments error, ASR base-url/key override falling back to chat values.
- [ ] **Step 4: Commit.** `refactor(ai): extract shared OpenAI-compatible LLM client with transcription support`
---
## Task 2: `internal/ai/translate` — generic segment translator
- [ ] **Step 1: Move the batch logic.** Package `aitranslate`. Move `buildIndexedJSON`, `extractJSONObject`, and the batch loop from `internal/subtitles/ai/translator.go`, generalized:
```go
type Segment struct{ ID, Text string }
type Request struct {
Segments []Segment
SystemPrompt string // caller-supplied; domain-specific
BatchSize int
ContextNeighbors int // preceding source segments sent untranslated
}
type ChatFn func(ctx context.Context, system, user string) (string, error)
func Translate(ctx context.Context, chat ChatFn, req Request,
onBatch func(batch []Segment, done, total int)) ([]Segment, error)
```
Wire protocol unchanged: 1-based indexed JSON per batch, same-keys response, malformed-response retries (`maxRetries = 2`), completeness check. `ChatFn` keeps the package free of an `llm` dependency and trivially testable.
- [ ] **Step 2: Tests:** batch splitting/boundaries, context neighbors, code-fence tolerance, omitted-key retry then failure, ctx cancellation between batches.
- [ ] **Step 3: Commit.** `refactor(ai): extract generic batched segment translator`
---
## Task 3: `internal/ai/jobrunner` — shared job lifecycle
- [ ] **Step 1: Extract the runner.** Move dispatch/heartbeat/reaper/cancel mechanics from `internal/subtitles/ai/service.go` (lines ~136–319) behind:
```go
type Store interface {
Heartbeat(ctx context.Context, id int64) error
ResetStaleJobs(ctx context.Context, before time.Time, message string) (int64, error)
MarkCancelled(ctx context.Context, id int64, message string) error
}
type Runner struct{ /* baseCtx, sem (shared), store, logger, cancels, wg */ }
func New(appCtx context.Context, sem chan struct{}, store Store, logger *slog.Logger) *Runner
func (r *Runner) Recover() // reap + background reaper loop
func (r *Runner) Dispatch(id int64, run func(ctx context.Context)) // semaphore + heartbeat + cancel registry
func (r *Runner) Cancel(id int64) bool // true if an in-flight goroutine was cancelled
```
Heartbeat-while-queued behavior (the comment block in `dispatch`) must be preserved verbatim — it is load-bearing for multi-instance safety. Constants (30 s heartbeat, 2 min stale, 1 min reaper) move here.
- [ ] **Step 2: Shared semaphore.** The semaphore is **constructed by the caller** (`cmd/silo`) at size `ai.max_concurrent_jobs` and passed to every `Runner`, so subtitle + metadata + ASR jobs share one bound.
- [ ] **Step 3: Tests:** dispatch bounded by a size-1 shared semaphore across two runners; cancel of queued job marks cancelled without running; reaper resets a stale heartbeat row (fake store).
- [ ] **Step 4: Commit.** `refactor(ai): extract shared job lifecycle runner`
---
## Task 4: Refactor `internal/subtitles/ai` onto the shared core
- [ ] **Step 1:** Delete `client.go`; `LLMTranslator` becomes an adapter: cues → `aitranslate.Segment` (ID = 1-based index, Text = joined lines), subtitle system prompt stays here, `splitCueLines` maps back. `Service` keeps job semantics but delegates lifecycle to `jobrunner.Runner`; `pgrepo` satisfies `jobrunner.Store`.
- [ ] **Step 2:** Update `cmd/silo/main.go` wiring (build `llm.Client` + shared semaphore once; pass into the subtitle service).
- [ ] **Step 3:** `go build ./... && go test ./internal/...` — subtitle translation behavior unchanged (same prompts, same batching, same job rows).
- [ ] **Step 4: Commit.** `refactor(subtitles): consume shared AI core`
---
## Task 5: Migration
- [ ] **Step 1:** `make migrate-create NAME=ai_metadata_translation_and_asr`, then fill the generated file:
```sql
-- +goose Up
-- +goose StatementBegin
CREATE TABLE public.metadata_translation_jobs (
id bigserial PRIMARY KEY,
target_kind text NOT NULL, -- 'item' | 'season' | 'episode'
content_id text NOT NULL,
include_children boolean NOT NULL DEFAULT true,
source_language text NOT NULL DEFAULT '',
target_language text NOT NULL,
engine text NOT NULL DEFAULT 'openai',
model text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'pending', -- pending|running|completed|failed|cancelled
progress double precision NOT NULL DEFAULT 0,
progress_message text NOT NULL DEFAULT '',
fields_done integer NOT NULL DEFAULT 0,
fields_total integer NOT NULL DEFAULT 0,
force boolean NOT NULL DEFAULT false,
error_message text NOT NULL DEFAULT '',
idempotency_key text NOT NULL,
requested_by integer,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
heartbeat_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX metadata_translation_jobs_active_idempotency_idx
ON public.metadata_translation_jobs (idempotency_key)
WHERE status IN ('pending', 'running');
CREATE INDEX metadata_translation_jobs_content_idx
ON public.metadata_translation_jobs (content_id, created_at DESC);
CREATE INDEX metadata_translation_jobs_status_idx
ON public.metadata_translation_jobs (status) WHERE status IN ('pending', 'running');
-- Field provenance: 'provider' | 'ai' | 'manual'. manual > provider > ai.
ALTER TABLE media_item_localizations
ADD COLUMN overview_source text NOT NULL DEFAULT 'provider'
CHECK (overview_source IN ('provider', 'ai', 'manual')),
ADD COLUMN tagline_source text NOT NULL DEFAULT 'provider'
CHECK (tagline_source IN ('provider', 'ai', 'manual'));
ALTER TABLE season_localizations
ADD COLUMN overview_source text NOT NULL DEFAULT 'provider'
CHECK (overview_source IN ('provider', 'ai', 'manual'));
ALTER TABLE episode_localizations
ADD COLUMN overview_source text NOT NULL DEFAULT 'provider'
CHECK (overview_source IN ('provider', 'ai', 'manual'));
ALTER TABLE media_folders
ADD COLUMN auto_translate_metadata boolean NOT NULL DEFAULT false;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE media_folders DROP COLUMN auto_translate_metadata;
ALTER TABLE episode_localizations DROP COLUMN overview_source;
ALTER TABLE season_localizations DROP COLUMN overview_source;
ALTER TABLE media_item_localizations DROP COLUMN tagline_source, DROP COLUMN overview_source;
DROP TABLE IF EXISTS public.metadata_translation_jobs;
-- +goose StatementEnd
```
- [ ] **Step 2:** `make migrate-up` against the local stack (`docker compose up -d postgres redis`), then `make migrate-status`.
- [ ] **Step 3: Commit.** `feat(metadata): migration for translation jobs, localization provenance, library auto-translate`
---
## Task 6: Settings & config
- [ ] **Step 1: Config structs** (`internal/config/config.go`): add `AIConfig` (fields mirroring `llm.Config`); slim `SubtitleAIConfig` to `Enabled`, `TranscribeEnabled`, `BatchSize`, `ContextNeighbors`; add `MetadataAIConfig{Enabled bool}`.
- [ ] **Step 2: Loader** (`internal/config/db_loader.go`): load `ai.*` with legacy fallback, following the `recommendations.embedding_auth_token` precedent at line ~409:
```go
cfg.AI.BaseURL = stringOr(m, "ai.base_url", stringOr(m, "subtitle_ai.base_url", "https://api.openai.com"))
cfg.AI.APIKey = stringOr(m, "ai.api_key", stringOr(m, "subtitle_ai.api_key", ""))
cfg.AI.ChatModel = stringOr(m, "ai.chat_model", stringOr(m, "subtitle_ai.chat_model", "gpt-4o-mini"))
// ai.max_concurrent_jobs ← subtitle_ai.max_concurrent_jobs ← 2
// ai.asr_model (default "whisper-1"), ai.asr_base_url, ai.asr_api_key (default "")
// subtitle_ai.transcribe_enabled, metadata_ai.enabled (default false)
```
**Never** rename the legacy rows in SQL — encrypted values are GCM-bound to their setting key (`internal/catalog/encrypted_settings_repo.go`).
- [ ] **Step 3:** Add `ai.api_key`, `ai.asr_api_key` to `sensitiveSettingKeys`.
- [ ] **Step 4:** Readiness helpers: subtitle translate = `SubtitleAI.Enabled && AI chat ready`; ASR = `SubtitleAI.TranscribeEnabled && AI.ASRModel != "" && (asr or chat base URL set)`; metadata = `MetadataAI.Enabled && AI chat ready`.
- [ ] **Step 5: Commit.** `feat(config): shared ai.* settings with legacy subtitle_ai fallback`
---
## Task 7: Provenance-aware localization writes
- [ ] **Step 1: Models** (`internal/models/media.go`): add `OverviewSource`/`TaglineSource` to `MediaItemLocalization`, `OverviewSource` to `SeasonLocalization`/`EpisodeLocalization`.
- [ ] **Step 2: Provider upsert rules** (`internal/catalog/localization_repo.go`): rewrite the three `Upsert` statements so, per AI-writable field:
- existing source `manual` → keep existing value and source;
- incoming value empty → keep existing value and source;
- otherwise → take incoming value, set source `provider`.
Single-statement `ON CONFLICT … DO UPDATE` with `CASE` (no read-modify-write). Pattern for one field:
```sql
overview = CASE
WHEN media_item_localizations.overview_source = 'manual' THEN media_item_localizations.overview
WHEN EXCLUDED.overview = '' THEN media_item_localizations.overview
ELSE EXCLUDED.overview END,
overview_source = CASE
WHEN media_item_localizations.overview_source = 'manual' THEN media_item_localizations.overview_source
WHEN EXCLUDED.overview = '' THEN media_item_localizations.overview_source
ELSE 'provider' END
```
- [ ] **Step 3: AI upsert methods**: `UpsertAITranslation(ctx, contentID, language string, overview, tagline *string, force bool)` per repo (nil pointer = field not part of this write). Insert path populates only the translated fields (other text columns empty). Update path per field: write when existing source is `ai`, existing value is empty, or `force` — never when `manual`; `force` may overwrite `provider` (the admin asked). Sets source `ai`.
- [ ] **Step 4: Serving hardening** (`internal/catalog/detail.go`): verify `LocalizeItemModel` / `LocalizeSeasonModel` / `LocalizeEpisodeModel` only override base fields with **non-empty** localization values (AI rows carry empty titles). Fix any field that clobbers; add a regression test.
- [ ] **Step 5: Tests** for the upsert matrix (provider-over-ai, ai-skips-provider-unless-force, manual-untouchable, empty-never-blanks), following the existing repo/service test patterns in `internal/catalog`.
- [ ] **Step 6: Commit.** `feat(catalog): field provenance for localizations with provider/ai/manual precedence`
---
## Task 8: `internal/metadata/translation` service
- [ ] **Step 1: Job model + repo** (`job.go`, `pgrepo.go`): mirror `internal/subtitles/ai/{job.go,pgrepo.go}` shapes against `metadata_translation_jobs`; satisfy `jobrunner.Store`. Idempotency key = SHA-256 of `content_id|target_kind|target_language|model`.
- [ ] **Step 2: Service** (`service.go`): `Enqueue`, `GetJob`, `ListJobs(contentID)`, `Cancel`, `Recover`, using `jobrunner.Runner` + the shared semaphore. The run loop:
1. Expand targets: `item` → item overview+tagline; series item with `include_children` → + every season overview + every episode overview; `season` (+children → its episodes); `episode` → its overview. Source text comes from the **base** rows (default metadata language); `source_language` recorded from the item's `default_metadata_language`.
2. Skip-if-filled: drop any field whose target-language localization value is already non-empty, unless `force`. All skipped → complete immediately ("Nothing to translate").
3. Build `aitranslate.Segment`s (IDs `item:overview`, `item:tagline`, `season:<content_id>:overview`, `episode:<content_id>:overview`), translate with `prompt.go`'s system prompt (names the title/year, "translate media catalog descriptions", preserve proper nouns/character names/tone, no added information), `BatchSize` = package constant `metadataBatchSize = 10`, `ContextNeighbors = 0`.
4. Per batch: provenance-aware AI upserts (Task 7) + `fields_done`/progress update. Persisting per batch means a cancelled job keeps completed fields.
- [ ] **Step 3: Tests** (fake repos + fake `ChatFn`): series expansion counts, skip-if-filled short-circuit (zero chat calls), force overwrites `ai`+`provider` but never `manual`, per-batch persistence on cancellation mid-job.
- [ ] **Step 4: Commit.** `feat(metadata): AI translation service for overviews and taglines`
---
## Task 9: Ingestion hook + library flag
- [ ] **Step 1:** Switch the provider localization writes in `internal/metadata/service.go` (item ~line 1476, season ~2921, episode ~3080) to the provenance-aware upserts — no other behavior change.
- [ ] **Step 2:** Thread `auto_translate_metadata` through the `MediaFolder` model, folder repo scan/update, and the folder settings API payload.
- [ ] **Step 3: Auto-enqueue hook.** After an item's refresh persists (single point at the end of the item flow — not per season/episode), when: folder flag set, folder `metadata_language` non-empty and ≠ item `default_metadata_language`, metadata AI ready, and the item's target-language localization is missing overview (or tagline) → `Enqueue` a non-force `item` job with `include_children=true`. Fire-and-forget (log on error); the active-idempotency index plus skip-if-filled make repeat refreshes free.
- [ ] **Step 4: Commit.** `feat(metadata): per-library auto-translate fallback on refresh`
---
## Task 10: Metadata AI API
- [ ] **Step 1:** `internal/api/handlers/metadata_ai.go`, mirroring `subtitle_ai.go` shapes:
- `GET /api/v1/metadata/ai/status` → `{enabled}`
- `POST /api/v1/metadata/ai/translate` `{content_id, target_kind, target_language, include_children, force}` → `{job}` (joins an in-flight duplicate, same as subtitles)
- `GET /api/v1/metadata/ai/jobs/{job_id}`, `POST /api/v1/metadata/ai/jobs/{job_id}/cancel`, `GET /api/v1/metadata/ai/jobs?content_id=…`
- [ ] **Step 2:** Gate with the same permission as metadata editing (metadata curation permission; see `internal/catalog/update.go` callers). Register routes; wire the service in `cmd/silo/main.go`.
- [ ] **Step 3:** Handler tests following `internal/api/handlers` conventions (validation: bad kind, missing language, disabled engine → 400/503 mapping).
- [ ] **Step 4: Commit.** `feat(api): metadata AI translation endpoints`
---
## Task 11: Whisper ASR
- [ ] **Step 1: Audio extraction** (`internal/playback/audio_extract.go`), alongside the existing subtitle extraction helpers:
```go
// ExtractAudioChunks extracts one audio track to 16 kHz mono WAV chunks in dir.
// Returns ordered chunk paths; chunkSeconds = 600 in production.
func ExtractAudioChunks(ctx context.Context, filePath string, audioTrackIndex int,
dir, ffmpegPath string, chunkSeconds int) ([]string, error)
```
Single ffmpeg pass: `-vn -map 0:a:<idx> -ac 1 -ar 16000 -c:a pcm_s16le -f segment -segment_time <n> <dir>/chunk%05d.wav`.
- [ ] **Step 2: Transcriber** (`internal/subtitles/ai/transcriber.go`):
```go
type Transcriber interface {
Transcribe(ctx context.Context, req TranscribeJobRequest,
onChunk func(cues []SubtitleCue, done, total int)) ([]SubtitleCue, string /*detected lang*/, error)
}
```
`WhisperTranscriber` implementation: temp dir (`os.MkdirTemp`, removed on all exit paths) → `ExtractAudioChunks` → per chunk `llm.Client.Transcribe` (per-request timeout ∝ chunk duration; pass the job's language hint) → offset segment times by `chunkIndex*chunkSeconds` → cues (one per segment, wrapped to ≤2 lines ×~42 chars via a `wrapCueText` helper; whitespace-only segments dropped). Chunk processing order starts at the chunk containing `StartPosition`, then wraps (mirrors `reorderFromPosition` semantics).
- [ ] **Step 3: Wire kinds into the service.** `Service.run` branches on `job.Kind`:
- `translate`: existing path.
- `transcribe`: resolve audio track (`source_index` = audio index, `-1` → default/first; reject files without audio), transcribe (progress 10–70 %, live cue streaming via the existing notifier callbacks), store SRT as provider `transcribed`, language = hint or detected, release name `"<Language> (AI transcribed)"`, notify `SubtitleReady`.
- `transcribe_translate`: `transcribe`, store the transcript track, then run the existing `Translator` on the cues (70–95 %), store provider `translated`, `result_subtitle_id` = translated track.
Enqueue validation per kind (ASR requires `TranscribeEnabled`; idempotency model component = `asr_model` or `asr_model+chat_model` for the chained kind).
- [ ] **Step 4:** API surface: `kind` field (optional, default `translate`) on the existing enqueue request in `subtitle_ai.go`; `transcribe_enabled` on the status response.
- [ ] **Step 5: Tests** (`transcriber_test.go`, fake client): timestamp stitching across chunks, playhead-first chunk ordering, cue wrapping, empty-segments chunk → job error, temp-dir cleanup on failure (assert via `t.TempDir` layout).
- [ ] **Step 6: Commit.** `feat(subtitles): Whisper ASR transcribe and transcribe_translate jobs`
---
## Task 12: Frontend — AI Services settings page
- [ ] **Step 1:** `web/src/pages/admin-settings/AIServicesSettings.tsx`: connection card (`ai.base_url`, `ai.api_key` via the sensitive-key pattern already used in `SubtitlesSettings.tsx:264`, `ai.chat_model`, `ai.asr_model`, optional `ai.asr_base_url`/`ai.asr_api_key`, `ai.max_concurrent_jobs`) + features card (`subtitle_ai.enabled`, `subtitle_ai.transcribe_enabled`, `metadata_ai.enabled`, `subtitle_ai.batch_size`, `subtitle_ai.context_neighbors`). Reads show effective values (new key, falling back to the legacy key when unset — same fallback order as the loader); writes always target the new `ai.*` keys.
- [ ] **Step 2:** Remove the AI card from `SubtitlesSettings.tsx` (leave a link/hint to the new page); register the page in the admin settings nav + route following the existing page registrations.
- [ ] **Step 3: Commit.** `feat(web): AI services settings page`
---
## Task 13: Frontend — metadata translate action
- [ ] **Step 1:** API client functions + types for the Task 10 endpoints.
- [ ] **Step 2:** `EditMetadataDialog.tsx`: "Translate with AI" action (visible when `metadata_ai` status is enabled): target language (default = library `metadata_language`), `include_children` (series only), `force` checkbox; enqueue, poll the job every ~1.5 s, show `progress_message`/`fields_done`, invalidate the item detail query on completion.
- [ ] **Step 3: Commit.** `feat(web): translate descriptions from the metadata editor`
---
## Task 14: Frontend — library auto-translate toggle
- [ ] **Step 1:** Add the `auto_translate_metadata` switch to the library settings form next to the metadata-language field (helper text: "When metadata providers have no translation for this library's language, translate descriptions with AI"). Disabled state with hint when `metadata_ai` is off.
- [ ] **Step 2: Commit.** `feat(web): library auto-translate toggle`
---
## Task 15: Frontend — player "Generate from audio"
- [ ] **Step 1:** `SubtitleTranslateModal.tsx`: when `transcribe_enabled`, add a source option "Generate from audio (AI)" listing the file's audio tracks; submit with `kind: "transcribe"` (or `transcribe_translate` when the chosen output language differs from the audio language). Surface it prominently when no text subtitle source exists (today's dead end for bitmap-only files).
- [ ] **Step 2: Commit.** `feat(web): generate subtitles from audio in the player`
---
## Task 16: Verification
- [ ] `go build ./... && go test ./...`
- [ ] `make lint`
- [ ] `cd web && pnpm run lint && pnpm run format:check`
- [ ] `make verify-local-paths`
- [ ] Manual smoke against the local stack: enqueue a metadata translation for a series (verify localization rows + provenance values), re-run a refresh (verify provider values overwrite `ai` rows and `manual` simulation survives), run a `transcribe` job on a short file against a local Whisper-compatible server.
- [ ] MR description: problem, approach, link to the design doc, risks (Whisper endpoint variance, chunk-boundary artifacts), AI-use disclosure, screenshots of the new settings page / editor action / player mode.
@@ -0,0 +1,276 @@
# Plan: Personal + Server sections on the user Collections tab
Status: Draft
Branch: `feat/collections-server-section`
Owner: TBD
Commands assume the repository root is the cwd.
## Problem
The user-facing **Collections** tab on the home screen (`web/src/pages/Collections.tsx`)
only shows the signed-in profile's *personal* collections. For most users this list is
empty or nearly empty, so the tab looks broken or pointless.
Meanwhile, server-curated collections (admin-created "library collections") only appear
*inside each individual library's* Collections tab (`web/src/pages/LibraryCollections.tsx`,
served by `GET /library/{id}/collections`). There is no place in the app where a user can
see the collections curated across the whole server — they are effectively hidden behind
per-library navigation.
We want the top-level Collections tab to become the single home for collections:
1. **Your collections** — the user's personal/shared collections, with a large section
title, shown at the top (current behavior, kept intact: grouping, drag-reorder, create,
edit, sync, delete).
2. **Server collections** — below that, a clearly titled section aggregating the visible
server (library) collections across every library the user can access, so they are no
longer buried per-library.
## Current state (what exists today)
### Frontend
- `web/src/pages/Collections.tsx` — the top-level tab. Renders the page header + a
`GroupedCollectionsBoard` of personal collections. Data via `useCollections()` /
`useCollectionGroups()` (`web/src/hooks/queries/collections.ts`), both backed by
`GET /collections` → `CollectionsListResponse { collections, groups }`.
- `web/src/pages/LibraryCollections.tsx` — per-library tab. Renders a poster grid
(`CollectionPosterCard`) grouped by admin groups + ungrouped. Data via
`useLibraryCollections(libraryId)` → `GET /library/{id}/collections` →
`LibraryTabResponse { groups, ungrouped }`. **This is the visual we want to reuse for the
server section** (poster cards, item-count badge, navigate to catalog href).
- `web/src/api/types.ts` — `Collection`, `CollectionGroup`, `CollectionsListResponse`,
`LibraryTabResponse`, `LibraryTabGroup`, `LibraryTabCollection`, `LibraryTabUngrouped`.
- Catalog hrefs: `buildLibraryCollectionCatalogHref(id, title)` and
`buildUserCollectionCatalogHref(id, title)` in `web/src/pages/catalogSearchParams.ts`.
### Backend
- `internal/api/handlers/collections.go` — personal collection handlers under `/collections`
(registered in `internal/api/router.go` ~lines 1700-1726, behind `RequireProfile`).
- `internal/api/handlers/library_collections.go`:
- `HandleListLibraryCollections` (line 813) — builds the per-library `LibraryTabResponse`.
- `requestCanAccessLibrary` (line 3311) — checks `access.GetScope(ctx).AllowedLibraryIDs`
(nil ⇒ access to all libraries).
- `LibraryCollectionRepository.ListAll(ctx, libraryID *int, opts)`
(`internal/catalog/library_collection_repo.go:368`) — **with `libraryID == nil` it
already returns server collections across all libraries** (uses
`libraryCollectionScopeFallbackJoin`), filtering `visibility = 'visible'` unless
`IncludeHidden`. This is the aggregation primitive we need.
- `presignGPURL` / `toLibraryCollectionResponses` — turn stored poster paths into signed
URLs for the response.
## Design decision: aggregate on the server, new endpoint
We add a new **user-facing aggregate endpoint** rather than fanning out N per-library calls
from the client (avoids N round-trips, keeps access/visibility filtering server-side, and
matches the "Performance first / Reliability first" priorities in `CLAUDE.md`).
### New endpoint
`GET /collections/server` (registered next to the other `/collections` routes in
`internal/api/router.go`, behind `RequireProfile`).
Response shape (new types, mirrors the poster-card needs without per-library group noise):
```jsonc
{
"libraries": [
{
"library_id": 3,
"library_name": "Movies",
"total_count": 80, // total visible server collections in this library
"collections": [ // CAPPED teaser slice (see "Scale & pagination")
{
"id": "...",
"title": "...",
"poster_url": "https://signed...",
"poster_thumbhash": "...",
"item_count": 42,
"featured": false
}
]
}
]
}
```
Grouping by library (with `library_name`) is the recommended default: it lets the frontend
render "Server collections" as one **horizontal scrollable row per library**, which keeps a
large catalog legible. (Alternative: a single flat list — simpler UI, but loses provenance
and the per-library "See all" affordance. Recommend per-library grouping.)
### Scale & pagination (many collections per library)
The server section is a **discovery** surface aggregating across *all* accessible libraries
at once, so a full wrapping grid per library does not scale (e.g. 5 libraries × 80
collections = 400 stacked tiles). Decisions:
- **Render each library as a horizontal carousel row**, not a wrapping grid. Heading = the
library name + a **"See all →"** link.
- **Cap the response** at ~20 collections per library and include `total_count`. Ordering of
the capped slice follows the repo's existing `featured DESC, sort_order ASC, title ASC`
("best first"), so the teaser row surfaces featured/curated collections.
- **"See all" links to the existing per-library Collections tab**
(`/library/{id}` Collections tab, served by the unchanged
`GET /library/{id}/collections`), which already renders the full wrapping grid with admin
grouping. We do **not** build a new full-aggregate view — reuse the canonical
"show everything for this library" surface.
- This caps payload size and the per-library query cost, and keeps the home tab fast
("Performance first").
### API contract impact
- `GET /collections` (personal) — **unchanged**.
- `GET /library/{id}/collections` (per-library tab, "See all" target) — **unchanged**;
reused as-is.
- `GET /collections/server` — **new, additive**. No existing response changes; no
Android/Apple contract break.
### Why a separate endpoint, not extending `GET /collections`
It is tempting to make `GET /collections` return both personal and server collections in one
call. We deliberately do **not**, for three reasons:
- **Client safety / semantics.** `GET /collections`'s `collections[]` array is the user's
*editable, personal* shelves — web and the Android/Apple clients render reorder/edit/
delete/sync affordances on it. Mixing read-only server collections into that same array is
a *silent semantic break*: the JSON still parses, so nothing errors, but clients would
offer "delete"/"reorder" on collections the user can't mutate (→ 403/404). A new sibling
field (`{ collections, groups, server }`) would be additive and parse-safe, but still
couples discovery into the personal-management contract.
- **Cache / refetch lifecycle.** `GET /collections` is the heavily-mutated query —
invalidated on every create, edit, reorder, group change, and sync (via
`invalidateUserCollectionQueries`). Server collections don't change on a personal reorder,
so folding them in would refetch the whole server-wide catalog on every personal mutation
("Performance first" violation). Separate endpoints ⇒ independent cache keys + lifecycles.
- **Zero downstream risk.** A brand-new `/collections/server` endpoint cannot break existing
clients, because nothing calls it yet. Consolidating into one call later is a *deliberate
follow-up requiring coordinated Android/Apple work* — and must use a distinct field, never
overload `collections[]`.
### Handler logic (`HandleListServerCollections`)
1. Resolve accessible library IDs: read `access.GetScope(ctx)`. If `AllowedLibraryIDs ==
nil`, the user can see all libraries → list all of them; else restrict to that set.
2. List visible server collections:
- Simplest correct approach: call `repo.ListAll(ctx, nil, ListLibraryCollectionsOptions{})`
once (server-wide, visible-only), then **filter each collection to libraries the user
can access** and bucket by library. This requires knowing each collection's
library membership (`library_collection_libraries`). If `ListAll` does not already
return membership, either (a) add a per-library loop calling
`repo.ListByLibrary(ctx, libID, opts)` for each accessible library (bounded by library
count, each query already does the grouping/sort), or (b) extend the repo to return
library membership. **Recommended: per-accessible-library loop** — reuses the existing,
tested `ListByLibrary` path and naturally yields the per-library buckets and respects
scope. Deduplicate collections that span multiple libraries if we choose flat output;
for per-library output, a collection legitimately appears under each of its libraries.
3. For each library, set `total_count` to the full visible count, then **cap
`collections` to ~20** (keep the repo's `featured DESC, sort_order ASC, title ASC`
ordering so featured/curated collections lead). Define the cap as a single named
constant.
4. Resolve library display names (catalog/library store lookup) for `library_name`.
5. Presign poster URLs via the existing `presignGPURL` helper — only for the capped slice,
so we never presign 100 posters we won't send.
6. Skip empty libraries. Return `200` with `{ libraries: [] }` when nothing is visible.
Reuse existing helpers (`presignGPURL`, sort helpers like `applyCollectionSort`) rather than
duplicating logic. Add the new response structs next to the existing
`libraryTabResponse` types in `library_collections.go`, or in `collections.go` if cleaner —
keep them in the package that owns the behavior.
## Frontend changes
### Types (`web/src/api/types.ts`)
Add:
```ts
export interface ServerCollectionsLibrary {
library_id: number;
library_name: string;
total_count: number; // total visible; collections[] is a capped teaser slice
collections: LibraryTabCollection[]; // reuse existing shape
}
export interface ServerCollectionsResponse {
libraries: ServerCollectionsLibrary[];
}
```
### Query hook (`web/src/hooks/queries/collections.ts` + `keys.ts`)
- Add `collectionKeys.server()` to `web/src/hooks/queries/keys.ts`.
- Add `useServerCollections()` → `GET /collections/server` →
`ServerCollectionsResponse`. Independent query key from `/collections`, so the personal
section keeps its single round-trip and the server section loads in parallel.
### Page (`web/src/pages/Collections.tsx`)
Restructure into two titled sections inside the existing `page-shell`:
1. **"Your collections"** section
- Add a large section title (e.g. `<h2 class="page-title ...">Your collections</h2>`)
above the existing `GroupedCollectionsBoard` / empty-state block. Keep all current
personal-collection behavior unchanged (create, group, reorder, sync, delete).
- The page-level `<h1>Collections</h1>` header + subtitle + action buttons stay at the
very top.
2. **"Server collections"** section (new)
- Title `<h2>Server collections</h2>` with a short subtitle ("Curated across every
library on this server").
- Use `useServerCollections()`. Render **one horizontal scrollable row per library**
(carousel), not a wrapping grid. Each poster card reuses the **same visual as
`LibraryCollections.tsx`** — extract `CollectionPosterCard` into a shared component so
both pages use it (avoids duplicate logic per `CLAUDE.md` maintainability rule).
Proposed location: `web/src/components/collections/CollectionPosterCard.tsx`.
- For each `library` bucket: a sub-heading row with `library_name` and a **"See all →"**
link routing to that library's existing Collections tab (the unchanged
`GET /library/{id}/collections` surface). Show "See all" only when
`total_count > collections.length`; optionally include the count, e.g. "See all (80)".
- Cards navigate via `buildLibraryCollectionCatalogHref(id, title)` and show the
item-count badge. These are admin/library collections (`kind="regular"`); the
sidebar-pin affordance can remain — pass `library.library_id` as `libraryId`.
- Use the project's existing horizontal-row/carousel pattern if one exists (check
`web/src/components` for a shelf/row component used on the home screen) rather than
hand-rolling overflow scrolling; reuse it for consistent snap/scroll behavior.
- Loading: skeleton row(s) (reuse the existing skeleton block pattern).
- Empty: if `libraries` is empty, render nothing (or a muted "No server collections yet"
line) — do not show a scary empty state, since the personal section above may have
content.
### Refactor note
Extracting `CollectionPosterCard` is the only shared-logic extraction required. Keep the
extraction behavior-identical for `LibraryCollections.tsx` (props: `collection`, `kind`,
`libraryId`). The per-library tab keeps its wrapping grid; only the new server section uses
horizontal rows.
## Out of scope / non-goals
- No changes to how admin/library collections are created or to per-library tabs.
- No reordering/editing of server collections from the user Collections tab (read-only).
- No new visibility model — we honor existing `visibility = 'visible'` + access scope.
## Risks / follow-ups
- **Access scope correctness**: must not leak collections from libraries outside
`AllowedLibraryIDs`. Mitigate by driving the query from accessible library IDs, and add a
test for a restricted-scope user.
- **Collections spanning multiple libraries** appear once per library in per-library output;
confirm this is the desired UX (vs. dedup). Decide before implementation.
- **Performance**: per-accessible-library loop is N queries. Acceptable for typical library
counts; if N grows large, revisit with a single membership-aware query. Add an index check
on `library_collection_libraries(library_id)` (likely already present).
- **Client parity**: this is a new server endpoint and a web-only UI change. Android/Apple
clients have their own collections surfaces — file follow-up issues if they should mirror
the personal+server split. No API contract break (purely additive endpoint).
- **Empty-state UX** for users with no personal AND no server collections — ensure the page
still reads sensibly.
## Verification
- Backend: `go build ./...`, `go vet ./...`, and a handler/unit test for
`HandleListServerCollections` covering (a) full access, (b) restricted scope, (c) empty.
- Frontend: `cd web && pnpm run lint && pnpm run format:check`; `make verify-local-paths`.
- Manual: log in as a normal profile with empty personal collections; confirm the
"Your collections" section shows the empty/create state and the "Server collections"
section lists library collections grouped by library with correct posters, counts, and
working navigation into each collection's catalog view.
## Implementation order
1. Backend: response types + `HandleListServerCollections` + route registration.
2. Backend test for access-scope filtering.
3. Frontend: types + `useServerCollections` hook + `collectionKeys.server()`.
4. Frontend: extract shared `CollectionPosterCard`; refactor `LibraryCollections.tsx`.
5. Frontend: restructure `Collections.tsx` into the two titled sections.
6. Lint/format/build/manual verification.
@@ -0,0 +1,311 @@
# Plan: interleave `transcribe_translate` so live AI subtitles follow the playhead
Issue: https://github.com/Silo-Server/silo-server/issues/154
Area: `internal/subtitles/ai` (+ `internal/playback` extraction, `internal/config`)
Type: bug fix to an existing (shipped) capability — makes the chained AI-subtitle
path deliver its designed live behavior. No new client-facing contract.
Commands assume the repository root is the cwd.
## Problem (confirmed)
`Service.runTranscribe` (`internal/subtitles/ai/service.go`) runs the chained
`transcribe_translate` kind as two **global, sequential** stages:
1. Transcribe the **entire** file (progress 5%→70%). Transcript cues are not
streamed for the chained kind —
`streamTranscript := streaming && job.Kind == JobKindTranscribe`
(`service.go:417`), so it is `false` for `transcribe_translate`.
2. Only then translate **all** cues (70%→95%), streaming each translated batch
as it lands (`service.go:482-493`).
Because translation cannot start until step 1 finishes for the whole file, the
first translated cue streams minutes into the job, long past the playhead.
### Two distinct bottlenecks (both must be addressed)
- **Transcription stage is global.** Translation waits for the whole-file ASR
pass. This is the dominant term for a feature film (~20 min ASR) and the
primary cause. Fixed by interleaving translation per chunk (Phase 1).
- **Extraction is global and precedes any callback.** `Transcribe` calls
`t.extract(...)` (`transcriber.go:124`) before the `onChunk` loop, and
`ExtractAudioChunks` blocks on a single whole-track ffmpeg pass
(`internal/playback/audio_extract.go:34,65`). So even with per-chunk
interleave and small chunks, the first cue still waits for full-file
extraction (~1–2 min for a 2h film). Fixed by playhead-first incremental
extraction (Phase 2).
The transcriber already exposes the per-chunk seam: `Transcribe` invokes
`onChunk(cues, done, total)` per chunk, playhead-first (`transcriber.go:111-174`),
and plain `transcribe` already streams through it (`service.go:426-429`).
Secondary defect found while confirming: `WhisperTranscriber.SetExtraction`
clamps any `chunkSeconds` outside `[60, 600]` to the **default 600**
(`transcriber.go:100-102`), so a small live chunk value silently becomes 10 min
instead of clamping to the floor.
## Goal & honest latency targets
Translated cues stream playhead-first for a session-attached
`transcribe_translate` job, instead of waiting for whole-file transcription.
Time-to-first-translated-cue, by phase:
- **Today:** `extract(full) + transcribe(full) + translate(first batch)` — many
minutes (≈ full ASR pass).
- **After Phase 1 (interleave, existing full extraction):**
`extract(full) + transcribe(first chunk) + translate(first chunk)` — removes
the whole-file ASR pass from the critical path; ≈ extraction time (~1–2 min for
a 2h film), then cues follow the playhead. Already usable for live after a
one-time wait.
- **After Phase 2 (playhead-first incremental extraction):**
`extract(one chunk near playhead) + transcribe + translate` — single-digit
seconds. This is what the issue means by "within seconds."
Phase 1 delivers most of the value with low risk; Phase 2 closes the remaining
extraction latency. Ship Phase 1 first; do not claim "within seconds" until
Phase 2 lands.
### Non-goals (this plan)
- Pacing/throttling to the playhead (rolling window). Optional in the issue;
follow-up below.
- Cross-chunk translation-context continuity beyond batch context neighbours.
## Decision: interleave only when streaming
**Session-attached (live) jobs** use the interleaved per-chunk path.
**Background (non-streaming) `transcribe_translate` jobs keep the current
whole-file transcribe-then-translate-at-end path unchanged.** Rationale: the
issue is live-only; this preserves background output byte-for-byte (full
cross-file translation context, no incremental-extraction cost) and keeps the
two behaviors cleanly separated by the existing `streaming` boolean. The
shared transcript-storage step is factored so it is not duplicated.
---
## Phase 1 — interleave translation per chunk (streaming jobs)
### 1a. `internal/subtitles/ai/service.go` — `runTranscribe`
For the chained kind **when `streaming`**, replace the kind-specific `onChunk`
body (`service.go:423-430`) with per-chunk translate-and-stream:
- Capture a translate error in a closure: `var translateErr error` and an
accumulator `var translatedAccum []SubtitleCue`.
- In `onChunk(chunk, done, total)`:
- If `translateErr != nil` or `len(chunk) == 0`, return (skip silence; stop
translating after a prior failure). Do **not** abort transcription.
- Else call `s.translator.Translate` on **that chunk's** cues. On error: set
`translateErr`, emit `TranslationFailed` once, and return (let transcription
continue). On success: stream translated cues via `TranslationCues` and
append to `translatedAccum`.
- Progress: single 5%→95% band keyed on chunk `done/total`
(message "Transcribing & translating").
- Keep `onChunk` signature unchanged (no error return). Aborting on translate
failure via the callback is intentionally avoided — see 1b.
After `Transcribe` returns:
- **Always store the transcript track** (unchanged, `service.go:447-466`,
including the transcript `SubtitleReady` broadcast). This must run regardless
of `translateErr`.
- If `translateErr != nil`: `finishWithError(ctx, job, translateErr)`. The
transcript is already persisted (cache preserved), and the live session
already received `TranslationFailed`.
- Else: assemble the translated track from `translatedAccum` (`sortCuesByStart`
+ `StoreSubtitle`, `service.go:499-514`), `CompleteJob`, and
`TranslationCompleted` (`service.go:515-522`).
- Remove the whole-file translate-at-end (`service.go:479-497`) for the
streaming path only.
Non-streaming chained jobs fall through to the existing whole-file path,
untouched.
### 1b. Transcript-on-translation-failure (resolves review P1)
Today the full transcript is stored before translation starts
(`service.go:447` then `:482`), so a translation failure still leaves a
transcript/cache row and an already-broadcast `SubtitleReady`. The interleaved
path preserves this exactly because:
- a per-chunk translate failure sets `translateErr` but lets the ASR pass run to
completion (it does **not** return an error from `onChunk`), so `Transcribe`
returns the full cue set normally;
- the transcript is then stored on the same post-transcribe path as today,
before the job is failed for the translated track.
An ASR failure or context cancellation still returns an error from `Transcribe`
and yields no transcript — identical to today.
### 1c. Live progress semantics (resolves review P2)
`SubtitleTranslationCuesPayload.Done/Total` is documented as overall progress
(`internal/playback/realtime.go:172-181`). For live `transcribe_translate`:
- `TranslationCues` carries **`done = chunks completed, total = total chunk
count`** — the same chunk-granular convention plain `transcribe` already emits
(`service.go:427-428`). Monotonic and stable across chunks.
- `TranslationStarted.TotalCues = 0` (indeterminate), consistent with the
transcribe path today (`service.go:412`), since the cue total is unknown before
ASR completes.
- Cues carry absolute Start/End, so the client places them regardless of
arrival order; unpause is driven by cue arrival near the playhead, not by
`Done/Total`. Confirm with client teams that `Done/Total` is treated as a
fraction, not absolute cue indices (clients already receive chunk counts from
the plain `transcribe` path, so this should hold).
### 1d. Small live chunks + clamp fix
- Add `TranscribeJobRequest.ChunkSeconds int` (0 = configured default). In
`Transcribe`, use `req.ChunkSeconds` when `> 0`, else the atomic default
(`transcriber.go:121-122`); validate/clamp identically to `SetExtraction`.
- Fix the clamp in `SetExtraction` (`transcriber.go:99-105`): clamp
out-of-range to the nearest bound, not to the 600 default.
- Lower `minASRChunkSeconds` from 60 to 15 (`transcriber.go:26`); update the
doc comment about request count / boundary word-clip tradeoff.
- Config: add `subtitle_ai.live_asr_chunk_seconds` (default 30):
- `internal/config/config.go`: new field beside `ASRChunkSeconds`
(`config.go:274-277`).
- `internal/config/db_loader.go`: load it next to `asr_chunk_seconds`
(`db_loader.go:472-476`).
- `internal/subtitles/ai/config.go`: add `LiveASRChunkSeconds int` to the
service `Config` so the service can set `req.ChunkSeconds` for streaming jobs.
- `internal/api/router.go`: plumb through `effectiveSubtitleAIConfig` and the
`OnConfigChange` `UpdateConfig` (`router.go:1024-1055`).
### 1e. Phase 1 tests
- `service_test.go`: fake `Transcriber` emitting several chunks playhead-first
via `onChunk`; fake `Translator` recording calls. For a streaming
`transcribe_translate` job assert: one `Translate` per non-empty chunk; first
`TranslationCues` streamed before the last chunk is transcribed; empty chunks
skipped; final stored translated track = all translated cues sorted by start;
`Done/Total` is chunk-granular and monotonic.
- Failure case: a translate error on chunk N sets the failure but transcription
completes, the transcript track **is** stored, `TranslationFailed` is emitted,
and the job ends via `finishWithError`.
- Non-streaming `transcribe_translate`: unchanged whole-file path; correct final
track; no `TranslationCues`.
- `transcriber_test.go`: `SetExtraction` clamp fix and `ChunkSeconds` override
(incl. the new 15s floor).
---
## Phase 2 — playhead-first incremental extraction (true seconds latency)
Phase 1 still waits on full-file extraction before the first cue. Phase 2
removes that by extracting and consuming chunks incrementally, seeked to the
playhead.
### 2a. New incremental extractor in `internal/playback`
Add a streaming, seek-based extractor alongside `ExtractAudioChunks`:
```
ExtractAudioChunksFrom(ctx, filePath, audioTrackIndex, dir, ffmpegPath,
startSec float64, chunkSeconds int,
onSegment func(AudioChunk) error) error
```
- Runs one ffmpeg pass with `-ss <startSec> -i <file> ... -f segment
-segment_list segments.csv` covering `startSec`→end.
- Consumes segments **as they complete**: poll the segment-list CSV (ffmpeg
appends `filename,start,end` when it closes each segment) on a short interval;
for each new row, invoke `onSegment(AudioChunk{Path, Start: startSec + csvStart})`.
Reconcile any trailing rows after `cmd.Wait()` so the final segment is not
missed.
- Honors `ctx` (CommandContext kills ffmpeg on cancel) and `onSegment` errors
(stop the pass). Caller owns `dir`; segments are deleted by the consumer after
ASR to cap disk at one extraction.
- Preserves exact per-segment starts from the CSV, so timing does not accumulate
drift within a pass (only the initial `-ss` seek is packet-accurate, sub-second,
and is further corrected by the existing `ProbeAudioStartOffset`).
Rejected alternative — **per-chunk seek extraction** (`-ss s -t d` per chunk):
simpler control flow but O(n) ffmpeg spawns and packet-aligned per-chunk seeks
that can clip words at every seam. The single-pass streaming consumer keeps the
existing segment-muxer boundary quality and far fewer process spawns.
### 2b. Transcriber incremental mode
- Add `TranscribeJobRequest.Incremental bool`, set by the service for streaming
jobs.
- When `Incremental`, `Transcribe` drives playhead-first in two streaming passes
(reproducing today's `chunkOrderForPosition` order): pass A `startSec =
playhead → end`, then pass B `0 → playhead`. Each completed segment is fed
straight into the existing per-chunk ASR loop body (transcribe → `onChunk`),
which Phase 1 already wires to translate+stream. The full cue set is still
accumulated and returned for transcript storage.
- When not `Incremental` (background, and plain transcribe unless we opt it in),
keep the existing whole-file `ExtractAudioChunks` + `chunkOrderForPosition`
path.
Note: timing composition (`-ss` reset PTS + segment CSV start + probe offset)
must be verified with a fixture; see tests.
### 2c. Phase 2 tests
- `audio_extract` test (or transcriber test with an injected extractor) covering
incremental emission order, exact-start mapping with a `startSec` offset, and
ctx-cancel mid-pass.
- Transcriber test asserting `Incremental` yields playhead-first chunk order
across the two passes and returns the complete cue set for transcript storage.
- Confirm first `onChunk` fires after ~one chunk of audio, not after full-file
extraction (timing/ordering assertion via a fake extractor, not wall-clock).
---
## Multi-repo / client impact
None expected to the contract. Reuses the existing notifier protocol
(`TranslationStarted` / `TranslationCues` / `TranslationCompleted` /
`TranslationFailed`, `notifier.go`) under the same translated-track `trackKey`.
Translated cues simply begin arriving far earlier, and `Done/Total` keeps the
chunk-granular meaning plain `transcribe` already uses (1c). Worth a smoke test
on one `silo-android` / `silo-apple` client to confirm the live track renders
cues arriving before the transcript track completes, and that `Done/Total` is
treated as a fraction.
## Risks & tradeoffs
- **Translation context at chunk boundaries (live only).** Per-chunk translation
resets the batch context-neighbour window at each ~30s boundary — accepted
quality tradeoff for live; background jobs are unchanged.
- **More/smaller ASR requests for live jobs.** 30s chunks mean more requests;
per-chunk timeout (`chunkSeconds * asrChunkTimeoutFactor`) and per-spawn
overhead scale; the 15s floor bounds the worst case.
- **Incremental extraction reliability (Phase 2).** Segment-list polling + the
`-ss` seek path need fixtures for boundary timing, the final-segment race, and
cancel/cleanup. Single ffmpeg pass per direction keeps cost low.
- **Boundary word-clips.** Smaller live chunks add more fixed-length boundaries
where a straddling word can clip — already a documented v1 limitation; the
silence-aligned follow-up would address it.
## Out of scope / follow-ups
- **Pace to playback (issue item 3):** rolling window ahead of the playhead to
bound work; separate change once interleave + incremental extraction land.
- **Cross-chunk translation context:** feed the previous chunk's tail source
cues as untranslated context into each chunk's translate call.
- **Silence-aligned chunk boundaries:** reduce boundary word-clips that smaller
live chunks make more frequent.
## v1 scope note
`docs/architecture/v1-scope.md` is **NOT LOCKED**; the issue carries
`v1-proposed`. This is a **bug fix** to an already-shipped capability (no new
user-facing capability, endpoint, or response field — only an internal config
key and corrected streaming behavior), so it proceeds under the "bug fixes
proceed normally" clause. Link the PR to the AI-subtitle capability item per the
v1 PR requirements.
## Pre-push checklist
- `make lint`
- `cd web && pnpm run lint && pnpm run format:check` (run only if web touched —
none expected)
- `make verify-local-paths`
- Go tests for `internal/subtitles/ai` and `internal/playback` in a
libvips-capable container (a bare-host `go test ./...` silently skips CGO
packages).
@@ -0,0 +1,188 @@
# Personal Collection Catalog Filter Standardization - Implementation Plan
- **Date:** 2026-06-23
- **Spec:** `docs/superpowers/specs/2026-06-23-personal-collection-catalog-filter-standardization-design.md` (rev. 3, post-adversarial-review). The spec owns the *why* and the decisions; this plan owns the *how*, with concrete files and ordering.
- **Note:** All paths are repository-relative. Commands assume the repository root is the cwd.
## Approach & PR Boundaries
Three concerns, landed in order. PR 1 is independently valuable and must merge first because PR 2 depends on its hardened predicate for correctness.
| PR | Concern | Depends on |
|----|---------|-----------|
| **PR 1** | Harden the catalog `watched` predicate (series/season rollup + row-type branching) and pin equivalence with item user-data. | — |
| **PR 2** | Replace the `watch_filter` / `media_filter` enum vocabulary with a `display_query_definition` fragment end to end (migration → storage → API → execution → web). | PR 1 |
| **PR 3** (deferred) | Wire display filters for smart/live collections via an executor-level `(source) AND (display)` conjunction. | PR 2 |
**Why PR 3 is deferred:** display-filter controls are currently exposed only for manual/exact and imported collections (`CollectionBuilder.tsx` renders them only when `collection_type === "manual"`; imported uses `ImportedCollectionEditor.tsx`). Smart collections never reach `FilterUserCollectionDisplayItems` today (`catalog_resolver.go` branches smart collections to `resolveQuerySource` before the filter call). So the OR-leak the adversarial review found is a guardrail for *future* wiring, not a v1 path. Build PR 2 so the same conjunction helper is reusable, but do not expose smart-collection display filters until PR 3.
---
## PR 1 — Catalog Watched Predicate Hardening
**Goal:** make the catalog `watched` rule produce the same played/read verdict as item user-data (`internal/api/handlers/user_state.go`) for every item kind, under both scoped and unscoped queries. This is the single-semantic foundation; everything else routes through it.
### Current state (verified)
- `internal/catalog/query_builder.go`:
- `buildWatchedClause` (≈900–912) wraps `userStateCompletionClause` and negates for `value:false`.
- `userStateCompletionClause` (≈1119–1171) checks completion against **the row's own `content_id`** (`uwp.media_item_id = mi.content_id` / `uwh.media_item_id = mi.content_id`). No episode rollup → a series row is effectively never watched.
- `ebookUserStateCompletionClause` (≈1173–1193) is selected **only when `qb.mediaScope == "ebook"`** → an ebook under an unscoped query is misclassified by the video clause.
- `internal/api/handlers/user_state.go` `allEpisodesCompleted` (≈171–185) defines the canonical rollup over the episode repository's set.
- Episode set (the parity target): `internal/catalog/episode_repo.go` `ListBySeriesIDs` / `ListBySeasonIDs` select from `episodes` filtered by `series_id` / `season_id` **AND** `episodeAvailabilityPredicate` = `EXISTS (SELECT 1 FROM episode_libraries el WHERE el.episode_id = episodes.content_id)`. No specials/season-0 exclusion, no media-file requirement. Links: `episodes.series_id`, `episodes.season_id`.
- The base relation always exposes `mi.type` (`media_items mi`, or the episode derived table that hard-codes `'episode'::text AS type`), so a `CASE` on row type is feasible.
### Changes
1. **Extract a reusable completion predicate.** In `internal/catalog/query_builder.go`, refactor `userStateCompletionClause` into a helper that, given a row alias and the linking column, emits the leaf completion EXISTS pair (progress + history, with hidden-history guard). Keep `ebookUserStateCompletionClause` as the ebook leaf. This is the shared building block for the branches below.
2. **Add series/season rollup branches with repository-parity episode sets.** Emit, for a series row, "≥1 child episode exists AND no child episode is incomplete," expressed so the child-episode set is **identical** to `episodeAvailabilityPredicate`:
```sql
EXISTS (SELECT 1 FROM episodes e
WHERE e.series_id = mi.content_id
AND EXISTS (SELECT 1 FROM episode_libraries el WHERE el.episode_id = e.content_id))
AND NOT EXISTS (
SELECT 1 FROM episodes e
WHERE e.series_id = mi.content_id
AND EXISTS (SELECT 1 FROM episode_libraries el WHERE el.episode_id = e.content_id)
AND NOT (<leaf completion predicate for e.content_id>))
```
Season rollup is the same with `e.season_id = mi.content_id`. The leaf predicate inside the `NOT EXISTS` reuses the helper from step 1 keyed on `e.content_id`. **The `episode_libraries` EXISTS must match `episodeAvailabilityPredicate` verbatim** — if `episode_repo.go` ever changes that predicate, this SQL changes with it. Add a code comment cross-referencing `episodeAvailabilityPredicate` so the coupling is discoverable.
3. **Branch by row type for unscoped queries.** When `qb.mediaScope` is empty/`video` (rows can be mixed), select the completion source from `mi.type` rather than from `mediaScope`:
- `movie` / `episode` / video leaf → leaf predicate,
- `series` → series rollup, `season` → season rollup,
- `ebook` → ebook leaf,
- `manga` → deferred; treat as never-completed for now (documented), do not invent a collection-only interpretation.
When `mediaScope` is a single concrete kind (e.g. `series`, `ebook`), the optimizer-friendly path may emit just that branch. Implement as a `CASE mi.type WHEN ... END` or chained `(mi.type = 'x' AND <clause>) OR ...`; verify argIdx accounting (each leaf/ebook clause increments `qb.argIdx` by 2 — the rollups reuse the leaf, so count the placeholders carefully and add a builder test that the produced arg count matches bound args).
4. **`last_watched` stays separate.** Do not route `last_watched` through the new predicate; it is a `user_last_watched` CTE timestamp aggregate. Leave as-is; add a one-line comment noting the intentional divergence.
### Tests (PR 1)
- `internal/catalog/query_builder_test.go`: assert the `watched` clause contains the series/season rollup EXISTS only when the scope/row-type can include series/seasons; assert arg count == bound args for each branch; assert the `episode_libraries` predicate text is present in the rollup.
- **Equivalence test (highest value):** seed a Postgres fixture with a series whose episodes are variously completed/not, a season, a movie, and an ebook for one profile; assert the catalog `watched` rule's matched set equals exactly the items whose `user_state.go` `Played` flag is true. Because `user_state.go` lives in `internal/api/handlers` (CGO/libvips), run this in the libvips-capable container; the test can drive both the catalog executor and the item-user-state builder against the same pool and diff the verdicts. If a same-package home is awkward, place the comparison test in `internal/api/handlers` where both sides are reachable.
### Release note (PR 1)
Catalog `watched` filtering for series/seasons changes from "never matches" to "matches when all available episodes are completed." Call this out in the PR body and release notes per the spec's semantics-change risk.
---
## PR 2 — Replace the Enum Vocabulary with `display_query_definition`
One concern: swap the persisted representation and every surface that reads/writes it. Clean replacement — no aliases, no backfill (the enums are branch-only and unreleased; see spec Current Branch State). Land the steps below together so no layer reads a column another layer has dropped.
### Step 2.1 — Migration reshape
Edit `migrations/sql/20260623184858_user_collection_display_filters.sql` in place so the canonical schema has no legacy columns:
- **Up:** `ALTER TABLE public.user_personal_collections ADD COLUMN IF NOT EXISTS display_query_definition jsonb;` (nullable; `NULL`/absent = no display filter). Remove the `watch_filter` / `media_filter` `ADD COLUMN` and their CHECK constraints.
- **Down:** `ALTER TABLE ... DROP COLUMN IF EXISTS display_query_definition;`
**Operational gotcha:** Goose keys applied migrations by version, so editing an already-applied migration will **not** re-run it. Any dev DB that already applied the original `20260623184858` (which added `watch_filter`/`media_filter`) must be rolled back to before it and re-applied: `make migrate-down` to the prior version, then `make migrate-up`. If a shared dev DB makes in-place editing risky, the alternative is a *new* timestamped migration (`make migrate-create NAME=replace_collection_display_filters`) that drops the two columns and adds `display_query_definition`; the spec prefers in-place reshape since nothing is merged, but call the choice out in the PR.
### Step 2.2 — Backend storage & types
- `internal/userstore/types.go`: in `Collection` (≈185–213), `CreateCollectionInput` (≈238–255), `UpdateCollectionInput` (≈257–278), remove `WatchFilter` / `MediaFilter`; add `DisplayQueryDefinition string` (and `*string` on the update input), mirroring the existing `QueryDefinition` fields.
- `internal/userstore/pgstore/collections.go`:
- `collectionSelectColumns` (≈15–18): drop `watch_filter, media_filter`; add `display_query_definition`.
- `scanCollection` (≈20–42) and the `ListCollections` scan (≈192–196): drop the two scan targets; add `&c.DisplayQueryDefinition`.
- `CreateCollection` INSERT (≈79–94): drop the two columns/args; add `display_query_definition` + arg.
- `UpdateCollection` (≈270–283): replace the two `add("watch_filter"…)` / `add("media_filter"…)` blocks with a single `display_query_definition` block driven by `input.DisplayQueryDefinition != nil`.
- The normalization at create-time (≈59–68) moves from enum normalization to **fragment normalization** (Step 2.5).
- `internal/userstore/collection_filters.go`: repurpose into the fragment normalizer's home, or delete the enum constants/`Normalize*` funcs if nothing else uses them after the swap. Keep the file's tests meaningful — convert `collection_filters_test.go` to cover the new normalizer (Step 2.5) rather than the deleted enums.
### Step 2.3 — API surface
- `internal/api/handlers/collections.go`:
- `createCollectionRequest` (≈39–50), `updateCollectionRequest` (≈52–67), `collectionResponse` (≈73–101): drop `watch_filter` / `media_filter`; add `display_query_definition json.RawMessage` following the existing `query_definition` handling (response assembly mirrors `defaultJSON([]byte(c.QueryDefinition))` at ≈843).
- `HandleCreateCollection` (≈270–279) / `HandleUpdateCollection` (≈341–356): replace enum validation with fragment normalization/validation (Step 2.5); reject invalid fragments with `400`.
- `collectionCapabilitiesResponse` (≈108–111) + `HandleCapabilities` (≈224–229): replace `watch_filters`/`media_filters` value lists with capability metadata describing the supported display-query fields (`type`, `watched`) and the UI presets (`all/watched/unwatched`, `all/movies/series`). Keep it additive and feature-detection-oriented per the v1 API rules.
- `internal/api/handlers/user_collection_imports.go`: `userImportSharedFields` (≈67–77) and `createImportedCollection` validation (≈214–223) — same swap to `display_query_definition`.
### Step 2.4 — Exact-collection execution through the catalog executor
Replace the custom Go filter pass and the metadata-only in-Go matcher with one SQL path.
- Delete `internal/catalog/user_collection_display_filters.go` (`FilterUserCollectionDisplayItems` and helpers) and its test, plus the duplicated `userCollectionAllEpisodesCompleted`.
- Call sites to rewrite:
- `internal/catalog/catalog_resolver.go` `resolveUserCollectionSource` (≈479–526, filter call at ≈517).
- `internal/sections/fetcher.go` `fetchUserCollection` (filter call at ≈1371).
- New behavior for exact collections (per spec Execution Model):
1. Load member IDs in source order (`user_personal_collection_items`, `ORDER BY position`) — unchanged.
2. Parse `collection.DisplayQueryDefinition` into a `QueryDefinition`, **force `Limit = nil`** and drop any fragment `sort`/`library_ids` defensively before execution.
3. Run the executor with `AccessFilter.AllowedContentIDs = memberIDs` (the pattern `resolveCandidateItemsWithQuery` already uses at `catalog_resolver.go` ≈606–607). The executor's `conditions` slice AND-joins `AllowedContentIDs` with the display fragment, so membership ∧ filter is automatic — no OR-leak risk here.
4. For source-ordered requests: request the **full** match set (no limit), collect matching IDs into a set, reapply original order, then apply request offset/limit. Pagination happens after reordering, never inside the executor.
5. Stop using `filterCatalogItems` (`catalog_resolver.go` ≈1772) for these collections — replace the `UseSourceOrder` branch in `resolveExactOrderedMediaItems` (≈571–586) with the match-set-then-reorder path. **Do not delete `filterCatalogItems`:** it has a second caller at `catalog_resolver.go:244` (the query-source path), so it stays; only the exact-collection call at ≈575 changes.
- Empty display fragment ⇒ skip the executor filter entirely and return members in source order (preserves today's "no filter" performance for unfiltered collections).
### Step 2.5 — Fragment normalization (shared backend helper)
A `display_query_definition` is a **filter-only** `QueryDefinition` fragment. Add one normalizer (e.g. in `internal/catalog` next to `QueryDefinition`, or `internal/userstore`) used by every write path (create/update/import):
- Accept only `match` + `groups`, with rules limited to the allowed display vocabulary (v1: `type`, `watched`).
- Reject (preferred, on write) or strip `limit`, `sort`, `library_ids`, `media_scope`.
- Reject unknown fields/ops so clients learn the contract.
- Return a canonical JSON string for storage. Used by `pgstore` create/update and the API handlers.
### Step 2.6 — Web
The frontend already has the machinery: `QueryDefinition` types, `createEmptyQueryDefinition` / `normalizeQueryDefinition` (`web/src/api/types.ts` ≈1318–1361), and `CollectionGuidedRulesEditor.tsx` which already converts `watched` rules ↔ a `watchStatus` field (`queryDefinitionToGuidedState` ≈93–254, `guidedStateToQueryDefinition` ≈257–374). Reuse it; do not add a parallel state model.
- `web/src/lib/collectionDisplayFilters.ts`: replace the enum option lists / `normalize*` helpers with converters **preset ⇄ `QueryDefinition` fragment**:
- `watched`/`unwatched`/`all` ⇄ a `{field:"watched",op:"is",value:true|false}` rule (or none),
- `movie`/`series`/`all` ⇄ a `{field:"type",op:"is",value:"movie"|"series"}` rule (or none),
- a builder that assembles the two presets into a single filter-only fragment, and a reader that derives the two preset values back from a fragment (for editing).
- `web/src/api/types.ts`: drop `UserCollectionWatchFilter` / `UserCollectionMediaFilter` and the `watch_filter` / `media_filter` fields on `Collection` (≈1270–1271), `CreateCollectionRequest` (≈1403–1404), `UpdateCollectionRequest` (≈1420–1421); add `display_query_definition?: QueryDefinition`.
- Editors — swap the two `<Select>` controls' wiring to read/write through the helper, keeping the same visible labels:
- `web/src/components/collections/CollectionBuilder.tsx` (value shape ≈45–57, UI ≈259–313, submit ≈88–101),
- `web/src/pages/ImportedCollectionEditor.tsx` (state ≈118–125, UI ≈277–354, save ≈184–214),
- `web/src/components/CollectionTemplateGallery/UserCollectionTemplateConfigForm.tsx` (state ≈99–100, submit ≈119–167).
- `web/src/hooks/queries/collections.ts` (`useCreateCollection` ≈85–101, `useUpdateCollection` ≈103–127): payloads now carry `display_query_definition` instead of the enums (the generic `buildUserCollectionPayload` needs no change beyond the type swap).
### Tests (PR 2)
- `internal/api/handlers/collections_test.go`: create/update/get round-trips `display_query_definition`; `watch_filter`/`media_filter` are absent from request and response; an invalid fragment (e.g. containing `limit` or an unknown field) returns `400`.
- Resolver/executor tests:
- exact collection routes through the catalog `watched` rule and preserves source order across pages;
- an exact collection larger than a page returns the full match set in source order regardless of fragment contents (guards the limit-truncation finding);
- a mixed-media exact collection (movie + ebook) with `watched`/`unwatched` classifies the ebook via `ebook_reader_progress`, not video history (relies on PR 1 row-type branching).
- Fragment-normalizer unit tests (replacing `collection_filters_test.go`): strips/rejects `limit`/`sort`/`library_ids`/`media_scope`; round-trips `type` + `watched` rules.
- Frontend: helper test for preset ⇄ fragment conversion (both directions); one editor test asserting the submit payload carries the canonical `display_query_definition`.
---
## PR 3 — Smart/Live Display Filters (deferred)
Only when smart/live collections expose display-filter controls. Implements the spec's executor-level conjunction so `(source) AND (display)` holds regardless of the source's top-level `match`.
- Seam: `internal/catalog/query_executor.go`, after the `AllowedContentIDs` handling (≈322) and before `ApplySectionAccessFilter`. Build the display fragment with a second `NewQueryBuilder("mi").WithArgIdx(argIdx)`, `rebindSQLPlaceholders` its output to the running `argIdx`, append the rebound clause to `conditions`, and advance `argIdx`/`args`. Because `conditions` is AND-joined (≈353), this yields `(sourceWhere) AND (displayWhere)` with no group-list manipulation and no OR-leak.
- Do **not** append the display group into `def.Groups` — that is the OR-leak the adversarial review found (`QueryBuilder.Build` joins top-level groups with the def's single `match`, ≈84–111).
- Tests: a `match: any` source plus a display filter never returns rows the source alone excludes; a contradictory `watched` overlay on a `match: any` source returns zero rows.
---
## Verification (every PR, pre-push)
Per `CLAUDE.md`:
- `make lint`
- `cd web && pnpm run lint && pnpm run format:check`
- `make verify-local-paths`
- Go tests in a **libvips-capable container** (a bare-host `go test ./...` silently skips CGO packages including `internal/api/handlers`, where the equivalence and DTO tests live). Prefix `GOWORK=off` for builds/tests in this repo.
- Targeted: `go test ./internal/catalog/... ./internal/api/handlers/... ./internal/userstore/...` and the relevant `web` helper/editor tests.
## Sequencing summary
1. PR 1 (predicate + equivalence test) → merge.
2. PR 2 (migration → types/storage → API/imports → fragment normalizer → exact execution → web), landed together → merge.
3. PR 3 only if/when smart-collection display filters are wired.
Link each PR to the capability sub-issue (`Part of #NNN`) per the v1 process; keep one concern per PR.
## Open items to confirm before/while implementing
- Confirm no `silo-android` / `silo-apple` build already consumes the branch's `watch_filter` / `media_filter` fields (spec assumes unreleased; if pinned, fall back to the contingency backfill path in the spec's Migration Strategy).
- Confirm `episodeAvailabilityPredicate` is the intended "child episode" definition to match (Open Question #4 in the spec) — it currently includes specials/season-0 if they have library links. Match it exactly; change item user-data and the catalog rule together if it should differ.
- Decide the fragment normalizer's package home (`internal/catalog` vs `internal/userstore`) based on import direction — it needs the `QueryDefinition` type without creating an import cycle.
@@ -0,0 +1,327 @@
# Hybrid Semantic Search Hardening Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Commands assume the repository root is the cwd.
**Goal:** Make Meilisearch hybrid semantic search safe to enable broadly by gating it on *trustworthy* per-type vector coverage, validating embedder capability, and exposing per-query/admin diagnostics — without regressing the hot search path.
**Architecture:** Semantic search stays gated behind the existing `SemanticEnabled && wordCount >= 3` rule, plus a new **coverage gate** that reads an in-memory, periodically-refreshed per-type coverage snapshot (never a per-request DB query). Coverage = `current-model embeddings ÷ embed-eligible items`, computed entirely from Postgres and latched with hysteresis. Existing provider/fallback fields are plumbed through the resolver into an additive `search_diagnostics` response object. A rate-limited capability probe validates the Meili embedder without downing keyword search.
**Tech Stack:** Go (pgx/pgxpool), Meilisearch hybrid search (`userProvided` embedder), React/TypeScript admin UI, Goose migrations.
## Global Constraints
- **API additive-only within `/api/v1`** — never rename/remove a response field, change a type, or repurpose a status code. New fields are `omitempty` additions. (CLAUDE.md)
- **Performance first, reliability first, predictable under load** — the search request path must not gain a synchronous DB count, lock wait, or remote probe. Fail **safe** (keyword-only), never panic. (CLAUDE.md Core Priorities)
- **One additive index migration** is in scope (revised — see Assumptions): a btree on `media_item_embeddings(model)` to support the periodic coverage query. No schema/column changes.
- **Canonical embedding dimensions** = `embeddingvectors.CanonicalDimensions` (= 3072; re-exported as `recommendations.CanonicalEmbeddingDimensions`). Never hardcode the number.
- **Single source of truth for embed-eligibility** — the predicate `(status='matched' OR type IN ('audiobook','ebook'))` must not be duplicated. It moves to the neutral `internal/embeddingvectors` package (imported by both `catalog` and `recommendations`; `recommendations` already imports `catalog`, so `catalog` cannot import `recommendations`).
- **Single phase** — measurement *and* enforcement ship together. The diagnostics shipped here are what let us retune thresholds later without code changes-in-anger.
- Build/test in worktrees with `GOWORK=off` and a stubbed `web/dist` (see worktree-build-quirks memory).
---
## Corrected Baseline (what actually exists today — verified)
Work from this, not from intuition. Each point was confirmed against the tree.
1. **The only semantic skip rule is `SemanticEnabled && len(Fields(normalizedQuery)) >= 3`** (`internal/catalog/search_meilisearch_provider.go:329`). There is **no** `SkipTotal`/preview skip, and `TestMeilisearchSearchRequestBuildsHybridForApproximateInteractiveSearch` (`search_provider_test.go:213`) asserts hybrid **IS** used with `SkipTotal: true`. → **Do not add a `SkipTotal` skip.** Layer the coverage gate onto the existing rule; remove nothing.
2. **`CatalogSearchResult` already carries `Provider` and `FallbackReason`** (`search_provider.go:64-71`). Diagnostics work is *plumbing these through* `CatalogResult` (which drops them, `catalog_resolver.go:24-30,291-296`) — not inventing provider fields.
3. **The provider already self-downgrades hybrid→keyword on a Meili error** (`search_meilisearch_provider.go:221-228`). Diagnostics must report the *post-downgrade* reality.
4. **`countCatalogSearchVectorDocuments` ignores `model`** (`search_indexer.go:757`), counts the *indexed* population (no eligibility filter), and its companion denominator (`document_count`) comes from **Meili index stats** (`search_service.go:129`), not Postgres. It has **four callers** — `search_service.go:139`, `search_indexer.go:133`, `:203`, `:310` — all must be updated when its signature changes.
5. **Indexed population ≠ embed-eligible population.** `LoadDocumentsAfter` (`search_indexer.go:534`) indexes every type-matched, non-manga row regardless of `status`; embeddings are only written for `(status='matched' OR audiobook OR ebook)` (`recommendations/repo.go:154-160`, `recommendationItemEligibilityWhereClause`). A denominator of "indexable items" is therefore structurally < 1.0 on any library with unmatched video → **the gate would never open.** Coverage must use the **embed-eligible** population for both numerator and denominator.
6. **Status fields that already exist:** `document_count`, `vector_document_count` (`search_provider.go:347-348`). The earlier draft's `index_document_count` does **not** exist — keep real names; add new fields alongside.
7. **`UpsertEmbedding` already enqueues a Meili index event after every write** (`recommendations/repo.go:186`) — verified correct, **no change**.
8. **Catalog search settings are restart-bound** (`config/restart_keys.go:75-85`, *"Catalog search provider construction is intentionally startup-bound in v1"*). The service is built **once** in `NewRouter` and never live-rebuilt. → the coverage refresher is a single process-lifetime goroutine; no live-rebuild leak exists in v1.
9. **`catalogSearchService` is a local var in `NewRouter`** (`router.go:437`), wired into handlers but **never returned** to `main.go`. The long-lived `deps.AppContext` (`router.go:88`) is the refresher's context.
10. **Legacy `/search` plain-`q` re-route is descoped** — see [Descoped](#descoped--not-in-this-pass).
---
## Design Decisions
### D1 — Coverage = current-model embeddings ÷ embed-eligible items (single Postgres source, per type)
Let `ELIG(alias)` = `embeddingvectors.ItemEligibilityWhereClause(alias)` → `(alias.status='matched' OR alias.type IN ('audiobook','ebook'))` (moved here from `recommendations`; `recommendations.recommendationItemEligibilityWhereClause` is refactored to call it — no behavior change, no duplication).
- **Denominator** (embed-eligible items, per type):
```sql
SELECT mi.type, COUNT(*) AS eligible
FROM media_items mi
WHERE NOT EXISTS (SELECT 1 FROM manga_chapters mc WHERE mc.chapter_content_id = mi.content_id)
AND ($1::text[] IS NULL OR mi.type = ANY($1))
AND (mi.status = 'matched' OR mi.type IN ('audiobook','ebook')) -- ELIG("mi")
GROUP BY mi.type
```
- **Numerator** (eligible items with a *current-model* embedding, per type):
```sql
SELECT mi.type, COUNT(*) AS vectorized
FROM media_item_embeddings e
JOIN media_items mi ON mi.content_id = e.media_item_id
WHERE NOT EXISTS (SELECT 1 FROM manga_chapters mc WHERE mc.chapter_content_id = mi.content_id)
AND ($1::text[] IS NULL OR mi.type = ANY($1))
AND (mi.status = 'matched' OR mi.type IN ('audiobook','ebook')) -- ELIG("mi")
AND ($2 = '' OR e.model = $2)
GROUP BY mi.type
```
Applying `ELIG` to **both** guarantees numerator ⊆ denominator → ratio ∈ `[0,1]` (an embedding left behind on an item that became unmatched can't push a type over 1.0). Unmatched items are excluded from both, so they never drag coverage. As backfill completes, ratio → 1.0.
**Active model (`$2`)** is read from the embedding lock (`recommendations.EmbeddingLock.Model`, `embedding_lock.go:16`). No lock ⇒ `$2 = ""` and the snapshot is marked not-ready (never gate semantic on for an unestablished embedding space).
> **Why model-filtering is load-bearing:** right after a model change, old vectors persist but are mismatched to the new query embedder. A model-blind count reads ~100% at the most dangerous moment. Filtering by the locked model collapses coverage to ~0 until re-embedding catches up.
**Index (migration, in scope):** `media_item_embeddings` has only an HNSW index on `embedding` (`migrations/sql/001_schema.sql:1857`, `014_...:25`) — nothing on `model`. Add `CREATE INDEX CONCURRENTLY idx_media_item_embeddings_model ON public.media_item_embeddings (model)` so the numerator filter is index-supported. (Goose: timestamped file via `make migrate-create`, with `-- +goose NO TRANSACTION` because `CONCURRENTLY` cannot run in a txn.)
### D2 — Coverage lives in an in-memory snapshot, refreshed off the request path
A `semanticCoverageTracker` (new, `internal/catalog/semantic_coverage.go`) holds an `atomic.Pointer[semanticCoverageSnapshot]`.
- **Refresh cadence:** a single background goroutine every `semanticCoverageRefreshInterval` (**2 minutes** — long enough that two `GROUP BY` aggregates are negligible steady-state load even at 1M rows; short enough to bound the post-model-change danger window, which also fails safe via the model-collapse rule below). The interval is a named const; **there is no after-sync trigger** (the indexer and service share no handle — see Descoped rationale; the interval alone is sufficient and avoids a cross-component wiring + race).
- **Single-flight & immutability:** `Refresh` holds a `sync.Mutex` across read-prev → query → compute → publish (background path; contention irrelevant). Each refresh builds a **fresh** `PerType` map and publishes a new immutable snapshot pointer; the published snapshot is **never mutated** after publish (avoids a `map` data race against concurrent `CoverageReady` reads). A `-race` test fires refreshes concurrently with reads.
- **Fail-safe reads:** `CoverageReady` reads only the atomic pointer — **no DB, no lock, no remote call.** If the snapshot is `nil` (boot, before first refresh) it returns `(false, "coverage not yet computed")` — **never** dereferences a nil map (that would be a hot-path panic).
- **Error retention:** on a refresh query error, retain the last-good snapshot (transient DB blips must not flip semantic off); log; do not publish a zeroed snapshot. Admin status flags `UpdatedAt` staleness.
- **Model-change collapse:** the snapshot records the `Model` it was computed for. When `Refresh` observes the active model differs from the published snapshot's model, it **immediately publishes an all-not-ready snapshot** (safe) and recomputes real coverage under the new model on the same pass. This bounds the mismatched-vector window to ≤ one interval and fails safe within it.
### D3 — Per-type readiness with hysteresis; scope = AND of its types; empty scope = all types
Each type's `ready` flag is **latched**: flips **true** at `ratio >= semanticCoverageEnableRatio` (0.90), flips **false** only at `ratio < semanticCoverageDisableRatio` (0.80). The snapshot carries prior latched state so each refresh applies hysteresis — preventing flap (identical queries returning differently-ranked results).
`CoverageReady(itemTypes)`:
- **Non-empty scope:** ready iff **every** requested type is latched-ready (`min` semantics — a weak subtype blocks the mixed scope). The reason names the first failing type.
- **Empty/nil scope** (the common "search everything" case — `MediaScopeItemTypes("") → nil`, so `ItemTypes == nil` reaches the gate): ready iff **every type in the snapshot** is latched-ready. This deliberately mirrors the empty-config semantics and refuses to let an unscoped search bypass a weak type. Reason names the first failing type, or `"coverage not yet computed"` if the snapshot is nil/empty.
### D4 — Diagnostics: existing fields, plumbed, reported post-downgrade, scoped to the provider path
- Add `Mode string` (`"keyword"`/`"hybrid"`) and `SemanticUsed bool` to `CatalogSearchResult`; the Meili provider sets them to reflect the **final** request after the internal hybrid→keyword downgrade.
- Add `Provider`, `Mode`, `SemanticUsed`, `FallbackReason` to `CatalogResult`; copy them in `resolveDirectSearchSource`.
- Add an `omitempty` `search_diagnostics` object to `catalogResponse`.
- **Scope:** diagnostics are produced **only** on the direct provider-backed search path (`useDirectSearchPath` = relevance sort, desc, no advanced rules — `catalog_resolver.go:1416-1427`). They are **omitted** for: browse, non-relevance-sorted `q=` searches (which never hit the provider), and `group=work` responses (the grouped wrapper builds a fresh `CatalogResult` at `catalog.go:287-293` and is already approximate — `TotalExact=false`). Each exclusion gets an explicit handler test.
### D5 — Capability validation: narrow, rate-limited, non-fatal, admin-path only
Embedder settings are written by Silo itself, so validation guards only **external drift** (Meili volume reset, manual edits). Add `GetSettings` to the Meili client; verify the configured embedder exists, `source == "userProvided"`, and `dimensions == embeddingvectors.CanonicalDimensions`. The hybrid probe:
- runs **only on the admin status path** (`HandleGetCatalogSearchStatus`), **not** on `CheckConnection`/health (which only does `GET /health`), and the admin query has no `refetchInterval` (`settings.ts`, `staleTime: 15_000`);
- runs **only when `state.ActiveIndexUID != ""`**, and is **cached for `semanticCapabilityProbeTTL` (5m)** → ≤ 1 probe / 5m regardless of refresh rate;
- uses a **non-degenerate unit sample vector** (`v[0]=1.0`, rest 0, length `CanonicalDimensions`) — an all-zero vector has zero norm and can spuriously fail cosine hybrid; targets `ActiveIndexUID` with `Hybrid.Embedder = config.Embedder`, `limit:1`.
Failures surface in admin status and force keyword-only; they **never** flip provider `Healthy` or trip the circuit.
### D6 — Backfill split: cheap discovery vs. bounded text-staleness, with fairness
The expensive part of `ListEmbeddingTextCandidates` is **building `current_text` for every eligible row** via 5 `LATERAL` joins to `item_people` (`recommendations/repo.go:544-589`) — *not* the `IS DISTINCT FROM` comparison.
- **Cheap discovery** (`ListMissingOrModelStaleEmbeddingIDs`): `LEFT JOIN media_item_embeddings`, filter `ELIG("mi") AND (e.media_item_id IS NULL OR e.model = '' OR e.model <> $currentModel)`, `ORDER BY mi.content_id`, `LIMIT`. **No `LATERAL`, no `current_text`.** The SQL is built by a package-private `buildMissingOrModelStaleEmbeddingIDsSQL() string` helper so a unit test can assert it contains neither `item_people` nor `LATERAL` (mirrors the existing `embeddingEligibilityWhereClause()` string-helper test pattern). Returns IDs; `current_text` is then built **only for that bounded batch** via `BuildEmbeddingTextForIDs(ids)`.
- **Text-staleness** (existing `ListEmbeddingTextCandidates`): keep, but run as a **bounded, unconditional quota per cycle** (`embeddingTextStaleQuotaPerRun`) so canonical-text-stale items progress even when the cheap backlog never empties (fairness against starvation).
- **Cursors:** the cheap pass advances `afterID` from the **cheap query's** last id (it is the ordered cursor), independent of `BuildEmbeddingTextForIDs` row order (`ANY($1)` does not preserve slice order, and a dropped id must not desync the cursor). The text-stale pass keeps its **own** `afterID`. Both monotonic.
---
## File Structure
| File | Responsibility | Change |
| --- | --- | --- |
| `migrations/sql/<ts>_media_item_embeddings_model_index.sql` | `(model)` btree, `CONCURRENTLY` / `NO TRANSACTION` | Create |
| `internal/embeddingvectors/eligibility.go` | `ItemEligibilityWhereClause(alias)` — single source of truth | Create |
| `internal/catalog/semantic_coverage.go` | tracker, snapshot, querier seam, per-type SQL, hysteresis, single-flight refresher | Create |
| `internal/catalog/search_provider.go` | gate + model-provider interfaces; `Mode`/`SemanticUsed`; semantic status structs | Modify |
| `internal/catalog/search_meilisearch_provider.go` | consult gate; set `Mode`/`SemanticUsed` (incl. downgrade); capability probe (unit vector) | Modify |
| `internal/catalog/search_meilisearch_client.go` | `GetSettings` + settings DTOs | Modify |
| `internal/catalog/search_service.go` | build/own tracker; `StartCoverageRefresh`; comma-ok model-provider; `Status()` reads snapshot | Modify |
| `internal/catalog/search_indexer.go` | `ELIG`+`model` numerator & eligible denominator; update **all 4** `countCatalogSearchVectorDocuments` callers | Modify |
| `internal/catalog/catalog_resolver.go` | carry diagnostics through `CatalogResult` (direct path only) | Modify |
| `internal/recommendations/repo.go` | delegate eligibility to `embeddingvectors`; `ListMissingOrModelStaleEmbeddingIDs` (+SQL helper); `BuildEmbeddingTextForIDs` | Modify |
| `internal/recommendations/similar.go` | `EmbedAll`: cheap drain + unconditional text-stale quota; dual cursors | Modify |
| `internal/recommendations/engine.go` | `ActiveEmbeddingModel(ctx)` from the lock | Modify |
| `internal/api/handlers/catalog.go` | `searchDiagnostics` DTO; populate on direct path; omit for grouped | Modify |
| `internal/api/router.go` | start `service.StartCoverageRefresh(deps.AppContext)` inside `NewRouter` | Modify |
| `web/src/hooks/queries/admin/settings.ts`, `web/src/pages/admin-settings/SearchSettings.tsx` | surface readiness/ratio/per-type/capability | Modify |
---
## Tasks
### Task 1: Shared eligibility predicate, model+eligibility coverage counts, supporting index
**Files:** Create `internal/embeddingvectors/eligibility.go`, `migrations/sql/<ts>_media_item_embeddings_model_index.sql`; Modify `internal/recommendations/repo.go:150-160`, `internal/catalog/search_indexer.go:757-782` (+ callers `:133,:203,:310`, `search_service.go:139`); Test `internal/embeddingvectors/eligibility_test.go`, `internal/catalog/search_indexer_test.go`.
**Interfaces — Produces:**
- `embeddingvectors.ItemEligibilityWhereClause(alias string) string`
- `countCatalogSearchVectorDocuments(ctx, q coverageQuerier, itemTypes []string, model string) (int, error)` (adds `model`; applies `ELIG`)
- `catalogSemanticCoverageByType(ctx, q coverageQuerier, itemTypes []string, model string) ([]catalogTypeCoverage, error)` → `{Type string; Eligible, Vectorized int}`
- [ ] Migration: `make migrate-create NAME=media_item_embeddings_model_index`; body uses `-- +goose NO TRANSACTION` then a self-healing guard for a leftover invalid index from a crashed build (follow the `20260618164519_add_episodes_season_id_index.sql` precedent: a `DO $$ ... NOT i.indisvalid ... DROP INDEX $$` block), then `CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_media_item_embeddings_model ON public.media_item_embeddings (model);` (down: `DROP INDEX CONCURRENTLY IF EXISTS`). The runner honors `NO TRANSACTION` (three shipping precedents). Run `make migrate-up`; verify with `make migrate-status`.
- [ ] Move the eligibility predicate to `embeddingvectors`; refactor `recommendationItemEligibilityWhereClause` to delegate (assert output string unchanged).
- [ ] Failing tests: (a) `model` filter excludes rows whose `e.model` differs; (b) an **unmatched, non-book** item inflates neither numerator nor denominator (the C1 regression guard); (c) denominator counts eligible items with no embedding row; (d) ratio ∈ [0,1] when a stale embedding exists on a now-unmatched item.
- [ ] Implement both queries with `ELIG`; merge per type in Go; update **all four** callers (pass active model where available, else `""`).
- [ ] `GOWORK=off go test ./internal/embeddingvectors ./internal/catalog -run 'Eligib|Coverage' -count=1` → PASS. Commit.
### Task 2: Active embedding model from the lock
**Files:** Modify recommendations `Engine` (`engine.go`), `internal/catalog/search_provider.go`; Test `internal/recommendations/*_test.go`.
**Produces:** `recommendations: func (e *Engine) ActiveEmbeddingModel(ctx) (string, error)` (→ `GetEmbeddingLock`; `lock.Model` or `""`); `catalog: type CatalogSemanticModelProvider interface { ActiveEmbeddingModel(ctx context.Context) (string, error) }`.
- [ ] Test: lock present → its `Model`; no lock → `("", nil)`.
- [ ] Implement; confirm `recEngine` satisfies both `CatalogSearchQueryVectorizer` (the real interface name, `search_provider.go:77`) and `CatalogSemanticModelProvider`.
- [ ] Run recommendations tests → PASS. Commit.
### Task 3: Coverage tracker — querier seam, snapshot, hysteresis, single-flight refresher
**Files:** Create `internal/catalog/semantic_coverage.go`, `semantic_coverage_test.go`; Modify `internal/catalog/search_provider.go` (consts + gate interface).
**Produces:**
```go
const (
semanticCoverageEnableRatio = 0.90
semanticCoverageDisableRatio = 0.80
semanticCoverageRefreshInterval = 2 * time.Minute
)
// coverageQuerier is the seam so Refresh is unit-testable without a real *pgxpool.Pool.
type coverageQuerier interface {
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
}
type catalogTypeCoverage struct { Type string; Eligible, Vectorized int; Ratio float64; Ready bool }
type semanticCoverageSnapshot struct { PerType map[string]catalogTypeCoverage; Overall float64; Model string; UpdatedAt time.Time }
type SemanticCoverageGate interface { CoverageReady(itemTypes []string) (ready bool, reason string) }
type semanticCoverageTracker struct { /* q coverageQuerier; indexTypes []string; models CatalogSemanticModelProvider; mu sync.Mutex; snap atomic.Pointer[semanticCoverageSnapshot]; clock func() time.Time */ }
func (t *semanticCoverageTracker) Refresh(ctx context.Context) error
func (t *semanticCoverageTracker) CoverageReady(itemTypes []string) (bool, string) // nil-snapshot ⇒ (false, "coverage not yet computed")
func (t *semanticCoverageTracker) Snapshot() *semanticCoverageSnapshot
func (t *semanticCoverageTracker) Run(ctx context.Context) // ticker; returns on ctx.Done()
```
- [ ] Test (no panic / fail-safe): `CoverageReady` on a freshly-constructed tracker (no `Refresh` yet) returns `(false, "coverage not yet computed")` and touches the querier **zero** times (querier stub fails the test if called).
- [ ] Test (hysteresis): 0.85→not ready; →0.92 ready; →0.85 **stays** ready; →0.79 not ready.
- [ ] Test (scope): `CoverageReady(["movie","series"])` false when `series` < threshold though `movie` ready; reason names `series`. Empty scope requires all snapshot types ready.
- [ ] Test (model collapse): snapshot computed for model A; active model becomes B → next `Refresh` publishes all-not-ready before recomputing.
- [ ] Test (`-race`): concurrent `Refresh` + `CoverageReady` loops; assert monotonic `UpdatedAt`, no race.
- [ ] Test (error retention): a `Refresh` whose query errors retains the prior snapshot.
- [ ] Implement single-flight mutex, fresh-map immutability, nil-safe reads, model-collapse, retain-last-good, ticker `Run`.
- [ ] `GOWORK=off go test ./internal/catalog -run Coverage -race -count=1` → PASS. Commit.
### Task 4: Wire gate + model provider; comma-ok degradation; start refresher in NewRouter
**Files:** Modify `internal/catalog/search_meilisearch_provider.go:27-66,329-334` (`MeilisearchProviderConfig` opens at `:27`), `internal/catalog/search_service.go:31-74`, `internal/api/router.go:437-552`.
**Consumes:** `SemanticCoverageGate`, `CatalogSemanticModelProvider`. **Produces:** `MeilisearchProviderConfig.Coverage SemanticCoverageGate`; `func (s *CatalogSearchService) StartCoverageRefresh(ctx context.Context)`.
- [ ] `shouldUseSemanticSearch` → `SemanticEnabled && wordCount>=3 && (p.config.Coverage == nil || ready)`; not-ready yields fallback reason `"semantic_not_ready: " + reason` (a **diagnostic, not an error**). Keep the `SkipTotal`-builds-hybrid test green.
- [ ] In `NewCatalogSearchServiceFromSettings`: only when `SemanticEnabled`, derive the model provider via **comma-ok** `mp, ok := queryVectorizer.(CatalogSemanticModelProvider)`. If `!ok` or `queryVectorizer == nil` (semantic enabled but recommendations disabled — a real config), build the tracker with a model provider that yields `""` ⇒ every type not-ready (reason `"embedding model provider unavailable"`); **never** assert without comma-ok (nil-interface assertion panics). Store the tracker on the service; pass it as `Coverage`.
- [ ] `StartCoverageRefresh` runs `tracker.Run(ctx)` once; no-op when there is no tracker (postgres/semantic-off). Call it from `NewRouter` after the service is built: `if catalogSearchService != nil { catalogSearchService.StartCoverageRefresh(deps.AppContext) }`. (The service is local to `NewRouter` and not returned; `deps.AppContext` is the process-lifetime ctx — Baseline #8/#9.)
- [ ] Test: stub gate not-ready ⇒ long query stays keyword-only; ready gate + vectorizer ⇒ hybrid emitted; nil vectorizer + semantic-enabled ⇒ not-ready, no panic.
- [ ] `GOWORK=off go test ./internal/catalog -count=1` → PASS. Commit.
### Task 5: Diagnostics plumbing (provider → resolver → handler), correctly scoped
**Files:** Modify `internal/catalog/search_provider.go:64-71`, `internal/catalog/search_meilisearch_provider.go:189-237`, `internal/catalog/catalog_resolver.go:24-30,269-297`, `internal/api/handlers/catalog.go:55-61,91-134`; Test `search_provider_test.go`, `catalog_test.go`.
**Produces:** `CatalogSearchResult{+Mode string; +SemanticUsed bool}`; `CatalogResult{+Provider,Mode string; +SemanticUsed bool; +FallbackReason string}`; handler `type searchDiagnostics struct { Provider, Mode string; SemanticUsed bool; FallbackReason string \`json:"fallback_reason,omitempty"\` }`; `catalogResponse{+SearchDiagnostics *searchDiagnostics \`json:"search_diagnostics,omitempty"\`}`.
- [ ] Provider: `Mode="hybrid", SemanticUsed=true` only when the hybrid request is issued **and survives**; on the downgrade (`:221-228`) set `Mode="keyword", SemanticUsed=false, FallbackReason=semanticFallback`.
- [ ] Resolver: copy the four fields in `resolveDirectSearchSource` (direct path only).
- [ ] Handler: populate `search_diagnostics` only on the non-grouped direct path; **omit** for `group=work` and for non-relevance-sort `q=` requests.
- [ ] Tests: (a) hybrid error ⇒ `semantic_used:false, mode:"keyword", fallback_reason` set (post-downgrade truth); (b) existing `total/total_exact/has_more/items/snapshot` byte-stable; (c) field **absent** for browse, for `group=work`, and for a `q=`+title-sort request.
- [ ] `GOWORK=off go test ./internal/catalog ./internal/api/handlers -count=1` → PASS. Commit.
### Task 6: Capability validation + rate-limited probe; admin status from snapshot
**Files:** Modify `internal/catalog/search_meilisearch_client.go:167+`, `internal/catalog/search_meilisearch_provider.go`, `internal/catalog/search_service.go:90-145`, `internal/catalog/search_provider.go` (status structs).
**Produces:**
```go
func (c *meilisearchClient) GetSettings(ctx, uid string) (meilisearchIndexSettings, error) // GET /indexes/{uid}/settings
type meilisearchIndexSettings struct { Embedders map[string]meilisearchEmbedderSettings `json:"embedders"` }
type meilisearchEmbedderSettings struct { Source string `json:"source"`; Dimensions int `json:"dimensions"` }
type CatalogSearchSemanticStatus struct {
Ready bool `json:"ready"`; DisabledReason string `json:"disabled_reason,omitempty"`
CoverageRatio float64 `json:"vector_coverage_ratio"`; CoverageUpdatedAt *time.Time `json:"coverage_updated_at,omitempty"`
PerType []CatalogSearchTypeCoverage `json:"per_type,omitempty"`; Capability CatalogSearchSemanticCapability `json:"capability"`
}
type CatalogSearchTypeCoverage struct { Type string `json:"type"`; Eligible int `json:"eligible"`; Vectorized int `json:"vectorized"`; CoverageRatio float64 `json:"vector_coverage_ratio"`; Ready bool `json:"ready"` }
type CatalogSearchSemanticCapability struct { OK bool `json:"ok"`; Reason string `json:"reason,omitempty"`; Embedder string `json:"embedder,omitempty"`; Dimensions int `json:"dimensions,omitempty"` }
const semanticCapabilityProbeTTL = 5 * time.Minute
```
- [ ] Add `Semantic CatalogSearchSemanticStatus` to `CatalogSearchRuntimeStatus`. **Keep** `document_count`/`vector_document_count` unchanged (additive only). The `Semantic` block (ratio, per-type, ready) is read from `tracker.Snapshot()` — **no fresh query** in `Status()`; if no tracker, `Ready=false, DisabledReason="semantic disabled"`.
- [ ] Capability: embedder present, `source=="userProvided"`, `dimensions==embeddingvectors.CanonicalDimensions`; each failure → distinct `Reason`. Probe (unit vector, `limit:1`, `Embedder=config.Embedder`) only when `ActiveIndexUID != ""`, cached `semanticCapabilityProbeTTL`.
- [ ] Tests: missing embedder / wrong source / wrong dimensions / probe failure each distinct; provider `Healthy` and circuit untouched in all four.
- [ ] `GOWORK=off go test ./internal/catalog -count=1` → PASS. Commit.
### Task 7: Backfill cheap/expensive split with fairness
**Files:** Modify `internal/recommendations/repo.go:472-621`, `internal/recommendations/similar.go:251-362`; Test `repo_test.go`, `similar_test.go`.
**Produces:** `buildMissingOrModelStaleEmbeddingIDsSQL() string`; `ListMissingOrModelStaleEmbeddingIDs(ctx, afterID, currentModel string, limit int) ([]string, error)`; `BuildEmbeddingTextForIDs(ctx, ids []string) ([]EmbeddingTextCandidate, error)`; `EmbedAll` two-pass loop with dual cursors and an unconditional `embeddingTextStaleQuotaPerRun`.
- [ ] Test (SQL shape): `buildMissingOrModelStaleEmbeddingIDsSQL()` contains neither `"item_people"` nor `"LATERAL"`.
- [ ] Test (behavior, no real DB needed for the shape test; behavior tests use existing recommendations test seams): an item that is *only* canonical-text-stale (people changed, model current) is **not** returned by the cheap query; a missing/model-stale item **is**.
- [ ] Test (parity): `BuildEmbeddingTextForIDs(ids)` reproduces the same `canonical_text` as the legacy full CTE for the same ids, regardless of `ids` order.
- [ ] Test (no starvation): with a permanently non-empty cheap backlog, a text-stale item is still embedded within a bounded number of `EmbedAll` runs (quota is unconditional); both cursors stay monotonic.
- [ ] Implement; cheap `afterID` advances from the cheap query's last id.
- [ ] `GOWORK=off go test ./internal/recommendations -count=1` → PASS. Commit.
### Task 8: Admin UI surfacing
**Files:** Modify `web/src/hooks/queries/admin/settings.ts:21-54`, `web/src/pages/admin-settings/SearchSettings.tsx:202-261`.
- [ ] Extend the `CatalogSearchStatus` TS type with the `semantic` block (ready, disabled_reason, vector_coverage_ratio, coverage_updated_at, per_type, capability).
- [ ] Render a "Semantic readiness" row (ready/disabled + reason), overall coverage %, a per-type coverage table, a capability badge, and a "coverage updated" timestamp. Keep "Vectorized Documents" (`vector_document_count`).
- [ ] `cd web && pnpm run lint && pnpm run format:check` → PASS. Commit.
---
## Test Plan
**Go unit (`internal/embeddingvectors`, `internal/catalog`):**
- Eligibility predicate single-sourced; unmatched non-book item excluded from numerator **and** denominator (C1 guard); ratio ∈ [0,1].
- Hybrid omitted when gate not ready; emitted when ready + capable + long query + vectorizer present.
- **Model-stale safety:** only-old-model embeddings ⇒ coverage ~0 ⇒ keyword-only; model-change collapse publishes not-ready within one interval.
- Hysteresis latch; mixed-scope AND; empty-scope = all-types; nil-snapshot returns not-ready with **no panic** and **no DB call**; `-race` refresh/read; error retention.
- Capability: missing embedder / wrong source / wrong dimensions / probe failure each distinct; keyword stays up.
**Go unit (`internal/recommendations`):**
- Cheap SQL excludes `item_people`/`LATERAL`; people-only-stale row excluded from cheap pass; per-ID text build matches legacy output order-independently; no text-stale starvation; dual cursors monotonic.
**Go unit (`internal/api/handlers`):**
- `search_diagnostics` present on direct path; **absent** for browse, `group=work`, and non-relevance-sort `q=`; `semantic_used` reflects post-downgrade reality; existing fields byte-stable.
**Admin status:** `semantic.ready`, `disabled_reason`, `vector_coverage_ratio`, `per_type`, `capability`, `coverage_updated_at` sourced from the snapshot (no fresh query).
**Focused command:**
```bash
GOWORK=off go test ./internal/embeddingvectors ./internal/catalog ./internal/api/handlers ./internal/recommendations -race -count=1
```
---
## Descoped — not in this pass
**Routing legacy `/search?q=` through the provider pipeline.** Sunset **Wed, 01 Jul 2026** (`internal/api/handlers/legacy_read_routes.go:10`, `legacyReadSunset`) — days away; hardening a path scheduled for deletion is negative ROI. The typed legacy path already routes through the resolver; only the bare-`q` fallback hits `itemRepo.Search`, which the sunset removes. Re-routing also risks `browseResponse`↔`catalogResponse` shape drift. Reopen as a one-task follow-up **only if the sunset slips**, with a byte-for-byte `browseResponse` assertion.
**After-sync coverage refresh trigger.** The indexer (`main.go:1730`) and the search service (`router.go:457`) share no handle, so an after-drain trigger would need new cross-component wiring and would race the interval refresh. The 2-minute interval plus model-change collapse is sufficient; the trigger is intentionally omitted.
---
## Assumptions
- **One additive index migration** (`media_item_embeddings(model)`, `CONCURRENTLY`). No schema/column changes. *(Revised from the original "no migration": the model-filtered numerator is only cheap with this index, and performance is priority #1.)*
- All runtime coverage state is **in-memory** (atomic per-type snapshot), rebuilt on boot and refreshed every 2 minutes; not persisted.
- Coverage is computed **entirely from Postgres** over the **embed-eligible** population (`status='matched' OR audiobook/ebook`), model-filtered, manga excluded, restricted to configured index types; it **never** uses the Meili `document_count`.
- Active embedding model comes from `recommendations.EmbeddingLock.Model`; **no lock / no model provider ⇒ semantic not ready.**
- A model change is reflected within ≤ one refresh interval and **fails safe** within that window via the model-collapse rule; the residual ≤2-minute mismatch window is acceptable for a rare, admin-gated reconfiguration.
- Thresholds (enable 0.90 / disable 0.80) and the 2-minute interval are named consts and starting heuristics; the diagnostics shipped here are the instrument for retuning them. Promoting them to settings is a possible follow-up, not this pass.
- `semantic_ratio` stays `0.30` (`DefaultMeilisearchSemanticRatio`).
- Scope is hybrid-search hardening only — not conversational search.
---
## Self-Review (writing-plans checklist)
- **Findings coverage:** C1 eligibility denominator (D1, Task 1) · C2 empty-scope semantics (D3, Task 3) · missing `model` index (D1 migration, Task 1) · 4-caller signature change (Baseline #4, Task 1) · refresher wiring in `NewRouter`/`AppContext` (Baseline #9, Task 4) · concurrent-refresh single-flight + immutability + `-race` (D2, Task 3) · nil-snapshot/error fail-safe (D2, Task 3) · model-change collapse (D2, Task 3) · comma-ok nil vectorizer (Task 4) · querier seam + SQL-string helper for testability (Task 3, Task 7) · grouped/non-relevance diagnostics scoping (D4, Task 5) · unit probe vector + admin-only/5m (D5, Task 6) · `Status()` reads snapshot (Task 6) · backfill cursors + fairness (D6, Task 7) · legacy & after-sync descoped (Descoped) — all mapped.
- **Type consistency:** `SemanticCoverageGate`/`CatalogSemanticModelProvider`/`coverageQuerier`/`semanticCoverageTracker`/`catalogTypeCoverage`/`CatalogSearchSemanticStatus` used identically across tasks; `Eligible` (not `Indexable`) is the denominator field name throughout.
- **No placeholders:** every new symbol has a signature; SQL is concrete; the migration body is specified.
@@ -0,0 +1,224 @@
# Audiobook Player QoL Improvements
Commands assume the repository root is the cwd. All paths are repository-relative.
## Goal
Bring the web audiobook player to feature parity with best-in-class audiobook apps
(Audiobookshelf, Audible, BookPlayer, Prologue) on the quality-of-life axis. The current
surface architecture (persistent mini bar + expandable Now Listening view, playback
survives navigation) is already the de-facto standard and stays as-is.
Explicitly **out of scope**: Media Session / lock-screen integration, bookmarks (needs a
server endpoint — follow-up), offline/download, queue.
## Features
1. **Keyboard shortcuts** — space/K play-pause, arrow skips, volume, chapter nav, speed step.
2. **Configurable skip intervals** — asymmetric defaults (back 10s, forward 30s), user-tunable.
3. **Expanded speed control** — 0.5×–3.0× with fine stepping, presets, and per-book memory.
4. **Chapter prev/next buttons** — alongside the seconds-skip buttons.
5. **Volume control** — reuse the shared `VolumeControl`, persisted like the video player.
6. **Smart rewind** — auto-rewind on resume, scaled by how long playback was paused.
7. **Time remaining at current speed** — real-clock remaining display in Now Listening.
No server/API changes are required; everything is web-frontend only, so no Android/Apple
client coordination is needed. (Per-book speed memory is device-local in v1; syncing it
server-side via profile settings is a noted follow-up.)
## Existing seams (read these first)
| File | Relevance |
| --- | --- |
| `web/src/pages/audiobooks/player/useAudiobookPlayback.ts` | Playback engine. `togglePlay` (~line 499), `skip` (~534), `setRate` (~541), returned API (~690+). All engine work lands here. |
| `web/src/pages/audiobooks/player/MiniBar.tsx` | Bottom bar. Hardcoded `SKIP_*_SECONDS = 30` and `PLAYBACK_RATES` at lines 10–12. |
| `web/src/pages/audiobooks/player/NowListening.tsx` | Full-screen view. Same hardcoded constants; dead "More" button (lines 50–56) becomes the settings menu trigger; remaining-time toggle at lines 33–37. |
| `web/src/pages/audiobooks/player/AudiobookPlayer.tsx` | Wrapper that owns mini/now-listening mode — mount point for the keyboard shortcuts hook. |
| `web/src/player/components/VolumeControl.tsx` | Existing slider + `getPersistedVolume`/`persistVolume` helpers (shared `player-volume`/`player-muted` storage keys). Styled for dark video overlay — needs a surface-toned variant. |
| `web/src/player/components/SpeedMenu.tsx` | Current preset-only menu (shared with video player). Audiobook gets a richer `SpeedControl`; video keeps `SpeedMenu` untouched. |
| `web/src/player/hooks/useKeyboardShortcuts.ts` | Video player's shortcut hook — pattern to mirror (input-field guard, single document listener). |
| `web/src/utils/storage.ts` | Typed localStorage wrapper — add new keys here, never call `localStorage` directly. |
| `web/src/player/components/SeekBar.tsx`, `CircleButton.tsx`, `SleepTimerMenu.tsx`, `ChaptersMenu.tsx` | Established player design language; reuse, don't fork. |
## Design language
These are additions to an existing, refined system — extend it, don't restyle it:
- **Surfaces**: mini bar is a themed surface (`bg-background`, `border-t`, token colors).
Popovers anchored to it use the established dark-glass treatment
(`bg-black/90 backdrop-blur-sm rounded-lg shadow-xl`, white/75 text, `data-active` rows)
exactly as `SpeedMenu`/`SleepTimerMenu` do today.
- **Type**: time and rate values are always `font-mono`/`tabular-nums` so nothing shifts as
digits tick. The new speed readout (`1.45×`) must reserve width for two decimals.
- **Buttons**: all transport controls are `CircleButton` (`sm` secondary flanks, `md`/`lg`
primary center). Chapter prev/next use `SkipBack`/`SkipForward` lucide glyphs at the same
stroke weight (1.6) as the rotate icons, so the cluster reads as one family.
- **Micro-interactions**, small and purposeful:
- Skip buttons: a quick ~18° icon flick in the skip direction on press
(CSS transform transition, ~150ms ease-out) — confirms the action without a toast.
- Speed stepper: value crossfades (opacity 120ms) on change; the active preset chip gets
the same `bg-white/5 text-white` treatment as current menu rows.
- Smart rewind: when it fires, the seek bar playhead animates the small jump back rather
than teleporting (SeekBar already animates position; verify it holds for ≤30s deltas).
- **Accessibility**: every new control keeps the existing patterns — `aria-label`,
`aria-expanded`/`aria-haspopup` on triggers, roving focus + Escape in menus (copy from
`SleepTimerMenu`), `role="slider"` semantics from `VolumeControl`/`SeekBar`.
## Phase 1 — Engine + preferences model
### 1a. Preferences hook
New `web/src/pages/audiobooks/player/useAudiobookPrefs.ts`:
- Add storage keys: `AUDIOBOOK_SKIP_BACK`, `AUDIOBOOK_SKIP_FORWARD`,
`AUDIOBOOK_SMART_REWIND` (bool), `AUDIOBOOK_RATES` (JSON map) in `web/src/utils/storage.ts`.
- Exposes `{ skipBack, skipForward, smartRewind, setSkipBack, setSkipForward, setSmartRewind }`.
- Defaults: back **10s**, forward **30s** (the asymmetric convention — small "what did she
say?" hops back, bigger hops forward), smart rewind **on**.
- Allowed skip values: 5, 10, 15, 30, 45, 60, 90 (two digits max keeps `SkipIcon`'s inset
number legible).
- Per-book rate map: single JSON object `{ [contentId]: rate }` under `AUDIOBOOK_RATES`,
LRU-capped at 50 entries (store `{ rate, at }` and evict oldest on insert).
Helpers `getBookRate(contentId)` / `setBookRate(contentId, rate)` live here too.
### 1b. Engine extensions (`useAudiobookPlayback.ts`)
- **Volume/mute**: initialize from `getPersistedVolume()`; apply to the `<audio>` element in
an effect; expose `volume`, `muted`, `setVolume`, `setMuted`; persist via `persistVolume`.
Sharing the video player's keys is intentional — one volume preference per device.
- **Per-book rate**: on mount, initialize `rate` from `getBookRate(contentId)` (fallback 1);
in `setRate`, also `setBookRate`. The hook needs `contentId` passed in (it already
receives the files/options object — extend that input).
- **Chapter navigation**: expose `nextChapter()` and `prevChapter()`.
- `next`: seek to start of the chapter after `currentChapter`; no-op at the last chapter.
- `prev`: if more than 3s into the current chapter, seek to its start; otherwise seek to
the previous chapter's start (the universal music/audiobook convention).
- **Smart rewind**: record `pausedAtRef = performance.now()` whenever playback pauses (in
the `pause` event handler so it also catches OS-initiated pauses). On resume, before
`audio.play()`, compute rewind from pause duration and `seekTo(currentTime - rewind)`:
- < 10s paused → 0s; < 1min → 3s; < 10min → 10s; < 1h → 20s; ≥ 1h → 30s.
- Clamp at 0; skip entirely when the smart-rewind pref is off or the resume immediately
follows a user seek (seeking sets a short suppress flag so explicit jumps aren't undone).
- Also apply on **cold resume**: when starting with `initialPositionSeconds > 0` from the
detail page, rewind by the same schedule using the age of the saved progress if the
API exposes an updated-at timestamp; otherwise apply a flat 10s. Keep this logic in one
pure function `smartRewindSeconds(pauseMs)` so it's unit-testable.
- **Skip intervals**: no engine change — `skip(delta)` already takes a signed delta; the UI
passes the configured values.
### 1c. Tests
Vitest, colocated like existing `*.test.tsx`:
- `smartRewindSeconds` schedule boundaries.
- Per-book rate LRU (cap, eviction, malformed-JSON tolerance).
- `prevChapter` 3s threshold behavior and first/last chapter edges
(chapter math is pure — test against `buildChapterList` fixtures from
`web/src/lib/audiobooks/chapters.ts`).
## Phase 2 — Controls UI
### 2a. SpeedControl (new, audiobook-specific)
New `web/src/pages/audiobooks/player/SpeedControl.tsx`, replacing `SpeedMenu` usage in both
audiobook views (video player keeps `SpeedMenu`):
- Trigger: same `player-utility-btn` showing the current rate (`1.45×`, tabular-nums).
- Popover (dark-glass, bottom-anchored like today):
- A stepper row: `−` / big mono readout / `+`, stepping **0.05** per tap, clamped to
**0.5–3.0**. Press-and-hold repeats (250ms initial, 80ms repeat).
- A preset chip row: 1× · 1.25× · 1.5× · 1.75× · 2× · 2.5× · 3×.
- Footnote line: "Remembered for this book" (quiet `text-white/40`, 11px) — makes the
per-book memory discoverable instead of magical.
- Keyboard: ArrowUp/Down step inside the popover; Escape closes (copy menu plumbing from
`SleepTimerMenu`).
### 2b. Chapter prev/next buttons
- Add `SkipBack`/`SkipForward` `CircleButton size="sm" variant="secondary"` on the outside
of the transport cluster in both `MiniBar` and `NowListening`:
`⏮ ↺10 ▶ ↻30 ⏭`. Wire to `prevChapter`/`nextChapter`; render only when
`playback.chapters.length > 0`; disable at the ends (prev stays enabled mid-chapter).
- In `MiniBar`, hide the chapter buttons below `sm:` — the 3-column grid is already tight
on phones and the chapters menu remains available.
### 2c. Volume
- Add a `tone` prop to `web/src/player/components/VolumeControl.tsx`
(`"overlay"` = current white-on-dark, `"surface"` = theme tokens: track `bg-muted`,
fill `bg-foreground`, focus ring `ring-ring`). Default `"overlay"` so the video player is
untouched.
- Mount in `MiniBar`'s right cluster (before the sleep timer) at `md:` and up, and in
`NowListening`'s utility row. Wire to the new engine `volume`/`muted` state.
### 2d. Player settings menu
New `web/src/pages/audiobooks/player/PlayerSettingsMenu.tsx` (dark-glass popover):
- **Skip back** / **Skip forward**: rows of small value chips (5/10/15/30/45/60/90s).
- **Smart rewind**: toggle row with a one-line description ("Backs up a little after a pause").
- Triggers: the currently-dead "More" (`MoreHorizontal`) button in `NowListening`
(lines 50–56) and a matching trigger in `MiniBar`'s right cluster.
- `MiniBar`/`NowListening` read `useAudiobookPrefs()` and pass the configured seconds to
the skip buttons and `SkipIcon` (which already renders a dynamic number) — delete the
`SKIP_*_SECONDS` constants.
## Phase 3 — Shortcuts + time display + polish
### 3a. Keyboard shortcuts
New `web/src/pages/audiobooks/player/useAudiobookKeyboardShortcuts.ts`, mounted in
`AudiobookPlayer.tsx` (so it's active in both mini and expanded modes, on every route):
| Key | Action |
| --- | --- |
| Space / K | play-pause |
| ← / → | skip by configured back/forward seconds |
| ↑ / ↓ | volume ±5% |
| M | mute toggle |
| N / P | next / previous chapter |
| Shift+. / Shift+, | speed +0.05 / −0.05 (YouTube convention) |
| E | expand / collapse Now Listening |
| Esc | collapse Now Listening (expanded mode only) |
- Mirror `useKeyboardShortcuts.ts` guards: ignore when target is input/textarea/
contentEditable, single document listener, cleanup on unmount.
- Additional guard: **do nothing while a video session is active** — the video player binds
the same keys. Check `WatchPlaybackProvider` state (or simply whether a `<video>` watch
route is mounted) and bail; the audiobook bar is backgrounded in that case anyway. Space
must also not fire when a button has focus (let the focused control handle Enter/Space).
- Add `title` tooltips with the shortcut hint to transport buttons
(e.g. `Back 10 seconds (←)`) for discoverability.
### 3b. Time remaining at current speed
In `NowListening`, the right time label currently toggles total ↔ remaining. Make it cycle
three states (persist last choice in component state only):
1. total (`12:04:00`)
2. remaining (`−3:21:09`)
3. remaining at speed (`−2:14:06 at 1.5×`) — `remaining / rate`, hidden (skipped in the
cycle) when `rate === 1`.
Keep the `data-testid="now-listening-right-time"` hook and update its test.
### 3c. Polish pass
- Skip-flick and speed-crossfade micro-interactions from the design section.
- Verify mini bar layout on 360px-wide viewports with all new controls (chapter buttons and
volume hidden, settings menu accessible).
- `cd web && pnpm run lint && pnpm run format:check`; run the player test suites.
## Risks / notes
- `useAudiobookPlayback.ts` is 707 lines and growing — extract smart rewind + prefs-coupled
logic into small pure modules (`smartRewind.ts`) rather than inflating the hook; consider
splitting volume/rate concerns into a `useAudioElementSettings` helper if the hook passes
~800 lines.
- Smart rewind must not fight the 10s progress reporter: rewinding on resume changes
`currentTime`, which triggers a progress write — that's correct (the rewound position is
the truth), just confirm no oscillation with the suppress-after-seek flag.
- Per-book rate uses `contentId`; switching narrators navigates to a different `contentId`,
so each narration remembers its own speed — acceptable, arguably desirable.
- Cross-device sync of prefs/per-book speed is a candidate follow-up once a profile
settings endpoint exists; the `useAudiobookPrefs` API is the seam to swap storage behind.
@@ -0,0 +1,388 @@
# Silo Notifications — Architecture Overview
**Date:** 2026-04-28
**Status:** Draft
**Scope:** Cross-cutting design covering all delivery channels: durable in-app inbox, realtime websocket, Apple Push (APNs), Android Push (FCM), and outbound webhooks.
**Companion docs:**
- [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md) — Foundation
- [`02-apns-relay.md`](./02-apns-relay.md) — Apple push
- [`03-fcm-relay.md`](./03-fcm-relay.md) — Android push
- [`04-outbound-webhooks.md`](./04-outbound-webhooks.md) — Discord and generic webhooks
## Goal
Silo should notify a profile when a new episode of a series they care about becomes available in their library. "Care about" means at least one of: the series is favorited, watchlisted, in continue-watching, or has a profile-eligible next-up cursor.
The notification should reach the profile through whichever delivery channels the profile has configured: an in-app inbox with realtime push to connected clients, mobile push notifications to Apple and Android devices, and outbound webhooks to user-chosen destinations like a Discord channel.
## Why this is hard
Three constraints pull in different directions:
1. **Self-hosted privacy.** Silo is self-hosted media. Notification content (episode titles, posters, profile names, library identity) must never leave the user's server unless the user has explicitly opted into a destination that requires it.
2. **Mobile push gatekeeping.** Apple and Google require pushes to the official App Store / Play Store builds to be authenticated by the credentials of the developer account that signed and published the app. There is no fully self-hosted, zero-Silo-infrastructure path to push to the official mobile apps.
3. **Multi-profile fairness.** A single user account on a Silo server can have multiple profiles (kid, adult, parent, etc.). Notifications must be scoped to the active profile. Devices, push tokens, webhooks, and preferences are all profile-scoped.
The design resolves these by:
- Routing all notification work through a durable per-profile inbox that lives on the user's own server. Everything else is a transport on top of this inbox.
- Operating a privacy-preserving relay for APNs and FCM that holds the official-app credentials and accepts only opaque push requests. Self-hosted servers opt in. The relay never sees notification content.
- Profile-scoping every database row, every API surface, every websocket subscription, and every push registration.
## Mental model
```
┌─────────────────────────────────────────────────┐
│ │
ingest/scan ────► release_events (one row per newly-available │
│ episode in a library) │
│ │ │
│ ▼ │
│ fanout worker │
│ │ │
│ │ loads profile_series_interest │
│ │ applies eligibility + preferences │
│ ▼ │
│ notification_deliveries (one row per │
│ eligible profile per release) │
│ │ │
│ │ AFTER COMMIT │
│ ▼ │
│ channel dispatcher ◄── fans out to channels: │
│ │
│ ┌───────────────┬───────────────┬─────────┐ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ websocket apple push android webhooks│
│ (in-app) (APNs relay push (per │
│ or custom) (FCM) profile)│
│ │ │ │ │ │
└───┼───────────────┼───────────────┼─────────┼───┘
▼ ▼ ▼ ▼
connected iOS / tvOS / Android Discord /
browser tabs macOS apps apps user URL
and apps (wake then (wake) (content
fetch metadata) included)
```
Three invariants make this work:
- **The durable row is the source of truth.** Realtime websocket events, mobile pushes, and webhooks are all triggered *after* the `notification_deliveries` row commits. If any transport fails, the inbox row still exists and shows up on next reconnect or refresh.
- **The fanout worker decides recipients exactly once.** Recipients are derived from a compact `profile_series_interest` index keyed by `(library_id, series_id)`. Per-channel transports are purely about delivery — they never make recipient decisions.
- **Dispatch enqueue is durable (outbox).** The same fanout transaction that inserts `notification_deliveries` also inserts `pending` per-target attempt rows for the push and webhook channels (`push_delivery_attempts` / `webhook_delivery_attempts`). The post-commit dispatcher *claims* those rows for immediate delivery; recovery workers sweep stale `pending` rows. A crash between delivery commit and dispatch therefore delays pushes/webhooks instead of silently dropping them. The websocket channel deliberately has no outbox row — it is best-effort because the inbox snapshot covers reconnect. See `01-release-events-and-inbox.md` "Transaction and Concurrency Rules".
## Channels at a glance
| Channel | Where it lives | Trust model | Content visibility |
|---|---|---|---|
| **In-app inbox** | User's own server, accessed via authenticated REST API | Same auth as the rest of Silo | Full content |
| **Realtime websocket** | User's own server, `/events/ws` channel `notifications` | Same auth, profile-scoped | Full content |
| **Apple Push (APNs)** | Silo-operated relay (or admin-supplied custom APNs credentials) | Opt-in. Self-hosters trust the relay to forward opaque requests. | Opaque payload only — app fetches content from user server after wake |
| **Android Push (FCM)** | Silo-operated relay (or admin-supplied custom FCM credentials) | Same as APNs | Opaque data-only message — app fetches content from user server after wake |
| **Outbound webhooks** | User-chosen destination URL (e.g., Discord, Slack, custom service) | Profile chose the destination; trust is implicit | Full content included by default. Discord type renders native embeds; generic type sends signed JSON |
## Triggers (what generates a notification)
A notification is generated when a new episode becomes available in a library and at least one profile has interest in the series:
- **Favorite** — the series is favorited by the profile.
- **Watchlist** — the series is on the profile's watchlist.
- **Continue watching** — the profile has in-progress watch state for the series.
- **Next up** — the new episode is at or beyond the profile's `next_expected_episode_key` cursor and the profile has progression state for the series.
A profile may match multiple reasons for a single release event. The `notification_deliveries` row records all matching reasons in a `reason_flags` JSONB column; the fanout produces exactly one delivery per `(profile_id, release_event_id)` regardless of how many reasons matched. A second uniqueness rule spans libraries: because Silo media items are catalog-level (`media_item_libraries` is a junction), the same episode landing in two libraries (e.g., "TV" and "TV 4K") shares one `episode_id`, and at most one `episode.available` delivery is created per `(profile_id, episode_id)` — dual-quality library setups do not double-notify.
**Back-catalog protection:** "newly available" must mean *newly released to this server*, not *newly seen by the notifications feature*. The first scan of a newly created library and the one-time feature-enable backfill **seed** `episode_availability` without creating release events, and the fanout worker applies a per-series burst cap for bulk additions to existing libraries. Without this, importing a 200-episode back-catalog of a series with hundreds of interested profiles would generate tens of thousands of deliveries and pushes in a single scan. Full rules in `01-release-events-and-inbox.md` "Seeding and Burst Suppression".
**v1 explicitly does not support:**
- Movie additions. Silo has no movie-availability detector parallel to the per-episode one in this design; without it, there's nothing to fan out from for movies. Users *can* watchlist movies that don't yet exist in their library (via TMDB lookups), but the missing piece is the ingest-time signal that a watchlisted movie has now arrived. v2 can add a `movie_availability` table and analogous release events; the fanout/inbox/channel layers in this design are movie-ready by construction.
- Metadata-only updates (poster refreshes, title corrections).
- Generic library-changed notifications.
- Aggregated notifications like "3 new episodes available." Per-episode deliveries first; aggregation is a presentation concern that can stack on top.
## Mode terminology
The design uses three vocabularies that correspond to different layers; they are intentionally not the same word. This is the canonical mapping:
| Layer | Terms | Where it lives |
|---|---|---|
| **Profile-level push mode** (UI-facing) | `off`, `in_app_only`, `private_push` | `push_devices.push_mode` |
| **APNs wire mode** (relay request `mode` field) | `private_alert`, `background_wake` | Sent to APNs relay or used in custom APNs payload builder |
| **FCM wire mode** (relay request `mode` field) | `private_data`, `background_wake` | Sent to FCM relay or used in custom FCM payload builder |
Mapping:
- Profile `private_push` → APNs `private_alert` for visible wakes, FCM `private_data` for visible wakes.
- Profile `in_app_only` → no remote push at all (websocket / inbox only).
- Profile `off` → no notifications for this device whatsoever.
- The `background_wake` wire mode is reserved for low-noise sync (per-channel spec details). It is not exposed at profile level in v1.
## Preference model
Preferences are profile-scoped and flat. There is no per-channel × per-reason matrix.
```
notification_preferences (per profile)
├── enabled bool — global kill switch
├── notify_favorites bool
├── notify_watchlist bool
├── notify_continue_watching bool
└── notify_next_up bool
```
Per-webhook reason gating uses **the same four boolean columns** on `notification_webhooks` (`notify_favorites`, `notify_watchlist`, `notify_continue_watching`, `notify_next_up`) — not a single structured `reason_filters` JSON field.
**Precedence:** profile preferences are a **hard gate** applied during fanout (a delivery row is not created if all matching reasons are disabled at the profile level). Per-webhook flags and per-device push mode are **additional filters** applied during dispatch — they can narrow what fires for a specific destination but cannot re-enable a reason the profile has globally disabled.
Each push device has a `push_mode` (`off`, `in_app_only`, `private_push`) that gates whether mobile push fires for that specific device. Each webhook has its own `enabled` flag and per-webhook `notify_favorites` / `notify_watchlist` / `notify_continue_watching` / `notify_next_up` booleans for finer control.
The fanout worker uses `notification_preferences` to decide whether to *create* a `notification_delivery` at all. The channel dispatcher uses per-device and per-webhook flags to decide which transports fire after the row commits.
This means: if a profile turns off `notify_continue_watching`, a delivery is never created for a continue-watching-only match, and no transports fire. If a profile leaves the reason flags on but disables a specific Discord webhook, the inbox row + websocket + push still fire normally — the webhook just doesn't.
## Mobile push: why a relay is necessary
Apple and Google operate the only push infrastructure that can wake a closed app on iOS and Android. To send a push to the official Silo app on the App Store or Play Store, the request must be authenticated by the credentials of the developer account that signed and published that app build:
- **APNs:** Apple Developer Team ID + Auth Key ID + `.p8` private key. The auth key is account-scoped (not topic-scoped); it can sign JWTs that authorize push to any bundle topic the team owns. The `apns-topic` request header selects which topic the push targets, and Apple rejects requests whose topic is not owned by the signing team.
- **FCM:** Firebase service account JSON whose private key signs a JWT exchanged for an OAuth2 access token. The service account is scoped to a specific Firebase project, and the project owns one or more registered Android app package names. (Package + SHA-1 cert fingerprint registration matters for Firebase Authentication and App Check, but for plain FCM v1 push the service account + package name + matching FCM registration token are what's required.)
Distributing these credentials to thousands of self-hosted Silo installations is not viable:
1. It violates Apple's and Google's developer agreements, which prohibit sharing credentials outside the team.
2. Anyone with a copy could push to every Silo app worldwide.
3. Apple actively monitors and revokes leaked keys.
Therefore the design separates two paths:
### Hosted relay (`silo_relay`) — the default for official-app users
A small Silo-operated service holds the official APNs `.p8` key and FCM service account JSON. Self-hosted servers opt in by configuring a relay API key, then call the relay with opaque push requests. The relay signs and forwards to APNs / FCM. The relay never sees notification content.
The relay is **stateless on the request path**. Its database holds only:
- relay accounts (one per opt-in self-hosted server installation)
- relay API keys (with `last_used_at`, revocable)
- minimal per-account allowlists (which app topics this account may push to)
- redacted operational logs (no APNs/FCM tokens, no notification content)
It does **not** store user identities, profile data, device-to-user mappings, or notification content. Apple and Google do the actual addressing — when a push request goes out, the relay just forwards `{token, opaque_ids}` to APNs/FCM, and APNs/FCM deliver based on the device token.
### Custom credentials (`custom_apns` / `custom_fcm`) — escape hatch
Power users, white-label deployments, or anyone running a fork that ships its own resigned mobile app can configure their own Apple/Google developer credentials. The user's Silo server signs requests directly using those credentials and sends to APNs/FCM without going through the Silo relay.
Custom mode is **only useful if the admin has shipped a custom-signed app build under their own developer account** with their own bundle/package ID. It does not let a self-hoster push to the official App Store / Play Store app — that's not technically possible without the official developer credentials.
### Addressing flow walkthrough
The most common question on this design is "how does the relay know which user/device to send to with thousands of servers and thousands of users?" The answer is: it doesn't, because Apple and Google handle addressing.
```
1. User installs official Silo app on iPhone from App Store.
2. App calls iOS push registration; Apple returns an opaque APNs device token
(~100 bytes, unique per app install per device).
3. App POSTs that token to the user's OWN server:
POST https://my-silo.example.com/api/v1/devices/push/apple
{ device_id, apns_token, ... }
4. User's server stores in its push_devices table, scoped to the active profile.
5. New episode lands. Fanout worker creates a notification_delivery row.
6. Channel dispatcher loads the profile's enabled push_devices and POSTs
to the relay (or signs directly via custom_apns):
POST https://relay.silo.app/v1/apple/send
Authorization: Bearer rk_thisServersRelayApiKey
{
"token": "740f...", ← APNs device token from step 3
"topic": "com.continuum.app.ios",
"environment": "production",
"mode": "private_alert",
"server_device_id": "01JOPAQUE...", ← lets app find right local server
"delivery_id": "01JDELIVERY..." ← lets app fetch notification meta
}
7. Relay signs JWT with Silo's .p8, forwards to APNs:
POST https://api.push.apple.com/3/device/740f...
apns-topic: com.continuum.app.ios
authorization: bearer <signed JWT>
{generic opaque payload with silo.* fields}
8. Apple delivers to the iPhone holding token 740f...
9. App wakes, reads server_device_id, finds matching local server account,
GETs full notification content from the user's own server.
```
The relay knows: a relay API key was used (so: which self-hosted server installation), the server's egress IP address (inherent to any hosted relay — the server connects to it directly), an APNs device token, a timestamp, and opaque IDs. The relay does not know: which user, which profile, what notification content, what server URL, what library, or what media item. Even a fully compromised relay leaks only timing + device tokens + egress IPs, not notification content or identity. The egress IP is the closest thing to an identity leak in this design — for home hosting it identifies the household's connection — and is listed here so the privacy claims stay exhaustive rather than over-strong.
## Threat model summary
Detailed per-channel threat models live in each channel's spec. The cross-cutting summary:
| Channel | Adversary | Worst case |
|---|---|---|
| In-app inbox + websocket | Network attacker | Existing Silo auth threat model. Notifications add no new surface. |
| APNs / FCM relay (operator) | Compromised relay or hostile relay operator | Can learn device tokens, push timing, and the server's egress IP for opted-in servers. Cannot learn user identity, notification content, server URL, or library data. Cannot fabricate meaningful pushes (only generic opaque payloads). |
| APNs / FCM (Apple / Google) | Apple / Google as platform operator | Can see app topic, device token, generic payload, timing. No user identity, no media metadata, no server URL. |
| Outbound webhooks | Webhook destination operator (e.g., Discord) | Sees full notification content (titles, posters, episode info) for the profile that configured the webhook. The profile chose this destination; this is the explicit trade. |
| Outbound webhooks | Network attacker who owns a URL submitted by the user | Can receive the profile's notifications until the webhook is removed. Mitigated by HMAC for generic webhooks, HTTPS-only, host blocklist (no localhost / RFC1918 by default), and a profile-visible "last seen" / failure status. |
## Channel dispatcher boundary
A single internal `Dispatcher` interface fans the durable row out to channels. Each channel implements the same minimal contract:
```go
type Dispatcher interface {
// Called once per notification_deliveries row, AFTER the row commits.
// Per-target dispatchers (push, webhooks) do not decide their own work:
// the fanout transaction already enqueued one `pending` attempt row per
// (delivery_id, target_id), where target_id is push_device.id or
// notification_webhook.id. Dispatch claims and sends those rows; a
// recovery worker sweeps `pending` rows whose claim never happened
// (crash between commit and dispatch). Idempotency is therefore
// by (delivery_id, target_id, attempt_number). The websocket dispatcher
// has no per-target row and is instead idempotent by delivery_id alone —
// re-publishing the same delivery_id is a no-op for connected clients.
Dispatch(ctx context.Context, delivery NotificationDelivery) error
}
```
Implementations:
- `WebsocketDispatcher` — publishes a `notification.created` event on the `notifications` channel, scoped to the delivery's `(user_id, profile_id)`. Best-effort; durable row is the source of truth.
- `ApplePushDispatcher` — selects enabled `push_devices` for the profile where `platform = 'apple'` and routes through the configured provider (`silo_relay` or `custom_apns`). Records each attempt in `push_delivery_attempts`.
- `AndroidPushDispatcher` — same but `platform = 'android'`, routes through `silo_relay` or `custom_fcm`.
- `WebhookDispatcher` — selects enabled `notification_webhooks` for the profile, applies per-webhook reason filters, and POSTs the channel-specific payload (Discord embed or generic JSON+HMAC). Records each attempt in `webhook_delivery_attempts`.
Channel failures are isolated: if APNs is down, webhooks and websocket still fire. If a webhook destination is unreachable, push still fires. The fanout worker does **not** wait for any dispatcher to succeed before marking the release event processed; the durable delivery rows plus the `pending` outbox attempt rows (enqueued in the same transaction) guarantee eventual delivery for push and webhooks even across a crash. The relay-bound dispatchers additionally pace their sends client-side (token bucket) so a burst never trips the relay's rate limits — see `02`/`03` "Relay pacing".
## Capability surface
The frontend and mobile clients need to know what's available without guessing. A single capability endpoint returns the truth:
```http
GET /api/v1/notifications/capability
```
```json
{
"in_app": { "enabled": true },
"apple_push": {
"available": true,
"provider": "silo_relay",
"supported_modes": ["private_push", "in_app_only"]
},
"android_push": {
"available": false,
"provider": "off",
"supported_modes": ["in_app_only"]
},
"webhooks": {
"available": true,
"max_per_profile": 10,
"supported_types": ["discord", "generic"]
}
}
```
Clients render setup UI from this response. They never have to introspect admin settings.
## Data model overview
The full schemas live in the per-channel specs. The shared shape:
| Table | Purpose | Spec |
|---|---|---|
| `episode_availability` | "Episode E became available in library L." Idempotent. Seeded silently for back-catalog. | 01 |
| `release_events` | One per `(library_id, episode_id)` newly-available event. | 01 |
| `notification_library_seed_state` | Per-library marker: availability has been seeded; release events may now be emitted. | 01 |
| `profile_series_interest` | Compact recipient index keyed by `(profile_id, library_id, series_id)`. | 01 |
| `notification_deliveries` | Per-profile durable inbox row. Cross-library dedupe via partial unique `(profile_id, episode_id)`. | 01 |
| `notification_preferences` | Per-profile reason toggles + master enable. | 01 |
| `push_devices` | Per-profile-per-device push registration. Platform-tagged (`apple` / `android`). | 02 / 03 |
| `push_delivery_attempts` | Per-attempt push log. No notification content. | 02 / 03 |
| `notification_webhooks` | Per-profile webhook destinations (Discord or generic). | 04 |
| `webhook_delivery_attempts` | Per-attempt webhook log. | 04 |
A single migration block lands the foundation tables. APNs/FCM/webhook tables can land in separate migrations as those channels ship — they don't block the v1 in-app inbox.
## API surface overview
Profile-scoped (require `X-Profile-Id` header):
- `GET /api/v1/notifications` — paginated inbox (newest-first, `before` cursor).
- `GET /api/v1/notifications/sync` — forward sync (`since` cursor); the endpoint mobile clients call after a push wake.
- `GET /api/v1/notifications/{id}` — single delivery by ID.
- `GET /api/v1/notifications/unread-count` — for the badge.
- `POST /api/v1/notifications/{id}/read` — mark single read.
- `POST /api/v1/notifications/read-all` — mark all read.
- `POST /api/v1/events/ws-ticket` — mint a short-lived single-use websocket ticket bound to `(user_id, profile_id)`.
- `GET /api/v1/notifications/preferences` — read profile prefs.
- `PUT /api/v1/notifications/preferences` — update profile prefs.
- `GET /api/v1/notifications/capability` — what channels are available.
- `POST /api/v1/devices/push/apple` — register an APNs device.
- `POST /api/v1/devices/push/fcm` — register an FCM device.
- `DELETE /api/v1/devices/push/{id}` — disable a push device.
- `GET /api/v1/notifications/webhooks` — list profile's webhooks.
- `POST /api/v1/notifications/webhooks` — create a webhook.
- `PUT /api/v1/notifications/webhooks/{id}` — update a webhook.
- `DELETE /api/v1/notifications/webhooks/{id}` — delete a webhook.
- `POST /api/v1/notifications/webhooks/{id}/test` — fire a test event.
Admin-scoped:
- Settings registry entries for APNs and FCM provider configuration (admin-only via existing `internal/api/handlers/settings.go` patterns).
## Realtime channel
A new event channel `notifications` joins the existing `catalog`, `jobs`, `sessions`, `tasks`, `scans`, `history_import`, `user_state`, `plugins` channels in `internal/events/types.go`. The websocket handler must validate active profile identity and must reject `notifications` events whose `profile_id` doesn't match the connection's bound profile.
Because browsers can't set custom headers on WebSocket connections, profile identity is carried by a **short-lived single-use ticket**: the client calls `POST /api/v1/events/ws-ticket` (normal auth + `X-Profile-Id`), receives an opaque ticket valid for ~30 seconds, and passes it as a `ticket` query parameter on the handshake. The server consumes the ticket and binds the connection to the `(user_id, profile_id)` it was minted for. A long-lived profile token must **not** be passed in the query string — self-hosted servers commonly sit behind reverse proxies whose access logs capture query strings, and a logged ticket that expired 30 seconds after minting is harmless where a logged profile token is not.
Event types on this channel:
- `notification.created` — a new delivery for the bound profile.
- `notification.read` — a delivery was marked read (allows multi-tab coherence).
Snapshot on subscribe: recent unread deliveries for the bound profile, so reconnecting clients hydrate without a separate REST call.
## Out of scope for v1
- Movie release notifications (no trigger surface; v2).
- Request-fulfilled notifications ("your requested item is now available"). Silo's requests system postdates this design; it is the most obvious next notification type and slots into the extensible `notification_deliveries.type` registry without schema changes. v2.
- Aggregated notifications ("3 new episodes available"; presentation layer, can stack on per-episode rows later). The per-series burst cap in `01` bounds bulk-addition volume until an aggregated delivery type exists.
- Web Push (browser PWA notifications via Push API). Possible v2 — would mirror APNs/FCM as a third push platform.
- Email or SMS delivery.
- Cross-profile / household-wide notification views.
- Admin-level system notifications ("scan complete", "library health alert"). The existing realtime events hub (`internal/events/`, with the operational publisher wrapper at `internal/notifications/hub.go`) already handles operational events; this folder's design is strictly user-facing media notifications.
- Notification templates / customization beyond Discord-vs-generic.
- Time-window quiet hours. Could be added to `notification_preferences` later; not v1.
- Locale negotiation for push payloads. APNs / FCM payloads use generic localization keys (`SILO_NOTIFICATION_GENERIC_BODY` etc.) so the OS picks language from device locale.
## Open questions
These are the few remaining items not yet decided. None block writing the per-channel specs.
1. **Service-level rate limits per relay account.** Per-second and per-day caps need real numbers. Suggest 10 req/sec burst, 50,000 req/day initial quota; tune from logs. The server-side dispatchers pace their relay calls client-side (default 5 req/sec token bucket, see `02`/`03`) so a hundreds-of-users server drains bursts smoothly instead of slamming into 429s; the per-series burst cap in `01` bounds worst-case burst size.
2. **Webhook destination domain blocklist edge cases.** RFC ranges are defined in `04-outbound-webhooks.md`; remaining open question is whether IPv6 v4-mapped addresses (`::ffff:0:0/96`) and CGNAT (`100.64.0.0/10`) need any admin escape hatch beyond the global `allow_private_destinations` flag.
3. **Discord image proxy timing.** Ship in v1 (build `media.discord-cdn-proxy.silo.app`) or defer to v1.5 with text-only embeds? Recommendation in `04`: defer to v1.5; v1 ships text-only embeds.
Resolved during review (no longer open):
- **Relay service code lives in a separate repo** (provisional `silo-push-relay`). This folder defines the contract.
- **Migration ordering.** Foundation schema lands as one migration; per-channel schemas can land separately as their channels ship.
- **`server_device_id` lifecycle.** Stable across token rotation; rotates only when a device is removed and re-registered.
## Phasing
Implementation phases gated by feature flags:
| Phase | Flag | Capability |
|---|---|---|
| 1 | `notifications.schema_enabled` | Tables exist; no writes. |
| 2 | `notifications.interest_updaters_enabled` | `profile_series_interest` updates land on favorites/watchlist/playback events. Backfill task runs. |
| 3 | `notifications.release_events_enabled` | Availability seeding backfill runs first, then ingest writes `release_events`. No fanout yet. |
| 4 | `notifications.fanout_enabled` | Fanout worker runs; `notification_deliveries` rows materialize. Websocket channel published. |
| 5 | `notifications.ui_enabled` | Inbox API + frontend badge + page exposed. |
| 6 | `notifications.webhooks_enabled` | Outbound webhooks + Discord/generic dispatchers. v1 ships **text-only** Discord embeds (no `image` or `avatar_url` fields) to avoid leaking the user's server origin to Discord. v1.5 adds the optional `media.discord-cdn-proxy.silo.app` proxy and re-enables embed images. |
| 7 | `notifications.apple_push_enabled` | APNs registration + dispatcher. Requires relay infra (or admin-supplied custom credentials). |
| 8 | `notifications.android_push_enabled` | FCM registration + dispatcher. Requires relay infra (or admin-supplied custom credentials). |
Phases 1-6 ship without any new external infrastructure. Phases 7-8 require Silo to provision Apple Developer + Firebase developer accounts and operate the relay service.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,891 @@
# Privacy-Preserving APNs Relay Spec
**Date:** 2026-04-27 (refined 2026-04-28)
**Status:** Draft (refined; open questions resolved)
**Scope:** Silo server push integration, Apple clients, opt-in central APNs relay, custom APNs provider configuration
**Depends On:**
- [`00-architecture-overview.md`](./00-architecture-overview.md) — read first for cross-cutting context
- [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md) — foundational durable inbox + fanout
**Sibling spec:**
- [`03-fcm-relay.md`](./03-fcm-relay.md) — Android push using the same threat model and architecture
## Summary
Silo should support remote Apple notifications through either an opt-in
central APNs relay or admin-configured APNs credentials, while keeping
notification metadata on the user's own Silo server.
The hosted relay should be a constrained transport service. It should receive
only the minimum fields needed to submit an APNs request:
- an APNs device token
- APNs environment and app topic
- a push mode
- an opaque server/device correlation key
- an opaque delivery identifier
- optional badge count
Neither provider path may send titles, item names, usernames, profile names,
library names, server URLs, artwork URLs, watched-state metadata, or
notification body text through APNs.
When the Apple device wakes, the app should fetch real notification metadata
directly from the user's Silo server using the existing authenticated
server connection.
## Problem
Reliable Apple remote notifications require APNs. A self-hosted Silo server
cannot directly wake an iPhone, iPad, or Apple TV app in the background without
going through Apple's push infrastructure.
That creates two product concerns:
1. Privacy: notification contents should not be exposed to Apple or a Silo
operated relay.
2. Operational friction: most server admins should not need to create and manage
their own Apple developer account, APNs key, bundle topic, and push service.
Silo should offer a practical default that preserves the self-hosted trust
model as much as the platform allows.
## Decision
Add configurable Apple push providers for the official Apple app:
- `off`: no remote Apple push
- `silo_relay`: Silo's hosted APNs relay
- `custom_apns`: admin-supplied APNs credentials used directly by the server
The relay submits APNs requests using Silo-controlled Apple credentials for
the official app bundle. A self-hosted server admin may opt in to use this relay.
The relay's API should intentionally prevent notification content from being
sent through the relay.
If `custom_apns` is selected, the user's Silo server submits directly to
APNs using admin-provided Apple developer credentials. This avoids the Silo
relay entirely, but it must use the same minimal payload and metadata-fetch
rules as the hosted relay path.
The default push mode should be **Private Alert**:
- APNs displays a generic Silo notification.
- The APNs payload contains only opaque identifiers.
- The app fetches the actual notification row from the user's server when it is
opened or granted background execution.
The implementation should also support **Background Wake** for low-noise sync,
with the explicit understanding that iOS background pushes are opportunistic and
not a reliable user-visible notification mechanism.
## Goals
- Make Apple push possible without every admin managing APNs credentials.
- Keep actual notification metadata on the user's Silo server.
- Make relay participation explicit and disabled by default.
- Make APNs credentials configurable for admins who want to avoid the hosted
relay.
- Keep the relay stateless for device subscriptions where possible.
- Keep APNs payloads opaque and generic.
- Keep the hosted relay and custom APNs paths behaviorally equivalent from the
app's point of view.
- Fit on top of the durable notification inbox design.
## Non-Goals
- Replacing APNs for Apple remote push.
- Shipping notification content through the central relay.
- Requiring Firebase or Google services for this Apple path.
- Solving Android push in this spec.
- Adding marketing, analytics, or delivery-tracking exports to the relay.
- Hiding APNs itself from Apple devices. Custom APNs still uses Apple APNs.
- Making silent background delivery as reliable as visible push.
## Threat Model
### What The User's Server Knows
The user's Silo server knows:
- the notification content
- the profile and user recipients
- the registered local devices
- APNs device tokens for those devices
- push delivery attempts and failures
This is acceptable because the server is already the authority for the user's
media, profiles, sessions, and notification inbox.
### What The Central Relay May Know
The relay may see:
- an opt-in relay account or install identifier
- the server's egress IP address (inherent to any hosted relay — the server
connects to it directly; for home hosting this identifies the household's
connection, making it the closest thing to an identity leak in this design)
- APNs device tokens submitted in send requests
- request timestamps
- coarse push mode, such as `private_alert` or `background_wake`
- opaque `collapse_id` values (per-server-keyed HMACs of series identity; the
relay can group one server's pushes into series-equivalence classes but
cannot recover which series, nor correlate across servers — see "Send Apple
Push" validation)
- APNs response status and APNs request IDs
- opaque delivery identifiers
The relay must not receive enough information to know what media item,
notification type, profile, user, library, server hostname, or server URL caused
the push.
### What Apple May Know
Apple/APNs may see:
- the official app topic
- the target device token
- APNs headers
- request timing
- the generic APNs payload
Apple must not receive media titles, usernames, library names, server URLs,
artwork URLs, or other notification metadata in the payload.
### Residual Metadata Leakage
This design cannot hide that a device received a Silo push at a particular
time, nor the egress IP of the server sending relay requests. It can only hide
the meaning and content of the push. Admins for whom IP exposure matters can
route relay traffic through their existing VPN/proxy egress; the relay path is
plain HTTPS and needs no special handling.
## User And Admin Controls
### Server Admin Control
The server admin must explicitly choose an Apple push provider.
Suggested setting:
```text
notifications.apple_push.provider = off | silo_relay | custom_apns
```
Default:
```text
off
```
If `silo_relay` is selected, the admin configures a relay API key and
accepts a clear privacy notice:
```text
Silo Relay can wake Apple devices through APNs, but notification details
stay on this server. The relay receives APNs device tokens, timestamps, opaque
delivery IDs, and generic push mode only.
```
If `custom_apns` is selected, the admin configures their own Apple developer
APNs credentials:
```text
notifications.apple_push.custom.team_id
notifications.apple_push.custom.key_id
notifications.apple_push.custom.private_key
notifications.apple_push.custom.default_environment = production | sandbox
notifications.apple_push.custom.allowed_topics = [...]
```
The server then sends directly to APNs and does not call the Silo relay.
The same private payload rules still apply.
### Device User Control
Each Apple device must opt in independently:
- the OS notification permission must be granted
- the app must be signed into a Silo server
- the active profile must enable push notifications for that device
- the server admin must have enabled an Apple push provider
Admins must not be able to silently enroll every profile/device into remote push
without the device having granted OS notification permission.
### Profile Control
Push preferences should sit on top of the durable notification preferences from
the inbox design.
Suggested profile/device modes:
- `off`: never send remote push to this device
- `in_app_only`: websocket and inbox only
- `private_push`: generic APNs wake/alert, fetch details from server
- `full_preview`: out of scope for v1; would require explicit user consent and
a separate privacy warning
V1 should ship only `off`, `in_app_only`, and `private_push`.
## System Architecture
```mermaid
sequenceDiagram
participant App as Apple App
participant Server as User Silo Server
participant Relay as Silo APNs Relay
participant APNs as Apple APNs
App->>App: Request OS notification permission
App->>App: Register for remote notifications
App->>Server: Register device token and opaque server_device_id
Server->>Server: Store encrypted token and device preferences
Server->>Server: Create durable notification_delivery
Server->>Relay: Send minimal APNs relay request
Relay->>APNs: Submit generic APNs payload
APNs->>App: Deliver private alert or background wake
App->>Server: Fetch notification metadata directly
Server->>App: Return inbox rows for active profile
```
## Ownership Boundaries
### This Repository
Silo owns:
- admin settings for selecting a push provider
- profile and device notification preferences
- APNs token registration from Apple clients
- durable storage of device registrations
- push fanout from `notification_deliveries`
- relay client integration
- retry, backoff, and APNs error handling
- inbox sync APIs used after wake
### Apple Client Repository
The Apple app owns:
- APNs registration
- notification permission UX
- device registration API calls
- local server/device correlation
- wake handling
- fetching metadata from the user's server
- rendering local/in-app notification details
### Central Relay Service
The central relay owns:
- APNs provider credentials for official Silo bundle topics
- relay API authentication
- request validation and rate limiting
- APNs request submission
- redacted operational logs
- minimal delivery status reporting
The relay should live outside this repo unless the project later decides to
co-locate the service code. This repo should define the contract and implement
the self-hosted server side.
### Custom APNs Mode
When `custom_apns` is selected, there is no central relay in the send path.
The user's Silo server owns:
- APNs provider authentication using the configured team ID, key ID, and private
key
- topic allowlisting for the official Silo app bundle IDs it intends to
support
- sandbox vs production environment selection
- APNs request construction
- APNs response handling
- APNs credential rotation and secret storage
Custom APNs mode must not unlock richer payloads. It exists to let admins avoid
the Silo relay, not to bypass the privacy-preserving notification contract.
## Data Model
This spec assumes `notification_deliveries` exists from the durable notification
inbox design.
### `push_devices`
Purpose: profile/device-scoped remote push registration. Shared across Apple and Android — see [`03-fcm-relay.md`](./03-fcm-relay.md) for the FCM-specific columns.
Columns:
- `id text primary key`
- `user_id integer not null` (matches `users.id integer`)
- `profile_id text not null`
- `device_id text not null`
- `platform text not null` — `'apple'` or `'android'`. Required; the row is invalid without it.
- `provider text not null` — for Apple rows: `'silo_relay'` or `'custom_apns'`. For Android rows: `'silo_relay'` or `'custom_fcm'`. (Effectively the active configured provider at registration time; updates rewrite the row.)
- `apns_environment text` — `'production'` / `'sandbox'`. Nullable; populated only for `platform = 'apple'`.
- `apns_topic text` — bundle topic. Nullable; populated only for `platform = 'apple'`.
- `apns_token_ciphertext bytea` — nullable; populated only for `platform = 'apple'`.
- `apns_token_hash text` — nullable; populated only for `platform = 'apple'`.
- `fcm_token_ciphertext bytea` — nullable; populated only for `platform = 'android'`. Defined here so the table is forward-compatible with [`03-fcm-relay.md`](./03-fcm-relay.md); the FCM spec adds the rest of the Android-specific columns.
- `fcm_token_hash text` — nullable; Android only.
- `fcm_project_id text` — nullable; Android only.
- `fcm_package_name text` — nullable; Android only.
- `server_device_id text not null` — random opaque value, generated server-side at registration. Stable across token rotation; rotates only when the device is removed and re-registered.
- `push_mode text not null default 'private_push'` — profile-level mode (see [`00-architecture-overview.md`](./00-architecture-overview.md) "Mode terminology").
- `enabled boolean not null default true`
- `last_seen_at timestamptz`
- `last_success_at timestamptz`
- `last_failure_at timestamptz`
- `last_failure_code text`
- `created_at timestamptz not null default now()`
- `updated_at timestamptz not null default now()`
Constraints:
- unique on `(profile_id, device_id, platform)` — a device can register exactly once per platform per profile, regardless of which provider the admin happens to have configured. This matters because admins may flip between `silo_relay` and `custom_apns` mid-rotation; a `provider`-scoped unique key would leave stale rows behind.
- unique on `(server_device_id)`
- `CHECK ((platform = 'apple' AND apns_token_hash IS NOT NULL AND fcm_token_hash IS NULL) OR (platform = 'android' AND fcm_token_hash IS NOT NULL AND apns_token_hash IS NULL))` — exactly the platform-appropriate token columns are populated.
Notes:
- `server_device_id` must be random and opaque. It is the value included in the
APNs payload so the app can map the push back to the correct local server
account.
- Store APNs tokens encrypted at rest when the local encryption facility exists.
- Store `apns_token_hash` for dedupe and diagnostics without logging raw tokens.
### `push_delivery_attempts`
Purpose: operational record of push attempts without notification content.
Columns:
- `id text primary key`
- `notification_delivery_id text not null`
- `push_device_id text not null`
- `provider text not null`
- `attempt_number integer not null` — 1-based counter incremented on each retry. Used as the `Idempotency-Key` suffix for relay calls (see Send Apple Push), and lets diagnostics distinguish "10 attempts of one delivery" from "10 deliveries each first-attempted".
- `relay_request_id text`
- `apns_id text` — Apple's APNs ID returned in the response. Nullable; null for FCM rows.
- `fcm_message_name text` — FCM v1 message name (see [`03-fcm-relay.md`](./03-fcm-relay.md)). Nullable; null for APNs rows.
- `status text not null` — `'pending'`, `'delivered'`, `'retrying'`, `'failed'`, `'device_disabled'`. `pending` rows are the dispatch outbox: the fanout transaction in [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md) inserts attempt 1 as `pending` in the same transaction as the delivery; the push dispatcher claims and sends them post-commit.
- `failure_code text`
- `attempted_at timestamptz not null default now()`
- `next_retry_at timestamptz`
Constraints:
- unique on `(notification_delivery_id, push_device_id, attempt_number)` to make the relay's `Idempotency-Key` deterministic and prevent double-claims of an attempt.
- add an index on `(status, next_retry_at)` for retry workers. The same index serves outbox recovery: a sweeper claims `pending` rows older than ~60s (crash between delivery commit and dispatch) with `FOR UPDATE SKIP LOCKED`.
Retention:
- keep successful attempts for a short operational window, such as 14 days
- keep failed attempts longer, such as 30 to 90 days, to diagnose device issues
## Server API Surface
### Register Apple Push Device
```http
POST /api/v1/devices/push/apple
```
Profile-scoped. Requires normal auth plus active profile context.
Request:
```json
{
"device_id": "apple-device-local-id",
"apns_token": "hex-or-base64-token",
"apns_environment": "production",
"apns_topic": "com.continuum.app.ios",
"push_mode": "private_push"
}
```
Response:
```json
{
"id": "01J...",
"server_device_id": "01JOPAQUE...",
"enabled": true,
"push_mode": "private_push"
}
```
Rules:
- `apns_topic` must be allowlisted by app build/channel.
- `apns_environment` must be `sandbox` or `production`.
- registration is idempotent by `(profile_id, device_id, provider)`.
- token rotation updates the encrypted token and token hash.
- a disabled server push provider should still allow registration, but should
report that remote push is unavailable in a capability field if the frontend
needs it.
### Disable Push Device
```http
DELETE /api/v1/devices/push/{id}
```
Profile-scoped and idempotent.
The server may either delete the row or mark `enabled = false`. Marking disabled
is preferred for diagnostics and future re-enable flows.
### Push Capability
```http
GET /api/v1/notifications/capability
```
The full envelope is defined in [`00-architecture-overview.md`](./00-architecture-overview.md). The Apple-relevant slice is:
```json
{
"apple_push": {
"available": true,
"provider": "silo_relay",
"available_providers": ["silo_relay", "custom_apns"],
"supported_modes": ["private_push", "in_app_only"],
"privacy_mode": "metadata_fetch"
}
}
```
The full response also includes `in_app`, `android_push` (see [`03-fcm-relay.md`](./03-fcm-relay.md)), and `webhooks` (see [`04-outbound-webhooks.md`](./04-outbound-webhooks.md)).
This lets clients present truthful setup UI without guessing whether the admin
has enabled remote push.
## Relay API Contract
The relay API should be intentionally narrow. The user server should not send an
arbitrary APNs payload.
### Send Apple Push
```http
POST /v1/apple/send
Authorization: Bearer <relay_api_key>
Idempotency-Key: <notification_delivery_id>:<push_device_id>:<attempt_number>
```
Request:
```json
{
"token": "apns-token",
"environment": "production",
"topic": "com.continuum.app.ios",
"mode": "private_alert",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY...",
"badge": null,
"collapse_id": "01JOPAQUE_COLLAPSE"
}
```
Response:
```json
{
"request_id": "01JRELAY...",
"apns_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "accepted"
}
```
Validation:
- `token` is required and must be plausible for APNs.
- `environment` must be `sandbox` or `production`. The relay forwards `production` requests to `https://api.push.apple.com` and `sandbox` requests to `https://api.development.push.apple.com` (Apple's current canonical hostnames; older docs/libraries also reference `api.sandbox.push.apple.com`, but `api.development.push.apple.com` is the current Apple-documented form).
- `topic` must be allowlisted for the relay account.
- `mode` must be `private_alert` or `background_wake`.
- `server_device_id` and `delivery_id` must be opaque values with length limits (recommend ≤128 chars, ULID-shaped).
- `collapse_id` must be opaque and ≤64 bytes — Apple enforces a 64-byte cap on `apns-collapse-id`. Reject longer values with HTTP 400.
- `collapse_id` derivation (server-side rule, not relay-enforced): the server computes `collapse_id = base32(HMAC-SHA256(server_collapse_secret, series_id))` truncated to 26 chars, where `server_collapse_secret` is a random per-server secret generated at first use. Never send raw or plainly-hashed `series_id` — an unkeyed hash would let the relay (or Apple) dictionary-match popular series IDs. Residual: the relay can still group one server's pushes into per-series equivalence classes (that is what collapse is for); the per-server key prevents recovering the series or correlating across servers.
- `badge` is optional. Default should be omitted to avoid leaking unread counts through APNs unless the user/admin explicitly enables badge sync.
- no free-form notification title or body fields are accepted.
- no image URL, media ID, username, server hostname, or server URL field is accepted.
### Relay-Built APNs Payloads
For `private_alert`, the relay constructs:
```json
{
"aps": {
"alert": {
"title-loc-key": "SILO_NOTIFICATION_TITLE",
"loc-key": "SILO_NOTIFICATION_GENERIC_BODY"
},
"sound": "default"
},
"silo": {
"v": 1,
"wake": "notifications.changed",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY..."
}
}
```
For `background_wake`, the relay constructs:
```json
{
"aps": {
"content-available": 1
},
"silo": {
"v": 1,
"wake": "notifications.changed",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY..."
}
}
```
Headers:
- `apns-topic`: selected from the allowlisted `topic`
- `apns-push-type`: `alert` for `private_alert`, `background` for
`background_wake`
- `apns-priority`: `10` for `private_alert`, `5` for `background_wake`
- `apns-collapse-id`: optional opaque `collapse_id`
The relay should not allow callers to override this payload shape in v1.
## Custom APNs Contract
`custom_apns` should share the same internal send model as `silo_relay`,
but replace the relay HTTP call with a direct APNs provider request.
The server-side push sender should accept only a structured internal request:
```json
{
"token": "apns-token",
"environment": "production",
"topic": "com.continuum.app.ios",
"mode": "private_alert",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY...",
"badge": null,
"collapse_id": "01JOPAQUE_COLLAPSE"
}
```
The sender builds the APNs payload locally using the exact same payload shapes
defined above for `private_alert` and `background_wake`.
Configuration:
- `team_id`: Apple developer team ID.
- `key_id`: APNs auth key ID.
- `private_key`: APNs `.p8` private key, stored as a secret.
- `default_environment`: `production` or `sandbox`.
- `allowed_topics`: explicit bundle topics this server is allowed to send for.
Rules:
- The admin may configure credentials, environment, and topics.
- The server must not expose a free-form APNs JSON payload setting.
- The server must not expose custom title/body templates for remote push in v1.
- Topic values must match the device registration topic and the configured
allowlist.
- Sandbox tokens must be sent to the sandbox endpoint and production tokens to
the production endpoint.
- APNs auth tokens should be cached briefly and regenerated before expiry.
- Credential validation should provide a test-send or dry-run diagnostic that
does not include notification content.
## Wake And Metadata Fetch
When the app receives a push:
1. Read `silo.v`, `server_device_id`, and `delivery_id`.
2. Find the local server account that owns `server_device_id`.
3. If background execution is available, call the server immediately.
4. If the app is opened from the notification, call the server before rendering
the target screen.
5. Fetch notification metadata from the user's server.
6. Render the real notification from server data.
Suggested fetch:
```http
GET /api/v1/notifications/sync?since=<last_cursor>
```
or:
```http
GET /api/v1/notifications/{delivery_id}
```
The sync endpoint is preferred because push delivery is not guaranteed and
multiple notification deliveries can be coalesced.
If the app cannot reach the server, it should keep the generic notification and
retry the inbox sync later.
## Fanout Rules
Push fanout should happen after durable inbox delivery commits.
Flow:
1. `notification_deliveries` row is created. **In the same transaction**, the
fanout worker enqueues one `pending` `push_delivery_attempts` row per
enabled `push_device` of the recipient profile (the dispatch outbox — see
[`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md)
"Transaction and Concurrency Rules").
2. The realtime websocket event is published for connected clients.
3. The push dispatcher claims the `pending` attempt rows post-commit
(`FOR UPDATE SKIP LOCKED`); a recovery sweeper claims rows older than ~60s
whose dispatch never ran.
4. Each claimed attempt sends through the configured provider:
- `silo_relay`: send the minimal relay request
- `custom_apns`: build the same minimal APNs payload locally and send it
directly to APNs
5. APNs errors update device state and attempt status.
Do not block notification delivery creation on APNs or relay availability.
### Relay pacing
The dispatcher paces relay calls client-side with a token bucket (default 5
req/sec, setting `notifications.push.relay_send_rate`), shared across the APNs
and FCM dispatchers since both consume the same relay account quota. On a
server with hundreds of users, a popular release can enqueue several hundred
push attempts at once; pacing drains the queue smoothly instead of slamming
into the relay's rate limit and burning retries on 429s. `TooManyRequests`
from the relay is still honored with backoff — pacing is the steady-state
mechanism, backoff is the safety net. The per-series burst cap in `01` bounds
worst-case queue depth. Custom APNs sends are paced separately and more
generously (Apple's own limits are far higher than any single-server relay
quota).
## APNs Error Handling
The server should handle relay/APNs failures without exposing notification
content.
Recommended handling:
- `BadDeviceToken` / `InvalidToken`: disable the token and require re-registration. (Apple has migrated some responses from `BadDeviceToken` to `InvalidToken`; treat them equivalently.)
- `Unregistered`: disable the token.
- `DeviceTokenNotForTopic`: disable and log a build/topic mismatch.
- `TooManyRequests`: retry with backoff.
- `ExpiredProviderToken`: applies only to `custom_apns`. The cached APNs JWT has expired. Regenerate the JWT immediately and retry the request once before backing off. The relay path never surfaces this code because the relay manages its own JWTs.
- `TooManyProviderTokenUpdates`: applies only to `custom_apns`. Apple has rate-limited JWT regeneration. Stop regenerating; reuse the existing token; retry after backoff. Indicates a bug in the JWT cache (regenerating more often than every 20 minutes).
- relay unavailable, APNs unavailable, or network timeout: retry with backoff.
- malformed request: mark failed and surface admin diagnostics.
Errors should be visible to admins as operational status, not to normal users
unless their device needs reauthorization.
## Privacy Requirements
The implementation must satisfy these requirements before shipping:
- Relay requests contain no notification title or body.
- Relay requests contain no media identifiers.
- Relay requests contain no server hostname or base URL.
- Relay requests contain no profile, username, library, collection, or item names.
- Relay requests *do* carry `apns-topic` (e.g., `com.continuum.app.ios`) and `mode` (`private_alert` / `background_wake`). These are platform/build identifiers that Apple already sees; documenting them here so the privacy claim list is exhaustive rather than over-strong.
- Direct APNs payloads contain no notification title or body beyond generic
localizable keys.
- Direct APNs payloads contain no media identifiers, server hostname, server URL,
profile name, username, library name, collection name, or item name.
- Relay logs redact raw APNs tokens.
- Relay logs redact authorization headers.
- Relay logs do not persist full request payloads.
- Server logs redact APNs tokens and relay API keys.
- Badge count is disabled by default.
- Push registration and remote push provider use are opt-in.
- Device unregister disables further push attempts for that device.
## Security Requirements
- Relay API uses TLS only.
- Relay API keys are scoped to a relay account or install identifier.
- Relay API keys can be revoked without changing the user's Silo server
authentication.
- Relay requests are rate limited by API key and coarse token hash.
- Relay supports idempotency keys to prevent duplicate APNs sends during retry.
- Relay does not accept arbitrary APNs payload JSON in v1.
- Server stores relay API keys as secrets.
- Server stores custom APNs private keys as secrets.
- Server never logs custom APNs private keys, generated provider tokens, or APNs
auth headers.
- Server stores APNs tokens encrypted at rest where local secret storage exists.
- Admin diagnostics must not print raw APNs tokens.
## Settings And UX
Admin settings should explain the tradeoff plainly:
```text
Apple Push Provider
Off
No Apple remote push. Devices still receive in-app realtime updates while open.
Silo Relay
Uses Silo's APNs relay to wake Apple devices. Notification details stay on
this server. The relay receives APNs device tokens, timestamps, and opaque
delivery IDs.
Custom APNs
Advanced. Send directly to Apple APNs using your own Apple developer
credentials. Notification details still stay on this server.
```
Device settings should describe the user-visible mode:
```text
Private Push
Show a generic Silo notification, then fetch details from your server when
this device wakes.
```
Do not claim the central relay is fully self-hosted. The truthful claim is:
```text
Notification content stays on your server. Apple, and the Silo relay if
selected, may still process generic wake messages needed for Apple push
delivery.
```
## Implementation Plan
### Task 1: Add Push Provider Settings
Files likely involved:
- `internal/api/handlers/settings.go`
- `web/src/lib/settingsManifest.ts`
- settings UI files as needed
Add server/admin settings for:
- provider selection
- relay endpoint
- relay API key
- custom APNs team ID
- custom APNs key ID
- custom APNs private key
- custom APNs environment
- custom APNs topic allowlist
- badge sync enabled or disabled
- relay send rate (`notifications.push.relay_send_rate`, shared with FCM pacing)
Default provider must be `off`.
### Task 2: Add Push Device Registration
Files likely involved:
- new migration under `migrations/`
- new package or service under `internal/notifications`
- `internal/api/handlers/notifications.go`
- `internal/api/router.go`
Add profile-scoped Apple push device registration, token rotation, and disable
APIs.
### Task 3: Add Apple Push Provider Clients
Files likely involved:
- `internal/notifications/apple_relay.go`
- `internal/notifications/apple_apns.go`
- config/settings accessors
Implement the narrow `/v1/apple/send` client. The client should not accept a
free-form title/body/content payload.
Implement the direct APNs client for `custom_apns` using the same internal
request shape and payload builder.
### Task 4: Add Push Fanout Worker
Files likely involved:
- `internal/notifications/push_fanout.go`
- notification delivery creation path from the durable inbox implementation
Trigger push fanout after `notification_deliveries` commit. Use retries and
record `push_delivery_attempts`.
### Task 5: Add Apple Client Registration And Wake Handling
Files live in the Apple client repository, not this server repo.
Required client behavior:
- request notification permission
- register APNs token with the user's server
- store `server_device_id` locally
- handle token rotation
- fetch notification metadata after wake/open
- unregister or disable on sign-out/profile removal
### Task 6: Add Admin Diagnostics
Expose high-level status:
- provider enabled/disabled
- number of registered Apple devices
- last relay success
- last relay failure code
- custom APNs credential presence and topic/environment status
- last custom APNs success
- last custom APNs failure code
- token/topic mismatch warnings
Do not expose raw APNs tokens.
## Validation Plan
Use minimal verification while the work is still design-only. When implemented,
verify:
- relay-disabled servers never call the relay
- `custom_apns` servers never call the relay
- device registration is profile-scoped and idempotent
- APNs tokens are redacted in logs
- relay request bodies contain no titles, body text, item IDs, server URLs, or
profile names
- direct APNs payloads contain no titles, body text, item IDs, server URLs, or
profile names beyond the generic localizable notification keys
- custom APNs private keys and generated provider tokens are redacted in logs
- creating one `notification_delivery` sends at most one push per enabled device
- APNs token rotation updates the stored token without duplicating devices
- `Unregistered` and `BadDeviceToken` disable the affected device
- app wake fetches notification metadata from the user's server
- server offline after push leaves only the generic notification visible
## Resolved Questions (was: Open Questions)
These four open questions from the original draft were resolved during the multi-channel refinement:
- **Topics: per-platform.** Official Silo builds use separate APNs topics for iOS, tvOS, and macOS. Each is a different bundle ID with its own provisioning. The `apns_topic` allowlist on a relay account enumerates the per-platform topics the account may push to.
- **Badge counts: disabled in v1.** Badge count leaks unread volume through APNs. v1 ships without badge updates; an explicit per-profile opt-in to badge sync can land in v2 alongside a privacy notice.
- **Relay statefulness: stateless v1.** No stored token aliases. The relay accepts raw APNs tokens on every request. Token aliases add complexity for a benefit (reduced repeat-token exposure to relay logs) that is small compared to "redact tokens from logs." Add aliases later if abuse-pattern analysis or rate-limit pressure justifies it.
- **Relay deployment repo: out of scope for this spec.** The relay implementation lives in a separate repo (provisional name `silo-push-relay`). This document defines the contract the user-facing Silo server implements; the relay's internal architecture (deployment platform, dependency tree, etc.) is the relay repo's concern.
## Remaining Open Questions
- **Multi-bundle topic allowlist UX.** When an admin configures custom APNs, how do they declare allowlisted topics — free-text textarea, or a structured list? Recommendation: structured list with platform tags (iOS / tvOS / macOS) so the UI can show which platforms have working push.
- **APNs JWT cache lifetime.** APNs JWTs are valid for 60 minutes per Apple's docs but may be reused across requests. Should the custom APNs path cache and reuse, or sign per-request? Recommendation: cache with regeneration at 50 minutes to leave headroom, matching Apple's recommendation.
@@ -0,0 +1,748 @@
# Privacy-Preserving FCM Relay Spec
**Date:** 2026-04-28
**Status:** Draft
**Scope:** Silo server push integration for Android, opt-in central FCM relay, custom FCM provider configuration
**Depends On:**
- [`00-architecture-overview.md`](./00-architecture-overview.md)
- [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md)
- [`02-apns-relay.md`](./02-apns-relay.md) — sibling spec; structurally identical with FCM-specific differences
## Summary
Silo should support remote Android notifications through either an opt-in central FCM relay or admin-configured Firebase Cloud Messaging credentials, while keeping notification metadata on the user's own Silo server.
The hosted relay should be a constrained transport service. It should receive only the minimum fields needed to submit a Firebase Cloud Messaging v1 API request:
- an FCM registration token
- the target Firebase project ID and Android package name (allowlisted)
- a push mode
- an opaque server/device correlation key
- an opaque delivery identifier
- optional badge count (disabled by default)
Neither provider path may send titles, item names, usernames, profile names, library names, server URLs, artwork URLs, watched-state metadata, or notification body text through FCM.
When the Android device wakes, the app should fetch real notification metadata directly from the user's Silo server using the existing authenticated server connection.
## Why this spec mirrors APNs
The reasoning, threat model, and fanout flow are identical to the APNs case. Apple and Google both gatekeep mobile push at the app-bundle level: only the developer who signed and published the app can authenticate pushes to it. The same arguments for a hosted relay apply unchanged. The differences are entirely about FCM's protocol and credentials:
- **Auth:** FCM v1 API uses **OAuth2 with a Google service account**, not a JWT signed with a `.p8` key.
- **Endpoint:** Pushes go to `https://fcm.googleapis.com/v1/projects/{project_id}/messages:send`.
- **Project model:** A Firebase project owns the Android app's package name + SHA-1 cert fingerprint mapping. The relay holds one service account JSON per official Firebase project.
- **Topics vs tokens:** FCM supports both topic broadcasts and per-token sends. Silo uses **per-token sends only** — topic broadcasts would leak audience information.
- **Message types:** FCM has `notification` and `data` messages. The privacy design uses **data-only messages** so the OS can't render content the relay shouldn't have constructed.
## Decision
Add configurable Android push providers for the official Silo Android app:
- `off`: no remote Android push
- `silo_relay`: Silo's hosted FCM relay
- `custom_fcm`: admin-supplied FCM service account JSON used directly by the server
The relay submits FCM v1 requests using Silo-controlled Firebase credentials for the official app package. A self-hosted server admin may opt in to use this relay. The relay's API should intentionally prevent notification content from being sent through the relay.
If `custom_fcm` is selected, the user's Silo server calls FCM v1 directly using admin-provided service account JSON. This avoids the Silo relay entirely, but it must use the same minimal payload and metadata-fetch rules as the hosted relay path.
The default push mode should be **Private Data Wake**:
- FCM delivers a data-only message.
- The data payload contains only opaque identifiers.
- The Android app handles the message via `FirebaseMessagingService.onMessageReceived` and constructs a generic local notification while it fetches the actual notification row from the user's server.
The implementation should also support **Background Wake** for low-noise sync, with the explicit understanding that Android background data messages are subject to Doze, App Standby, and battery-optimization restrictions and are not a reliable user-visible notification mechanism.
## Goals
- Make Android push possible without every admin managing their own Firebase project.
- Keep actual notification metadata on the user's Silo server.
- Make relay participation explicit and disabled by default.
- Make FCM credentials configurable for admins who want to avoid the hosted relay.
- Keep the relay stateless for device subscriptions where possible.
- Keep FCM payloads opaque and generic (data-only).
- Keep the hosted relay and custom FCM paths behaviorally equivalent from the app's point of view.
- Fit on top of the durable notification inbox design.
## Non-Goals
- Replacing FCM for Android remote push.
- Shipping notification content through the central relay.
- Requiring Apple push services for this Android path.
- Supporting Huawei Mobile Services (HMS) push, Amazon Device Messaging, or other Android-adjacent push systems in v1. Could be added later as parallel providers.
- Adding marketing, analytics, or delivery-tracking exports to the relay.
- Hiding FCM itself from Android devices. Custom FCM still uses Google FCM.
- Making silent background delivery as reliable as visible notifications.
## Threat Model
### What The User's Server Knows
The user's Silo server knows:
- the notification content
- the profile and user recipients
- the registered local devices
- FCM registration tokens for those devices
- push delivery attempts and failures
This is acceptable because the server is already the authority for the user's media, profiles, sessions, and notification inbox.
### What The Central Relay May Know
The relay may see:
- an opt-in relay account or install identifier
- the server's egress IP address (inherent to any hosted relay — the server connects to it directly; see the identical note in [`02-apns-relay.md`](./02-apns-relay.md))
- FCM registration tokens submitted in send requests
- request timestamps
- coarse push mode, such as `private_data` or `background_wake`
- opaque `collapse_key` values (per-server-keyed HMACs of series identity; same derivation rule as APNs `collapse_id`)
- FCM response status and FCM message names
- opaque delivery identifiers
The relay must not receive enough information to know what media item, notification type, profile, user, library, server hostname, or server URL caused the push.
### What Google May Know
Google / FCM may see:
- the official app package name and Firebase project
- the target FCM registration token
- FCM v1 request headers
- request timing
- the generic FCM data payload (which contains only opaque identifiers)
Google must not receive media titles, usernames, library names, server URLs, artwork URLs, or other notification metadata in the payload. In particular, the design uses **data-only messages**, never `notification` messages, so Google's servers don't carry rendering content.
### Residual Metadata Leakage
This design cannot hide that a device received a Silo push at a particular time, nor the egress IP of the server sending relay requests. It can only hide the meaning and content of the push. Additionally, on Android, FCM delivery may be delayed by Doze / App Standby; an adversary observing wake patterns may infer rough activity but not content.
## User And Admin Controls
### Server Admin Control
The server admin must explicitly choose an Android push provider.
Suggested setting:
```text
notifications.android_push.provider = off | silo_relay | custom_fcm
```
Default:
```text
off
```
If `silo_relay` is selected, the admin configures a relay API key and accepts a clear privacy notice:
```text
Silo Relay can wake Android devices through Firebase Cloud Messaging,
but notification details stay on this server. The relay receives FCM
registration tokens, timestamps, opaque delivery IDs, and generic push mode
only.
```
If `custom_fcm` is selected, the admin configures their own Firebase service account JSON:
```text
notifications.android_push.custom.service_account_json
notifications.android_push.custom.project_id
notifications.android_push.custom.allowed_packages = [...]
```
The server then sends directly to FCM and does not call the Silo relay. The same private payload rules still apply.
### Device User Control
Each Android device must opt in independently:
- the OS notification permission must be granted (Android 13+ requires `POST_NOTIFICATIONS` runtime permission)
- the app must be signed into a Silo server
- the active profile must enable push notifications for that device
- the server admin must have enabled an Android push provider
Admins must not be able to silently enroll every profile/device into remote push without the device having granted OS notification permission.
### Profile Control
Push preferences sit on top of the durable notification preferences from the inbox design. The push-mode field on a `push_devices` row is shared across platforms (`apple` and `android`) so the same `push_mode` enum applies.
Suggested profile/device modes:
- `off`: never send remote push to this device
- `in_app_only`: websocket and inbox only
- `private_push`: generic FCM data wake, fetch details from server
- `full_preview`: out of scope for v1
V1 should ship only `off`, `in_app_only`, and `private_push`.
## System Architecture
```mermaid
sequenceDiagram
participant App as Android App
participant Server as User Silo Server
participant Relay as Silo FCM Relay
participant FCM as Google FCM
App->>App: Request POST_NOTIFICATIONS permission (API 33+)
App->>App: Get FCM registration token via FirebaseMessaging.getToken()
App->>Server: Register registration token and opaque server_device_id
Server->>Server: Store encrypted token and device preferences
Server->>Server: Create durable notification_delivery
Server->>Relay: Send minimal FCM relay request
Relay->>FCM: Submit data-only message via FCM v1 API
FCM->>App: Deliver data message; FirebaseMessagingService.onMessageReceived
App->>Server: Fetch notification metadata directly
Server->>App: Return inbox rows for active profile
```
## Ownership Boundaries
### This Repository
Silo owns:
- admin settings for selecting an Android push provider
- profile and device notification preferences
- FCM token registration from Android clients
- durable storage of device registrations
- push fanout from `notification_deliveries`
- relay client integration
- retry, backoff, and FCM error handling
- inbox sync APIs used after wake
### Android Client Repository
The Android app owns:
- FCM registration via Firebase SDK
- notification permission UX (`POST_NOTIFICATIONS` on API 33+)
- device registration API calls
- local server/device correlation
- `FirebaseMessagingService.onMessageReceived` handling
- fetching metadata from the user's server
- rendering local notifications via `NotificationManager` after the wake
### Central Relay Service
The central relay owns:
- FCM service account credentials for the official Silo Firebase project(s)
- relay API authentication
- request validation and rate limiting
- FCM v1 request submission with OAuth2 access token caching
- redacted operational logs
- minimal delivery status reporting
The relay should live outside this repo unless the project later decides to co-locate the service code. This repo defines the contract and implements the self-hosted server side. The relay implementation can share most code with the APNs relay; only the upstream provider client differs.
### Custom FCM Mode
When `custom_fcm` is selected, there is no central relay in the send path.
The user's Silo server owns:
- FCM v1 OAuth2 token generation from the configured service account JSON
- access token caching (Google's OAuth2 access tokens are short-lived; cache and refresh on expiry)
- package allowlisting for the official Silo app package(s) it intends to support
- FCM v1 request construction (data-only)
- FCM response handling
- FCM credential rotation and secret storage
Custom FCM mode must not unlock richer payloads. It exists to let admins avoid the Silo relay, not to bypass the privacy-preserving notification contract.
## Data Model
The `push_devices` and `push_delivery_attempts` schemas are owned by [`02-apns-relay.md`](./02-apns-relay.md) and are platform-tagged from the start (`platform = 'apple' | 'android'`) so this spec adds no new columns. The Android-relevant columns on `push_devices` are:
- `platform text not null` — must be `'android'` for FCM rows.
- `provider text not null` — `'silo_relay'` or `'custom_fcm'`.
- `fcm_token_ciphertext bytea` — required for Android rows.
- `fcm_token_hash text` — required for Android rows; used for dedupe/diagnostics without logging raw tokens.
- `fcm_project_id text` — the Firebase project the token was minted under.
- `fcm_package_name text` — the Android package name (e.g., `com.continuum.app.android`).
- All `apns_*` columns must be null for Android rows. The `CHECK` constraint defined in `02-apns-relay.md` enforces this exclusivity.
Unique constraints (defined in `02-apns-relay.md`): `(profile_id, device_id, platform)` and `(server_device_id)`. Both are intentionally platform-scoped, not provider-scoped — the same Android device cannot register twice even if the admin flips between `silo_relay` and `custom_fcm`.
`push_delivery_attempts` columns relevant to FCM (also owned by 02):
- `provider text not null` — for FCM rows: `'silo_relay'` or `'custom_fcm'`.
- `apns_id text` — null for FCM rows.
- `fcm_message_name text` — populated on success with FCM v1's response `name` (e.g., `"projects/continuum-prod-android/messages/0:..."`); null for APNs rows.
- `attempt_number integer not null` — 1-based; participates in the unique constraint and in the relay `Idempotency-Key`.
Retention is identical to APNs: ~14 days for successes, 30-90 for failures.
## Server API Surface
### Register Android Push Device
```http
POST /api/v1/devices/push/fcm
```
Profile-scoped. Requires normal auth plus active profile context.
Request:
```json
{
"device_id": "android-device-local-id",
"fcm_token": "long-fcm-registration-token",
"fcm_project_id": "continuum-prod-android",
"fcm_package_name": "com.continuum.app.android",
"push_mode": "private_push"
}
```
Response:
```json
{
"id": "01J...",
"server_device_id": "01JOPAQUE...",
"enabled": true,
"push_mode": "private_push"
}
```
Rules:
- `fcm_package_name` must be allowlisted for the configured provider's allowed_packages list.
- `fcm_project_id` must match the configured Firebase project for the active provider.
- registration is idempotent by `(profile_id, device_id, platform)` where platform is implied as `android`.
- token rotation updates the encrypted token and token hash. FCM tokens rotate periodically; the app should call this endpoint whenever `FirebaseMessagingService.onNewToken` fires.
- a disabled server push provider should still allow registration, but should report that remote push is unavailable in the capability response.
### Disable Push Device
Shared with APNs, see [`02-apns-relay.md`](./02-apns-relay.md):
```http
DELETE /api/v1/devices/push/{id}
```
### Push Capability
Extended from the APNs spec to include Android:
```http
GET /api/v1/notifications/capability
```
Response:
```json
{
"in_app": { "enabled": true },
"apple_push": {
"available": true,
"provider": "silo_relay",
"available_providers": ["silo_relay", "custom_apns"],
"supported_modes": ["private_push", "in_app_only"]
},
"android_push": {
"available": true,
"provider": "silo_relay",
"available_providers": ["silo_relay", "custom_fcm"],
"supported_modes": ["private_push", "in_app_only"]
},
"webhooks": {
"available": true,
"max_per_profile": 10,
"supported_types": ["discord", "generic"]
}
}
```
## Relay API Contract
The relay API should be intentionally narrow. The user server should not send an arbitrary FCM payload.
### Send Android Push
```http
POST /v1/fcm/send
Authorization: Bearer <relay_api_key>
Idempotency-Key: <notification_delivery_id>:<push_device_id>:<attempt_number>
```
Request:
```json
{
"token": "fcm-registration-token",
"project_id": "continuum-prod-android",
"package_name": "com.continuum.app.android",
"mode": "private_data",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY...",
"badge": null,
"collapse_key": "01JOPAQUE_COLLAPSE"
}
```
Response:
```json
{
"request_id": "01JRELAY...",
"fcm_message_name": "projects/continuum-prod-android/messages/0:1234567890123456%abcdef",
"status": "accepted"
}
```
Validation:
- `token` is required and must be plausible for FCM (FCM tokens are typically 152-180+ chars, base64url-ish).
- `project_id` must match the configured Firebase project for this relay account's allowed packages.
- `package_name` must be allowlisted for the relay account.
- `mode` must be `private_data` or `background_wake`. Note: this is the **wire mode** (FCM-side); the profile-level mode is `private_push`. See [`00-architecture-overview.md`](./00-architecture-overview.md) "Mode terminology" for the mapping.
- `server_device_id`, `delivery_id`, and `collapse_key` must be opaque values with length limits.
- `collapse_key` derivation (server-side rule, not relay-enforced): identical to the APNs `collapse_id` rule in [`02-apns-relay.md`](./02-apns-relay.md) — `base32(HMAC-SHA256(server_collapse_secret, series_id))` truncated, using the same per-server secret. Never send raw or plainly-hashed `series_id`.
- `badge` is optional. Default should be omitted to avoid leaking unread counts unless the user/admin explicitly enables badge sync.
- no free-form notification title or body fields are accepted.
- no image URL, media ID, username, server hostname, or server URL field is accepted.
### Relay-Built FCM Payloads
For `private_data`, the relay constructs:
```json
{
"message": {
"token": "fcm-registration-token",
"data": {
"v": "1",
"wake": "notifications.changed",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY..."
},
"android": {
"priority": "high",
"collapse_key": "01JOPAQUE_COLLAPSE"
}
}
}
```
For `background_wake`, the relay constructs:
```json
{
"message": {
"token": "fcm-registration-token",
"data": {
"v": "1",
"wake": "notifications.changed",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY..."
},
"android": {
"priority": "normal",
"collapse_key": "01JOPAQUE_COLLAPSE"
}
}
}
```
Notes:
- The relay sends **data-only messages** (no top-level `notification` field) so Google never carries rendering content. The Android app constructs the local notification in `onMessageReceived`.
- `priority: high` is used for `private_data` so the message wakes the app even under Doze. High-priority data messages are subject to FCM's anti-abuse quotas — if a server hits the quota, deliveries fall back to normal priority and may be deferred.
- `collapse_key` allows multiple pushes for the same series to coalesce when the device is offline, mirroring `apns-collapse-id`.
- The relay does not allow callers to override this payload shape in v1.
## Custom FCM Contract
`custom_fcm` should share the same internal send model as `silo_relay`, but replace the relay HTTP call with a direct FCM v1 API request.
The server-side push sender should accept only a structured internal request (same shape as the relay request body above):
```json
{
"token": "fcm-registration-token",
"project_id": "continuum-prod-android",
"package_name": "com.continuum.app.android",
"mode": "private_data",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY...",
"badge": null,
"collapse_key": "01JOPAQUE_COLLAPSE"
}
```
The sender builds the FCM v1 payload locally using the exact same payload shapes defined above for `private_data` and `background_wake`.
Configuration:
- `service_account_json`: full Firebase service account JSON, stored as a secret.
- `project_id`: Firebase project ID.
- `allowed_packages`: explicit Android package names this server is allowed to send for.
Rules:
- The admin may configure credentials, project ID, and allowed packages.
- The server must not expose a free-form FCM JSON payload setting.
- The server must not expose custom title/body templates for remote push in v1.
- Package values must match the device registration package and the configured allowlist.
- OAuth2 access tokens should be cached and regenerated before expiry (Google access tokens are typically valid for 1 hour).
- Credential validation should provide a test-send or dry-run diagnostic that does not include notification content. FCM v1 supports `validate_only: true` requests for this purpose.
## Wake And Metadata Fetch
When the app receives an FCM data message:
1. `FirebaseMessagingService.onMessageReceived(remoteMessage)` fires.
2. Read `data.v`, `data.server_device_id`, and `data.delivery_id`.
3. Find the local server account that owns `server_device_id`.
4. If the app is in the foreground, update in-app inbox state directly.
5. If background:
- Construct a generic local notification using `NotificationManager` with a localized "New Silo notification" title.
- Schedule a `WorkManager` job to fetch the actual metadata from the user's server.
- When the fetch returns, update the visible notification with real content (or replace it).
6. If the app is opened from the notification, call the server before rendering the target screen.
Suggested fetch:
```http
GET /api/v1/notifications/sync?since=<last_cursor>
```
or:
```http
GET /api/v1/notifications/{delivery_id}
```
The sync endpoint is preferred because push delivery is not guaranteed and multiple notification deliveries can be coalesced.
If the app cannot reach the server, it should keep the generic notification and retry the inbox sync when connectivity returns.
## Fanout Rules
Push fanout should happen after durable inbox delivery commits. Same flow as APNs:
1. `notification_deliveries` row is created. In the same transaction, the fanout worker enqueues one `pending` `push_delivery_attempts` row per enabled Android `push_device` (the dispatch outbox — see [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md)).
2. The realtime websocket event is published for connected clients.
3. The push dispatcher claims the `pending` attempt rows post-commit, partitioned by platform; a recovery sweeper claims rows older than ~60s whose dispatch never ran.
4. Each claimed attempt sends through the configured Android provider:
- `silo_relay`: send the minimal relay request.
- `custom_fcm`: build the same minimal FCM v1 payload locally and send directly.
5. FCM errors update device state and attempt status.
Do not block notification delivery creation on FCM or relay availability.
Relay pacing is shared with APNs: one token bucket (`notifications.push.relay_send_rate`, default 5 req/sec) covers both dispatchers because both consume the same relay account quota. See [`02-apns-relay.md`](./02-apns-relay.md) "Relay pacing".
## FCM Error Handling
The server should handle relay/FCM failures without exposing notification content.
FCM v1 error codes worth handling explicitly:
- `UNREGISTERED` (404): the token is no longer valid (app uninstalled or data cleared). Disable the device and require re-registration.
- `INVALID_ARGUMENT` (400): malformed request. Mark failed and surface admin diagnostics. Should be rare given the constrained payload.
- `SENDER_ID_MISMATCH` (403): the token was minted under a different Firebase project. Disable and log.
- `QUOTA_EXCEEDED` (429): retry with backoff, **honoring the `Retry-After` response header** if present (FCM uses standard HTTP semantics here). Common when high-priority data-message quotas are hit; FCM specifically throttles high-priority data messages that don't surface a user-visible notification on the device. Consider falling back to `priority: normal` after repeated 429s within a cooldown window.
- `UNAVAILABLE` (503): FCM is temporarily down. Retry with backoff.
- `INTERNAL` (500): retry with backoff.
- `THIRD_PARTY_AUTH_ERROR` (401): credential failure (relay or custom_fcm). Surface in admin diagnostics; do not retry until the admin fixes it.
- relay unavailable, FCM unavailable, or network timeout: retry with backoff.
Errors should be visible to admins as operational status, not to normal users unless their device needs reauthorization.
## Privacy Requirements
The implementation must satisfy these requirements before shipping:
- Relay requests contain no notification title or body.
- Relay requests contain no media identifiers.
- Relay requests contain no server hostname or base URL.
- Relay requests contain no profile, username, library, collection, or item names.
- Direct FCM payloads contain no notification title or body. Specifically: **no top-level `notification` object** in the FCM v1 message — data-only messages only.
- Direct FCM payloads contain no media identifiers, server hostname, server URL, profile name, username, library name, collection name, or item name.
- Relay logs redact raw FCM tokens.
- Relay logs redact authorization headers and OAuth2 access tokens.
- Relay logs do not persist full request payloads.
- Server logs redact FCM tokens, custom service account JSON contents, and generated OAuth2 access tokens.
- Badge count is disabled by default.
- Push registration and remote push provider use are opt-in.
- Device unregister disables further push attempts for that device.
## Security Requirements
- Relay API uses TLS only.
- Relay API keys are scoped to a relay account or install identifier.
- Relay API keys can be revoked without changing the user's Silo server authentication.
- Relay requests are rate limited by API key and coarse token hash.
- Relay supports idempotency keys to prevent duplicate FCM sends during retry.
- Relay does not accept arbitrary FCM payload JSON in v1.
- Server stores relay API keys as secrets.
- Server stores custom FCM service account JSON as a secret.
- Server never logs custom FCM service account JSON contents, generated OAuth2 access tokens, or FCM authorization headers.
- Server stores FCM registration tokens encrypted at rest where local secret storage exists.
- Admin diagnostics must not print raw FCM tokens or service account JSON.
- Custom FCM service account JSON must validate as a real Google service account JSON before being accepted (parse + sanity check `type: "service_account"` and required key fields).
## Settings And UX
Admin settings should explain the tradeoff plainly:
```text
Android Push Provider
Off
No Android remote push. Devices still receive in-app realtime updates while open.
Silo Relay
Uses Silo's FCM relay to wake Android devices. Notification details stay on
this server. The relay receives FCM tokens, timestamps, and opaque delivery IDs.
Custom FCM
Advanced. Send directly to Google FCM using your own Firebase service account.
Notification details still stay on this server. Only useful if you've published
your own signed Android app variant under your own Firebase project.
```
Device settings (shared with APNs) should describe the user-visible mode:
```text
Private Push
Show a generic Silo notification, then fetch details from your server when
this device wakes.
```
Do not claim the central relay is fully self-hosted. The truthful claim is:
```text
Notification content stays on your server. Google FCM, and the Silo relay
if selected, may still process generic wake messages needed for Android push
delivery.
```
## Implementation Plan
### Task 1: Add Android Push Provider Settings
Files likely involved:
- `internal/api/handlers/settings.go`
- `web/src/lib/settingsManifest.ts` (or admin-scope equivalent)
- settings UI files as needed
Add server/admin settings for:
- provider selection
- relay endpoint
- relay API key
- custom FCM service account JSON
- custom FCM project ID
- custom FCM allowed packages
- badge sync enabled or disabled (shared with Apple settings)
Default provider must be `off`.
### Task 2: Extend Push Device Registration
Files likely involved:
- migration extending `push_devices` from the APNs spec to add FCM-specific columns and a check constraint
- `internal/notifications` package
- `internal/api/handlers/notifications.go`
- `internal/api/router.go`
Add profile-scoped Android push device registration, token rotation, and disable APIs.
If the APNs spec lands first, this task is purely additive (new columns nullable, new endpoint, expanded `platform` enum).
### Task 3: Add Android Push Provider Clients
Files likely involved:
- `internal/notifications/fcm_relay.go`
- `internal/notifications/fcm_direct.go`
- config/settings accessors
Implement the narrow `/v1/fcm/send` relay client. The client should not accept a free-form notification payload.
Implement the direct FCM v1 client for `custom_fcm` using:
- `golang.org/x/oauth2/google` for service-account-based OAuth2 token generation, or an equivalent that doesn't add a heavy Google SDK dependency.
- Cached access token with refresh-before-expiry.
- Standard `net/http` for the FCM v1 endpoint.
Both clients use the same internal request shape and payload builder so the dispatcher code is uniform.
### Task 4: Extend Push Fanout Worker
Files likely involved:
- `internal/notifications/push_fanout.go` (shared with APNs)
Trigger Android push fanout for devices with `platform = 'android'`. Use retries and record `push_delivery_attempts` with `fcm_message_name` populated on success.
### Task 5: Add Android Client Registration And Wake Handling
Files live in the Android client repository, not this server repo.
Required client behavior:
- request `POST_NOTIFICATIONS` permission on Android 13+
- get FCM registration token via `FirebaseMessaging.getInstance().getToken()`
- register token with the user's server
- handle `FirebaseMessagingService.onNewToken` for token rotation
- store `server_device_id` locally, scoped to the active server account
- handle `onMessageReceived` for both foreground and background data messages
- fetch notification metadata after wake/open
- unregister or disable on sign-out / profile removal / app data clear
### Task 6: Add Admin Diagnostics
Expose high-level status (extending the APNs diagnostics surface):
- provider enabled/disabled
- number of registered Android devices
- last relay success
- last relay failure code
- custom FCM credential presence and project/package status
- last custom FCM success
- last custom FCM failure code
- token/package/project mismatch warnings
Do not expose raw FCM tokens or service account JSON.
## Validation Plan
Use minimal verification while the work is still design-only. When implemented, verify:
- relay-disabled servers never call the relay
- `custom_fcm` servers never call the relay
- device registration is profile-scoped and idempotent
- FCM tokens are redacted in logs
- relay request bodies contain no titles, body text, item IDs, server URLs, or profile names
- direct FCM payloads contain no `notification` object and no titles, body text, item IDs, server URLs, or profile names
- custom FCM service account JSON is redacted in logs
- generated OAuth2 access tokens are redacted in logs
- creating one `notification_delivery` sends at most one push per enabled device
- FCM token rotation updates the stored token without duplicating devices
- `UNREGISTERED` and `SENDER_ID_MISMATCH` disable the affected device
- `QUOTA_EXCEEDED` triggers backoff
- app wake fetches notification metadata from the user's server
- server offline after push leaves only the generic notification visible
## Open Questions
- Should official Silo Android builds use one Firebase project per build channel (debug / staging / prod), or share?
- Should badge counts (Android notification dots / numbers) be allowed in `private_push`, or deferred?
- Should the relay store hashed FCM token aliases to avoid sending raw tokens on every request? Defer to v2 unless rate-limit pressure justifies it.
- Should the server fall back to `priority: normal` after repeated `QUOTA_EXCEEDED` to keep deliveries flowing at the cost of latency? Recommendation: yes, with a cooldown timer.
- Should we also add a Huawei Mobile Services (HMS) push path for non-Google Android devices? Out of scope v1; could mirror this spec as `04-hms-relay.md` later.
@@ -0,0 +1,731 @@
# Outbound Webhooks Spec
**Date:** 2026-04-28
**Status:** Draft
**Scope:** Profile-scoped outbound webhook destinations for Silo notifications. Native Discord embed type and generic JSON+HMAC type.
**Depends On:**
- [`00-architecture-overview.md`](./00-architecture-overview.md)
- [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md)
## Summary
Silo should let each profile send their notifications to user-chosen webhook destinations: a Discord channel, a Slack incoming webhook, a personal automation service, or any HTTP endpoint that accepts JSON. Webhooks are configured per profile, support per-webhook reason filters, and include full notification content by default — the profile chose the destination, so trust is implicit.
Two webhook types ship in v1:
- **Discord** — native Discord embed payload. The user pastes a Discord channel webhook URL; Silo POSTs Discord-formatted embeds.
- **Generic** — canonical Silo JSON, HMAC-SHA256 signed via `X-Silo-Signature`. Suitable for Slack-incoming-webhook-style targets, custom user automations, or any HTTP endpoint.
## Why this is different from APNs / FCM
The privacy model inverts. APNs and FCM relay paths route through infrastructure operators (Apple, Google, Silo's relay) that the user did not directly choose. Therefore those paths carry no notification content — only opaque IDs.
Outbound webhooks go to a destination the **profile explicitly configured**. By choosing the URL, the profile is consenting to send notification content to that endpoint. There is no privacy benefit to gating content out of webhooks the profile set up themselves; doing so would just make the feature useless ("New episode! [check Silo]" is no better than what's already in the app).
The trust model is therefore:
- Profile chose the URL → notification content is included by default.
- Discord embeds get series title, episode title, season/episode numbers, and poster.
- Generic webhooks get the same data in canonical JSON, plus an HMAC signature derived from a per-webhook signing secret.
- Silo still enforces privacy guardrails the profile cannot opt out of: HTTPS only, no localhost / RFC1918 destinations by default, redacted logs, retry/disable on persistent failure.
## Decision
Add per-profile webhook destinations, configurable through the profile's notification preferences UI:
- Profile creates a webhook by pasting a URL and optionally a name and reason filters.
- Webhook type is detected from the URL or selected explicitly: `discord` (matches Discord webhook URL pattern) or `generic`.
- Silo POSTs a typed payload to the URL whenever a `notification_deliveries` row commits for that profile and the webhook's reason filters allow it.
- Failed deliveries are retried with exponential backoff and disabled after a configurable consecutive-failure threshold.
- Each webhook has a profile-visible status: enabled, last success, last failure code, consecutive failure count.
## Goals
- Let a profile send Silo notifications to Discord, Slack, or any user-chosen URL.
- Render natively in Discord without the user copy-pasting JSON templates.
- Provide a stable, signed, well-documented generic format for everything else.
- Keep the feature profile-scoped: a profile only sees and controls their own webhooks.
- Add no new infrastructure dependencies (no relay needed; webhooks are direct outbound HTTP from the user's Silo server).
- Make failure modes legible to the profile so they can fix a broken webhook themselves.
## Non-Goals
- Server-wide / admin-level webhooks for system events (scan complete, library health, etc.). Out of scope v1; Silo's existing realtime hub already handles operational events.
- Webhook destinations that require OAuth (Slack apps, Discord bots, Microsoft Teams Adaptive Cards). Webhook-style endpoints with an unauth'd POST URL are sufficient for v1.
- Per-event-type custom payload templates. The Discord embed shape and the generic JSON shape are fixed in v1; richer customization is v2.
- Webhook retry windows beyond 24 hours. After ~24h of consecutive failures, the webhook is auto-disabled and the profile is notified in-app.
- Multiple webhook destinations per webhook row (e.g., one row firing to two URLs). One URL per row.
- Two-way webhooks. Silo only POSTs out; it does not consume webhook responses for state.
## Trust Model
The profile chose the destination URL. By creating a webhook, the profile consents to send full notification content (titles, posters, episode metadata) to that URL.
What we still don't trust the profile about:
- **HTTPS:** Required. `http://` URLs are rejected.
- **Local / private destinations:** Rejected by default. Specifically, the URL host must resolve to an address outside all of the following ranges:
- **IPv4 private/special:** `0.0.0.0/8`, `10.0.0.0/8`, `100.64.0.0/10` (CGNAT / RFC6598), `127.0.0.0/8`, `169.254.0.0/16`, `172.16.0.0/12`, `192.0.0.0/24` (IETF protocol assignments), `192.0.2.0/24` / `198.51.100.0/24` / `203.0.113.0/24` (TEST-NET-1/2/3), `192.88.99.0/24` (deprecated 6to4 anycast), `192.168.0.0/16`, `198.18.0.0/15` (benchmarking, RFC 2544), `224.0.0.0/4` (multicast), `240.0.0.0/4` (reserved future use)
- **IPv6 private/special:** `::/128` (unspecified), `::1/128` (loopback), `fc00::/7` (ULA), `fe80::/10` (link-local), `2001:db8::/32` (documentation), `64:ff9b::/96` (NAT64)
- **IPv4-mapped IPv6:** `::ffff:0:0/96` — the most likely real-world bypass. A literal `::ffff:127.0.0.1` resolves to loopback when the connection is established but bypasses naive IPv4-only checks. The validator must unwrap v4-mapped addresses and re-check against the IPv4 deny set.
- **DNS names that resolve to any of the above** at registration time *and* at delivery time (DNS rebinding mitigation; see below).
An admin-only setting `notifications.webhooks.allow_private_destinations = true` may be set for dev environments. It applies globally to the server and is intended only for development.
- **Server URL leakage:** Webhook payloads must **not** include the user's Silo server hostname, base URL, or absolute artwork URLs that include the server origin. Posters in Discord embeds use a profile-token-signed proxy URL (or, for v1, the embed omits images if the server URL would leak). Generic webhooks include only relative paths and let the receiver fetch from the server using their own knowledge of the server URL.
- **Relay leakage:** The webhook delivery path runs entirely on the user's own Silo server; no Silo-operated infrastructure is involved.
## User Flow
```
1. Profile opens Settings -> Notifications -> Webhooks.
2. Profile clicks "Add webhook".
3. Pastes a URL like https://discord.com/api/webhooks/123/abc.
4. Silo auto-detects Discord type from URL pattern.
5. Profile names the webhook ("Family Discord") and toggles
reason filters: favorites on, watchlist on, continue_watching off, next_up off.
6. Profile clicks "Test", Silo POSTs a sample notification.
7. Discord channel shows the test embed; profile clicks "Save".
8. From now on, matching new-episode notifications post to Discord.
9. If the webhook URL becomes invalid (Discord deletes it), Silo:
a. Marks each 404'd delivery failed immediately (non-retryable 4xx);
transient network failures instead retry with backoff for ~24h.
b. After 3 consecutive non-retryable-4xx deliveries (or the retry
threshold for persistent network failures), auto-disables the webhook.
c. Posts an in-app notification to the profile: "Your 'Family Discord'
webhook stopped working. Last error: 404. Edit settings to fix."
```
## Data Model
### `notification_webhooks`
Purpose: profile-scoped webhook destination.
Columns:
- `id text primary key`
- `user_id integer not null` (matches `users.id integer`)
- `profile_id text not null`
- `name varchar(64) not null` — user-friendly label, capped to bound row size
- `type text not null` — `'discord'` or `'generic'`
- `url_ciphertext bytea not null` — destination URL, encrypted at rest
- `url_host varchar(253) not null` — denormalized host (no path) for diagnostics and validation logs (253 = max DNS host length)
- `signing_secret_ciphertext bytea` — null for `discord` (Discord webhooks don't sign); required for `generic`
- `enabled boolean not null default true`
- `notify_favorites boolean not null default true`
- `notify_watchlist boolean not null default true`
- `notify_continue_watching boolean not null default true`
- `notify_next_up boolean not null default true`
- `consecutive_failures integer not null default 0`
- `disabled_reason varchar(256)` — populated when auto-disabled
- `last_success_at timestamptz`
- `last_failure_at timestamptz`
- `last_failure_status integer`
- `last_failure_message varchar(256)` — short, non-sensitive diagnostic, e.g., "404 Not Found"
- `created_at timestamptz not null default now()`
- `updated_at timestamptz not null default now()`
Constraints:
- `(profile_id, name)` is unique per profile to prevent duplicate-name confusion.
- `CHECK (type IN ('discord', 'generic'))`.
- `CHECK (type = 'discord' OR signing_secret_ciphertext IS NOT NULL)` — generic webhooks must have a signing secret.
Indexes:
- `(profile_id)` for the listing endpoint.
- `(profile_id, enabled)` partial index where `enabled = true` for the dispatcher hot path.
Notes:
- URLs and signing secrets are encrypted using the same local secret-storage facility as other sensitive columns (e.g., APNs tokens). If that facility doesn't exist yet, this spec lands the columns as plain `bytea` containing UTF-8 bytes for v1; an explicit follow-up adds the encryption layer.
- `disabled_reason` is set when the system auto-disables; cleared when the profile re-enables.
### `webhook_delivery_attempts`
Purpose: operational record of webhook attempts.
Columns:
- `id text primary key`
- `notification_delivery_id text not null references notification_deliveries(id) on delete cascade`
- `webhook_id text not null references notification_webhooks(id) on delete cascade`
- `attempt_number integer not null`
- `attempted_at timestamptz not null default now()`
- `next_retry_at timestamptz`
- `http_status integer`
- `outcome text not null` — `'pending'`, `'delivered'`, `'retrying'`, `'failed'`, `'auto_disabled'`
- `failure_message varchar(256)` — short diagnostic (e.g., HTTP status text, DNS error class). Must not include payload contents.
Constraints:
- `(webhook_id, notification_delivery_id, attempt_number)` is unique to prevent double-claims under retry concurrency.
- `CHECK (outcome IN ('pending', 'delivered', 'retrying', 'failed', 'auto_disabled'))`.
- index on `(webhook_id, attempted_at desc)` for per-webhook history listing.
- index on `(outcome, next_retry_at)` for the retry worker.
Retention:
- keep `delivered` rows for ~7 days
- keep `failed`/`auto_disabled` rows for ~30 days for profile-visible debugging
- keep `pending`/`retrying` rows until they resolve
## Server API Surface
All endpoints are profile-scoped and require `X-Profile-Id` middleware.
### List Webhooks
```http
GET /api/v1/notifications/webhooks
```
Response:
```json
{
"webhooks": [
{
"id": "01J...",
"name": "Family Discord",
"type": "discord",
"url_host": "discord.com",
"enabled": true,
"notify_favorites": true,
"notify_watchlist": true,
"notify_continue_watching": false,
"notify_next_up": false,
"last_success_at": "2026-04-27T18:32:11Z",
"last_failure_at": null,
"last_failure_status": null,
"last_failure_message": null,
"consecutive_failures": 0,
"disabled_reason": null
}
]
}
```
Note the response **never includes the full URL or signing secret**. It returns only `url_host` so the profile can identify which Discord/Slack/etc. the webhook points at without leaking the secret URL token (which functions as the auth credential for Discord webhooks).
### Create Webhook
```http
POST /api/v1/notifications/webhooks
```
Request:
```json
{
"name": "Family Discord",
"url": "https://discord.com/api/webhooks/123456/abcdef-token",
"type": "discord",
"notify_favorites": true,
"notify_watchlist": true,
"notify_continue_watching": false,
"notify_next_up": false
}
```
Rules:
- `type` is optional; if omitted, the server detects Discord URLs via pattern (`https://discord.com/api/webhooks/{id}/{token}` or `https://discordapp.com/...`). All other URLs default to `generic`.
- For `generic`, the server generates a random signing secret (32 bytes, base64) and returns it **once** in the response. The profile is responsible for storing it on the receiving service.
- URL must pass private-destination guards.
- URL must use `https`.
- `name` length capped at 64 chars; reject empty or whitespace-only.
- Per-profile cap: max 10 webhooks (configurable via admin setting; documented as `notifications.webhooks.max_per_profile`).
Response:
```json
{
"id": "01J...",
"name": "Family Discord",
"type": "discord",
"url_host": "discord.com",
"enabled": true,
"notify_favorites": true,
"notify_watchlist": true,
"notify_continue_watching": false,
"notify_next_up": false,
"signing_secret": null
}
```
For `generic`:
```json
{
"id": "01J...",
"name": "My Slack Webhook",
"type": "generic",
"url_host": "hooks.slack.com",
"enabled": true,
"notify_favorites": true,
"notify_watchlist": true,
"notify_continue_watching": true,
"notify_next_up": true,
"signing_secret": "base64-encoded-secret-shown-once"
}
```
`signing_secret` is **only** returned at create time and is never re-fetchable. Rotation requires a separate endpoint.
### Update Webhook
```http
PUT /api/v1/notifications/webhooks/{id}
```
Request fields are all optional; included fields are updated:
```json
{
"name": "Renamed",
"enabled": true,
"notify_favorites": false,
"url": "https://discord.com/api/webhooks/.../new-token"
}
```
Updating the URL re-validates against the private-destination guard and resets `consecutive_failures` to 0.
### Delete Webhook
```http
DELETE /api/v1/notifications/webhooks/{id}
```
Idempotent. Cascades to `webhook_delivery_attempts`.
### Rotate Signing Secret (generic only)
```http
POST /api/v1/notifications/webhooks/{id}/rotate-secret
```
Generates and returns a new signing secret. The profile must update the receiving service to use the new secret.
### Test Webhook
```http
POST /api/v1/notifications/webhooks/{id}/test
```
Synchronously POSTs a sample payload to the destination and returns the result:
```json
{
"ok": true,
"http_status": 204,
"duration_ms": 187
}
```
or:
```json
{
"ok": false,
"http_status": 404,
"duration_ms": 234,
"message": "404 Not Found"
}
```
The test payload is identical in shape to a real notification but is clearly marked (e.g., embed footer text "Silo test notification" for Discord; `"test": true` field in generic). Test sends do not consume the retry/auto-disable counters.
## Payload Formats
### Discord
The Discord webhook API documents the `POST /api/webhooks/{id}/{token}` endpoint, which accepts a JSON body with optional `content` (plain text) and `embeds` (rich cards). Silo sends embeds only; no `content` field, so the message renders cleanly without a leading text line.
**v1 payload (text-only, no images):**
```json
{
"embeds": [
{
"title": "Severance — S2 E1: Hello, Ms. Cobel",
"description": "New episode available on Silo",
"color": 5814783,
"footer": {
"text": "Silo • Severance"
},
"timestamp": "2026-04-28T12:34:56Z",
"fields": [
{ "name": "Reason", "value": "Favorited & Continue Watching", "inline": true },
{ "name": "Season", "value": "2", "inline": true },
{ "name": "Episode", "value": "1", "inline": true }
]
}
],
"username": "Silo"
}
```
**v1.5 payload (with image proxy, additive):**
In v1.5, the builder adds `image` (poster) and `avatar_url` (Silo mark) fields, both pointing at `media.discord-cdn-proxy.silo.app` URLs. **In v1, these fields MUST be omitted entirely** — the builder must not fall back to absolute URLs that include the user's server origin. The privacy contract is broken if Discord sees the user's server URL in any embed field.
Notes on the Discord payload:
- **v1 builder rule:** the `image` field, the embed's `url` field, and the top-level `avatar_url` field must be omitted. Discord renders the embed without an image; the channel webhook's configured avatar is used. There is no fallback to the user's server URL.
- **v1.5 builder rule:** `image.url` and `avatar_url` use a CDN proxy hosted at `media.discord-cdn-proxy.silo.app`. The proxy is a small Silo-operated service that takes a short-lived signed URL token (issued by the user's server when constructing the webhook payload) and server-side streams the corresponding asset from the user's server. The leak vector being mitigated is *Discord's own infrastructure* fetching and caching the embed image — Discord's CDN (`media.discordapp.net`) fetches once from whatever origin the embed names; end-users only ever see the cached `media.discordapp.net` URL. The proxy ensures the origin Discord fetches from is the proxy, not the user's server.
- **Discord embed limits** (apply to both v1 and v1.5; payload builder must enforce):
- 6,000 char total across `title` + `description` + `field.name` + `field.value` + `footer.text` per embed
- `title`: 256 chars
- `description`: 4,096 chars
- `fields`: 25 max; `field.name` 256 chars, `field.value` 1,024 chars
- `footer.text`: 2,048 chars
- 10 embeds per message (Silo sends one)
- **Truncation policy:** prefer to truncate `description` first (with ellipsis), then field values. Never truncate `title`. If even truncated content exceeds the 6,000-char total, drop fields right-to-left until under cap.
- `color` is a Discord embed accent color encoded as decimal RGB. Silo picks per-reason: favorite=5814783 (Silo brand purple), watchlist=3066993 (green), continue_watching=15844367 (yellow), next_up=15158332 (red).
- `username` overrides the Discord webhook's default name to "Silo".
- The webhook URL itself is the auth — Discord's webhook tokens are bearer credentials in the URL path. This is why we never return the URL on read.
The Discord embed structure follows Discord's webhook API. See [Discord embed object docs](https://discord.com/developers/docs/resources/channel#embed-object) for the full schema.
### Generic
JSON body, signed via HMAC-SHA256 over the raw bytes Silo sends. Receivers verify against the literal bytes they received — no canonicalization required on either side.
Headers:
- `Content-Type: application/json`
- `User-Agent: Silo-Webhook/1.0`
- `X-Silo-Event: notification.created`
- `X-Silo-Webhook-Id: 01J...` — the webhook row ID
- `X-Silo-Delivery-Id: 01J...` — the underlying `notification_deliveries.id`
- `X-Silo-Timestamp: 1714299296` — Unix epoch seconds, integer
- `X-Silo-Signature: t=1714299296,v1=<hex-hmac-sha256>`
- HMAC-SHA256 of the byte string `{X-Silo-Timestamp}.{request body bytes}` using the per-webhook signing secret. The result is hex-encoded.
- Format follows Stripe's signing convention so receivers can use existing libraries (Stripe Go SDK, `stripe-signature` ports, etc.).
- **The timestamp value in the body's `timestamp` field is informational and may be RFC3339 for human readability; only the `X-Silo-Timestamp` header value (Unix epoch integer) participates in the HMAC computation.**
Body:
```json
{
"event": "notification.created",
"delivery_id": "01J...",
"webhook_id": "01J...",
"timestamp": "2026-04-28T12:34:56Z",
"version": 1,
"test": false,
"profile_id": "profile-1",
"library_id": 7,
"type": "episode.available",
"reason_flags": {
"favorite": true,
"watchlist": false,
"continue_watching": true,
"next_up": true
},
"series": {
"id": "series-123",
"title": "Severance"
},
"episode": {
"id": "episode-456",
"title": "Hello, Ms. Cobel",
"season_number": 2,
"episode_number": 1
}
}
```
Notes on the generic payload:
- The body is canonicalized (sorted keys, UTF-8 encoded) before HMAC computation so the receiver can verify deterministically.
- `profile_id` is included so the receiver can route by profile (one Slack channel per profile, etc.). It is the profile's own ID and is acceptable to share with the destination the profile chose.
- No server URL, no absolute poster URLs, no library name. The receiver cannot link this back to the user's server hostname unless the profile has separately told them.
- `version: 1` allows future schema changes without breaking existing receivers.
- `test: true` distinguishes test sends from real sends.
### Signature Verification (generic)
Receivers verify by:
```
parts = X-Silo-Signature.split(",")
ts = parts.find("t=").value // Unix epoch integer (string form)
v1 = parts.find("v1=").value // hex string
raw_body = literal request body bytes (do NOT re-parse and re-serialize JSON)
expected = hex(hmac_sha256(secret, ts + "." + raw_body))
if constant_time_compare(v1, expected) and abs(now_epoch - parse_int(ts)) < 300:
accept
else:
reject
```
Key rules:
- Sign and verify the **literal request body bytes**, not a re-canonicalized form. This eliminates JSON-canonicalization ambiguity (number formatting, escape rules, key ordering) and matches Stripe's pattern.
- `ts` and `now_epoch` are both Unix epoch seconds (integer). The timestamp window of 300 seconds (5 minutes) prevents replay; receivers may tighten if they wish.
- Use a constant-time comparator (`hmac.equal`, `crypto/subtle.ConstantTimeCompare`, etc.) to avoid timing attacks.
### Signing Secret Rotation
`POST /api/v1/notifications/webhooks/{id}/rotate-secret` generates a new signing secret and returns it once. After rotation:
- The new secret takes effect immediately for all subsequent deliveries.
- **Pending and retrying delivery attempts re-sign with the current secret on each retry**, not the secret that was active when the attempt was first enqueued. This means receivers who have updated to the new secret will accept the retried delivery; receivers still on the old secret will reject it. The expected operational pattern is: profile rotates the secret and updates their receiver atomically (or near-atomically); brief retry-window mismatches are acceptable.
- Silo does **not** keep the old secret around after rotation. There is no dual-acceptance window.
## Delivery Semantics
### Send path
1. The fanout transaction in [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md) commits the `notification_deliveries` row **and**, in the same transaction, one `webhook_delivery_attempts` row with `outcome = 'pending'` per enabled, reason-matching webhook (the dispatch outbox). Two filters apply at enqueue time:
- **Type deny list.** `webhook.auto_disabled` deliveries never enqueue webhook attempts — a webhook auto-disable notice must never be re-dispatched as a webhook, otherwise a broken webhook would generate an auto-disable notification that fires another webhook attempt that fails again, looping. Other notification types may join this list as they're added.
- **Per-webhook reason filter** (`notify_favorites`, etc.). No matching reason, no attempt row.
2. `WebhookDispatcher.Dispatch(delivery)` runs post-commit and claims the delivery's `pending` attempt rows (`FOR UPDATE SKIP LOCKED`).
3. For each claimed attempt:
- Construct the payload (Discord embed or generic JSON).
- POST the payload with a 10-second total timeout.
- On success (HTTP 2xx): mark attempt `delivered`, update webhook `last_success_at`, reset `consecutive_failures = 0`.
- On failure: mark attempt `retrying` with `next_retry_at`, increment `consecutive_failures` on the webhook.
4. The retry worker reads `webhook_delivery_attempts WHERE (outcome = 'retrying' AND next_retry_at <= now()) OR (outcome = 'pending' AND attempted_at <= now() - interval '60 seconds')` — the second arm is outbox recovery for attempts whose post-commit dispatch never ran (process crash).
5. When `consecutive_failures` exceeds the threshold (default 10) over a span > 24h, the webhook is auto-disabled (`enabled = false`, `disabled_reason = '...'`), and an in-app notification is posted to the profile via the in-app inbox.
### Preference precedence (relationship to `notification_preferences`)
Profile-level `notification_preferences.notify_*` flags are a **hard gate** applied during fanout (see [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md)). A delivery row is never created if all matching reasons are disabled at the profile level — so the dispatcher never sees those events.
Per-webhook `notify_*` flags are an **additional filter** applied during dispatch. They can narrow what fires for a specific webhook, but they cannot re-enable a reason the profile has globally disabled. The frontend must reflect this: if a profile turns off `notify_continue_watching` globally, the per-webhook continue-watching checkbox should appear disabled with explanatory text rather than implying the user can re-enable it for one destination.
### Retry schedule
Exponential backoff, capped at 24h total:
| Attempt | Delay since first attempt |
|---|---|
| 1 | 0 (immediate) |
| 2 | 30s |
| 3 | 2m |
| 4 | 10m |
| 5 | 30m |
| 6 | 2h |
| 7 | 6h |
| 8 | 12h |
| 9 | 18h |
| 10 | 24h, then auto-disable |
Each attempt has a 10s total timeout.
**4xx skip-retry:** 4xx HTTP responses (except `408 Request Timeout`, `425 Too Early`, `429 Too Many Requests`) are deterministic destination-side rejections — retrying *this delivery* won't help, so the attempt is marked `failed` immediately without walking the 10-attempt schedule. Auto-disable, however, requires **3 consecutive deliveries** to fail with a non-retryable 4xx (tracked via `consecutive_failures` plus the failure-status class) before the webhook is disabled. A single 4xx is not proof the webhook is dead: destination-side WAF/CDN blips (Discord behind Cloudflare) intermittently return 403/400 for valid webhooks, and instant disable on one such blip would force pointless manual re-enables. Three consecutive deterministic rejections is strong evidence the URL is actually gone. The profile is notified in-app per "Auto-Disable Notification" below.
`429 Too Many Requests` honors the `Retry-After` header if present (overriding the schedule above for that attempt).
### Concurrency
The dispatcher fans webhook deliveries in parallel within a small bounded pool (e.g., 16 workers per server process). One slow webhook destination cannot block other deliveries.
### Test delivery isolation
`POST /webhooks/{id}/test` sends synchronously, does not write to `webhook_delivery_attempts`, does not affect `consecutive_failures`, and returns the HTTP result inline.
## Privacy Requirements
- Webhook URLs are encrypted at rest where the local encryption facility supports it.
- Webhook URLs are never returned in API responses (only `url_host`).
- Generic signing secrets are returned only at create / rotate time; never readable after.
- Logs redact full URLs (host only) and full signing secrets.
- Logs do not persist webhook payload bodies. Failure logs may include HTTP status text.
- Webhook payloads must not include the Silo server URL or absolute artwork URLs that include the server origin.
- An admin must not be able to read another profile's webhook URLs or signing secrets.
## Security Requirements
- HTTPS-only destinations.
- Private-destination guard on URL submission and on each delivery (re-resolve to catch DNS rebinding to private IPs).
- HMAC-SHA256 with per-webhook 32-byte signing secrets for `generic`.
- Signing secret returned only at create / rotate; rotation immediately invalidates the previous secret.
- Per-profile cap on webhook count (default 10).
- Per-profile rate limit on webhook delivery (e.g., 60 deliveries / 60 seconds / profile across all webhooks). Notifications that exceed the limit are still durably stored in the inbox; webhooks just don't fire for them. Logged so admins can spot runaway scenarios.
- TLS verification is enforced and not user-overridable.
- HTTP redirects are followed (max 3 hops) but the final URL must still pass the private-destination guard at each hop.
## DNS Rebinding Mitigation
A naive implementation that resolves the URL once at registration time is vulnerable to DNS rebinding: the host could resolve to a public IP at registration and a private IP at delivery time. Mitigation:
- At delivery time, resolve the host yourself, validate the resolved IPs against the private deny set, then connect using the validated IP (or a controlled `net.Dialer` that re-validates the address it's about to connect to).
- Do not let the standard library transparently re-resolve on each request.
- The `internal/notifications/webhook_http.go` HTTP client should use a custom `Dialer.Control` callback that inspects the resolved address and refuses connections to private ranges.
## Settings And UX
Profile-level settings:
```text
Webhooks
Add webhook
Send Silo notifications to a webhook URL.
Discord URLs render as native Discord embeds.
Other URLs receive signed JSON.
[Family Discord] ✓ enabled
discord.com
✓ Favorites ✓ Watchlist ☐ Continue watching ☐ Next up
Last success: 2 hours ago
[ Test ] [ Edit ] [ Delete ]
[Slack: #media] ✓ enabled
hooks.slack.com
✓ all reasons
Last failure: 3 minutes ago — 404 Not Found
⚠ This webhook is failing. Check the destination URL.
[ Test ] [ Edit ] [ Delete ]
+ Add webhook
```
Admin-level settings (server-wide guards, default values):
- `notifications.webhooks.max_per_profile` (default 10)
- `notifications.webhooks.allow_private_destinations` (default false; for dev environments)
- `notifications.webhooks.deliveries_per_minute_per_profile` (default 60)
## Implementation Plan
### Task 1: Schema
Files:
- new migration under `migrations/` adding `notification_webhooks` and `webhook_delivery_attempts` tables.
### Task 2: Webhook Repository And Service
Files:
- `internal/notifications/webhook_repo.go`
- `internal/notifications/webhook_service.go` (CRUD, validation, signing-secret handling)
Includes:
- URL validation (https, private-destination guard)
- Discord URL detection
- signing secret generation
- per-profile count enforcement
### Task 3: HTTP Client With Address Guard
Files:
- `internal/notifications/webhook_http.go`
Custom HTTP client with:
- 10-second timeout
- TLS verification non-overridable
- `Dialer.Control` callback that re-validates the resolved IP at connect time
- bounded redirect handling
- structured error reporting (DNS error class, TCP, TLS, HTTP status) for `failure_message`
### Task 4: Payload Builders
Files:
- `internal/notifications/webhook_payload_discord.go`
- `internal/notifications/webhook_payload_generic.go`
Each pure function `func Build(delivery NotificationDelivery, hook Webhook) ([]byte, error)`. No side effects, easy to unit test.
### Task 5: Dispatcher Integration
Files:
- `internal/notifications/webhook_dispatcher.go`
Implements the channel `Dispatcher` interface from `00-architecture-overview.md`. Loads enabled webhooks for the delivery's profile, applies reason filters, posts payloads, records attempts.
### Task 6: Retry Worker
Files:
- `internal/notifications/webhook_retry_worker.go`
Polls `webhook_delivery_attempts WHERE outcome = 'retrying' AND next_retry_at <= now()` with `FOR UPDATE SKIP LOCKED`. Re-dispatches and updates state. Handles auto-disable.
### Task 7: API Handlers And Routes
Files:
- `internal/api/handlers/notifications_webhooks.go`
- `internal/api/router.go`
Routes:
- `GET /api/v1/notifications/webhooks`
- `POST /api/v1/notifications/webhooks`
- `PUT /api/v1/notifications/webhooks/{id}`
- `DELETE /api/v1/notifications/webhooks/{id}`
- `POST /api/v1/notifications/webhooks/{id}/test`
- `POST /api/v1/notifications/webhooks/{id}/rotate-secret`
All require `RequireProfile` middleware.
### Task 8: Frontend
Files:
- `web/src/pages/settings/NotificationWebhooksSettings.tsx`
- `web/src/hooks/queries/notification-webhooks.ts`
- form components, status pills, test-result display
Includes:
- "Show signing secret once" UX with explicit "I've saved it" confirmation.
- Show secret-rotation flow.
- Last-failure status with admin-level message ("404 Not Found"), not stack traces.
- Test button.
- Per-reason filter checkboxes.
### Task 9: Auto-Disable Notification
When auto-disable fires, create a `notification_deliveries` row with `type = "webhook.auto_disabled"`, including the webhook name and last failure code. The profile sees this in their inbox so a broken webhook doesn't fail silently.
### Task 10: Optional CDN Proxy For Discord Images
Files:
- separate repo (e.g., `silo-discord-cdn-proxy`)
- `internal/notifications/webhook_image_signer.go` for issuing short-lived signed URL tokens
Out of scope for v1 unless Discord image rendering is wanted in the first release. v1 ships embeds without images; v1.5 adds the proxy and image URLs.
## Validation Plan
- Unit tests for URL validators (HTTPS, private-IP guard, DNS resolution).
- Unit tests for payload builders (Discord shape, generic canonicalization, HMAC determinism).
- Integration test: round-trip a generic webhook against a stubbed receiver that verifies the signature.
- Integration test: Discord embed against `https://discord.com/api/webhooks/.../test-only` (or recorded fixture) — manual verification only since Discord doesn't offer a sandbox.
- Concurrency test: 100 deliveries in parallel; verify retry rows aren't double-counted.
- Auto-disable test: simulate 3 consecutive deliveries returning 404 and verify the webhook is disabled with the correct in-app notification; verify 1-2 isolated 4xx blips do **not** disable it; verify persistent timeouts exhaust the 10-attempt schedule before disabling.
- Outbox recovery test: commit deliveries with `pending` attempt rows, skip the inline dispatcher, and verify the retry worker sends them after the 60s recovery window.
- Manual verification:
- Create a Discord webhook URL.
- Add it to a profile.
- Trigger a release event.
- Confirm the embed renders correctly in Discord.
- Disable the Discord webhook on Discord's side.
- Trigger another event.
- Verify the webhook auto-disables after retries.
- Verify the profile receives an in-app "webhook auto-disabled" notification.
## Open Questions
- **Discord image proxy scope.** Build it in v1, or ship v1 without images and add v1.5? Recommendation: ship without images in v1 — text-only embeds are useful and require no new infra. Add the proxy in v1.5 if user feedback wants images.
- **Slack and Microsoft Teams native types.** Should we add `slack` and `teams` types alongside `discord` and `generic`? Both Slack incoming webhooks and Teams Adaptive Cards have their own JSON shapes. Recommendation: `generic` works for Slack (Slack incoming webhooks accept arbitrary JSON; users can build their own message format with a Workflow). Teams requires Adaptive Card JSON specifically. Defer both to v2 unless concrete user demand emerges.
- **Per-event-type templating.** Some users will want to customize the embed text. Recommendation: defer to v2; v1 fixed shapes ship faster and most users will accept them.
- **Webhook "channels" beyond episode.available.** Should webhook payloads cover other event types (release-aggregated, server announcements)? V1 only fires on `notification.created` for `episode.available`. Other types are forward-compatible via the `event` and `type` fields.
- **Outgoing IP.** Webhook deliveries originate from the user's Silo server IP. Do we need an option to route through a proxy? Recommendation: not v1; document that deliveries come from the server's egress IP.
- **Backpressure.** What happens when a profile's webhook is slow but reachable, blocking delivery for an hour at a time? The 10s timeout caps individual deliveries; bounded worker pool caps fanout concurrency. A single bad webhook shouldn't degrade other webhooks because each delivery runs independently.
@@ -0,0 +1,54 @@
# Web Push Spec
**Date:** 2026-06-11
**Status:** Implemented
**Scope:** Browser push notifications (Push API + VAPID) as a third push platform alongside the deferred APNs/FCM channels.
**Depends On:**
- [`00-architecture-overview.md`](./00-architecture-overview.md)
- [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md)
## Why Web Push ships before APNs/FCM
The architecture overview deferred mobile push because Apple and Google require pushes to official store builds to be signed by the publisher's credentials, forcing a Silo-operated relay. Web Push has neither problem:
- **No accounts, no relay.** The server self-provisions a VAPID keypair on first use. Any standards-compliant browser push service (Chrome, Firefox, Edge, Safari 16+) accepts VAPID-signed requests from any origin.
- **Content-safe by protocol.** Payloads are encrypted end-to-end (RFC 8291, `aes128gcm`) to keys held only by the subscribed browser. The vendor push service relays ciphertext. Unlike the APNs/FCM design, payloads can therefore carry full display content (titles, episode numbers, poster URLs) without violating the self-hosted privacy model — there is no opaque-wake/fetch dance.
The residual leak matches the relay threat model: the push service sees the user server's egress IP, delivery timing, and payload size. It never sees content or identity.
## Data model
- `web_push_subscriptions` — profile-scoped browser registrations: `endpoint` (unique; a resubscription from the same browser under a different profile reassigns the row), `p256dh`, `auth`, `device_name`, failure bookkeeping. No FK to profiles (per-user SQLite stores); profile deletion purges in code.
- `web_push_delivery_attempts` — the durable dispatch outbox, mirroring `webhook_delivery_attempts`: `pending` rows enqueued in the fanout transaction, claimed post-commit with a lease, swept by the retry loop after a crash.
## VAPID identity
Generated once and persisted in `server_settings`:
- `notifications.web_push.vapid_public_key` — served to clients via the capability endpoint.
- `notifications.web_push.vapid_private_key` — encrypted at rest (`SensitiveSettingKeys`).
The private key is persisted before the public key so a crash between writes regenerates the pair instead of stranding clients with an unusable public key. The pair must never be rotated casually: browsers bind subscriptions to it.
## API surface (profile-scoped)
- `GET /api/v1/notifications/capability` — `web_push: { available, public_key }`.
- `POST /api/v1/notifications/web-push/subscriptions` — body is `PushSubscription.toJSON()` plus `device_name`.
- `GET /api/v1/notifications/web-push/subscriptions` — for the settings UI device list.
- `DELETE /api/v1/notifications/web-push/subscriptions/{id}`
- `POST /api/v1/notifications/web-push/unsubscribe` — by endpoint (browsers don't know row IDs).
Subscription endpoints are attacker-controllable URLs the server will POST to, so they pass the same HTTPS + private-destination guard as webhooks, both at registration and at connect time (guarded dialer).
## Delivery semantics
- Fanout enqueues one `pending` attempt per enabled subscription of each recipient profile, in the same transaction as the delivery rows. No per-reason filters: profile preferences already gate delivery creation.
- Retry schedule is short (30s/2m/10m/30m, 5 attempts): vendor push services queue messages for offline devices themselves (TTL 12h), so server-side retries only ride out transient push-service errors.
- `404`/`410` from the push service is the protocol's unsubscribe signal: the subscription row is deleted, not retried.
- `notifications.web_push_enabled` is the kill switch (default on).
## Client
- `web/public/sw.js` — displays notifications and routes clicks (episode deep link, or the inbox).
- `web/src/lib/webPush.ts` — permission + subscribe/unsubscribe flows.
- Settings → Notifications → "Browser Notifications" — this-browser toggle plus a revocable list of the profile's other subscribed devices.
@@ -0,0 +1,169 @@
# Notifications v1.5 Roadmap
**Date:** 2026-06-11
**Status:** Draft (work not started)
**Scope:** The remaining notification work between the shipped v1 and the deferred v2 push channels. Each item is independent and sized to land as its own PR.
**Depends On:**
- [`00-architecture-overview.md`](./00-architecture-overview.md)
- [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md) — implemented
- [`04-outbound-webhooks.md`](./04-outbound-webhooks.md) — implemented
- [`05-web-push.md`](./05-web-push.md) — implemented
## Where v1 landed (context for this doc)
Implemented 2026-06-11: the full foundation (availability seeding, release events,
interest index, fanout worker with burst caps, websocket channel with ticket
handshake, inbox/sync/preferences/capability APIs, web inbox + badge + settings),
outbound webhooks (Discord + generic HMAC, SSRF guard, durable outbox, retry +
auto-disable), Web Push (VAPID self-provisioned, E2E-encrypted payloads, service
worker), and the shared SMTP core (`internal/mail`, see
`docs/architecture/email.md`) with an admin Email settings page — but no feature
consuming email yet.
**Deferred to v2 by explicit decision:** APNs (`02`) and FCM (`03`) — they
require Silo-operated relay infrastructure and developer accounts. Also v2 per
the original plans: movie availability, aggregated notifications ("3 new
episodes"), quiet hours, cross-profile views.
---
## 1. Admin settings UI for notification controls
**Why:** every `notifications.*` setting works today but is reachable only
through the raw admin settings API. Admins should not need `curl` to find the
kill switches.
**What:** an admin settings page ("Notifications", next to the Email page added
in v1) exposing:
| Group | Keys |
|---|---|
| Kill switches | `notifications.release_events_enabled`, `notifications.fanout_enabled`, `notifications.ui_enabled`, `notifications.webhooks_enabled`, `notifications.web_push_enabled` |
| Fanout tuning | `notifications.fanout.settle_seconds` (default 30), `notifications.fanout.max_series_burst` (default 3) |
| Webhook guards | `notifications.webhooks.max_per_profile` (10), `notifications.webhooks.allow_private_destinations` (false; dev only — label it loudly), `notifications.webhooks.deliveries_per_minute_per_profile` (60) |
| Retention | `notifications.retention.read_days` (90), `notifications.retention.unread_days` (180), `notifications.retention.event_days` (30) |
**Files:** add `web/src/pages/admin-settings/NotificationsAdminSettings.tsx`
(follow `EmailSettings.tsx` / `useSettingsForm`), register in
`web/src/pages/admin-settings/AdminSettingsLayout.tsx`. No backend work — all
keys are live-read.
**Effort:** small (one page, no migrations, no Go changes).
---
## 2. Request-fulfilled notifications (`request.fulfilled`)
**Why:** `00-architecture-overview.md` calls this "the most obvious next
notification type." Users who request media currently learn it arrived by
checking manually; every delivery channel they configured should tell them.
**Design:** the `notification_deliveries.type` registry is extensible by
construction — no schema change.
- New type `request.fulfilled`. `reason_flags` carries the operational shape
(like `webhook.auto_disabled` does), e.g.
`{"request_id": "...", "tmdb_id": 123, "media_type": "movie"}` — never the
four reason booleans.
- **Hook point:** the request reconciliation service (`internal/mediarequests`)
is where a request transitions to available/fulfilled. On that transition,
insert a delivery via `DeliveryRepository.InsertOperational` (the path the
webhook auto-disable notice already uses) and publish through the system's
dispatchers so websocket, web push, and webhooks all fire.
- **Recipient:** the requesting profile (requests are profile-attributed). No
`profile_series_interest` involvement — this is a direct, not fanned-out,
notification.
- **Webhook enqueue:** operational inserts bypass the fanout outbox, so either
(a) extend `InsertOperational` to optionally enqueue per-target attempt rows,
or (b) add a small shared "dispatch one delivery durably" helper used by both
this and the auto-disable notice. Prefer (b); the auto-disable notice
deliberately skips webhooks (loop guard) but request notices should not.
- **Per-reason preferences:** add nothing in v1.5. The profile master toggle
(`notification_preferences.enabled`) gates it; a dedicated
`notify_requests` flag can come later if users ask.
- **Clients:** the web inbox/toast/web-push renderers fall back to a generic
card for unknown types; add a `request.fulfilled` case with the media title,
poster, and a deep link to the item (or the request page until matched).
**Effort:** medium-small. The delivery/dispatch machinery all exists.
---
## 3. Email digest channel
**Why:** first real consumer of `internal/mail`; reaches users who don't keep a
browser open and have no webhook.
**Open design decisions (resolve before building):**
- **Account-level, not profile-level.** Email addresses live on `users`;
profiles have none. A digest therefore aggregates across the account's
profiles (group by profile inside the email body).
- **Digest, not per-episode.** Per-episode email is spam at hundreds-of-users
scale and duplicates the realtime channels. Recommend: opt-in daily digest of
unread deliveries, sent by a taskmanager task (reuse the checkpointed
iteration pattern from the interest backfill), with a per-user
enable + cadence setting.
- **Unsubscribe / preference surface:** account settings, not profile
notification preferences.
**Files (sketch):** `internal/notifications/email_digest.go` (compose from
`DeliveryRepository`, send via `mail.Sender`, branch on
`mail.ErrNotConfigured`), a `taskmanager` task, a small user-settings surface.
**Effort:** medium. Blocked on the design decisions above, not on plumbing.
---
## 4. Native client adoption (no push required)
**Why:** the Android and Apple apps gain a full notification experience today —
APNs/FCM only add closed-app wake-ups later.
Server surfaces ready for clients (`silo-android`, `silo-apple`):
- `GET /api/v1/notifications` + `unread-count` + read endpoints — inbox UI.
- `GET /api/v1/notifications/sync` — opaque forward cursor for
reconnect/foreground catch-up (this is also the wake-fetch endpoint the v2
push specs assume, so client work done now is reused).
- `POST /api/v1/events/ws-ticket` + `ticket` query param on `/api/v1/events/ws`,
`notifications` channel — realtime while the app is open. Snapshot on
subscribe hydrates recent unread.
- `GET /api/v1/notifications/capability` — drive setup UI from this, never from
admin settings.
- `GET/PUT /api/v1/notifications/preferences` — per-profile reason toggles.
**Effort:** client-repo work; the server side is done. Coordinate per the
multi-repo guidance in the repo root `CLAUDE.md`.
---
## 5. Hardening backlog (defer freely)
- **DB-backed integration tests** from the `01` verification plan: idempotent
availability/event inserts, cross-library delivery dedupe, per-series burst
cap, outbox recovery (pending rows with no dispatch → retry worker sends),
multi-node claim safety. The behaviors shipped and were exercised manually on
dev; they are not yet pinned by automated tests because the repo has no
Postgres test harness for this package.
- **Metrics:** `01` names Prometheus-style counters
(`release_events_suppressed_total`, etc.); v1 ships them as structured log
fields. Revisit when the repo grows a metrics registry — keep the names.
- **Discord embed images** via the `media.discord-cdn-proxy` service
(see `04`, "v1.5 payload"). Requires a new Silo-operated repo/service plus
`webhook_image_signer.go`; v1 deliberately ships text-only embeds so the
user's server origin never reaches Discord.
- **Webhook delivery history endpoint:** `webhook_delivery_attempts` already
has the listing index; a `GET /api/v1/notifications/webhooks/{id}/attempts`
endpoint + UI table would make failures self-diagnosable beyond the
last-failure summary.
---
## Suggested order
1. Admin settings UI (#1) — smallest, completes operability.
2. Request-fulfilled (#2) — highest product value per effort.
3. Webhook history endpoint (#5, last bullet) — pairs naturally with #1.
4. Email digest (#3) — after its design decisions are made.
5. Native clients (#4) — parallel track in the client repos.
@@ -0,0 +1,109 @@
# Notifications: Email Channel
**Date:** 2026-06-11
**Status:** Implemented (written post-implementation)
**Scope:** Item 3 of [`06-v1.5-roadmap.md`](./06-v1.5-roadmap.md) — the first real consumer of the shared SMTP core (`internal/mail`, `docs/architecture/email.md`).
**Depends On:** [`00-architecture-overview.md`](./00-architecture-overview.md), [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md)
## Decisions (resolving the roadmap's open questions)
- **Account-level, as planned.** Email addresses live on `users`; one mode
covers every profile on the account and one email aggregates across them.
- **Per-episode AND digest, not digest-only.** The roadmap recommended
digest-only; product direction chose to offer per-episode alerts too, gated
by an admin allowance (`notifications.email.allow_per_episode`). When the
admin disallows it, accounts set to per-episode are **coerced to the daily
digest** rather than silenced.
- **Interest-scoped by construction.** Email consumes existing
`notification_deliveries` rows, which the fanout only creates for profiles
with series interest (favorites, watchlist, continue-watching, next-up) and
for direct notices (`request.fulfilled`, `webhook.auto_disabled`). The
channel adds no targeting of its own — it is never "all new content".
- **Opt-in, default off.** Users enable it per account in Settings →
Notifications; enabling initializes the watermark to now so history never
floods a fresh opt-in.
## Architecture: watermark sweep, not a third outbox
Webhooks and web push use per-target outbox attempt rows. Email deliberately
does not:
- Deliveries already carry `user_id`, and an account whose profiles follow the
same series gets one row per profile — a per-row outbox would email the same
episode several times. The sweep collapses them (dedupe by `episode_id`, by
`request_id` for requests).
- A per-account watermark over `(created_at, id)` that advances **only after a
successful SMTP send** gives durability for free: a crash or SMTP outage
re-sends on the next pass instead of dropping.
- Both cadences are the same mechanism: per-episode sweeps every minute (and
is nudged by the dispatcher seconds after fanout commits); the digest is the
same sweep gated on "today's send hour passed and not yet stamped today".
State lives in `notification_email_prefs` (`migrations/sql/`,
`email_notification_channel`): mode, watermark, `last_digest_at`, and failure
backoff counters (`last_attempt_at`, `consecutive_failures`; 1m doubling,
capped at 6h). No FK to `users` per the notification-tables rule; deleted or
disabled accounts drop out of the recipient join. A supporting index
`notification_deliveries_user_created_idx (user_id, created_at, id)` serves
the sweep.
**Multi-node safety:** each account is processed inside one transaction that
claims the prefs row `FOR UPDATE SKIP LOCKED`, re-derives eligibility from the
locked row (mode flips and another node's digest stamp are both re-checked),
sends, then commits the watermark/stamp. Failed sends commit only the backoff
counters. `mail.ErrNotConfigured` aborts the whole pass; three consecutive
send failures end it early (SMTP trouble is global, not per-recipient).
**Flood bounds:** one email renders at most 30 lines (`…and N more in your
Silo inbox`), one pass fetches at most 200 rows per account, and upstream the
per-series burst cap already limits fanout volume. Digest emails include only
rows still unread at compose time; the watermark passes read rows silently.
## Files
| Piece | Location |
|---|---|
| Modes, prefs repo (`notification_email_prefs`) | `internal/notifications/email_prefs_repo.go` |
| Worker, dispatcher nudge, System service methods | `internal/notifications/email_digest.go` |
| Subject/text/HTML rendering | `internal/notifications/email_compose.go` |
| Account sweep query | `DeliveryRepository.ListForUserSince` (`internal/notifications/delivery_repo.go`) |
| Settings accessors | `internal/notifications/settings.go` |
| API handlers | `internal/api/handlers/notifications_email.go` (+ capability in `notifications.go`) |
| Web UI (user) | `EmailSection` in `web/src/pages/settings/NotificationsSettings.tsx` |
| Web UI (admin) | Email group in `web/src/pages/admin-settings/NotificationsAdminSettings.tsx` |
| Logic tests | `internal/notifications/email_logic_test.go` |
The worker is wired in `notifications.NewSystem` (new `mail.Sender` parameter,
passed from `cmd/silo/main.go`); its dispatcher joins the `MultiDispatcher`,
so operational deliveries (`request.fulfilled`, `webhook.auto_disabled`) nudge
it exactly like fanout rows do.
## Settings
| Key | Default | Meaning |
|---|---|---|
| `notifications.email_enabled` | `true` | Channel kill switch (availability still requires SMTP configured via `email.*`) |
| `notifications.email.allow_per_episode` | `true` | Admin allowance for the per-episode cadence |
| `notifications.email.digest_hour` | `8` | Hour (0–23, server-local) daily digests go out |
| `notifications.email.external_url` | empty | Public base URL for deep links in emails; empty sends link-free emails (the server origin is never leaked implicitly) |
## API
- `GET /api/v1/notifications/email-preferences` → `{"mode": "off" | "per_episode" | "daily_digest"}`
- `PUT /api/v1/notifications/email-preferences` `{"mode": ...}` — 400 codes:
`bad_request` (unknown mode), `not_allowed` (per-episode disallowed),
`no_email` (account has no address). Any profile on the account may set it.
- `GET /api/v1/notifications/capability` gained
`"email": {"available", "modes", "digest_hour"}`; clients gate setup UI on
it as usual. `available` requires the kill switch on **and**
`mail.Sender.Enabled()` — never read `email.*` settings directly.
## Deliberately not in v1
- Posters/images in emails (would require externally reachable presigned URLs).
- `List-Unsubscribe` headers / tokenized unsubscribe endpoint (self-hosted,
opt-in; revisit if servers grow beyond household scale).
- Per-user digest hour (admin-global for now).
- DB-backed integration tests for the sweep (same Postgres-harness gap as the
rest of `01`'s verification backlog; pure logic is covered by
`email_logic_test.go`).
@@ -0,0 +1,88 @@
# Silo Notifications — Design Index
> **Provenance:** Imported 2026-06-11 from `ContinuumApp/continuum` (`docs/superpowers/plans/notifications/` at `04aa9266`), written 2026-04-28 before the project was renamed to Silo. Reviewed and amended 2026-06-11: product naming and all wire contracts (payload keys, headers, settings enums, localization keys, provisional domains/repo names) are normalized to Silo. Bundle topics, Android package names, and Firebase project IDs intentionally keep their current official client-build values (`com.continuum.app.*`, `continuum-prod-android`) — they are allowlist *config*, not part of this design's contract, and the shipped client builds still use them. Relay/proxy hostnames (`relay.silo.app`, `media.discord-cdn-proxy.silo.app`) are provisional. See "2026-06-11 review amendments" below for the design-level changes made during review.
This folder collects the design work for Silo's notification system, covering durable in-app inbox + websocket realtime, Apple Push (APNs), Android Push (FCM), and outbound webhooks (Discord-native + generic).
A self-contained visual summary of the design decisions (who/what/where/why per decision, pipeline, trust model) lives at [`design-decisions.html`](./design-decisions.html) — open it in any browser; no build or network access required.
## Reading order
1. **[`00-architecture-overview.md`](./00-architecture-overview.md)** — Cross-cutting overview. Read this first. Explains the channel model, fanout pipeline, preference shape, threat model, and addressing model (why a relay is required for mobile push). Links into the per-channel specs.
2. **[`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md)** — Foundation. Durable `release_events` -> per-profile `notification_deliveries` fanout, the in-app inbox API, the websocket channel, and per-profile preferences. Vendor-agnostic; nothing else works without this.
3. **[`02-apns-relay.md`](./02-apns-relay.md)** — Apple push. Privacy-preserving design with three provider modes (`off` / `silo_relay` / `custom_apns`).
4. **[`03-fcm-relay.md`](./03-fcm-relay.md)** — Android push. Mirrors the APNs spec with `off` / `silo_relay` / `custom_fcm` modes using FCM v1 (OAuth2 service-account JSON).
5. **[`04-outbound-webhooks.md`](./04-outbound-webhooks.md)** — Profile-scoped outbound webhooks. Native Discord embed type and generic JSON+HMAC type. Content included by default because the profile chose the destination.
## Status
| Doc | Status |
|---|---|
| 00-architecture-overview | Draft (amended 2026-06-11) |
| 01-release-events-and-inbox | Draft (refined, amended 2026-06-11) |
| 02-apns-relay | Draft (refined, amended 2026-06-11) |
| 03-fcm-relay | Draft (amended 2026-06-11) |
| 04-outbound-webhooks | Draft (amended 2026-06-11) |
Nothing in this folder has been implemented. The codebase has zero notification-related code beyond the existing realtime events hub (`internal/events/`, with the operational publisher wrapper at `internal/notifications/hub.go`), which publishes catalog/jobs events and is unrelated to the user-facing notification system designed here.
## 2026-06-11 review amendments
A scaling and privacy review (self-hosted servers with hundreds of users) resolved four design-level gaps. The detailed designs live in the per-doc sections; this is the index:
1. **Back-catalog seeding + burst suppression** (`01`). First scan of a new library and the feature-enable backfill seed `episode_availability` *without* creating release events; bulk additions to existing libraries are bounded by a per-series fanout burst cap. Without this, importing a 200-episode back-catalog of a popular series would generate tens of thousands of deliveries and pushes in one scan.
2. **Durable dispatch outbox** (`01`, referenced by `02`/`03`/`04`). The fanout transaction that inserts `notification_deliveries` also inserts `pending` per-target attempt rows for the push and webhook channels. A crash between delivery commit and dispatch no longer silently loses pushes/webhooks; recovery workers drain stale pending rows.
3. **Cross-library episode dedupe** (`01`). Media items in Silo are catalog-level (`media_item_libraries` junction), so the same episode landing in "TV" and "TV 4K" shares one `episode_id`. A partial unique index on `(profile_id, episode_id)` guarantees at most one `episode.available` delivery per profile per episode across libraries.
4. **Mobile wake-fetch endpoints defined in the foundation** (`01`). `GET /api/v1/notifications/sync` (forward cursor) and `GET /api/v1/notifications/{id}` — previously referenced by `02`/`03` but defined nowhere.
Smaller amendments: relay threat models now list the server's egress IP as residual leakage (`00`/`02`/`03`); `collapse_id`/`collapse_key` derivation is specified as per-server-keyed HMAC so the relay can't read series identity out of it (`02`/`03`); the websocket handshake uses a short-lived single-use ticket instead of a long-lived profile token in the query string (`00`/`01`); client-side relay pacing (`02`/`03`); webhook auto-disable requires 3 consecutive non-retryable 4xx instead of 1 (`04`); SSRF deny list gains `198.18.0.0/15` and `192.88.99.0/24` (`04`); interest recompute fires on watch-state transitions, not every progress tick (`01`); profile-deletion cascades and `release_events.dedupe_key` composition are specified (`01`); migration tasks now follow the repo's timestamped Goose convention (`01`).
## Scope summary
**In scope (v1):**
- Episode-availability notifications (a new episode lands in a library and is relevant to a profile via favorites / watchlist / continue-watching / next-up).
- Durable per-profile inbox with read/unread state.
- Realtime websocket delivery for connected clients.
- Apple Push via opt-in Silo-operated relay or admin-supplied custom APNs credentials.
- Android Push via opt-in Silo-operated relay or admin-supplied custom FCM credentials.
- Outbound webhooks per profile, with native Discord embed rendering and generic JSON+HMAC.
**Out of scope (v1):**
- Movie release notifications (the trigger surface — favoriting a movie that doesn't yet exist — isn't a flow Silo supports).
- Email, SMS, or other delivery channels.
- Browser Web Push (PWA) notifications.
- Cross-profile administrator dashboards.
- Marketing or promotional notifications.
- Third-party ingest events (Plex/Sonarr/Radarr) as notification sources.
## Cross-cutting decisions
These hold across all channels and were resolved during spec review:
- **Episode-only triggers in v1.** Confirmed: there's no UX path for a user to favorite/watchlist a movie before it exists in their library.
- **Per-profile webhooks include content by default.** The profile chose the destination; trust is implicit. Discord-native embeds show series and episode details; generic webhooks include the same.
- **Mobile push payloads stay opaque by default.** APNs and FCM relay paths never see notification content. The app fetches metadata from the user's own server after wake. No badge counts in v1 (deferred to user-opt-in in v2).
- **Preference shape is flat:** per-profile reason flags (favorites / watchlist / continue-watching / next-up) plus a master enable toggle on each push device and each webhook. Not a per-channel × per-reason matrix.
- **Per-platform APNs topics.** iOS, tvOS, and macOS each get their own bundle topic. Single-build collapse is a v2 concern.
- **Stateless relay v1.** No stored token aliases. Add only if abuse/rate-limit pressure justifies it.
- **Relay services live in their own repos.** This folder defines the contracts the user-server side implements; the actual relay deployment (APNs / FCM) is a separate operational repo.
- **Back-catalog never floods.** Initial library scans and the feature-enable backfill seed availability silently; per-series burst caps bound fanout for bulk additions (see `01`).
- **One delivery per episode per profile, across libraries.** Dual-quality library setups (e.g., "TV" + "TV 4K") do not double-notify.
- **Dispatch enqueue is durable (outbox).** Push and webhook sends survive a crash between delivery commit and dispatch; the websocket channel stays best-effort because the inbox snapshot covers reconnect.
## Phasing
Implementation should land in this order, gated by feature flags so partial deploys are safe:
1. Schema (Phase 1 of `01-release-events-and-inbox.md`).
2. `profile_series_interest` updaters and backfill task (no fanout yet).
3. Availability seeding backfill, then release event creation (still no fanout). Seeding **must** complete before release events are enabled, or the first scan after enablement emits an event per back-catalog episode.
4. In-app fanout worker, websocket channel, inbox APIs.
5. Frontend inbox + badge + preference UI.
6. Outbound webhooks (no new infra; can ship before mobile push).
7. APNs relay + custom APNs.
8. FCM relay + custom FCM.
The relay services (steps 7-8) require Silo to provision Apple Developer + Firebase developer accounts and host the relay services. Steps 1-6 require no new external infrastructure.
@@ -0,0 +1,281 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Silo notifications — design decisions</title>
<style>
:root {
color-scheme: light dark;
--background: #f4f4f6;
--card: #ffffff;
--border: #d8d8de;
--fg: #1a1a1e;
--fg-2: #3a3a42;
--muted: #63636b;
--chip: #e8e8ec;
--green: #22c55e;
--purple: #a855f7;
--amber: #f59e0b;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #141417;
--card: #1c1c20;
--border: #28282e;
--fg: #e8e8ec;
--fg-2: #d0d0d6;
--muted: #9696a0;
--chip: #232328;
--green: #81c995;
--purple: #c78dbd;
--amber: #e8a87c;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--background);
color: var(--fg);
font-family: "Outfit", ui-sans-serif, system-ui, "Helvetica Neue", sans-serif;
font-size: 14px;
line-height: 1.6;
}
main { max-width: 880px; margin: 0 auto; padding: 52px 28px 48px; }
h1 { font-size: 24px; font-weight: 600; margin: 0 0 6px; letter-spacing: -0.01em; }
.sub { color: var(--muted); font-size: 14px; margin: 0 0 8px; }
.sub code { font-size: 12.5px; }
h2 {
font-size: 17px; font-weight: 600; margin: 44px 0 14px;
padding-bottom: 8px; border-bottom: 1px solid var(--border);
}
p { color: var(--fg-2); margin: 0 0 12px; max-width: 72ch; }
code {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 12.5px; background: var(--chip); border-radius: 4px; padding: 1px 5px;
}
.legend { display: flex; flex-wrap: wrap; gap: 8px 22px; margin: 10px 0 0; font-size: 13px; color: var(--muted); }
.legend span { display: inline-flex; align-items: center; gap: 7px; }
.sw { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
table { width: 100%; border-collapse: collapse; margin: 4px 0 0; }
th { text-align: left; font-size: 13px; font-weight: 600; color: var(--fg); padding: 8px 14px 8px 0; border-bottom: 1px solid var(--border); }
td { font-size: 13.5px; color: var(--fg-2); padding: 9px 14px 9px 0; border-bottom: 1px solid var(--border); vertical-align: top; }
td:first-child, th:first-child { white-space: nowrap; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 14px; }
.card {
background: var(--card); border: 1px solid var(--border);
border-radius: 12px; padding: 16px 18px;
}
.card h3 { font-size: 14.5px; font-weight: 600; margin: 0 0 10px; }
.kv { display: grid; grid-template-columns: 54px 1fr; row-gap: 7px; column-gap: 10px; }
.kv dt { font-size: 13px; font-weight: 500; color: var(--muted); margin: 0; }
.kv dd { font-size: 13.5px; color: var(--fg-2); margin: 0; line-height: 1.55; }
.svgwrap { border: 1px solid var(--border); border-radius: 12px; background: var(--card); padding: 18px 14px 10px; }
svg text { font-family: "Outfit", ui-sans-serif, system-ui, sans-serif; }
.bt { font-size: 13px; font-weight: 600; fill: var(--fg); }
.bm { font-size: 12.5px; font-weight: 600; fill: var(--fg); font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
.bs { font-size: 11.5px; fill: var(--muted); }
.bx { fill: var(--card); stroke: var(--border); }
ul.docs { padding-left: 20px; margin: 0; color: var(--fg-2); }
ul.docs li { margin: 4px 0; }
footer { margin-top: 40px; font-size: 13px; color: var(--muted); }
</style>
</head>
<body>
<main>
<h1>Silo notifications — design decisions</h1>
<p class="sub">Episode-availability notifications for a self-hosted, privacy-focused media server, designed to hold up with hundreds of users on one install.</p>
<p class="sub">Companion to the specs in <code>docs/superpowers/plans/notifications/</code> · drafts amended 2026-06-11</p>
<h2>How a notification flows</h2>
<p>Ingest records availability, a worker fans out to interested profiles, and a durable inbox row is written before any channel fires. Every channel below the row is a transport, never a source of truth.</p>
<div class="svgwrap">
<svg width="100%" viewBox="0 0 760 452" role="img" aria-label="Pipeline from library scan to release events to the fanout worker, into a durable notification_deliveries row, which dispatches to websocket, Apple push, Android push, and webhook channels after commit. The push channels continue through the opt-in Silo push relay, which sees only a device token and opaque IDs, then through APNs or FCM, and the woken device fetches notification details from the user's own server. A dashed path shows custom credentials skipping the relay.">
<defs>
<marker id="a" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M2 1L8 5L2 9" fill="none" stroke="var(--muted)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</marker>
</defs>
<rect class="bx" x="20" y="24" width="190" height="52" rx="8"/>
<text class="bt" x="115" y="45" text-anchor="middle">Scan and ingest</text>
<text class="bs" x="115" y="63" text-anchor="middle">new episode lands</text>
<rect class="bx" x="280" y="24" width="190" height="52" rx="8"/>
<text class="bm" x="375" y="45" text-anchor="middle">release_events</text>
<text class="bs" x="375" y="63" text-anchor="middle">one row per episode</text>
<rect class="bx" x="540" y="24" width="190" height="52" rx="8"/>
<text class="bt" x="635" y="45" text-anchor="middle">Fanout worker</text>
<text class="bs" x="635" y="63" text-anchor="middle">burst caps + eligibility</text>
<line x1="214" y1="50" x2="274" y2="50" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
<line x1="474" y1="50" x2="534" y2="50" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
<path d="M635 76 L635 98 L380 98 L380 114" fill="none" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
<rect class="bx" x="250" y="120" width="260" height="52" rx="8"/>
<text class="bm" x="380" y="141" text-anchor="middle">notification_deliveries</text>
<text class="bs" x="380" y="159" text-anchor="middle">durable row, dispatched after commit</text>
<line x1="380" y1="174" x2="97" y2="206" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
<line x1="380" y1="174" x2="287" y2="206" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
<line x1="380" y1="174" x2="473" y2="206" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
<line x1="380" y1="174" x2="663" y2="206" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
<rect class="bx" x="10" y="212" width="170" height="52" rx="8" stroke="var(--green)"/>
<text class="bt" x="95" y="233" text-anchor="middle">Websocket</text>
<text class="bs" x="95" y="251" text-anchor="middle">full content</text>
<rect class="bx" x="200" y="212" width="170" height="52" rx="8" stroke="var(--purple)"/>
<text class="bt" x="285" y="233" text-anchor="middle">Apple push</text>
<text class="bs" x="285" y="251" text-anchor="middle">opaque wake</text>
<rect class="bx" x="390" y="212" width="170" height="52" rx="8" stroke="var(--purple)"/>
<text class="bt" x="475" y="233" text-anchor="middle">Android push</text>
<text class="bs" x="475" y="251" text-anchor="middle">opaque wake</text>
<rect class="bx" x="580" y="212" width="170" height="52" rx="8" stroke="var(--amber)"/>
<text class="bt" x="665" y="233" text-anchor="middle">Webhooks</text>
<text class="bs" x="665" y="251" text-anchor="middle">your chosen URL</text>
<line x1="285" y1="268" x2="345" y2="292" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
<line x1="475" y1="268" x2="415" y2="292" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
<rect class="bx" x="290" y="296" width="180" height="52" rx="8" stroke="var(--purple)"/>
<text class="bt" x="380" y="317" text-anchor="middle">Silo push relay</text>
<text class="bs" x="380" y="335" text-anchor="middle">sees token + opaque IDs</text>
<line x1="380" y1="352" x2="380" y2="380" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
<path d="M230 268 L230 410 L284 410" fill="none" stroke="var(--muted)" stroke-width="1" stroke-dasharray="4 4" marker-end="url(#a)"/>
<text class="bs" x="222" y="352" text-anchor="end">custom credentials</text>
<text class="bs" x="222" y="368" text-anchor="end">skip the relay</text>
<rect class="bx" x="290" y="384" width="180" height="52" rx="8" stroke="var(--purple)"/>
<text class="bt" x="380" y="405" text-anchor="middle">APNs / FCM</text>
<text class="bs" x="380" y="423" text-anchor="middle">deliver the generic wake</text>
<line x1="474" y1="410" x2="536" y2="410" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
<rect class="bx" x="540" y="384" width="190" height="52" rx="8" stroke="var(--green)"/>
<text class="bt" x="635" y="405" text-anchor="middle">Device wakes</text>
<text class="bs" x="635" y="423" text-anchor="middle">fetches details from home</text>
</svg>
<div class="legend" style="padding: 8px 6px 8px;">
<span><i class="sw" style="background: var(--green);"></i>content stays in Silo</span>
<span><i class="sw" style="background: var(--purple);"></i>opaque wake only, device fetches from home</span>
<span><i class="sw" style="background: var(--amber);"></i>full content to a destination the profile chose</span>
</div>
</div>
<h2>Trust model</h2>
<table>
<thead><tr><th>Channel</th><th>Who sees content</th><th>Why that's acceptable</th></tr></thead>
<tbody>
<tr><td><i class="sw" style="background: var(--green); margin-right: 8px;"></i>Inbox + websocket</td><td>Only the user's own server and signed-in clients</td><td>Same auth boundary as the rest of Silo</td></tr>
<tr><td><i class="sw" style="background: var(--purple); margin-right: 8px;"></i>Apple / Android push</td><td>Nobody — relay and Apple/Google see a device token, timestamps, and opaque IDs</td><td>The device fetches real content from the user's server after waking</td></tr>
<tr><td><i class="sw" style="background: var(--amber); margin-right: 8px;"></i>Webhooks</td><td>The destination operator (e.g., Discord)</td><td>The profile pasted that URL; consent is explicit</td></tr>
</tbody>
</table>
<h2>Foundation</h2>
<div class="grid">
<div class="card">
<h3>Durable inbox is the source of truth</h3>
<dl class="kv">
<dt>Who</dt><dd>Every profile on the server</dd>
<dt>What</dt><dd>One delivery row per event; every channel is just a transport on top</dd>
<dt>Where</dt><dd>The server's own PostgreSQL</dd>
<dt>Why</dt><dd>Transports fail — the row survives, so reconnect or refresh always recovers</dd>
</dl>
</div>
<div class="card">
<h3>Preferences are flat, not a matrix</h3>
<dl class="kv">
<dt>Who</dt><dd>Profiles tuning what they hear about</dd>
<dt>What</dt><dd>Four reason toggles plus a master switch; per-device and per-webhook filters can only narrow further</dd>
<dt>Where</dt><dd>Hard gate at fanout, extra filters at dispatch</dd>
<dt>Why</dt><dd>A per-channel by per-reason grid is unexplainable in UI and impossible to debug</dd>
</dl>
</div>
</div>
<h2>Privacy boundaries</h2>
<div class="grid">
<div class="card">
<h3>Opaque push through a hosted relay</h3>
<dl class="kv">
<dt>Who</dt><dd>Admins who opt in; official store app builds</dd>
<dt>What</dt><dd>The relay holds the Apple and Google signing credentials and forwards a device token plus opaque IDs only</dd>
<dt>Where</dt><dd>Silo-operated service in its own repo, stateless on the request path</dd>
<dt>Why</dt><dd>Only the publisher's credentials can wake store builds; content never leaves the server — the device fetches details from home after waking</dd>
</dl>
</div>
<div class="card">
<h3>Custom credentials escape hatch</h3>
<dl class="kv">
<dt>Who</dt><dd>Power users and forks shipping their own signed app builds</dd>
<dt>What</dt><dd><code>custom_apns</code> / <code>custom_fcm</code> send straight to Apple or Google with the exact same minimal payload</dd>
<dt>Where</dt><dd>Entirely on the user's server</dd>
<dt>Why</dt><dd>Zero Silo infrastructure in the path, without loosening the privacy contract</dd>
</dl>
</div>
<div class="card">
<h3>Profiles, not accounts, are the unit</h3>
<dl class="kv">
<dt>Who</dt><dd>Households sharing one login across several profiles</dd>
<dt>What</dt><dd>Preferences, devices, webhooks, inbox rows, and websocket bindings are all profile-keyed</dd>
<dt>Where</dt><dd>Every table, API route, and event envelope</dd>
<dt>Why</dt><dd>A kid's profile and a parent's profile must not see each other's activity</dd>
</dl>
</div>
<div class="card">
<h3>Webhooks carry full content, by choice</h3>
<dl class="kv">
<dt>Who</dt><dd>Each profile, up to 10 destinations</dd>
<dt>What</dt><dd>Discord embeds or HMAC-signed JSON with series and episode details</dd>
<dt>Where</dt><dd>Direct HTTPS from the server to the URL the profile pasted</dd>
<dt>Why</dt><dd>Choosing the destination is the consent; HTTPS-only, the SSRF deny list, and no-server-origin rules stay mandatory</dd>
</dl>
</div>
</div>
<h2>Scale and reliability</h2>
<div class="grid">
<div class="card">
<h3>Fanout reads a compact interest index</h3>
<dl class="kv">
<dt>Who</dt><dd>Servers with hundreds of users, roughly 1,000 profiles</dd>
<dt>What</dt><dd><code>profile_series_interest</code> keyed by library and series, updated on favorite, watchlist, and watch transitions</dd>
<dt>Where</dt><dd>Written at interaction time, read once per release event</dd>
<dt>Why</dt><dd>Cost scales with profiles interested in one series, not with a million-episode catalog</dd>
</dl>
</div>
<div class="card">
<h3>Back-catalog imports never flood</h3>
<dl class="kv">
<dt>Who</dt><dd>Anyone adding a new library or a full season pack</dd>
<dt>What</dt><dd>First scans seed availability silently; bulk additions cap at 3 notifications per series per scan</dd>
<dt>Where</dt><dd>Scanner seeding plus the fanout worker burst cap</dd>
<dt>Why</dt><dd>One 200-episode import with 300 interested profiles would otherwise mean about 60,000 pushes in a single scan</dd>
</dl>
</div>
<div class="card">
<h3>Dispatch enqueue is durable (outbox)</h3>
<dl class="kv">
<dt>Who</dt><dd>Operators who care what a crash costs</dd>
<dt>What</dt><dd>Pending push and webhook attempt rows commit in the same transaction as the inbox row</dd>
<dt>Where</dt><dd>The fanout transaction; recovery workers sweep stale rows</dd>
<dt>Why</dt><dd>A crash between commit and dispatch delays sends instead of silently losing them</dd>
</dl>
</div>
<div class="card">
<h3>One delivery per episode, across libraries</h3>
<dl class="kv">
<dt>Who</dt><dd>Dual-quality setups like TV plus TV 4K</dd>
<dt>What</dt><dd>A partial unique index on profile and episode spans libraries; the first event processed wins</dd>
<dt>Where</dt><dd>The <code>notification_deliveries</code> schema</dd>
<dt>Why</dt><dd>Media items are catalog-level in Silo, so the same episode landing twice must not notify twice</dd>
</dl>
</div>
</div>
<h2>Source documents</h2>
<ul class="docs">
<li><code>00-architecture-overview.md</code> — channel model, fanout pipeline, threat model, relay addressing</li>
<li><code>01-release-events-and-inbox.md</code> — durable inbox, fanout worker, seeding and burst suppression, sync API</li>
<li><code>02-apns-relay.md</code> — Apple push: hosted relay and custom APNs</li>
<li><code>03-fcm-relay.md</code> — Android push: hosted relay and custom FCM</li>
<li><code>04-outbound-webhooks.md</code> — Discord and generic JSON+HMAC webhooks</li>
<li><code>README.md</code> — reading order, status, and the 2026-06-11 amendment index</li>
</ul>
<footer>Generated 2026-06-11 from the amended notification specs. Commands and paths assume the repository root is the cwd.</footer>
</main>
</body>
</html>
@@ -0,0 +1,967 @@
# Silo Push Relay — Engineering Spec
**Date:** 2026-06-13
**Status:** Draft (implementation-grade)
**Service:** `silo-push-relay` (provisional repo name; the relay code lives in a **separate repository**)
**Provisional hostname:** `relay.silo.app`
**Stack:** Go 1.25+, PostgreSQL, Redis
**Provenance / source contracts (read these first):**
- [`../00-architecture-overview.md`](../00-architecture-overview.md) — cross-cutting design; "Mobile push: why a relay is necessary", addressing-flow walkthrough, mode terminology, cross-channel threat model.
- [`../02-apns-relay.md`](../02-apns-relay.md) — **the external APNs relay contract** (`POST /v1/apple/send`): request/response/validation, relay-built payloads, headers, error handling. The self-hosted Silo server is already designed against this; the relay is its counterpart and must honor it exactly.
- [`../03-fcm-relay.md`](../03-fcm-relay.md) — **the external FCM relay contract** (`POST /v1/fcm/send`): request/response/validation, relay-built data-only payloads, Android config, error handling.
- [`./02-apns-fcm-2026-reference.md`](./02-apns-fcm-2026-reference.md) — the 2026-current Apple/Google/Go reference this service is built on. Where the older contract docs (authored 2026-04) glossed an upstream-provider mechanic, **this reference wins for the upstream side**; the **external relay contract from 02/03 is never changed** — reconciliations are called out explicitly in §5.2 (FCM upstream field casing), §6.2 (sandbox host), §6.4 (APNs error mapping), and §7.5.
> **Commands assume the repository root is the cwd.** No local absolute filesystem paths or transient worktree IDs appear in this document; all repo references are repository-relative.
---
## 1. Title, Provenance, Status, Scope
### 1.1 What this service is
`silo-push-relay` is a small, **stateless-on-the-request-path** HTTP service that:
1. Holds the official Silo Apple (`.p8` APNs auth key) and Google (Firebase service-account JSON) push credentials.
2. Accepts authenticated, **opaque, content-free** push requests from opted-in self-hosted Silo servers on two endpoints: `POST /v1/apple/send` and `POST /v1/fcm/send`.
3. Builds a fixed, generic APNs / FCM payload from those opaque fields and forwards it to APNs / FCM on the caller's behalf.
4. Maps the upstream provider response back to a narrow caller-facing result.
It is the **server counterpart** to the relay-client integration in `02-apns-relay.md` and `03-fcm-relay.md`. Those two documents define the wire contract from the self-hosted Silo server's perspective; this document specifies the service that answers it. **The external request/response contract in 02/03 is authoritative and is reproduced here byte-for-byte; this spec adds the server-internal design (upstream clients, storage, auth, rate limiting, idempotency, ops) that 02/03 deliberately left to "a separate repo".**
### 1.2 What this service is NOT
- It is **not** a Silo media server, and it shares no database, auth system, or deployment with `silo-server`.
- It is **not** a general-purpose push gateway: it accepts only the narrow JSON shape in §5; it never accepts a free-form APNs/FCM payload, title, body, or media identifier.
- It is **not** an addressing service. Apple and Google do the addressing from the device token. The relay never resolves a token to a user, profile, device-to-user mapping, or server.
- It does **not** store notification content, user/profile identity, server URLs, or token→user aliases (v1 is stateless on the request path; see `02-apns-relay.md` "Relay statefulness: stateless v1").
- It is **not** in the `custom_apns` / `custom_fcm` path. When a self-hosted admin configures their own credentials, the Silo server talks to APNs/FCM directly and never touches this relay (see `00-architecture-overview.md` "Custom credentials").
### 1.3 Relationship to the contract docs and the separate repo
| Concern | Owner | Document |
|---|---|---|
| External relay request/response/validation/payloads | `02`/`03` (authoritative) | `../02-apns-relay.md`, `../03-fcm-relay.md` |
| Self-hosted server's relay-client, dispatcher, pacing, retry | `silo-server` | `../02`, `../03`, `../00` |
| Relay service internals (this doc): upstream clients, DB, auth, rate limit, idempotency, deploy | `silo-push-relay` (separate repo) | **this file** |
| 2026-current upstream provider mechanics (JWT, OAuth2, endpoints, error tables) | reference | `./02-apns-fcm-2026-reference.md` |
These design docs are authored **here in `silo-server`** alongside the `02`/`03` contracts purely for co-location. The relay **code** lives elsewhere (`silo-push-relay`).
---
## 2. Goals and Non-Goals
### 2.1 Goals
- **G1 — Faithful contract.** Implement `POST /v1/apple/send` and `POST /v1/fcm/send` exactly as `02`/`03` specify: same request fields, same validation rules, same response shape, same relay-built payloads, same headers.
- **G2 — Content-free by construction.** The request schema admits **only** the opaque fields in §5. Any unexpected/free-form field is rejected with `400`. The relay cannot forward content even if a caller tries.
- **G3 — Credential custody.** Hold the official `.p8` and SA JSON in a secret manager, never on disk in the repo/image, never in env vars where avoidable (§11).
- **G4 — Stateless request path.** No per-request DB writes other than throttled `last_used_at` and redacted op-logs. Idempotency + rate-limit state live in Redis. The relay can be horizontally scaled and a single instance can be killed mid-flight without data loss (the server-side outbox in `../01-release-events-and-inbox.md` guarantees re-delivery).
- **G5 — Correct upstream mechanics (2026).** One cached ES256 JWT per APNs team (regenerated ~50 min, never < 20 min apart); one cached OAuth2 access token per FCM project (~1 h, refresh with margin); HTTP/2 connection reuse with PING health checks; correct reason→error mapping. Per `./02-apns-fcm-2026-reference.md`.
- **G6 — Multi-tenant isolation.** Every API key, rate-limit bucket, idempotency key, and log line is scoped by `account_id`. Per-account allowlists bound which APNs topics / FCM (project, package) an account may push to.
- **G7 — Minimal attack surface for admin ops.** No public admin HTTP API in v1. Account/key/allowlist administration is a CLI (`relayctl`) writing directly to the DB (§5.6).
- **G8 — Operable.** Structured redacted logs, Prometheus metrics, `/healthz` + `/readyz`, graceful shutdown.
### 2.2 Non-goals
- **NG1** — Storing token→user aliases, device subscriptions, or any user/profile identity (deferred; `02` "stateless v1").
- **NG2** — Accepting arbitrary APNs/FCM JSON, custom titles/bodies, badge by default, or topic broadcasts (FCM `topic`/`condition` are never used — per-token only, per `03`).
- **NG3** — Delivery receipts, analytics, marketing exports, or open-tracking.
- **NG4** — APNs broadcast / Live Activity channels (`/4/broadcasts/...`) — out of scope; see `./02-apns-fcm-2026-reference.md` §1.10. v1 uses only `/3/device/<token>`.
- **NG5** — Web Push, HMS, ADM, or any non-APNs/non-FCM transport.
- **NG6** — A public self-service signup / billing surface. Accounts are provisioned by Silo operators via `relayctl`.
- **NG7** — The `custom_apns` / `custom_fcm` direct paths (those run inside `silo-server`, not here).
---
## 3. Trust and Threat Model
### 3.1 What the relay sees (restated from `00`/`02`/`03`)
The relay, even fully compromised, sees only:
- **Which self-hosted installation** is calling — the relay API key (`rk_…`) maps 1:1 to a relay account.
- **The server's egress IP** — inherent to any hosted relay; the server connects directly. For home hosting this identifies the household connection. **This is the closest thing to an identity leak in the design** and is listed so the privacy claim stays exhaustive, not over-strong (`00` addressing-flow note).
- **A device token** — APNs device token (~100 bytes hex) or FCM registration token (~152–180+ chars).
- **Request timing.**
- **Coarse wire mode** — `private_alert`/`background_wake` (APNs) or `private_data`/`background_wake` (FCM).
- **Opaque `collapse_id` / `collapse_key`** — per-server-keyed HMACs (see §3.3).
- **Opaque correlation IDs** — `server_device_id`, `delivery_id`.
- **Upstream response status** — APNs `apns-id` / FCM message name, status code, reason string.
- **`apns-topic` / FCM (`project_id`, `package_name`)** — platform/build identifiers Apple/Google already see (`02` privacy note).
### 3.2 What the relay must NEVER see or store
Per `00`/`02`/`03`: no notification title or body, no media identifiers, no profile/user/username/library/collection/item names, no server hostname or base URL, no artwork URLs, no watched-state metadata, no device→user mapping, and **no token aliases** (stateless v1). The request schema (§5) has no field that could carry any of these, and an unknown field is a hard `400` (§12).
### 3.3 Collapse-ID equivalence classes (opaque to the relay)
The Silo server computes `collapse_id = base32(HMAC-SHA256(server_collapse_secret, series_id))` truncated to 26 chars (APNs) / equivalent for FCM `collapse_key`, where `server_collapse_secret` is a random per-server secret (`02`/`03`). The relay treats this as **opaque** and never derives it. Residual leakage: the relay can group **one server's** pushes into per-series equivalence classes (that is what collapse is *for*), but the per-server key prevents recovering the series identity or correlating the same series across two different servers.
### 3.4 Residual leakage (exhaustive)
| Leak | To whom | Why it's irreducible | Mitigation |
|---|---|---|---|
| Egress IP | Relay operator | Server connects directly over HTTPS | Admin may route relay traffic through their own VPN/proxy egress (`02` residual-leakage note) |
| Push timing | Relay operator + Apple/Google | A push is an observable network event | None — only meaning/content is hidden |
| Device token | Relay operator + Apple/Google | Required to address the device | Tokens never logged raw; truncated/hashed (§11/§12) |
| Collapse equivalence class | Relay operator | Collapse must be a stable per-series key | Per-server HMAC key prevents series recovery & cross-server correlation (§3.3) |
| App topic / Firebase project + package | Relay operator + Apple/Google | Required by the upstream APIs | Platform/build identifiers Apple/Google already hold |
The relay **cannot** learn user identity, notification content, server URL, or library data even when fully compromised (`00` threat-model summary). It **cannot fabricate meaningful pushes** — only generic opaque wake payloads.
### 3.5 Adversaries
| Adversary | Capability | Worst case | Control |
|---|---|---|---|
| Hostile/compromised relay operator | Full DB + request stream | Learns device tokens, timing, egress IPs of opted-in servers; can send generic wakes | No content/identity; per-server collapse keying; opt-in & disabled by default |
| Stolen relay API key | Can send opaque pushes as that account | Generic wakes to tokens the thief already holds; bounded by per-account allowlist + rate limits | Revoke key (`relayctl key revoke`); rotate; per-account quota caps spend (OWASP API4:2023) |
| Cross-tenant attacker | Tries to push to another account's topic/token | Rejected: topic/project/package not on attacker's allowlist | Per-account allowlist enforcement (§8.4); namespaced Redis keys |
| Apple / Google | Platform operator | Sees topic, token, generic payload, timing | No identity/content (data-only FCM; loc-key-only APNs alert) |
---
## 4. Architecture
### 4.1 Request path (stateless)
```
1. TLS terminate (LB or in-process) → HTTP/2 inbound.
2. Auth middleware: parse "Authorization: Bearer rk_<prefix>_<secret>";
SELECT row by indexed prefix; constant-time-compare hash; reject revoked/expired.
3. Rate-limit middleware: per-account Redis token bucket (Lua: refill→check→consume).
Over limit → 429 + Retry-After.
4. Body decode with DisallowUnknownFields (any unexpected field → 400).
5. Validate fields (§5) incl. allowlist check (topic / project+package).
6. Idempotency: Idempotency-Key header → Redis SET NX lock; replay / 409 / 422.
7. Build the fixed relay payload (§5) + headers; forward to upstream APNs/FCM
client (cached JWT / OAuth2 token, reused HTTP/2 conn).
8. Map upstream response → caller-facing JSON (§5).
9. Store the {status, body} under the idempotency key (TTL ~24h);
throttled last_used_at update; emit redacted op-log + metrics.
```
The only synchronous DB touch on the hot path is the **API-key lookup** (indexed, O(1) by prefix) and, at most once per minute per key, a `last_used_at` write. Idempotency and rate-limit state are in Redis. No notification content, no token alias, ever persisted.
### 4.2 Component diagram
```mermaid
flowchart TB
subgraph Server["Self-hosted Silo server (caller)"]
DISP[Push dispatcher + client-side pacing\n5 req/s token bucket]
end
DISP -->|"POST /v1/apple/send Bearer rk_… Idempotency-Key"| LB[(TLS / Load balancer)]
DISP -->|"POST /v1/fcm/send"| LB
subgraph Relay["silo-push-relay (stateless replicas)"]
LB --> API[HTTP server\nnet/http, HTTP/2, timeouts]
API --> AUTH[Auth middleware\nprefix lookup + ConstantTimeCompare]
AUTH --> RL[Rate-limit middleware\nRedis Lua token bucket]
RL --> IDEM[Idempotency\nRedis SET NX / replay / 409 / 422]
IDEM --> VAL[Validate + allowlist\nDisallowUnknownFields]
VAL --> AC[APNs client\ncached ES256 JWT ~50m\nper-team HTTP/2 pool]
VAL --> FC[FCM client\ncached OAuth2 token ~1h\nHTTP/2]
API --> OPS[/healthz /readyz /metrics/]
AUTH -. redacted .-> LOG[slog JSON]
end
AC -->|"POST /3/device/<token>"| APNS[(api.push.apple.com\n/ api.sandbox.push.apple.com)]
FC -->|"POST /v1/projects/{id}/messages:send"| FCM[(fcm.googleapis.com)]
AUTH --> PG[(PostgreSQL\naccounts / api_keys /\nallowlists / op_logs)]
RL --> REDIS[(Redis\nidempotency + rate buckets)]
IDEM --> REDIS
CLI[relayctl CLI\naccount/key/allowlist admin] --> PG
SM[(Secret manager)] -.->|.p8 / SA JSON / pepper| AC
SM -.-> FC
```
### 4.3 Data stores
| Store | Holds | Why | Notes |
|---|---|---|---|
| **PostgreSQL** | `relay_accounts`, `relay_api_keys`, `relay_apns_allowlist`, `relay_fcm_allowlist`, `relay_op_logs` | Durable, low-write config + redacted audit | `jackc/pgx/v5` + `pgxpool` (`./02-apns-fcm-2026-reference.md` §3.4). Never stores tokens, content, or identity. |
| **Redis** | `idem:{account}:{key}` (idempotency lock/result), `rl:{account}` (token-bucket state) | Fast, TTL'd, shared across replicas | `redis/go-redis/v9`. Atomic Lua for rate-limit; `SET NX` for idempotency. Idle keys TTL out. |
| **Secret manager** | APNs `.p8`, FCM SA JSON, API-key HMAC pepper, DB/Redis creds | Credential custody | KMS/Vault/secret-manager preferred over mounted file over env var (OWASP, `./02-apns-fcm-2026-reference.md` §4.5/§5). |
### 4.4 Credential holding
- **APNs:** one (or a few, scoped) `.p8` ES256 keys + Team ID + Key ID per official team. Prefer team-scoped + topic-specific keys (Feb-2025 hardening) for least privilege (`./02-apns-fcm-2026-reference.md` §1.2). A connection pool is **per APNs team** (a connection cannot serve multiple teams — §1.1 of the reference).
- **FCM:** one service-account JSON per official Firebase project (`continuum-prod-android`). On GCP, prefer keyless Workload Identity / attached SA; off-GCP, the JSON lives in the secret manager (`./02-apns-fcm-2026-reference.md` §2.2/§4.5).
- Credentials are loaded **once at startup** from the secret manager into memory; never written to disk, never logged, never returned by any endpoint. Reload requires a process restart (v1) or SIGHUP-triggered re-read (optional).
---
## 5. Public API Contract
All endpoints are HTTPS-only over HTTP/2. Authentication is `Authorization: Bearer rk_<key>` (§8.2). `Content-Type: application/json` for send endpoints. Bodies are decoded with `DisallowUnknownFields` — **any unrecognized field is a `400`** (§12). Unless stated otherwise, error bodies use the shape in §5.5.
### 5.1 `POST /v1/apple/send`
Authoritative source: `02-apns-relay.md` "Send Apple Push". Reproduced exactly.
**Request**
```http
POST /v1/apple/send HTTP/2
Host: relay.silo.app
Authorization: Bearer rk_live_3Qw9...secret...
Idempotency-Key: 01JDELIVERY...:01JDEVICE...:1
Content-Type: application/json
```
```json
{
"token": "apns-token",
"environment": "production",
"topic": "com.continuum.app.ios",
"mode": "private_alert",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY...",
"badge": null,
"collapse_id": "01JOPAQUE_COLLAPSE"
}
```
**Field table**
| Field | Type | Required | Rule |
|---|---|---|---|
| `token` | string | yes | Required; must be plausible for APNs — non-empty, hex-shaped (`^[0-9a-fA-F]+$`). Apple device tokens are commonly **64 hex chars (32 bytes)** but Apple does not guarantee a fixed length and has lengthened them historically, so the relay enforces a **generous bound (e.g. ≥ 64 and ≤ 256 hex chars)** rather than a tight guess that could reject valid future-length tokens. Reject non-hex / out-of-bound → `400 invalid_token`. |
| `environment` | string | yes | `production` or `sandbox`. Selects upstream host (§6.2). Any other value → `400 invalid_environment`. |
| `topic` | string | yes | Must be on the account's APNs topic allowlist (§8.4). Not allowlisted → `403 topic_not_allowed`. |
| `mode` | string | yes | `private_alert` or `background_wake`. Other → `400 invalid_mode`. |
| `server_device_id` | string | yes | Opaque; length-limited (≤128 chars, ULID-shaped recommended). Over limit → `400 invalid_field`. |
| `delivery_id` | string | yes | Opaque; length-limited (≤128). |
| `badge` | integer \| null | no | Default omitted. When present, must be a **bounded non-negative integer `0 ≤ badge ≤ 9999`** — out of range → `400 invalid_field`. This is the one numeric field copied verbatim into `aps.badge`, so it is bounded to keep it from becoming an unbounded caller-controlled channel visible to Apple. Badge is **disabled by default** to avoid leaking unread counts (`02` privacy); the relay forwards it only if provided. |
| `collapse_id` | string \| null | no | Opaque; **≤ 64 bytes** (Apple's `apns-collapse-id` cap). Over 64 bytes → `400 invalid_collapse_id`. |
No other fields are accepted: **no title, body, image URL, media ID, username, server hostname, or server URL** field exists (`02`). An unknown key → `400 unexpected_field`.
**`Idempotency-Key` header** (required for sends): format `<notification_delivery_id>:<push_device_id>:<attempt_number>` (`02`/`03`). See §10.
**Response — 200**
```json
{
"request_id": "01JRELAY...",
"apns_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "accepted"
}
```
- `request_id` — relay-generated ULID, also echoed as the `X-Request-Id` response header and used in logs.
- `apns_id` — Apple's `apns-id` response header (UUID).
- `status` — `accepted` on a 200 from APNs.
> **Sandbox `apns-unique-id` (additive observability).** On a **200 in the sandbox/DEVELOPMENT environment only**, APNs additionally returns an `apns-unique-id` response header used by the Push Notifications Console Delivery Log (reference §1.6). It is the single most useful field for diagnosing "accepted-but-not-delivered" on sandbox. The relay **captures `apns-unique-id` (sandbox only)** into the op-log and the `relayctl ping-upstream` output, and may echo it in the sandbox 200 body, to aid delivery-log correlation during client QA. It is **never** present or logged for production sends.
**Relay-built APNs payloads** (the relay constructs these; callers cannot override — `02`).
`private_alert`:
```json
{
"aps": {
"alert": {
"title-loc-key": "SILO_NOTIFICATION_TITLE",
"loc-key": "SILO_NOTIFICATION_GENERIC_BODY"
},
"sound": "default"
},
"silo": {
"v": 1,
"wake": "notifications.changed",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY..."
}
}
```
When `badge` is present and non-null, the relay adds `"badge": <n>` inside `aps`. Otherwise it is omitted entirely (no `badge` key).
`background_wake`:
```json
{
"aps": { "content-available": 1 },
"silo": {
"v": 1,
"wake": "notifications.changed",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY..."
}
}
```
**Exact APNs request headers the relay sets** (`02` Headers + reconciled with `./02-apns-fcm-2026-reference.md` §1.4):
| Upstream header | `private_alert` | `background_wake` | Source |
|---|---|---|---|
| `:method` | `POST` | `POST` | fixed |
| `:path` | `/3/device/<token>` | `/3/device/<token>` | `token` field |
| `authorization` | `bearer <cached ES256 JWT>` | same | relay JWT (§6.1) |
| `apns-topic` | the allowlisted `topic` | same | `topic` field |
| `apns-push-type` | `alert` | `background` | `02` |
| `apns-priority` | `10` | `5` | `02` (and reference §1.4.1: background **must** be 5; 10 is an error) |
| `apns-collapse-id` | `collapse_id` if present (≤64B) | same | `02` |
| `apns-id` | omitted (Apple auto-generates; echoed back) | same | reference §1.4 |
| `apns-expiration` | **finite default** (e.g. `now + 4 h`) | **short TTL** (e.g. `now + 1 h`) | reference §1.4. **Configurable**; ships a sane non-30-day default — see below. |
> **`apns-expiration` policy (v1 ships a finite default, not the 30-day store-and-retry).** With `apns-expiration` omitted, Apple stores and retries an undelivered push for up to **30 days** (reference §1.4) — a wake fired days later is useless or confusing for content that is fetched live from the user's server on wake (the underlying notification may already be read/deleted server-side). The relay therefore sets a **finite, configurable `apns-expiration`**: a few hours for `private_alert` (default ~4 h) and a **short** TTL for `background_wake` (default ~1 h), so a device offline for days does not get a stale wake. Both defaults are config values (per-deployment); the relay never ships the 30-day default. The FCM path sets the parallel `android.ttl` (§5.2) for parity. (Resolved from former open question §15 #5; values are tunable from product input.)
Payload is **uncompressed JSON**, ≤ 4 KB (reference §1.5). The relay-built payloads are far under 4 KB; the relay still rejects with `400 payload_too_large` defensively if a (future) field pushes it over, and maps an upstream `413` per §6.4.
**Error mapping back to the caller** — see §6.4.
### 5.2 `POST /v1/fcm/send`
Authoritative source: `03-fcm-relay.md` "Send Android Push". Reproduced exactly.
**Request**
```http
POST /v1/fcm/send HTTP/2
Host: relay.silo.app
Authorization: Bearer rk_live_3Qw9...secret...
Idempotency-Key: 01JDELIVERY...:01JDEVICE...:1
Content-Type: application/json
```
```json
{
"token": "fcm-registration-token",
"project_id": "continuum-prod-android",
"package_name": "com.continuum.app.android",
"mode": "private_data",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY...",
"badge": null,
"collapse_key": "01JOPAQUE_COLLAPSE"
}
```
**Field table**
| Field | Type | Required | Rule |
|---|---|---|---|
| `token` | string | yes | Plausible FCM token: non-empty, base64url-ish, **length 100 ≤ len ≤ 4096** (FCM tokens are typically 152–180+ chars — `03`; the upper bound guards against oversized-token abuse without rejecting legitimate longer tokens). Implausible → `400 invalid_token`. |
| `project_id` | string | yes | Must match the configured Firebase project for this account's allowlist (§8.4). Mismatch → `403 project_not_allowed`. |
| `package_name` | string | yes | Must be on the account's FCM package allowlist, paired with `project_id` (§8.4). Not allowlisted → `403 package_not_allowed`. |
| `mode` | string | yes | `private_data` or `background_wake` (the **wire** mode; profile-level is `private_push` — `00`/`03`). Other → `400 invalid_mode`. |
| `server_device_id` | string | yes | Opaque; ≤128 chars. |
| `delivery_id` | string | yes | Opaque; ≤128 chars. |
| `badge` | integer \| null | no | Accepted for caller-contract parity with the APNs endpoint, but **FCM has no data-only home for it** (the relay never builds a `notification` block). v1 **ignores `badge` on the FCM path** — it is neither rendered nor smuggled into the `data` map (§5.2 construction rules, §12). When present it must still pass the same bound as APNs (`0 ≤ badge ≤ 9999`); otherwise → `400 invalid_field`. |
| `collapse_key` | string \| null | no | Opaque; **≤ 64 bytes**. This is the **inbound caller field name** (snake_case, per `03`); the relay maps it to the FCM REST field `android.collapseKey` (camelCase — see §5.2 construction rules). Over 64 bytes → `400 invalid_collapse_key`. |
No free-form `notification`, title, body, image URL, media ID, username, or server URL field exists (`03`). Unknown key → `400 unexpected_field`.
**Response — 200**
```json
{
"request_id": "01JRELAY...",
"fcm_message_name": "projects/continuum-prod-android/messages/0:1234567890123456%abcdef",
"status": "accepted"
}
```
- `fcm_message_name` — FCM v1 response `name`.
**Relay-built FCM payloads — data-only, no `notification` block** (`03`).
`private_data`:
```json
{
"message": {
"token": "fcm-registration-token",
"data": {
"v": "1",
"wake": "notifications.changed",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY..."
},
"android": {
"priority": "HIGH",
"collapseKey": "01JOPAQUE_COLLAPSE"
}
}
}
```
`background_wake`:
```json
{
"message": {
"token": "fcm-registration-token",
"data": {
"v": "1",
"wake": "notifications.changed",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY..."
},
"android": {
"priority": "NORMAL",
"collapseKey": "01JOPAQUE_COLLAPSE"
}
}
}
```
**Construction rules (reconciled with `./02-apns-fcm-2026-reference.md` §2.3/§2.4):**
- `message.data` is `map<string,string>` — **all values are strings** (`"v": "1"`, not `1`). It is a **fixed, closed set of keys** — exactly `{v, wake, server_device_id, delivery_id}` — with **no caller-controlled key or value** beyond the already-opaque IDs (§12). Reserved keys (`from`, `message_type`, `notification`, `google.*`, `gcm.*`) are never used, and `badge` is never injected into `data`.
- No top-level `message.notification` — **data-only** so Google carries no rendering content (`03`).
- `android.priority`: `"HIGH"` for `private_data` (wakes under Doze), `"NORMAL"` for `background_wake`. The relay emits the **canonical uppercase enum** values documented for FCM REST `AndroidConfig.priority` (reference §2.4); FCM also accepts lowercase, but the canonical form is used to avoid relying on a parenthetical allowance. **Reconciliation:** the `03` contract reproduces lowercase `high`/`normal`; the wire field accepts both and `priority` is relay-set (not a caller field), so this is an internal-upstream normalization with no external-contract impact.
- `android.collapseKey` (REST **camelCase** — reference §2.4): set to the caller's `collapse_key` when present, omitted otherwise. **Maps to FCM `android.collapseKey`** — the relay POSTs raw JSON to the FCM HTTP v1 REST endpoint, which rejects unknown fields (including a snake_case `collapse_key` inside `android`) with `INVALID_ARGUMENT`. **Reconciliation (parallel to the sandbox-host one in §6.2):** the inbound caller field stays `collapse_key` per `03`/§5.2; only the relay-built upstream FCM field is `collapseKey`.
- `android.ttl` (a duration string, e.g. `"14400s"`): set to a **finite, configurable** TTL for parity with the APNs `apns-expiration` policy (FCM otherwise defaults to **4 weeks** of storage). Default ~4 h for `private_data`, a shorter TTL for `background_wake`, so a device offline for days does not get a stale wake. Configurable per deployment.
- `validateOnly` (top-level, **camelCase** — reference §2.5) is the dry-run flag for the `relayctl` test path only (§7.3, §7.5); never set on production sends.
- The relay never sets FCM `topic`/`condition`; only `message.token` (per-token only — `03`).
- Payload ≤ 4096 bytes for token sends (reference §2.3); the fixed payload is far under.
> **Reconciliation note (priority vs. reference §2.8).** The reference warns that high-priority data messages that never produce a user-facing notification can be deprioritized to normal by FCM over a ~7-day window. The `03` contract nonetheless specifies `priority: high` for `private_data` (the app *does* surface a notification after wake), and the relay **honors the `03` contract** — it does not second-guess priority. Deprioritization is an upstream behavior, not a contract change; the `03` doc already documents the quota fallback (caller may switch to normal after repeated 429s — §7.4).
**Exact FCM request** the relay sends:
```http
POST /v1/projects/continuum-prod-android/messages:send HTTP/2
Host: fcm.googleapis.com
Authorization: Bearer <cached OAuth2 access token>
Content-Type: application/json
```
**Error mapping** — see §7.4.
### 5.3 `GET /healthz`
Liveness. Returns `200 {"status":"ok"}` if the process is up. No dependency checks. Used by the orchestrator's liveness probe. Unauthenticated.
### 5.4 `GET /readyz` and `GET /metrics`
- **`GET /readyz`** — readiness. Readiness gates on **core shared dependencies only**: Postgres reachable (`SELECT 1` via pool) and Redis reachable (`PING`). Per-provider health (APNs signer initialized; FCM token source initialized) is reported **separately, not as a binary gate**, so that a **single-provider** credential/init failure does **not** pull the whole replica from the LB and kill the healthy provider too (APNs and FCM are independent upstreams; the "channel failures are isolated" principle from `00` carried into the relay's own health model). The body is `200 {"status":"ready","providers":{"apns":"healthy|degraded","fcm":"healthy|degraded"}}` when core deps are up, or `503 {"status":"not_ready","checks":{...}}` only when **PG or Redis** is down. When a provider is `degraded`, the **send handler for that provider** returns `503 upstream_unavailable` for that provider only (the other provider keeps serving); a per-provider gauge and circuit-breaker state make `APNs degraded, FCM healthy` visible to operators rather than a binary not-ready. See §13.2 (`relay_provider_healthy`).
- **`GET /metrics`** — Prometheus exposition (`promhttp`). Bound to the internal network / scrape-only. See §13.2 for the catalog.
### 5.5 Standard error body
All 4xx/5xx (except health endpoints) return:
```json
{
"error": {
"code": "topic_not_allowed",
"message": "topic is not on this account's allowlist",
"request_id": "01JRELAY..."
}
}
```
`code` is a stable machine string (see §6.4 / §7.4 tables). `request_id` matches `X-Request-Id`. `Retry-After` is set on `429`/`503` (§9, §10).
### 5.6 Admin surface — `relayctl` CLI (no public admin HTTP API)
To minimize attack surface (G7), there is **no HTTP admin API** in v1. Administration is a Go CLI, `relayctl`, that connects **directly to the relay's PostgreSQL** (via a privileged DSN held by operators only) and performs the operations below. It runs from an operator workstation or a jump host — never exposed to the public internet.
| Command | Action | DB effect |
|---|---|---|
| `relayctl account create --name <label> [--note <text>]` | Provision a relay account (one per opt-in installation). Prints the new `account_id`. | INSERT `relay_accounts`. |
| `relayctl account list [--active]` | List accounts with status, key count, last activity. | SELECT. |
| `relayctl account disable --account <id>` | Suspend an account (all keys rejected, but rows retained). | UPDATE `relay_accounts.status='disabled'`. |
| `relayctl key issue --account <id> [--env live\|test] [--expires <dur>]` | Generate a new API key. **Prints the full `rk_…` token exactly once** (only the hash is stored). | INSERT `relay_api_keys`. |
| `relayctl key list --account <id>` | List keys (prefix, created, last_used, revoked) — never the secret. | SELECT. |
| `relayctl key revoke --key-prefix <prefix>` | Revoke a key immediately. | UPDATE `relay_api_keys.revoked_at=now()`. |
| `relayctl allowlist apns set --account <id> --topics com.continuum.app.ios,.tvos,.macos` | Replace the account's APNs topic allowlist. | UPSERT `relay_apns_allowlist`. |
| `relayctl allowlist fcm set --account <id> --project continuum-prod-android --packages com.continuum.app.android` | Replace the account's FCM (project, packages) allowlist. | UPSERT `relay_fcm_allowlist`. |
| `relayctl allowlist show --account <id>` | Print effective allowlists. | SELECT. |
`relayctl` operations are themselves audited into `relay_op_logs` (event `admin.*`) with the operator identity (OS user / `--actor`) but never the secret token. The CLI shares the relay's data-access package so schema and constraints stay in one place.
---
## 6. Upstream APNs Client
Built per `./02-apns-fcm-2026-reference.md` §1 and §3.1. Library choice: `github.com/sideshow/apns2 v0.25.0` **or** a hand-rolled `net/http` + `golang.org/x/net/http2` + `golang-jwt/jwt/v5` client (reference §3.1; bus-factor caveat in §15). Either way, the mechanics below are required.
### 6.1 Auth — JWT ES256 lifecycle
- **Algorithm:** ES256 (ECDSA P-256) **only** — APNs accepts no other (reference §1.1).
- **Header:** `{"alg":"ES256","kid":"<10-char Key ID>"}`. **Claims:** `{"iss":"<10-char Team ID>","iat":<epoch seconds>}`.
- **Signing key:** the official `.p8` private key from the secret manager.
- **Caching:** generate **one** JWT, hold it in memory behind a mutex, reuse across all senders/connections/topics for that team (reference §1.1/§4.4).
- **Refresh cadence:** regenerate on a **~50-minute timer** (well inside Apple's 1-hour `iat` window), **never more often than once per 20 minutes**, and eagerly on a `403 ExpiredProviderToken` (then retry the request once). Over-frequent refresh → `429 TooManyProviderTokenUpdates`; back off and slow refresh (reference §1.1 lifecycle table).
- **Single-flight regeneration (stampede guard).** Under a burst of concurrent sends sharing one cached JWT, an expiry or a `403 ExpiredProviderToken` is observed by **many in-flight goroutines at once**. JWT regeneration **must be single-flighted** — e.g. `golang.org/x/sync/singleflight` keyed by team, or a generation counter behind the mutex: a goroutine seeing `ExpiredProviderToken` checks whether the JWT was **already refreshed since its request started** (compare its captured generation/`iat` to the current one) and, if so, **reuses the new token** instead of triggering another refresh. Only **one** refresh happens per ~20-min window regardless of how many concurrent 403s arrive. A naive per-goroutine regenerate-on-403 would trip `429 TooManyProviderTokenUpdates`; the 20-minute floor is honored **by this concurrency mechanism**, not just asserted. (Mirrors risk R3.)
- **Per-team pools.** A connection cannot map to multiple teams; open a separate JWT + HTTP/2 connection pool per APNs team (reference §1.1). v1 has one official team, so one pool — but the code keys pools by team to stay correct.
- **Key selection — `(environment, topic) → .p8 key`.** With Apple's Feb-2025 team-scoped/topic-specific keys (max 2 team-scoped keys per environment), more than one `.p8` may be loaded (e.g. a topic-specific key for `com.continuum.app.ios` plus a team key; or per-environment scoped keys). A **sandbox send must use a sandbox-scoped key and a production send a production-scoped key** — using the wrong one yields `403 BadEnvironmentKeyIdInToken` / `UnrelatedKeyIdInToken`. The relay therefore holds an explicit **key-selection map `(environment, topic) → key id`** in config, falling back to an unscoped team key only where no scoped key exists. **Startup validation:** every allowlisted `(topic, environment)` must resolve to a usable key, or startup fails. A `BadEnvironmentKeyIdInToken`/`UnrelatedKeyIdInToken` at runtime maps to `upstream_auth_error` (§6.4) and is treated as a **key-selection misconfiguration** (ops alert).
### 6.2 Endpoint selection
| `environment` field | Upstream host | Port |
|---|---|---|
| `production` | `https://api.push.apple.com` | 443 (2197 fallback) |
| `sandbox` | `https://api.sandbox.push.apple.com` | 443 (2197 fallback) |
Both hosts are **hardcoded constants** selected only by the `environment` enum — never derived from any other request field (§11 egress/SSRF posture).
> **Reconciliation (host name).** `02-apns-relay.md` validation text names the sandbox host `https://api.development.push.apple.com`. **Both `api.sandbox.push.apple.com` and `api.development.push.apple.com` are valid, functionally-equivalent Apple sandbox aliases — both resolve.** The relay **standardizes on `api.sandbox.push.apple.com`** (reference §1.3; it also matches `sideshow/apns2 v0.25.0`'s `HostDevelopment` constant, which points at `api.sandbox.push.apple.com`). This is an *internal upstream* detail and does **not** change the external contract: callers still send `"environment": "sandbox"` exactly as `02` specifies. (See reference open question §5 item 4 / this spec §15 — now a note that the two hosts are interchangeable, not a discrepancy to resolve at build time.)
### 6.3 Connection management
- HTTP/2 + TLS 1.2+ (1.3 acceptable). **Reuse one client / connection pool per process per environment per team**; never create a client per push (TLS setup is slow — reference §3.1). One well-tuned connection can do thousands of pushes/sec.
- Set a non-zero `ReadIdleTimeout` (~15 s) + `PingTimeout` (~15 s) on the `http2.Transport` to reap half-open connections (reference §3.3 — "the single most important tuning for a long-lived relay").
- Do **not** hardcode `SETTINGS_MAX_CONCURRENT_STREAMS`; read it from the server (reference §1.3/§3.3).
- Handle `GOAWAY`: drop the connection from the pool and reconnect; finish in-flight streams ≤ `Last-Stream-ID` (RFC 9113 §6.8). Go's `net/http2` transport does most of this when one client is reused (reference §1.3). **A stream severed by `GOAWAY`/reset before a final APNs response is mapped to `503 upstream_unavailable` (retryable) and is NOT retried in-process** — see §6.4 "Connection-severed / GOAWAY" for the exact caller-facing rule and the at-most-once rationale.
- Ensure the TLS trust store includes **USERTrust RSA** (SHA-2 root) — already in standard OS/Go pools; only matters if the relay pins or ships a minimal trust store (reference §1.7).
### 6.4 Response / reason mapping → caller-facing error
The relay parses the APNs JSON `{"reason":"…"}` (and the `timestamp` on 410), not just the status code (reference §1.6). It maps to a caller-facing `error.code` and decides retryability. **The relay does not itself retry transient upstream errors across separate caller requests** — it returns a retryable status so the caller's outbox/backoff (in `../01-release-events-and-inbox.md`/`02`) drives retries; the one exception is the in-process JWT-expiry retry (§6.1).
| APNs status | APNs `reason` | Caller HTTP | Caller `error.code` | Retryable (caller)? |
|---|---|---|---|---|
| 200 | — | 200 | — | success |
| 400 | `BadDeviceToken`, `InvalidToken`, `DeviceTokenNotForTopic`, `MissingDeviceToken` | 422 | `bad_device_token` | **No — purge token** (caller disables device, per `02`). `02` treats `BadDeviceToken`/`InvalidToken` equivalently: "Apple has migrated some responses from `BadDeviceToken` to `InvalidToken`; treat them equivalently." See the environment-mismatch caveat below. |
| 400 | `BadTopic`, `MissingTopic`, `TopicDisallowed` | 400 | `bad_topic` | No (config error) |
| 400 | `BadCollapseId` | 400 | `invalid_collapse_id` | No |
| 400 | `BadExpirationDate`, `BadPriority`, `BadMessageId`, `InvalidPushType`, `PayloadEmpty`, `DuplicateHeaders`, `IdleTimeout` | 400 | `bad_request` | No |
| 403 | `ExpiredProviderToken` | (internal) | — | **Relay regenerates JWT, retries once** (§6.1), then maps result |
| 403 | `InvalidProviderToken`, `MissingProviderToken`, `UnrelatedKeyIdInToken`, `BadEnvironmentKeyIdInToken`, `Forbidden`, `BadCertificate*` | 502 | `upstream_auth_error` | No (relay misconfig — **ops alert**, not caller-fixable) |
| 404 | `BadPath` | 502 | `upstream_error` | No |
| 405 | `MethodNotAllowed` | 502 | `upstream_error` | No |
| 410 | `Unregistered`, `ExpiredToken` | 410 | `unregistered` | **No — purge token** (caller disables device). `timestamp` (ms) passed through in `error.message` so caller can compare against its registration time. |
| 413 | `PayloadTooLarge` | 400 | `payload_too_large` | No |
| 429 | `TooManyProviderTokenUpdates` | 503 | `upstream_throttled` | Yes — relay slows JWT refresh; caller backs off |
| 429 | `TooManyRequests` | 429 | `rate_limited` | Yes — per-device backoff; `Retry-After` set |
| 500 | `InternalServerError` | 503 | `upstream_unavailable` | Yes (caller backoff ~15 min) |
| 503 | `ServiceUnavailable`, `Shutdown` | 503 | `upstream_unavailable` | Yes (caller backoff) |
**Default / fallback for unrecognized reasons.** Any **unmapped** `reason` string on a `400`/`403`/`410` (e.g. a future Apple reason the relay does not know) maps to `502 upstream_error`, **non-retryable, no token purge** — a safe default that surfaces the anomaly for investigation rather than silently dropping it or purging a possibly-live token. An unmapped `5xx` maps to `503 upstream_unavailable` (retryable). The exact unmapped reason is recorded in the op-log `upstream_reason` for triage.
**Connection-severed / GOAWAY (no final response).** A request whose HTTP/2 stream is terminated by a `GOAWAY`, connection reset, or any transport drop **before a final APNs response arrives** is mapped to `503 upstream_unavailable` (retryable) — the same as a network timeout. The relay does **NOT** transparently retry such a request in-process; the caller's outbox (`01-release-events-and-inbox.md`) re-dispatches it under the same idempotency key, preserving at-most-once semantics (a request that may have been delivered must not be silently re-sent by the relay). Apple emits `GOAWAY` routinely for maintenance/overload, so this is a normal, expected outcome, distinguished in the op-log/metrics with a `connection_severed` outcome label.
**Environment / sandbox-vs-production mismatch caveat.** A caller that sends `environment=sandbox` with a production token (or vice-versa) gets `BadDeviceToken`/`InvalidToken` from APNs for a token that may be perfectly valid in the other environment. The relay still returns `422 bad_device_token`, but **the caller should NOT treat the first such error from a newly-onboarded device as conclusive grounds to purge** when an environment mismatch is plausible. To let operators distinguish a systemic misconfiguration from genuinely dead tokens, the relay tags these op-logs/metrics so a burst of `bad_device_token` concentrated on **one account** (a likely env mixup) is visible separately from scattered dead tokens across many accounts. See §5.1 (token rules) and §8.4.
Mapping to the caller's own error handling in `02`: `bad_device_token`/`unregistered` → the caller disables the device (`02` "BadDeviceToken / Unregistered: disable the token"); `upstream_throttled`/`rate_limited`/`upstream_unavailable` → the caller retries with backoff. The relay never surfaces `ExpiredProviderToken`/`TooManyProviderTokenUpdates` as caller-facing codes (those are the relay's own JWT lifecycle — `02` notes they apply only to `custom_apns`, never the relay path).
Network timeout / connection failure to APNs → `503 upstream_unavailable` (retryable).
---
## 7. Upstream FCM Client
Built per `./02-apns-fcm-2026-reference.md` §2 and §3.2. Preferred path: `golang.org/x/oauth2/google` `TokenSource` (auto-caches + refreshes) + a custom HTTP/2 client posting the relay-built JSON (reference §3.2 — lighter than the Admin SDK and gives direct control of connection reuse and error classification). The Admin SDK (`firebase.google.com/go/v4/messaging`) is an acceptable alternative; pick exactly one.
### 7.1 OAuth2 service-account token source + caching
- Build a `TokenSource` from the SA JSON with `google.CredentialsFromJSONWithType(ctx, jsonKey, google.ServiceAccount, "https://www.googleapis.com/auth/firebase.messaging")` (least-privilege scope; `CredentialsFromJSON` is deprecated — reference §2.2/§3.2). The `TokenSource` caches and auto-refreshes.
- Access tokens are valid **1 hour**; never mint per request. The library re-mints before expiry; if hand-rolling the JWT-bearer grant, refresh with a ~5-minute safety margin (reference §2.2).
- **Credential resolution:** prefer keyless **Workload Identity / attached SA** when the relay runs on GCP; off-GCP, load the SA JSON from the secret manager (reference §2.2/§4.5; §11 here).
- One token source **per Firebase project**. v1 has one official project (`continuum-prod-android`).
### 7.2 Endpoint
```http
POST https://fcm.googleapis.com/v1/projects/{PROJECT_ID}/messages:send
Authorization: Bearer <oauth2_access_token>
Content-Type: application/json
```
Global host only — **no regional/data-residency send endpoints exist** as of June 2026 (reference §2.3). The host `fcm.googleapis.com` is a **hardcoded constant**, never derived from request input. `{PROJECT_ID}` is the validated `project_id` from the request: it is checked against a **strict charset (`^[a-z0-9-]+$`) before** allowlist lookup and before URL interpolation, so an allowlist row can never inject path-traversal or override the host (§11 egress/SSRF posture).
### 7.3 Data-only message construction
As in §5.2: `message.token` set to the caller's `token`; `message.data` is string-valued only (a fixed, closed key set — §5.2/§12); `message.android.priority` is the canonical `"HIGH"`/`"NORMAL"` enum by mode; the caller's `collapse_key` is mapped to the REST field `message.android.collapseKey` (camelCase) when present; **no `notification` block**. The relay can optionally use `validateOnly: true` (top-level, camelCase) for the `relayctl`-driven test path (reference §2.5) but **never** on production sends.
### 7.4 Error mapping → caller-facing error + Retry-After
The relay classifies on `error.status` / the `FcmError.errorCode` in `details[]`, not the bare HTTP code (reference §2.6).
| FCM HTTP | `error.status` | Caller HTTP | Caller `error.code` | Retryable (caller)? |
|---|---|---|---|---|
| 200 | — | 200 | — | success |
| 400 | `INVALID_ARGUMENT` | 422 | `invalid_argument` | No. Ambiguous (bad payload **or** bad token); since the relay payload is independently valid, the caller may treat it as a dead token (`03` / reference §2.6). |
| 401 | `THIRD_PARTY_AUTH_ERROR` | 502 | `upstream_error` | **Not expected on this pure-Android path** — see note below. No auto-purge; surface for investigation. Do **NOT** page as a relay-credential failure. |
| 403 | `SENDER_ID_MISMATCH` | 403 | `sender_id_mismatch` | **No — disable device** (token minted under a different project; `03`). |
| 404 | `UNREGISTERED` | 410 | `unregistered` | **No — purge token** (`03`). Caller-facing **410** is an **intentional cross-provider normalization** — see note below. |
| 429 | `QUOTA_EXCEEDED` (RESOURCE_EXHAUSTED) | 429 | `rate_limited` | Yes — **honor `Retry-After`** if present; else default **60 s** (reference §2.7). |
| 429 / 400 | per-device rate (`DeviceMessageRateExceeded` — see note) | 429 | `rate_limited` | Yes — **retryable; NOT a token purge**. Conservative default `Retry-After` (60 s) if none provided. |
| 401 / 403 | (HTTP **status**, not `FcmError` code) `UNAUTHENTICATED` / `PERMISSION_DENIED` on `messages:send`, **or** a non-200 from `oauth2.googleapis.com/token` at mint time | 502 | `upstream_auth_error` | No — **relay's own OAuth2 / service-account credential failure** (**ops alert**). See note below. |
| 500 | `INTERNAL` | 503 | `upstream_unavailable` | Yes (backoff; no documented `Retry-After`). |
| 503 | `UNAVAILABLE` | 503 | `upstream_unavailable` | Yes — **honor `Retry-After`** if present (reference §2.7). |
| — | `UNSPECIFIED_ERROR` / any unmapped `errorCode` | 502 | `upstream_error` | No auto-purge; surface for investigation (reference §2.6). |
**Note — `THIRD_PARTY_AUTH_ERROR` is not a relay-credential signal on this path.** Per Firebase's error-codes page, `THIRD_PARTY_AUTH_ERROR` means *"APNs certificate or web-push auth key was invalid or missing"* and is returned **only** for messages targeting **iOS-via-APNs or Web Push** registration tokens — i.e. when FCM itself must authenticate downstream to Apple/VAPID. The relay's `/v1/fcm/send` sends **only Android registration tokens** (data-only `message.token`, package `com.continuum.app.android`), so a legitimate Android send can never produce `THIRD_PARTY_AUTH_ERROR`. If it ever appears it indicates a **per-token/registration anomaly** (an Android token that is somehow APNs/web-bound — a client/registration bug), not a relay `.p8`/SA misconfiguration; treat it as `502 upstream_error`, surface for investigation, and do **not** page on-call as a credential failure.
**Note — the relay's OWN FCM credential failure surfaces differently.** A failure of the relay's OAuth2 service-account auth shows up as a non-200 from the Google token endpoint (`oauth2.googleapis.com/token`) at mint time, or as an HTTP **status** `401 UNAUTHENTICATED` / `403 PERMISSION_DENIED` on `messages:send` (the gRPC/REST status, **not** an `FcmError.errorCode`). **These** are the signals that map to `upstream_auth_error` and warrant an ops alert (the alerting condition in `01-implementation-plan.md` keys on this `upstream_reason`, not on `THIRD_PARTY_AUTH_ERROR`).
**Note — `UNREGISTERED` is deliberately re-mapped from FCM's native 404 to caller-facing 410.** FCM natively returns `UNREGISTERED` as HTTP **404**; the relay normalizes it to caller-facing **410** so it matches the same "dead token, purge" semantic the APNs path uses for `Unregistered` (where APNs natively uses 410). This is an **intentional cross-provider normalization** (not a bug) so the caller's device-disable logic is uniform across APNs and FCM. Similarly `SENDER_ID_MISMATCH` keeps FCM's native 403.
**Note — per-device rate exceedance.** FCM enforces per-device Android caps (~240 msg/min, ~5000 msg/hr) that surface as a `DeviceMessageRateExceeded`-class error, **distinct from project-level `QUOTA_EXCEEDED`** (reference §2.7). Because the relay fans out bursty per-device wakes, this is a plausible real outcome. The exact error code/string for per-device exceedance is **not firmly documented**, so the relay detects it **defensively** by classifying on `error.status`/`errorCode` and maps it to a **retryable `429 rate_limited`** with a conservative default `Retry-After` — **never a token purge** (the device is fine, just rate-limited). The relay's coarse per-`(account, token_hash)` limiter (§9.1) is the local guard, but an upstream per-device error must still map to `429`, not fall through to `502`.
**Retry-After honoring (reference §2.7):** on FCM `429`, the relay reads the upstream `Retry-After`; if absent it defaults to 60 s. It forwards this as the caller-facing `Retry-After` header on the `429 rate_limited` response (relative `delay-seconds`, not an absolute epoch — reference §4.2). On `503 UNAVAILABLE` it forwards any upstream `Retry-After`.
Mapping to `03` caller handling: `unregistered`/`sender_id_mismatch`/`invalid_argument` → caller disables the device; `rate_limited` → caller backs off and may fall back to `priority: NORMAL` after repeated 429s (`03` open question, resolved "yes with cooldown"); `upstream_auth_error` → caller surfaces admin diagnostics, no retry. Note that `upstream_auth_error` is now sourced from the **relay's own** OAuth2/SA credential signals (HTTP `401 UNAUTHENTICATED` / `403 PERMISSION_DENIED` on `messages:send`, or a token-mint failure), **not** from `THIRD_PARTY_AUTH_ERROR` — which on this Android-only path is a registration anomaly, not a relay-credential problem (see the notes above and §7.5).
### 7.5 Reconciliations with the older `03` contract
- **`Retry-After` on 429.** `03` says "honoring the `Retry-After` response header if present (FCM uses standard HTTP semantics here)". The reference (§2.6/§2.7) refines this: FCM's error page promises only `google.rpc.QuotaFailure` (not `RetryInfo`); treat `Retry-After`/`RetryInfo` as optional and fall back to 60 s. The relay implements the **reference's** refinement while still surfacing a `Retry-After` to the caller exactly as `03` expects.
- **No batch endpoint.** The reference (§2.1) confirms FCM's batch endpoint is discontinued; the relay sends **one POST per request** (it already does — one caller request = one device).
- **Upstream field casing — `collapseKey`, `priority`, `validateOnly`.** `03` writes the upstream Android fields in snake_case (`collapse_key`) and lowercase priority (`high`/`normal`), and writes the dry-run flag as `validate_only`. The correct **FCM HTTP v1 REST** field names are `android.collapseKey` (camelCase), the canonical `priority` enum `"HIGH"`/`"NORMAL"` (uppercase; lowercase also accepted), and top-level `validateOnly` (camelCase) (reference §2.4/§2.5). The relay uses the **REST-correct names on the upstream wire** while leaving the **inbound caller field `collapse_key` unchanged** per `03`. FCM v1 rejects unknown fields with `INVALID_ARGUMENT`, so this reconciliation is mandatory, not cosmetic.
- **FCM 4-distinct-collapse-key-per-device cap.** FCM stores at most **4 distinct collapse keys per device while offline**; a 5th distinct key evicts one nondeterministically (reference §2.7 — a delivery/storage behavior, **not** a per-message validation limit). Because `collapse_key` is a per-series HMAC and a device may follow **more than 4 series**, collapsible coalescing is **best-effort**: excess distinct keys evict nondeterministically and a collapsible wake may be dropped. This is **acceptable** because the durable inbox + sync (`01-release-events-and-inbox.md`) recovers any dropped wake; the relay treats `collapse_key` opaquely and takes no action. **Asymmetry note:** APNs has no equivalent documented per-device `collapse-id` cap, so the FCM path alone carries this best-effort caveat.
- **External contract unchanged.** None of the above alters the `03` request/response shapes; only the relay's internal upstream behavior is specified more precisely.
---
## 8. Account, Key and Allowlist Model
### 8.1 DB schema (PostgreSQL)
Goose-style migration. All IDs are ULIDs stored as `text` for consistency with the Silo convention; timestamps are `timestamptz`.
```sql
-- relay_accounts: one per opt-in self-hosted installation.
CREATE TABLE relay_accounts (
id text PRIMARY KEY, -- ULID
name text NOT NULL, -- operator-facing label
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active','disabled')),
note text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- relay_api_keys: revocable bearer keys (rk_ prefix). Secret never stored.
CREATE TABLE relay_api_keys (
id text PRIMARY KEY, -- ULID
account_id text NOT NULL REFERENCES relay_accounts(id) ON DELETE CASCADE,
key_prefix text NOT NULL, -- non-secret, indexed lookup handle (e.g. rk_live_3Qw9XYZ)
key_hash bytea NOT NULL, -- HMAC-SHA256(pepper, secret), 32 bytes
env text NOT NULL DEFAULT 'live'
CHECK (env IN ('live','test')),
created_at timestamptz NOT NULL DEFAULT now(),
last_used_at timestamptz, -- throttled write (<= 1/min)
expires_at timestamptz, -- optional
revoked_at timestamptz -- non-null = revoked
);
CREATE UNIQUE INDEX relay_api_keys_prefix_uidx ON relay_api_keys (key_prefix);
CREATE INDEX relay_api_keys_account_idx ON relay_api_keys (account_id);
-- relay_apns_allowlist: which APNs topics an account may push to.
CREATE TABLE relay_apns_allowlist (
account_id text NOT NULL REFERENCES relay_accounts(id) ON DELETE CASCADE,
topic text NOT NULL, -- e.g. com.continuum.app.ios
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, topic)
);
-- relay_fcm_allowlist: which (project, package) pairs an account may push to.
CREATE TABLE relay_fcm_allowlist (
account_id text NOT NULL REFERENCES relay_accounts(id) ON DELETE CASCADE,
project_id text NOT NULL, -- e.g. continuum-prod-android
package_name text NOT NULL, -- e.g. com.continuum.app.android
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, project_id, package_name)
);
-- relay_op_logs: redacted operational/audit log. No tokens, no content.
CREATE TABLE relay_op_logs (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
occurred_at timestamptz NOT NULL DEFAULT now(),
account_id text REFERENCES relay_accounts(id) ON DELETE SET NULL,
request_id text, -- ULID echoed to caller
event text NOT NULL, -- e.g. send.apns, send.fcm, auth.reject, admin.key_issue
provider text, -- 'apns' | 'fcm' | null
outcome text, -- 'accepted' | 'rejected' | 'upstream_error' | ...
status_code int, -- caller-facing HTTP status
error_code text, -- caller-facing error.code
upstream_reason text, -- APNs reason / FCM status (non-sensitive)
token_hash text, -- SHA-256(token) hex, NOT the raw token
egress_ip inet, -- caller egress IP
latency_ms int
);
CREATE INDEX relay_op_logs_account_time_idx ON relay_op_logs (account_id, occurred_at DESC);
CREATE INDEX relay_op_logs_time_idx ON relay_op_logs (occurred_at DESC);
```
Notes:
- `relay_op_logs` is the only table written on the hot path besides the throttled `last_used_at`. It carries **no raw token, no payload, no identity** — only a `token_hash` (SHA-256 hex) for correlating repeated failures to one device without storing the token (reference §4.6). Retention: see §13.
- No table stores APNs/FCM device tokens, notification content, user/profile IDs, or token aliases — by design (`00` "Its database holds only…").
### 8.2 API-key format, hashing, lookup, compare
- **Format:** `rk_<env>_<random>`. The **prefix** = `rk_<env>_<first 6–8 chars of the random>` is **non-secret**, stored in cleartext, and uniquely indexed (`key_prefix`). The full random part is 32 bytes from `crypto/rand`, base62/base64url-encoded. GitHub/Stripe-style design — lets a request `SELECT` by indexed prefix in O(1), then constant-time-compare (reference §4.1).
- **Hashing:** store `HMAC-SHA256(pepper, secret)` (32 bytes) where `pepper` is a server-side secret in the secret manager. A fast keyed hash is appropriate for **high-entropy 256-bit random keys** — OWASP's slow-KDF (argon2id) guidance is scoped to low-entropy passwords and would needlessly throttle per-request auth (reference §4.1). Never store plaintext.
- **Lookup + compare on each request (constant-time regardless of whether the prefix exists):**
1. Parse `Authorization: Bearer rk_<env>_<rest>`; reject malformed → `401 unauthorized`.
2. Derive `key_prefix` from the token; `SELECT … FROM relay_api_keys WHERE key_prefix = $1` (unique index).
3. If no row, or `revoked_at IS NOT NULL`, or `expires_at < now()`, or the account is `disabled` → still perform a **dummy `HMAC-SHA256` against a fixed decoy hash with `crypto/subtle.ConstantTimeCompare`**, then return the uniform `401 unauthorized` (do not distinguish reasons to the caller). **Do not short-circuit before the HMAC+compare** — an unknown prefix must take the same time as a known prefix with a wrong secret, or the timing difference is a key-enumeration oracle (a known/live prefix would otherwise reject measurably faster). The fine-grained `reason` (`unknown_prefix`/`hash_mismatch`/…) is kept **only in internal logs and the internal-only `/metrics`** (§13.2), never surfaced to a tenant or an untrusted scraper.
4. Compute `HMAC-SHA256(pepper, presented_secret)` and `crypto/subtle.ConstantTimeCompare` against `key_hash` (or the decoy). Mismatch → `401`. Never use `==`/`bytes.Equal` on secret material (reference §4.1).
5. Throttle-update `last_used_at` (at most once per minute per key). **The throttle clock lives in Redis** (`lastused:{key_prefix}` set `NX EX 60`) so the "≤1/min/key" bound holds **cross-replica** (a per-instance clock would allow N writes/min/key across N replicas); only when that `NX` succeeds does the relay issue the Postgres `UPDATE`. The Redis key carries no secret. This avoids a Postgres write per request (reference §4.1) without leaking a per-key validity side channel through DB write cadence.
- **Rotation:** multiple live keys per account are supported (issue a new key, deploy it on the server, then revoke the old) for zero-downtime rotation (reference §4.1).
- **Client-IP resolution (trusted-proxy requirement).** The relay runs **behind a TLS load balancer** (§4.2, §14.1), so Go's `http.Request.RemoteAddr` is the LB/proxy address, not the caller. The relay derives the client IP as follows: **use `RemoteAddr` unless the immediate peer is in the allowlisted trusted-LB CIDR set, in which case take exactly the right-most untrusted hop from the LB-set forwarding header (`X-Forwarded-For`)**. **Caller-supplied forwarding headers are ignored** — a caller cannot set `X-Forwarded-For`/`X-Real-IP` to forge an IP. The trusted-proxy CIDR list is a **config value**. Both the per-IP auth-failure limiter and the op-log `egress_ip` consume this single resolved IP. (Without this, a caller could either spread brute-force across unlimited fake IPs to defeat the per-IP throttle (OWASP API2:2023) and poison `egress_ip`, or — if `RemoteAddr` were trusted blindly behind the LB — collapse every tenant onto one IP and trip the throttle for all at once.)
- **Anti-brute-force — per-IP auth-failure limiter (specified).** Auth failures are rate-limited **per resolved client IP** (independent of the per-account bucket) to blunt key-guessing (OWASP API2:2023, reference §4.1). Concretely:
- **Store:** Redis, key `authfail:{client_ip}`, sliding/fixed window with `EXPIRE`.
- **Window / threshold:** e.g. **20 failures / 5 min** per IP (tunable).
- **Response on breach — *soft* lockout, not a hard block:** added latency / a `429` with `Retry-After` (e.g. 60 s backoff) rather than dropping the connection. This is deliberate because the doc anticipates **VPN/CGNAT shared egress** (§3.4): multiple self-hosted servers can share one egress IP, so a hard per-IP block would be collateral damage from a noisy/compromised neighbor. Optionally scope tracking by `(key_prefix, IP)` to further isolate neighbors.
- **Fail-open/closed stance:** this limiter **fails open** if Redis is unreachable (availability over a perfectly-enforced anti-guessing cap), logging `authfail_limiter.degraded`. The hard auth boundary (HMAC compare, allowlist) is always enforced from Postgres regardless.
- **Metric + runbook:** `relay_auth_failures_total{reason=…}` (internal only) plus a documented response runbook for the key-guessing alert (R8, §6.4 monitoring): inspect the offending IP/prefix, confirm whether a single account or a guessing pattern, revoke/rotate as needed.
### 8.3 The `rk_` token is shown exactly once
`relayctl key issue` prints the full `rk_…` token to stdout **once**; only the HMAC hash and prefix are persisted. There is no endpoint or command that can recover a secret. Lost keys are revoked and re-issued.
### 8.4 Allowlist enforcement
Enforced **after** auth, **before** building the upstream payload:
- **APNs (`/v1/apple/send`):** the request `topic` must exist in `relay_apns_allowlist` for the caller's `account_id`. Not present → `403 topic_not_allowed`. The official build config allowlist (allowlist config, **not** part of the contract) is the set `com.continuum.app.ios`, `com.continuum.app.tvos`, `com.continuum.app.macos` (`02` resolved-questions: per-platform topics).
- **FCM (`/v1/fcm/send`):** the `(project_id, package_name)` pair must exist in `relay_fcm_allowlist` for the caller's `account_id`. Missing project → `403 project_not_allowed`; project present but package not → `403 package_not_allowed`. Official build config: project `continuum-prod-android`, package `com.continuum.app.android`.
- Allowlists are loaded per request from Postgres (cheap, small) or from a short-TTL in-memory cache keyed by `account_id` (invalidated on `relayctl allowlist … set`). The cache must be per-account-scoped so one account's allowlist can never authorize another's push.
These are **allowlist config values**, set by `relayctl`, not part of the external wire contract — a different deployment (e.g., a fork) would set different topics/packages.
> **Environment ⇄ token consistency (operational caveat).** The allowlist does **not** (and cannot) verify that the request `environment` matches the environment the device `token` was minted for. A `sandbox`-vs-`production` mixup presents downstream as APNs `BadDeviceToken`/`InvalidToken` → `422 bad_device_token` (§6.4), which would otherwise tell the caller to purge a perfectly good token. To let operators catch a **systemic** misconfiguration, the relay emits a distinct op-log/metric tag for these so a burst of `bad_device_token` concentrated on **one account** (likely an env mixup) is distinguishable from scattered genuinely-dead tokens across many accounts. The cross-field rule is documented in §5.1 and §6.4.
---
## 9. Rate Limiting and Quotas
### 9.1 Model
Per-account **token bucket** enforced in Redis with an atomic Lua script (refill → check → consume in one `EVAL`), state stored as a HASH `rl:{account_id} = {tokens, last_refill_ts}` with a TTL for idle-account eviction (reference §4.2). A Redis-backed limiter (not per-instance `x/time/rate`) is required because the relay runs multiple replicas behind a load balancer — per-instance limiters would not enforce a global cap.
A second, coarser limiter keys on `(account_id, token_hash)` to blunt hammering a single device (the `02`/`03` "rate limited by API key and coarse token hash" requirement).
**Redis operational requirements (eviction, HA, sizing).** Redis backs both idempotency (fail-closed) and rate limiting (fail-open), so its configuration is correctness-relevant, not just performance:
- **Non-evicting policy for idempotency.** The idempotency keyspace **must** run under `maxmemory-policy noeviction` (or live in a **dedicated Redis/db** separate from the rate-limit keyspace) so that memory pressure can **never silently evict an in-flight idempotency lock** — an evicted lock would silently violate at-most-once (a concurrent retry would get `NX`-success and double-send). Rate-limit keys may be evicted harmlessly; idempotency keys must not. This is called out in risks R4/R5.
- **HA topology.** Run Redis with an HA topology (Sentinel or Cluster) with prompt failover. A Redis failover/restart makes **every** send `503` while idempotency fails closed (§10) — a push-delaying, not push-losing, event (the caller outbox retries), **but only as long as idempotency stays fail-closed**. A Redis outage should **page ops**.
- **Keyspace sizing.** Size the idempotency keyspace from the quota math: ~`50k/day/account × N accounts`, each entry holding `{status, body, upstream_id, canonical_hash}` under a ~24 h TTL. Provision `maxmemory` with headroom above that working set so `noeviction` never starts rejecting writes.
### 9.2 Suggested limits (ties to `00` open question #1)
| Limit | Default | Source / rationale |
|---|---|---|
| Per-account burst | **~10 req/s** | `00` open question #1 ("Suggest 10 req/sec burst") |
| Per-account daily quota | **50,000 req/day** | `00` open question #1 ("50,000 req/day initial quota") |
| Bucket refill rate | 10 tokens/s, burst capacity 20 | derived from the 10 req/s target with a small burst allowance |
| Per-(account, token) coarse cap | e.g. 1 req/3 s sustained | abuse guard on a single device |
These are tunable per account (future column on `relay_accounts`); v1 ships a single global default and tunes from logs (`00`). The Silo server already paces its calls client-side (default 5 req/s shared token bucket — `02`/`03` "Relay pacing"), so a well-behaved caller stays comfortably under the 10 req/s burst; the relay limit is the backstop against a misbehaving or compromised caller (OWASP API4:2023 — cap per-tenant downstream spend).
**Daily-quota mechanics (store, reset, fail-open).** The per-second token bucket (§9.1) does **not** model the 50,000 req/day cap; the daily counter is a **separate Redis counter** keyed `rl:day:{account_id}:{yyyymmdd}` with an `EXPIRE` past the day boundary, incremented atomically per accepted send:
- **Reset semantics:** a **fixed UTC calendar day** (the `{yyyymmdd}` is UTC), not a rolling 24 h — simple, predictable for operators, and the key self-expires.
- **Breach:** when the counter reaches 50,000 the account's sends return `429 rate_limited` for the rest of the UTC day with a `Retry-After` = seconds until 00:00 UTC. Daily exhaustion blocks **all** of an account's pushes until reset — a notable availability event, so it is **alerted** (see below), not silent.
- **Fail-open during a Redis outage:** like the per-second bucket, the daily counter **fails open** if Redis is unreachable — but this is exactly the abuse-amplification window, so it is **bounded** by the in-process fallback limiter in §9.4 (a compromised key cannot burn unlimited upstream sends during the outage) and the outage pages ops. The daily quota **cannot be enforced cross-replica while Redis is down**; that limitation is explicit.
- **Alerting:** a metric/alert fires for accounts **approaching** (e.g. ≥80%) or **hitting** the daily cap, since a legitimate hundreds-of-users server can plausibly hit 50k/day on a big release burst — operators should see this and raise the cap rather than have the account silently throttle. Reconcile the 50k/day default against the `02`/`03` burst-cap math when tuning.
### 9.3 429 response
On limit exceeded the relay returns:
```http
HTTP/2 429
Retry-After: 3
Content-Type: application/json
X-Request-Id: 01JRELAY...
{ "error": { "code": "rate_limited",
"message": "account rate limit exceeded",
"request_id": "01JRELAY..." } }
```
`Retry-After` is a **relative `delay-seconds`** integer (never an absolute Unix epoch — reference §4.2), computed from the bucket's time-to-next-token. The daily-quota breach also returns `429 rate_limited` with a longer `Retry-After` (seconds until the daily window rolls).
### 9.4 Fail-open vs fail-closed (bounded, not unlimited)
If Redis is unreachable, the rate-limit middleware **fails open** for availability (a push is more valuable than a perfectly enforced cap), but **bounded** — it does **not** simply remove the cap. When Redis is down the relay falls back to a **conservative per-instance in-process limiter** (`golang.org/x/time/rate`, keyed per `account_id`) that caps each account to a small multiple of its normal rate. This matters because the per-account quota is the primary control bounding a **stolen-key** attacker's spend and per-tenant downstream APNs/FCM cost (OWASP API4:2023, §3.5 "Stolen relay API key", G6): an unbounded fail-open would let a compromised `rk_` key send without limit against the official Apple/Google credentials — including burning the shared FCM project quota (§9.5) common to all tenants — for the entire outage. The **allowlist (always enforced from Postgres)** remains the hard boundary. A `rate_limit.degraded` op-log + metric fires; the **daily quota cannot be enforced cross-replica** during the outage, and an outage **pages ops**. The idempotency layer (§10), by contrast, **fails closed** to avoid double-sends. (Reference §4.2 — explicitly a per-endpoint decision.)
### 9.5 Shared FCM project quota — global limiter / circuit breaker
The per-account limits above are **local to each account**, but the **FCM project quota (~600k messages/min, reference §2.7) is SHARED across ALL relay accounts** sending to `continuum-prod-android`. Without a relay-side guard, one noisy/compromised account can exhaust the shared project quota and every other account's sends start returning `429 QUOTA_EXCEEDED` — a **multi-tenant fairness** failure (one noisy tenant degrades all). The relay therefore adds a **global (per-project) outbound limiter / circuit breaker** at the aggregation point — the only place that *can* see aggregate FCM rate across accounts:
- **(a) Track aggregate FCM send rate** against the documented project quota (a Redis counter keyed per project, e.g. `fcmq:{project_id}:{yyyymmddhhmm}` with a per-minute window).
- **(b) Shed load before hammering FCM:** when near the quota, return `503 upstream_throttled` (retryable) **before** forwarding, rather than spending quota on calls that FCM will reject anyway (some errors still count against quota).
- **(c) Anti-spike smoothing (optional):** honor the reference §2.7 guidance to avoid concentrating traffic in the 2-minute windows around `:00/:15/:30/:45` — the relay, as the cross-account aggregation point, is the only component that can actually implement this ramp.
- **Observability:** a `relay_fcm_project_quota_headroom` gauge plus an alert when headroom is low. This is explicitly a **multi-tenant fairness** control (a single account must not exhaust the shared project quota for all).
---
## 10. Idempotency
### 10.1 Key format
`Idempotency-Key: <notification_delivery_id>:<push_device_id>:<attempt_number>` (`02`/`03`). This is **scoped per account** in storage (so two accounts cannot collide). The relay validates the header is present and non-empty on send endpoints; missing → `400 missing_idempotency_key`. The three-part shape is not strictly parsed (it is opaque to the relay), but it is length-limited (≤ 255 chars, Stripe-style — reference §4.3).
### 10.2 Store, TTL, locking, duplicate semantics
Redis, namespaced `idem:{account_id}:{key}` (reference §4.3):
1. `SET idem:{account}:{key} <in_flight_marker> NX EX <lock_ttl>` (`lock_ttl` = 30 s). The `<in_flight_marker>` carries a **unique per-attempt nonce** so the completing request can prove ownership (see invariant below).
2. **NX success** → this is the first request. Process it; on completion, **compare-and-set**: overwrite the key with the serialized `{status, body, upstream_id, canonical_hash}` and a **~24 h TTL** **only if the stored value is still this request's own in-flight marker** (i.e. the lock was not stolen by a concurrent request after a TTL expiry). Success *and* error are both recorded (reference §4.3 / Stripe model).
3. **NX fail + stored completed result** → **replay** it: return the original status code and body verbatim (so a retried send returns the same `apns_id`/`fcm_message_name`, never a second upstream send).
4. **NX fail + still the in-flight marker** → the original is mid-flight → **`409 conflict`** (`{"error":{"code":"idempotency_conflict",…}}`) so the caller retries later.
5. **Same key, different request payload** → **`422 unprocessable`** (`idempotency_key_reuse`). The relay stores a `canonical_hash` of the request alongside the result and compares (reference §4.3). **The canonical hash is computed over the validated fields with the device `token` replaced by its already-computed `token_hash` (SHA-256) — the raw token is NEVER part of the canonical form.** The stored idempotency value contains only `{status, body, upstream_id, canonical_hash}`; it holds **no raw token** (matching the op-log guarantee §12, NG1). This keeps the "no token aliases / stateless v1" privacy guarantee intact even though the value lives in Redis under `idem:{account}:{key}` for ~24 h.
> **Lock-TTL invariant (no double-send, no result-clobber).** The upstream APNs/FCM call **must run under a hard context deadline strictly shorter than `lock_ttl`** — e.g. **upstream timeout 10 s, `lock_ttl` 30 s** — and the handler **must refuse to send upstream if its own deadline would exceed the lock TTL**. Otherwise a slow first attempt (HTTP/2 setup + JWT/OAuth refresh + GOAWAY-reconnect can stack up) could outlive the 30 s lock; the lock would expire mid-flight, a concurrent retry of the same `Idempotency-Key` would get `NX`-success and fire a **second** upstream send (defeating at-most-once), and when the slow first attempt finally completed it would clobber the stored result with a different `apns_id`. The **compare-and-set on completion** (step 2) prevents the clobber — a lock that was stolen by a concurrent request cannot have its result overwritten by the original — and the **deadline-shorter-than-TTL** rule prevents the double-send in the first place. (Optionally, a lock heartbeat may renew the marker while genuinely in flight, but the deadline rule is the primary guarantee.) This invariant is the whole reason the idempotency store exists (R4).
This guarantees **at-most-once upstream send** under the caller's concurrent retries (the `../01-release-events-and-inbox.md` outbox can re-claim and re-dispatch the same `(delivery_id, device_id, attempt_number)`), which is exactly why the key includes `attempt_number`: attempt 2 is a *new* idempotency key (intended re-send after a failure), while two concurrent dispatches of attempt 1 collapse to one upstream call.
### 10.3 What a duplicate returns (summary)
| Situation | Response |
|---|---|
| First request | Process; record `{status, body, upstream_id, canonical_hash}` (no raw token — §10.2) for ~24 h. |
| Duplicate, original completed | Replay stored status + body (same `apns_id`/`fcm_message_name`). |
| Duplicate, original in-flight | `409 idempotency_conflict`. |
| Same key, different payload | `422 idempotency_key_reuse`. |
| Missing key | `400 missing_idempotency_key`. |
Idempotency **fails closed**: if Redis is unavailable, send endpoints return `503 upstream_unavailable` (retryable) rather than risk a double-send (contrast §9.4).
---
## 11. Security Requirements
- **TLS only.** Public endpoints are HTTPS over HTTP/2; no plaintext listener. TLS 1.2+ (prefer 1.3). HSTS on the public hostname. (`02`/`03` "Relay API uses TLS only".)
- **Secret management.** APNs `.p8`, FCM SA JSON, the API-key HMAC pepper, and DB/Redis credentials live in a **secret manager** (KMS/Vault/cloud secret-manager). Ranking: secret-manager > tightly-permissioned mounted file > env var (**discouraged**) > in-image/repo (**forbidden**) (reference §4.5). On GCP, prefer keyless Workload Identity for FCM and avoid a downloadable SA JSON entirely (reference §4.5). Never commit or bake credentials into the image.
- **Key storage.** API keys stored only as `HMAC-SHA256(pepper, secret)` + non-secret prefix; constant-time compare; revocable; optional expiry; multiple live keys per account for rotation (§8.2). The relay's own `rk_` secret is shown once and never recoverable.
- **No raw secrets in transit out.** No endpoint returns the `.p8`, SA JSON, pepper, or any `rk_` secret. `/metrics` and op-logs carry none.
- **Redaction (enforced in middleware so it cannot be bypassed).** Never log: `Authorization`/bearer header, raw API key (log only the non-secret prefix or a hash), raw APNs/FCM device tokens (log `SHA-256` truncated hash only), notification payload bodies, the relay JWT, or OAuth2 access tokens (reference §4.6; `02`/`03` privacy/security requirements). See §12/§13.
- **Isolation / multi-tenancy.** Every API key, rate-limit bucket, idempotency key, allowlist cache entry, and log line is scoped by `account_id`. Shared-store keys are namespaced (`idem:{account}:…`, `rl:{account}`) to prevent cross-tenant collision or replay (reference §4.7). For APNs, connection pools are per-team (§6.1).
- **Network posture.** `/metrics` and `/readyz` bound to the internal/scrape network; only `/v1/*` and `/healthz` are public. `relayctl` reaches Postgres over a private network with a privileged DSN held by operators only — there is no public admin API (§5.6).
- **Input hardening.** `DisallowUnknownFields`, body size cap (e.g. 16 KiB — far above the ~1 KB legitimate body), explicit server timeouts (`ReadHeaderTimeout`, `ReadTimeout`, `WriteTimeout`, `IdleTimeout`, `MaxHeaderBytes`) since the zero value means no timeout (reference §3.3). Graceful shutdown via `srv.Shutdown(ctx)` on SIGTERM.
- **Egress / SSRF posture.** Both upstream hosts are **hardcoded constants** — `api.push.apple.com` / `api.sandbox.push.apple.com` (APNs) and `fcm.googleapis.com` (FCM) — **never derived from any request field**. The only request-derived value interpolated into an upstream URL is the FCM `project_id` (into `/v1/projects/{project_id}/messages:send`); it is **allowlist-checked** (§8.4) **and** validated against a strict charset **before** allowlist lookup and URL construction: `project_id` and `package_name` against `^[a-z0-9-]+$` / reverse-DNS, `topic` against the APNs reverse-DNS shape. This ensures an allowlist row can never introduce a path-traversal/host-override and the host can never be templated from caller input (SSRF/path-injection prevention).
- **Anti-brute-force** on auth failures per resolved client IP, with trusted-proxy IP resolution (§8.2).
---
## 12. Privacy Requirements
The relay's privacy guarantee is **structural**, not policy-based: the request schema has no field that can carry content, and unknown fields are rejected.
- **Content-free guarantee.** The send-endpoint structs contain only the fields in §5.1/§5.2. There is **no** title, body, image URL, media ID, username, server hostname, server URL, library/collection/item name, or watched-state field (`02`/`03` privacy requirements). The relay-built payloads use only generic localization keys (`SILO_NOTIFICATION_TITLE` / `SILO_NOTIFICATION_GENERIC_BODY`) and opaque IDs.
- **Reject any unexpected field.** JSON decoding uses `DisallowUnknownFields`; any field not in the schema → `400 unexpected_field`. This is the enforcement point for the content-free guarantee — a caller (buggy or hostile) cannot smuggle content through an extra key.
- **Badge disabled by default.** `badge` is omitted unless explicitly provided, so unread counts don't leak through APNs/FCM by default (`02`/`03`).
- **FCM is data-only with a closed key set.** No top-level `notification` block is ever constructed, so Google carries no rendering content (`03`). The `message.data` map is a **fixed, closed set of keys** — exactly `{v, wake, server_device_id, delivery_id}` — with **no caller-controlled key or value** beyond the already-opaque IDs (§5.2). `badge` is **never** injected into `data` (it has no data-only home and is ignored on the FCM path — §5.2), so the data-map allowlist cannot be widened by a provided badge or any extra key.
- **No raw token in any store.** Neither the op-log nor the idempotency store holds a raw device token: the op-log carries only `token_hash` (SHA-256), and the idempotency value's `canonical_hash` is computed with the token replaced by `token_hash` (§10.2). The stateless-v1 "no token aliases" guarantee (NG1) holds across both stores.
- **Redacted logging.** Op-logs and structured logs **never** contain: raw device tokens (only `SHA-256` hash), notification content (none exists), the `Authorization` header, the relay JWT, OAuth2 tokens, or the raw API key (only the prefix). Full request payloads are never persisted (`02`/`03` "Relay logs do not persist full request payloads"). What **is** logged for triage: `request_id`, `account_id`, status code, caller `error.code`, upstream reason, `token_hash`, egress IP, latency (reference §4.6).
- **No identity storage.** The relay stores no user/profile identity, no device→user mapping, no server URL, and no token aliases (`00`).
---
## 13. Observability
### 13.1 Structured logs (with redaction)
`log/slog` JSON handler (reference §3.5). Every request emits one structured line through a redaction middleware that strips the disallowed fields (§12). Example (a rejected, allowlist-failed APNs send):
```json
{
"time": "2026-06-13T10:00:00Z",
"level": "WARN",
"msg": "send.apns rejected",
"request_id": "01JRELAY...",
"account_id": "01JACCOUNT...",
"provider": "apns",
"event": "send.apns",
"outcome": "rejected",
"status_code": 403,
"error_code": "topic_not_allowed",
"token_hash": "9f86d081...c2b5",
"egress_ip": "203.0.113.7",
"latency_ms": 2
}
```
Redaction is implemented in middleware so no code path can log a raw token, the bearer header, the JWT, or an OAuth2 token. The redacted audit subset also lands in `relay_op_logs` (§8.1) for queryable history. **Op-log retention:** successful sends **>14 days** are deleted; failures/upstream errors **>90 days** are deleted (mirrors the `02`/`03` `push_delivery_attempts` retention guidance). At 50k/day/account this table grows unbounded without active GC, so retention is a **shipped deliverable, not a deferral** — enforced by a scheduled sweeper / `relayctl logs --gc` (specified in `01-implementation-plan.md` Phase 5), with a disk-usage / table-growth alert. Time-partitioning of `relay_op_logs` is the recommended store strategy; see §15 #8.
### 13.2 Metrics catalog (Prometheus, `/metrics`)
`prometheus/client_golang` (reference §3.5). Labels avoid high-cardinality fields (no token, no `request_id`).
| Metric | Type | Labels | Meaning |
|---|---|---|---|
| `relay_requests_total` | counter | `provider`, `endpoint`, `status_code`, `error_code` | All inbound send requests by outcome. |
| `relay_request_duration_seconds` | histogram | `provider`, `endpoint` | End-to-end relay latency. |
| `relay_upstream_requests_total` | counter | `provider`, `upstream_status`, `upstream_reason` | APNs/FCM calls by upstream result. |
| `relay_upstream_duration_seconds` | histogram | `provider` | Upstream call latency. |
| `relay_auth_failures_total` | counter | `reason` (`bad_format`,`unknown_prefix`,`hash_mismatch`,`revoked`,`expired`,`account_disabled`) | Auth rejections. **`/metrics` is internal/scrape-only** — the `reason` split (which distinguishes `unknown_prefix` from `hash_mismatch`) must **never** be exposed to a tenant or untrusted scraper, or it becomes a key-enumeration oracle paired with the constant-time auth path (§8.2). |
| `relay_rate_limited_total` | counter | `account_id`(low-card, hashed/bucketed if needed), `kind` (`account`,`token`) | 429s emitted. |
| `relay_rate_limit_degraded_total` | counter | — | Fail-open events when Redis is down (§9.4). |
| `relay_idempotency_total` | counter | `result` (`first`,`replay`,`conflict`,`reuse`) | Idempotency decisions. |
| `relay_apns_jwt_refresh_total` | counter | `result` (`scheduled`,`expired_retry`,`throttled`) | JWT lifecycle (§6.1). |
| `relay_fcm_token_refresh_total` | counter | `result` | OAuth2 token refreshes (§7.1). |
| `relay_redis_up` / `relay_pg_up` | gauge | — | Core dependency health (drives `/readyz` — §5.4). |
| `relay_provider_healthy` | gauge | `provider` | Per-provider init/circuit-breaker health (`1` healthy / `0` degraded) — does **not** gate `/readyz` (§5.4). |
| `relay_provider_credential_healthy` | gauge | `provider` | Synthetic credential-validation result (§13.4); `0` triggers the credential alert. |
| `relay_fcm_project_quota_headroom` | gauge | `project_id` | Remaining headroom against the shared FCM project quota (§9.5). |
| `relay_daily_quota_used_ratio` | gauge | `account_id`(low-card/bucketed) | Fraction of the 50k/day cap consumed; alerts ≥80% (§9.2). |
| `relay_connection_severed_total` | counter | `provider` | Sends mapped to `503` by GOAWAY/connection reset before a final response (§6.4). |
| `relay_authfail_limiter_degraded_total` | counter | — | Per-IP auth-failure limiter fail-open events (§8.2). |
### 13.3 Admin diagnostics
`relayctl` provides read paths for operators (no raw secrets/tokens): `account list`, `key list`, `allowlist show`, and a `relayctl logs --account <id> [--since …] [--outcome rejected]` that queries `relay_op_logs` (redacted by construction). A `relayctl ping-upstream --provider apns|fcm --env sandbox` performs a credential-only health check (JWT sign / token mint, optionally an FCM `validateOnly:true` send) without sending real content — useful after a credential rotation. The same check captures the sandbox `apns-unique-id` (§5.1) when APNs returns one.
### 13.4 Credential health, validation and rotation alerting
Credentials can be revoked or rotated **out from under the relay** at any time — Apple "actively monitors and revokes leaked `.p8` keys" (`00`), and FCM SA keys / Workload-Identity bindings can be disabled. Relying only on production sends starting to fail (upstream `403 InvalidProviderToken` / the relay's own `401 UNAUTHENTICATED` / `403 PERMISSION_DENIED`) is too late. The relay therefore validates credentials **proactively**, distinguishing an **auth-layer** failure from a mere token-layer rejection:
- **Startup + periodic synthetic validation.** On startup and on a periodic timer, the relay exercises the auth layer end-to-end: an FCM `validateOnly:true` send, and an APNs send to a **known-bad token** that still forces APNs to authenticate the JWT (so an auth-layer `403 InvalidProviderToken`/`BadCertificate*` is distinguishable from a token-layer `400 BadDeviceToken` — the latter proves the credential is *accepted*). Note `/readyz` alone is **insufficient**: it only proves a JWT was **signed locally** / a token **minted**, not that APNs/FCM **accept** the credential.
- **`credential healthy` gauge.** Wire the synthetic-validation result into a per-provider `relay_provider_credential_healthy` gauge (§13.2) and surface it on the per-provider readiness signal (§5.4).
- **Continuous health via cron.** Schedule `relayctl ping-upstream` (e.g. a cron / scheduled run) for continuous credential health between restarts.
- **Alert on first auth failure.** Alert on the **first** `upstream_auth_error` (the relay's own credential signals — §6.4/§7.4), not after a sustained failure rate, since a revoked credential fails *all* sends immediately.
---
## 14. Deployment and Operations
### 14.1 Public endpoint & TLS
- One public hostname, `relay.silo.app`, terminating TLS (1.2+, prefer 1.3) at a load balancer or in-process. HTTP/2 end to end. Only `/v1/apple/send`, `/v1/fcm/send`, and `/healthz` are public; `/readyz` and `/metrics` are internal-only.
- Set explicit server timeouts and graceful shutdown (§11).
### 14.2 High availability — "relay-down delays, never loses"
The relay being down does **not** lose pushes: the self-hosted server's dispatch is an **outbox**. The fanout transaction in `../01-release-events-and-inbox.md` inserts `pending` `push_delivery_attempts` rows in the same transaction as the `notification_deliveries` row; the dispatcher claims them post-commit and a recovery sweeper re-claims stale `pending` rows (`00`/`02`/`03`). So if the relay returns `503`/times out, the caller marks the attempt `retrying` and retries with backoff — the durable inbox row and the outbox attempt row guarantee eventual delivery. **This means the relay can be operated with ordinary HA (multiple stateless replicas, rolling deploys) rather than five-nines durability.** A short relay outage only delays pushes.
- **Stateless replicas.** No in-memory request state that can't be reconstructed; idempotency and rate-limit state are in Redis, shared across replicas. Scale horizontally behind the LB.
- **Rolling deploys / kill-safety.** A replica can be drained (`srv.Shutdown`) or killed mid-request; the caller retries the same idempotency key, which either replays the recorded result or re-sends at-most-once (§10).
- **Drain ordering (no orphaned idempotency locks).** A clean drain must not turn into a 30 s stall per retry. On `SIGTERM`: (1) flip `/readyz` to `503` so the LB **stops routing new requests** to this replica; (2) keep serving **in-flight** handlers; (3) bound the `srv.Shutdown` grace period to **less than the idempotency `lock_ttl`** (§10.2) — and/or **release/downgrade the in-flight idempotency marker on handler context-cancellation** — so that a send killed mid-flight does **not** leave a stale 30 s `in_flight` marker in Redis. Otherwise the caller's immediate retry hits §10.2 case 4 and gets `409 idempotency_conflict` for up to `lock_ttl` seconds. The relationship between the shutdown grace period and `lock_ttl` is load-bearing and is asserted by a drain test (no orphaned in-flight markers after a rolling restart — see `01-implementation-plan.md`).
### 14.3 Secret provisioning
- Provision the APNs `.p8` (+ Team ID, Key ID) and FCM SA JSON (or Workload Identity binding) into the secret manager out-of-band. The HMAC pepper and DB/Redis creds likewise. Never in the image or repo (§11).
- **Credential rotation runbook (overlap window, dual-key).** Apple permits **multiple active `.p8` keys per team**, and the relay already keys connection pools by team (§6.1), so rotation is **make-before-break**: (1) provision the new credential into the secret manager alongside the old; (2) load both — the relay supports an **overlap window** where old and new keys are both valid and the key-selection map (§6.1) can resolve to either; (3) run `relayctl ping-upstream` / the synthetic validation (§13.4) to confirm the **new** credential authenticates against APNs/FCM; (4) cut sends over to the new key (rolling restart picks up the new secret); (5) retire/revoke the old credential only after the new one is confirmed healthy. The same overlap pattern applies to FCM SA keys / Workload-Identity bindings. This avoids the failure mode where a single-key rotation revokes the old credential before the new one is proven, taking all pushes down.
### 14.4 Sandbox vs production
- The relay supports both APNs environments via the request `environment` field, routing to `api.push.apple.com` (production) or `api.sandbox.push.apple.com` (sandbox) (§6.2). FCM has a single global host (no sandbox split — reference §2.3); test sends use `validateOnly:true` (reference §2.5).
- A **staging relay deployment** (separate hostname, separate accounts) is recommended for client QA so test traffic never touches production accounts/quotas.
### 14.5 Scaling & regions
- **Scaling.** One well-tuned APNs HTTP/2 connection handles thousands of pushes/sec (reference §1.3/§3.1); one FCM token source serves the project. The bottleneck at relay scale is inbound throughput and Redis, both horizontally scalable. Keep upstream clients **process-singletons** (one per env/team/project), not per-request.
- **Regions.** APNs and FCM are global; FCM has no data-residency send endpoints (reference §2.3). v1 runs a single region close to the operator's Postgres/Redis. Multi-region is possible later (the request path is stateless; Redis/Postgres would need regional strategy) but is out of scope for v1.
- **Quota awareness.** FCM's default project quota is ~600k messages/min (reference §2.7); the relay's per-account 50k/day default (§9.2) is far under that even summed across many accounts. The **shared** project quota is nonetheless guarded by the relay-side global limiter / circuit breaker (§9.5) so one noisy account cannot exhaust it for all tenants; operators monitor `relay_fcm_project_quota_headroom` (§13.2), the project quota in Cloud Console, and `relay_upstream_requests_total{upstream_reason="QUOTA_EXCEEDED"}`.
---
## 15. Open Questions
1. **Per-account rate limits — real numbers.** v1 ships the `00`-suggested ~10 req/s burst + 50k/day defaults (§9.2); tune from production logs. Should limits become per-account columns on `relay_accounts` with `relayctl quota set`? (Ties to `00` open question #1.)
2. **APNs sandbox hostname (resolved — note only).** `api.sandbox.push.apple.com` and `api.development.push.apple.com` are **interchangeable, both-resolving** Apple sandbox aliases; the relay standardizes on `api.sandbox.push.apple.com` (reference §1.3, reference open question §5 item 4). The external contract (`"environment":"sandbox"`) is unaffected either way. No build-time decision required.
3. **`apns2` vs hand-rolled APNs client.** `sideshow/apns2` has had no release since v0.25.0 (Oct 2024) — bus-factor risk. Decide before implementation: adopt `apns2` or hand-roll `net/http2` + `golang-jwt/jwt/v5` (reference §3.1, §5.2).
4. **FCM credentials: GCP-keyless vs off-GCP JSON.** If the relay runs on GCP, use Workload Identity / attached SA (no downloadable key); off-GCP, the SA JSON lives in the secret manager. Resolve in the deployment design (reference §4.5 / §2.2).
5. **`apns-expiration` / `android.ttl` default values (resolved; tune from product input).** v1 **ships finite defaults** — `apns-expiration` ~4 h for `private_alert` / ~1 h for `background_wake`, and the parallel FCM `android.ttl` (§5.1/§5.2) — rather than the 30-day APNs / 4-week FCM store-and-retry, so stale wakes don't fire days later. The exact durations are configurable and may be tuned with product input (reference §1.4).
6. **API-key hashing: HMAC pepper vs plain salted SHA-256.** This spec chooses HMAC-SHA-256 with a secret-manager pepper for defense-in-depth. Pepper rotation is operationally heavier (re-hash all keys or support dual peppers). Confirm the tradeoff (reference §4.1, open question §12).
7. **Idempotency-Key spec status.** The IETF draft expired 2026-04-18 without becoming an RFC; the header name + Stripe-style semantics are de-facto standard. Re-check for an RFC before relying on its exact status-code semantics (reference §4.3).
8. **Op-log volume & retention.** At 50k/day/account across many accounts, `relay_op_logs` grows unbounded without GC. v1 **ships** the retention sweep (delete success >14 d, failure >90 d — §13.1) as a scheduled `relayctl logs --gc` / built-in sweeper (`01-implementation-plan.md` Phase 5), with a table-growth alert. Open: whether to **time-partition** `relay_op_logs` (recommended) or offload to a log pipeline rather than Postgres at higher volume.
9. **Multi-region.** Out of scope for v1 (single region). If latency to distant self-hosters matters, design a regional Redis/Postgres strategy for the otherwise-stateless request path.
10. **FCM priority deprioritization.** The relay honors `03`'s `priority: high` for `private_data`. If FCM deprioritizes Silo's app-instance traffic over the 7-day window (reference §2.8), is any relay-side change warranted, or is this purely the caller's `priority: normal` fallback concern? Lean: caller's concern; revisit if metrics show systematic deprioritization.
@@ -0,0 +1,738 @@
# Silo Push Relay — Implementation Plan
**Date:** 2026-06-13
**Status:** Draft (implementation-grade, phased)
**Service:** `silo-push-relay` (provisional repo name; the relay code lives in a **separate repository**)
**Provisional hostname:** `relay.silo.app`
**Stack:** Go 1.25+, PostgreSQL, Redis
**Source documents (read before executing any phase):**
- [`./00-relay-spec.md`](./00-relay-spec.md) — the engineering spec this plan implements. Section numbers below (e.g. "spec §6.4") refer to it.
- [`./02-apns-fcm-2026-reference.md`](./02-apns-fcm-2026-reference.md) — 2026-current Apple/Google/Go reference (cited as "reference §N").
- [`../02-apns-relay.md`](../02-apns-relay.md) and [`../03-fcm-relay.md`](../03-fcm-relay.md) — the **authoritative external wire contract** (the `02`/`03` request/response shapes). The relay must honor these byte-for-byte.
> **Commands assume the repository root is the cwd.** This document contains no local absolute filesystem paths or transient worktree IDs; all repo references are repository-relative.
> **Repo boundary.** This plan describes a **separate repository** (`silo-push-relay`). The design docs are authored here in `silo-server` purely for co-location with the `02`/`03` contracts. The relay **code** lives elsewhere. The one deliberate coupling is the shared `pushwire` payload-builder package, mirrored into both repos (see §3 and Phase 3/4).
---
## 1. Overview
### 1.1 What is being built
A small, **stateless-on-the-request-path** Go HTTP service that holds the official Silo Apple (`.p8` APNs auth key) and Google (Firebase service-account JSON) push credentials, accepts authenticated **content-free** push requests from opted-in self-hosted Silo servers on two endpoints, builds a fixed generic APNs/FCM payload, forwards it upstream, and maps the upstream result back to a narrow caller-facing response.
The two send endpoints and their contract are fixed by `02`/`03` and reproduced in spec §5:
| Endpoint | Purpose | Authoritative contract |
|---|---|---|
| `POST /v1/apple/send` | Forward one opaque push to APNs for one device token | `../02-apns-relay.md`, spec §5.1 |
| `POST /v1/fcm/send` | Forward one opaque data-only push to FCM for one device token | `../03-fcm-relay.md`, spec §5.2 |
| `GET /healthz` | Liveness (public) | spec §5.3 |
| `GET /readyz` | Readiness incl. PG/Redis core deps + per-provider credential health (internal) | spec §5.4 |
| `GET /metrics` | Prometheus exposition (internal) | endpoint spec §5.4; metric catalog §13.2 |
Administration is a CLI (`relayctl`) that writes directly to the relay's PostgreSQL — **there is no public admin HTTP API** in v1 (spec §5.6).
### 1.2 The v1 cut line
**In scope (v1):**
- `POST /v1/apple/send` and `POST /v1/fcm/send` exactly per `02`/`03`.
- Bearer auth (`rk_` keys), per-account allowlists, Redis token-bucket rate limiting, Redis idempotency.
- APNs token-based (ES256 `.p8`) client with cached JWT; FCM HTTP v1 client with cached OAuth2 token.
- PostgreSQL storage of accounts/keys/allowlists/redacted op-logs only; Goose-style migrations.
- `relayctl` admin CLI; structured redacted logs; Prometheus metrics; `/healthz` + `/readyz`; graceful shutdown.
- Single region, multiple stateless replicas behind a TLS load balancer.
**Explicitly out of scope (v1)** — from spec §2.2 / NG1–NG7:
- Token→user aliases, device subscriptions, any user/profile identity (stateless v1).
- Arbitrary APNs/FCM JSON, custom titles/bodies, topic/condition broadcasts, badge-by-default.
- Delivery receipts, analytics, open-tracking.
- APNs broadcast / Live Activity channels (`/4/broadcasts/...`).
- Web Push, HMS, ADM, or any non-APNs/non-FCM transport.
- A public self-service signup / billing surface.
- The `custom_apns` / `custom_fcm` direct paths (those run inside `silo-server`).
- Multi-region (the request path is stateless, but Redis/Postgres regional strategy is deferred).
### 1.3 The real long pole: provider account provisioning
**Apple/Firebase account provisioning is the critical-path dependency, not the code.** Obtaining the official Apple Developer team, generating a `.p8` APNs auth key (Key ID + Team ID), registering per-platform bundle topics, standing up the Firebase project(s), and minting a service-account JSON can take days-to-weeks of organizational/approval lead time and is **fully decoupled from coding**.
> **Action: start the §7 provisioning checklist on day 1, in parallel with Phase 0.** Phases 0–2 (scaffold, storage, auth/rate-limit/idempotency) need **no** live provider credentials and can complete entirely against fakes and contract tests. Only Phase 3 (APNs integration tests against sandbox) and Phase 4 (FCM `validateOnly`) are gated on credentials. If provisioning slips, the code still reaches "everything but live upstream" without blocking.
```mermaid
flowchart LR
subgraph Track A — Code
P0[Phase 0\nscaffold] --> P1[Phase 1\nstorage] --> P2[Phase 2\nauth/RL/idem]
P2 --> P3[Phase 3\nAPNs]
P2 --> P4[Phase 4\nFCM]
P3 --> P5[Phase 5\nobservability]
P4 --> P5
P5 --> P6[Phase 6\nharden+deploy]
end
subgraph Track B — Provisioning (parallel, long pole)
A1[Apple team + .p8 + topics]
G1[Firebase project + SA JSON + package]
end
A1 -. gates .-> P3
G1 -. gates .-> P4
A1 -. gates .-> P6
G1 -. gates .-> P6
```
---
## 2. Repository Bootstrap
### 2.1 Go module path
```
module github.com/silo-app/silo-push-relay
```
Target **Go 1.25+** (required by `jackc/pgx v5.10` and `firebase.google.com/go/v4 v4.20`; reference §3 version-targeting note). Pin in `go.mod` with `go 1.25`.
### 2.2 Directory layout
```
silo-push-relay/
├── cmd/
│ ├── relay/ # main HTTP service entrypoint (wires config → stores → clients → server)
│ │ └── main.go
│ └── relayctl/ # admin CLI (account/key/allowlist/logs/ping-upstream); writes directly to PG
│ └── main.go
├── internal/
│ ├── config/ # env + secret-manager config loading, validation, defaults
│ ├── httpapi/ # router, handlers, middleware (auth, rate limit, idempotency, redaction), error bodies
│ ├── apns/ # APNs upstream client: ES256 JWT lifecycle, HTTP/2 pool, reason→error mapping
│ ├── fcm/ # FCM upstream client: OAuth2 token source, HTTP/2 client, status→error mapping
│ ├── accounts/ # data-access for accounts/keys/allowlists (shared by httpapi + relayctl)
│ ├── ratelimit/ # Redis Lua token-bucket limiter (per-account + coarse per-token)
│ ├── idempotency/ # Redis SET NX lock / replay / 409 / 422 store
│ ├── pushwire/ # SHARED payload builders (APNs + FCM payload/header construction) — mirrored into silo-server
│ ├── oplog/ # redacted op-log writer (PG) + token hashing helper
│ ├── observability/ # slog JSON handler + redaction, Prometheus registry/metrics, /healthz /readyz /metrics
│ └── store/ # pgxpool + go-redis bootstrapping, health pings, migration runner hook
├── migrations/
│ └── sql/ # Goose-style timestamped SQL migrations (see §2.6)
├── deploy/
│ ├── Dockerfile
│ └── (k8s / compose manifests as applicable)
├── Makefile
├── go.mod
├── go.sum
├── .golangci.yml
├── .github/workflows/ci.yml # or equivalent CI config
└── README.md
```
Package ownership mirrors the Silo convention (CLAUDE.md "keep new code in the package that owns the behavior"): no catch-all `utils` package; shared payload logic lives in `pushwire`; shared data-access lives in `accounts`/`store` so `relayctl` and `httpapi` share schema and constraints (spec §5.6 "The CLI shares the relay's data-access package").
### 2.3 The shared `pushwire` package (maintainability win)
`internal/pushwire` is the single source of truth for **how an opaque request becomes an upstream payload + headers** — the APNs `aps`/`silo` dictionaries and `apns-*` headers (spec §5.1), and the FCM data-only `message` body + `android` config (spec §5.2). It is intentionally **dependency-light** (pure Go structs + JSON, no network) so it can be **mirrored into `silo-server`** under its `custom_apns` / `custom_fcm` paths, where the self-hosted server builds the *same* payloads when an admin uses their own credentials. One package, two repos: the relay and the direct-credential path can never drift on payload shape. (See §3 dependency table and Phase 3/4.)
> **Mirroring mechanism & sync runbook (drift/ownership hazard — make it explicit).** Keep `pushwire` import-cycle-free and free of relay-only types (no DB, no config). **Designate one repo as the canonical source of truth** for `pushwire` (the relay repo `silo-push-relay`, since it owns the upstream payload contract). The copy in `silo-server` is a **checked-in generated artifact**, not a hand-edited file: commit a **content checksum** of the canonical package alongside it, and have **both** repos' CI verify their local copy against that checksum (a copied package in repo A cannot fail repo B's CI on its own, so a passive golden-file test in one repo is insufficient). Bump a **versioned payload-schema constant** on any payload change so a mismatch is unambiguous. Add a golden-file + closed-allowlist-key contract test on both sides (Phase 3/4 / §5.2). Document the ownership/sync runbook (who regenerates, in what order, how the checksum is bumped) so a payload change cannot silently diverge until the other repo happens to update. The preferred end state is a small shared module both repos import rather than a manual copy.
### 2.4 Config strategy
Twelve-factor-ish, but credentials come from a **secret manager**, not env vars (spec §11, reference §4.5 ranking: secret-manager > mounted file > env var > in-image). `internal/config` resolves, in order of preference per secret:
| Config | Source (preferred → fallback) | Notes |
|---|---|---|
| APNs `.p8` private key, Key ID, Team ID | secret manager → mounted file | never env var, never in image |
| FCM SA JSON **or** Workload Identity binding | GCP attached SA / Workload Identity → secret manager JSON | off-GCP uses JSON; on-GCP keyless (reference §4.5) |
| API-key HMAC pepper | secret manager | rotation is heavier (spec open Q#6) |
| Postgres DSN, Redis URL | secret manager → env | pool-tuned |
| Non-secret tuning (timeouts, rate defaults, listen addrs, log level, environment label) | env vars / flags | safe to log |
`config.Load(ctx)` returns a validated, fully-populated struct or a hard error; the process refuses to start with a missing/invalid credential. Provide a `config.Validate()` that `cmd/relay` calls before opening any listener. Non-secret defaults (rate limits from spec §9.2, timeouts from spec §11) live as constants with env overrides.
### 2.5 Makefile (mirror Silo's Makefile-driven workflow)
`make` targets, modeled on `silo-server`'s Makefile (CLAUDE.md §Build):
```make
make build # go build ./cmd/relay ./cmd/relayctl
make run # run cmd/relay locally against docker-compose PG+Redis
make test # go test ./... (unit + contract; no live providers)
make test-integration # go test -tags=integration ./... (APNs sandbox + FCM validateOnly; needs creds)
make lint # golangci-lint run
make fmt # gofmt -w + goimports
make migrate-create NAME=add_thing # timestamped Goose migration scaffold
make migrate-up # apply migrations
make migrate-status # list migration state
make relayctl ARGS="account list" # build+run the admin CLI
make docker # build deploy/Dockerfile
make loadtest # run the k6/vegeta burst scenario (Phase 6)
```
`make migrate-create NAME=...` must produce a **timestamped** filename (CLAUDE.md: "New migrations must use timestamped filenames created with `make migrate-create`; do not run `goose fix` or create paired `.up.sql`/`.down.sql` files"). The relay uses single-file Goose SQL migrations with `-- +goose Up` / `-- +goose Down`.
### 2.6 Migrations (Goose-style)
`migrations/sql/<timestamp>_<name>.sql`, run by a Goose runner wired in `internal/store` and invokable via `make migrate-up` and on `cmd/relay` startup (behind a flag). The v1 schema is exactly spec §8.1 (`relay_accounts`, `relay_api_keys`, `relay_apns_allowlist`, `relay_fcm_allowlist`, `relay_op_logs`). IDs are ULIDs stored as `text`; timestamps are `timestamptz` (Silo convention).
### 2.7 CI outline
CI (GitHub Actions or equivalent) runs on every push/PR:
```yaml
jobs:
build-test:
steps:
- setup-go 1.25
- make lint # golangci-lint
- make build
- make test # unit + contract tests (no provider creds)
- upload coverage
migrate-check:
services: [postgres, redis]
steps:
- make migrate-up # migrations apply cleanly on a fresh DB
- make migrate-status
integration: # gated: only when provider creds are available (Phase 3+)
if: secrets.APNS_P8 != '' && secrets.FCM_SA_JSON != ''
services: [postgres, redis]
steps:
- make test-integration # APNs sandbox + FCM validateOnly contract tests
```
The `integration` job is **conditional on secrets being present** so Phases 0–2 are green long before any credential exists.
---
## 3. Dependency Choices
All versions are from the reference §3.7 pinned list. Pin exact versions in `go.mod`; bump deliberately.
| Concern | Library | Version | One-line rationale |
|---|---|---|---|
| APNs client | `github.com/sideshow/apns2` **or** hand-rolled | `v0.25.0` / `x/net/http2 v0.56.0` + `golang-jwt/jwt/v5 v5.3.1` | De-facto Go APNs HTTP/2 client (auto JWT + conn reuse); **decide vs. hand-rolled before Phase 3** because `apns2` has had no release since Oct 2024 (bus-factor; reference §3.1, spec open Q#3). |
| FCM auth | `golang.org/x/oauth2/google` | `v0.36.0` | `CredentialsFromJSONWithType(..., google.ServiceAccount, firebase.messaging scope)` gives an auto-caching/-refreshing `TokenSource`; lighter than the Admin SDK and gives direct control of conn reuse + error classification (reference §3.2 — preferred for a fixed payload). |
| FCM (alt, batteries-included) | `firebase.google.com/go/v4/messaging` | `v4.20.0` | Acceptable alternative with `IsUnregistered`/`IsQuotaExceeded` helpers; **pick exactly one** FCM path, not both (reference §3.2). Plan defaults to the `x/oauth2` path. |
| JWT (ES256 sign for APNs; RS256 for hand-rolled FCM grant) | `github.com/golang-jwt/jwt/v5` | `v5.3.1` | Maintained JWT lib; ES256 for APNs, RS256 if hand-rolling the FCM token exchange (reference §3.1). |
| HTTP/2 transport tuning | `golang.org/x/net/http2` | `v0.56.0` | `ReadIdleTimeout`+`PingTimeout` to reap half-open upstream conns — "the single most important tuning for a long-lived relay" (reference §3.3). |
| Postgres | `github.com/jackc/pgx/v5` + `pgxpool` | `v5.10.0` | Recommended driver/pool; `pgxpool.New` is concurrency-safe + health-checked (reference §3.4). |
| Redis | `github.com/redis/go-redis/v9` | `v9.20.1` | Context-first client with pooling; atomic Lua for rate-limit, `SET NX` for idempotency (reference §3.4). |
| Router | stdlib `net/http` (Go 1.22+ `http.ServeMux` patterns) | stdlib | Two routes + health/metrics need no framework; stdlib `ServeMux` supports method+path patterns and gets HTTP/2 over TLS automatically. Avoids a dependency; middleware is plain `http.Handler` wrapping. |
| Structured logging | `log/slog` | stdlib (Go 1.21+) | `slog.NewJSONHandler` for production; redaction middleware so secrets can't leak (reference §3.5, spec §13.1). |
| Metrics | `github.com/prometheus/client_golang` | `v1.23.2` | Custom registry + `promhttp.HandlerFor` for `/metrics`; per-provider latency histograms + outcome counters (reference §3.5, spec §13.2). |
| Per-instance rate (outbound pacing helper) | `golang.org/x/time/rate` | current | `Reservation.Delay()` to compute relative `Retry-After`; used for in-process pacing, **not** the cross-replica cap (that is Redis — reference §4.2). |
| ULID generation | `github.com/oklog/ulid/v2` (or equivalent) | current | Generate `request_id` and entity IDs as ULIDs (Silo convention; spec §8.1). |
**Decision to make before Phase 3 (spec open Q#3):** adopt `sideshow/apns2` or hand-roll the APNs client. Recommendation: prototype both in Phase 3 behind the `internal/apns` interface; pick `apns2` if its recent commit/issue activity is acceptable, else hand-roll on `x/net/http2` + `golang-jwt/jwt/v5` mirroring the verified `apns2` internals (reference §3.1: `HostProduction`/`HostDevelopment`, `TokenTimeout=3000`s, `ReadIdleTimeout=15s`). Either way the rest of the service depends only on the `internal/apns` interface, not the concrete client.
**`pushwire` maintainability note (reference + spec §5).** The shared `pushwire` package builds the exact APNs/FCM payloads and headers. Because `silo-server`'s `custom_apns`/`custom_fcm` paths must produce identical payloads, `pushwire` is mirrored into `silo-server` with **one canonical source repo** (the relay), a **committed checksum verified in CI on both sides**, and a **closed-allowlist-key** test so an added `data`/payload key fails CI regardless of golden-file regeneration order (§2.3 sync runbook, §5.2). This is also a **supply-chain control for the content-free guarantee**: the payload-builder that enforces "data-only, no `notification` block, fixed key set" is enforced in two repos that must not drift. Payload shape is defined once.
---
## 4. Phased Tasks
Effort sizes: **S** ≈ 1 day, **M** ≈ 2–3 days, **L** ≈ 4–5 days (one engineer). Sizes are rough.
### Phase 0 — Scaffold, config, health, CI (size: M)
**Goal.** A buildable, lintable, testable repo that boots `cmd/relay`, serves `/healthz`, loads config, and is green in CI — with **no** provider dependency.
**Files to create/modify.**
- `go.mod`, `go.sum`, `.golangci.yml`, `Makefile`, `README.md`, `.github/workflows/ci.yml`, `deploy/Dockerfile`.
- `cmd/relay/main.go` — load config, build slog JSON logger, start an `http.Server` with explicit timeouts and graceful shutdown, mount `/healthz`.
- `internal/config/config.go` — `Load(ctx)`, `Validate()`, defaults; secret-manager interface stub (real wiring in Phase 6).
- `internal/observability/logger.go` — `slog.NewJSONHandler` setup.
- `internal/httpapi/server.go`, `internal/httpapi/router.go`, `internal/httpapi/health.go` — router, `/healthz`.
- `internal/httpapi/errors.go` — the standard error body (spec §5.5) and a `writeError(w, status, code, msg, requestID)` helper.
**Implementation notes.**
- Server timeouts are **mandatory** (zero = no timeout): `ReadHeaderTimeout`, `ReadTimeout`, `WriteTimeout`, `IdleTimeout`, `MaxHeaderBytes` (reference §3.3, spec §11). Body size cap ~16 KiB.
- Graceful shutdown: on SIGTERM/SIGINT call `srv.Shutdown(ctx)` with a bounded context (spec §14.2).
- `/healthz` returns `200 {"status":"ok"}`, unauthenticated, no dependency checks (spec §5.3).
- Error body shape is fixed (spec §5.5):
```json
{ "error": { "code": "string_code", "message": "human readable", "request_id": "01JRELAY..." } }
```
Every request gets a ULID `request_id`, echoed as the `X-Request-Id` response header.
**Tests.**
- Unit: `config.Validate` rejects missing required fields; `writeError` emits exact JSON + `X-Request-Id`.
- Unit: `/healthz` returns 200 with the exact body.
- CI: `make lint`, `make build`, `make test` green; `migrate-check` job present (no migrations yet → no-op pass).
**Acceptance criteria.**
- [ ] `make build` produces `relay` and `relayctl` binaries.
- [ ] `make lint` and `make test` pass in CI with zero provider credentials.
- [ ] `cmd/relay` boots, serves `/healthz` 200, and shuts down gracefully on SIGTERM.
- [ ] Every response carries `X-Request-Id`; errors use the §5.5 shape.
---
### Phase 1 — Storage, migrations, `relayctl` admin CLI (size: L)
**Goal.** The full PostgreSQL schema, a migration runner, the shared data-access package, and a working `relayctl` that can provision accounts/keys/allowlists and read op-logs — all without touching providers.
**Files to create/modify.**
- `migrations/sql/<ts>_init_relay_schema.sql` — exact schema from spec §8.1: `relay_accounts`, `relay_api_keys`, `relay_apns_allowlist`, `relay_fcm_allowlist`, `relay_op_logs`, plus indexes (`relay_api_keys_prefix_uidx`, `relay_api_keys_account_idx`, `relay_op_logs_account_time_idx`, `relay_op_logs_time_idx`).
- `internal/store/postgres.go` — `pgxpool.New(ctx, dsn)`, `Ping`, migration runner hook.
- `internal/store/redis.go` — `go-redis` client + `PING` (used Phase 2).
- `internal/accounts/` — data-access: `CreateAccount`, `ListAccounts`, `DisableAccount`, `IssueKey`, `ListKeys`, `RevokeKey`, `LookupKeyByPrefix`, `SetApnsAllowlist`, `SetFcmAllowlist`, `GetAllowlists`. Used by both `httpapi` and `relayctl`.
- `internal/accounts/keys.go` — key generation + hashing (see notes).
- `internal/oplog/oplog.go` — `Write(ctx, entry)` to `relay_op_logs`; `TokenHash(token) string` (SHA-256 hex).
- `cmd/relayctl/main.go` + subcommands per spec §5.6 table.
**Implementation notes.**
- **API-key format & hashing** (spec §8.2, reference §4.1):
- Format `rk_<env>_<base62/base64url>`; env ∈ {`live`,`test`}. Random secret = 32 bytes from `crypto/rand`.
- **Prefix** = `rk_<env>_<first 6–8 chars of random>`, non-secret, stored cleartext, uniquely indexed.
- Store `key_hash = HMAC-SHA256(pepper, secret)` (32 bytes). Never store plaintext. Fast keyed hash is correct for 256-bit random keys (do **not** use argon2id here).
- `relayctl key issue` prints the full `rk_…` **exactly once** to stdout; only hash+prefix persist (spec §8.3).
- `relayctl` connects directly to Postgres with a privileged DSN held by operators (no public admin API; spec §5.6). Each mutating command writes an `admin.*` op-log with operator identity (OS user / `--actor`) and never the secret token (spec §5.6).
- Allowlist commands replace the set atomically (UPSERT/DELETE in one tx) for `relay_apns_allowlist` / `relay_fcm_allowlist`.
- Official allowlist config values to seed in examples/tests (allowlist config, **not** the contract — spec §8.4): APNs topics `com.continuum.app.ios`, `com.continuum.app.tvos`, `com.continuum.app.macos`; FCM project `continuum-prod-android`, package `com.continuum.app.android`.
**`relayctl` command surface (spec §5.6 / §13.3).**
| Command | Effect |
|---|---|
| `relayctl account create --name <label> [--note <text>]` | INSERT `relay_accounts`; print `account_id` |
| `relayctl account list [--active]` | SELECT accounts + key count + last activity |
| `relayctl account disable --account <id>` | `status='disabled'` |
| `relayctl key issue --account <id> [--env live\|test] [--expires <dur>]` | INSERT key; print `rk_…` once |
| `relayctl key list --account <id>` | SELECT (prefix/created/last_used/revoked; never secret) |
| `relayctl key revoke --key-prefix <prefix>` | `revoked_at=now()` |
| `relayctl allowlist apns set --account <id> --topics a,b,c` | replace APNs allowlist |
| `relayctl allowlist fcm set --account <id> --project <p> --packages a,b` | replace FCM allowlist |
| `relayctl allowlist show --account <id>` | print effective allowlists |
| `relayctl logs --account <id> [--since …] [--outcome rejected]` | query `relay_op_logs` (redacted) |
| `relayctl logs --gc` | retention GC: delete success rows >14d, failure/reject rows >90d (Phase 5; cron-schedulable) |
| `relayctl ping-upstream --provider apns\|fcm --env sandbox` | credential-only health check; flips `relay_credential_healthy` (Phase 3/4/6) |
**Tests.**
- Integration (PG via docker-compose): `make migrate-up` applies cleanly on a fresh DB; `make migrate-status` lists state.
- Unit/integration: `IssueKey` → `LookupKeyByPrefix` round-trips; hash is HMAC-SHA256; plaintext never stored.
- Integration: full `relayctl` flow — create account → issue key → set allowlists → list/show; `admin.*` op-logs written without secrets.
- Unit: `oplog.TokenHash` is stable SHA-256 hex; op-log entry rejects/strips raw token.
**Acceptance criteria.**
- [ ] Migrations apply on a fresh DB and match spec §8.1 exactly (column names/types/constraints/indexes).
- [ ] `relayctl` can provision an account, issue a key (printed once), set both allowlists, and read redacted logs.
- [ ] No table stores tokens, content, or identity; `relay_op_logs` holds only `token_hash`, never raw tokens.
- [ ] Key hash is HMAC-SHA256(pepper, secret); constant-time compare verified in a unit test.
---
### Phase 2 — Auth middleware, rate limit, idempotency (size: L)
**Goal.** The full request-path middleware chain (auth → rate limit → idempotency → decode/validate/allowlist) wired in front of stub send handlers that return a deterministic fake upstream result. No real provider yet.
**Files to create/modify.**
- `internal/httpapi/middleware_auth.go` — bearer parse, prefix lookup, constant-time compare, revoked/expired/disabled rejection, throttled `last_used_at`, per-IP anti-brute-force.
- `internal/ratelimit/limiter.go` + `ratelimit/bucket.lua` — Redis Lua token bucket (per-account + coarse per-token).
- `internal/httpapi/middleware_ratelimit.go` — applies the limiter, emits `429` + `Retry-After`.
- `internal/idempotency/store.go` + `idempotency/idem.lua` (if needed) — `SET NX` lock, replay, 409, 422.
- `internal/httpapi/middleware_idempotency.go` — wraps send handlers.
- `internal/httpapi/decode.go` — `DisallowUnknownFields` decoder + field validation + allowlist enforcement.
- `internal/httpapi/apple.go`, `internal/httpapi/fcm.go` — handler skeletons returning a fake `accepted` result (real upstream in Phase 3/4).
**Implementation notes.**
- **Auth** (spec §8.2): parse `Authorization: Bearer rk_<env>_<rest>` (malformed → `401 unauthorized`); derive `key_prefix`; `SELECT … WHERE key_prefix=$1` (unique index); reject if no row / revoked / expired / account disabled (do not distinguish reasons to caller — all `401 unauthorized`); `subtle.ConstantTimeCompare(HMAC(pepper, presented), key_hash)`; throttle `last_used_at` to ≤1/min/key; rate-limit auth failures per resolved client IP (see client-IP resolution below).
- **Constant-time auth path (no enumeration oracle).** On a missing/revoked/expired row, **still** perform a dummy `HMAC-SHA256` against a fixed decoy hash with `subtle.ConstantTimeCompare` before returning the uniform `401`, so "unknown prefix" is not measurably faster than "known prefix, wrong secret." Keep the fine-grained failure reason (`unknown_prefix` vs `hash_mismatch`) in **internal** logs/metrics only — never in a response and never exposed to an untrusted `/metrics` scraper.
- **Client-IP resolution / trusted proxy (security-critical).** The relay sits behind a TLS load balancer, so Go's `http.Request.RemoteAddr` is the LB address, not the caller. Derive the client IP from `RemoteAddr` **unless** the immediate peer is in an **allowlisted trusted-proxy CIDR list** (a config value), in which case take the **right-most untrusted hop** from the LB-set forwarding header. **Caller-supplied `X-Forwarded-For`/`X-Real-IP` headers are ignored** — trusting them would let an attacker spread brute-force across unlimited fake IPs (defeating the per-IP throttle) and poison `egress_ip` in op-logs. Both the per-IP auth-failure limiter and the logged `egress_ip` consume this single resolved IP. Account for shared egress (CGNAT/VPN — multiple self-hosted servers behind one IP, anticipated in spec §3.4): make the brute-force lockout **soft** (added latency / `Retry-After`), not a hard block, so a noisy/compromised neighbor cannot lock out legitimate servers sharing the IP.
- **Bounded fail-open on Redis outage.** The per-account/daily rate limit is the primary cap on a stolen-key attacker's spend (and on shared downstream APNs/FCM quota). A naive fail-open removes that cap entirely during a Redis outage. Instead, when Redis is unreachable, fall back to a conservative **in-process** per-account limiter (`x/time/rate`) so each account is still capped to a small multiple of its normal rate; the allowlist (always enforced from Postgres) remains the hard boundary. A Redis outage pages ops; the cross-replica daily quota cannot be enforced during the outage (documented limitation).
- **Rate limit** (spec §9): Redis HASH `rl:{account_id} = {tokens, last_refill_ts}` with TTL, atomic Lua (refill→check→consume in one `EVAL`). Defaults (spec §9.2): per-account ~10 req/s burst, refill 10/s with burst capacity 20; per-account daily 50,000 req/day; coarse `(account_id, token_hash)` ~1 req/3s. Over limit → `429 rate_limited` with **relative** `Retry-After` (delay-seconds, never an absolute epoch — reference §4.2). On Redis down: **bounded fail-open** (in-process per-account fallback limiter, above — not unlimited), log `rate_limit.degraded`, increment `relay_rate_limit_degraded_total` (spec §9.4).
- **Daily quota mechanics.** Store the daily counter as a **separate Redis counter** `rl:day:{account_id}:{yyyymmdd}` with `INCR` + `EXPIRE` to the end of the window — it is **not** the per-second token bucket and **not** a Postgres column. Reset semantics: **fixed UTC calendar day** (`yyyymmdd` in UTC). During a Redis outage the daily counter, like the per-second bucket, falls back to the bounded in-process limiter and the precise cross-replica daily cap cannot be enforced (documented; pages ops). Emit `relay_rate_limited_total{kind="daily"}` and alert on accounts approaching/hitting the cap (a big-release burst can legitimately reach 50k/day; §6.4).
- **Project-level FCM circuit breaker (multi-tenant fairness).** The ~600k msgs/min FCM project quota (reference §2.7) is **shared across all relay accounts**, but the per-account limit is local to each account — one noisy account can exhaust the shared quota for everyone. Add a relay-side **global per-project outbound limiter / circuit breaker** that tracks aggregate FCM send rate against the project quota and **sheds load with `503 upstream_throttled` before hammering FCM** when near quota (the relay is the only aggregation point that can honor reference §2.7's anti-spike / smooth-traffic guidance — avoid sending within a 2-minute window of :00/:15/:30/:45). Export `relay_fcm_project_quota_headroom` and alert (§6.4).
- **Idempotency** (spec §10): require `Idempotency-Key` header on sends; missing → `400 missing_idempotency_key`. Length-limit ≤255 chars; the three-part `<delivery_id>:<device_id>:<attempt>` shape is **opaque** (not strictly parsed). Namespaced `idem:{account_id}:{key}`. `SET … NX EX <lock_ttl>` (`lock_ttl` ≈ 30s) in-flight lock:
- NX success → process; on completion overwrite with serialized `{status, body, upstream_id, canonical_hash}` + ~24h TTL (success **and** error recorded).
- NX fail + stored completed result → **replay** verbatim (same `apns_id`/`fcm_message_name`).
- NX fail + still in-flight → `409 idempotency_conflict`.
- Same key + different request payload (compare stored `canonical_hash`) → `422 idempotency_key_reuse`.
- **Fails closed**: Redis unavailable → `503 upstream_unavailable` (never risk a double-send; spec §10.2).
- **Lock-TTL invariant (no double-send / no clobber).** The upstream APNs/FCM call (plus HTTP/2 setup, JWT/OAuth refresh, GOAWAY reconnect) can otherwise outlast `lock_ttl`, letting a concurrent retry's NX succeed and fire a **second** upstream send. Pin the upstream call with a hard context deadline **strictly shorter than `lock_ttl`** (≈10s upstream vs 30s lock) and refuse to send upstream if the handler's own deadline has already passed the lock TTL. On completion, use a **compare-and-set** that only overwrites the stored value if it is still *this* request's in-flight marker, so a lock stolen by a concurrent request cannot have its result clobbered.
- **Privacy invariant (no stored token).** The `canonical_hash` is computed over the validated fields with the device token **replaced by its already-computed `token_hash` (SHA-256)**; the stored idempotency value is exactly `{status, body, upstream_id, canonical_hash}` and holds **no raw token** — matching the op-log no-raw-token guarantee (NG1, spec §3.2/§12).
- **Drain interaction.** On handler context-cancellation (e.g. a drain/shutdown that severs an in-flight send), **release or downgrade** the in-flight marker so an immediate caller retry does not get spurious `409 idempotency_conflict` for the full `lock_ttl` (see Phase 6 drain ordering and §5.7 drain test).
- **Decode/validate/allowlist** (spec §5.1/§5.2/§8.4): `DisallowUnknownFields` (unknown key → `400 unexpected_field`). Validate every field per the spec field tables (token shape, environment, mode, ID length caps, collapse-id ≤64 bytes for APNs). Allowlist check **after** auth, **before** payload build: APNs `topic` ∈ account's `relay_apns_allowlist` else `403 topic_not_allowed`; FCM `(project_id, package_name)` ∈ `relay_fcm_allowlist` else `403 project_not_allowed` / `403 package_not_allowed`. Allowlist may be a short-TTL per-account-scoped in-memory cache invalidated on `relayctl allowlist … set`.
**Middleware order (spec §4.1).**
```
TLS → auth → rate-limit → decode(DisallowUnknownFields) → validate+allowlist → idempotency → [Phase 3/4 upstream] → map → store idem result → op-log + metrics
```
**Tests.**
- Unit: bearer parse edge cases; constant-time compare; revoked/expired/disabled all map to `401`; `last_used_at` throttle.
- Unit: the auth path is **constant-time for unknown-prefix vs wrong-secret** (dummy HMAC + `ConstantTimeCompare` on a missing/revoked row) — no enumeration timing oracle; the fine-grained reason stays internal-only.
- Unit: **trusted-proxy client-IP resolution** — a forged caller `X-Forwarded-For`/`X-Real-IP` is ignored; only the LB-set header from an allowlisted trusted-proxy peer is honored; `RemoteAddr` is used otherwise.
- Integration (Redis): token bucket refill/consume math; `429` + correct relative `Retry-After`; **daily counter** (`rl:day:{account}:{yyyymmdd}`, fixed-UTC reset, `EXPIRE`) trips at 50k; **bounded** fail-open on Redis down (in-process per-account cap engaged, not unlimited).
- Integration (Redis): idempotency first/replay/409/422/missing-key matrix (spec §10.3); fail-closed `503` on Redis down; the stored value carries **no raw token** (canonical hash uses `token_hash`).
- Integration (Redis): **Redis dropped mid-request** (not just down at start) — rate-limit layer degrades to bounded fail-open; idempotency layer fails closed (`503`); a lock whose TTL expires mid-send cannot be stolen into a double-send (upstream deadline < `lock_ttl`).
- Contract: `DisallowUnknownFields` rejects every disallowed field (`title`, `body`, `image_url`, `media_id`, `username`, `server_url`, …) with `400 unexpected_field`.
- Contract: allowlist rejections produce exact codes `topic_not_allowed` / `project_not_allowed` / `package_not_allowed`.
**Acceptance criteria.**
- [ ] A request with a valid key, allowlisted topic, and `Idempotency-Key` reaches the (stub) handler and returns `accepted`.
- [ ] Duplicate idempotency key replays the identical body; in-flight → `409`; payload mismatch → `422`; missing → `400`.
- [ ] Over-limit → `429` with relative `Retry-After`; Redis-down rate-limit fails open, idempotency fails closed.
- [ ] Every unknown field and every non-allowlisted topic/project/package is rejected with the exact §5/§8 error code.
---
### Phase 3 — APNs client + `POST /v1/apple/send` (size: L) · gated on Apple provisioning
**Goal.** A production APNs client (cached ES256 JWT, reused HTTP/2 pool, correct reason→error mapping) and the live `/v1/apple/send` endpoint building the §5.1 payloads via `pushwire`, validated against the APNs **sandbox**.
**Files to create/modify.**
- `internal/apns/client.go` — the `APNsClient` interface + concrete impl (apns2 **or** hand-rolled per §3 decision).
- `internal/apns/jwt.go` — ES256 JWT lifecycle (cache, ~50min refresh, never <20min, eager refresh on `ExpiredProviderToken`).
- `internal/apns/transport.go` — per-team HTTP/2 pool with `ReadIdleTimeout`/`PingTimeout`; env→host selection.
- `internal/apns/errors.go` — APNs status+`reason` → caller `error.code` + retryability (spec §6.4 table).
- `internal/pushwire/apns.go` — build `private_alert` / `background_wake` `aps`+`silo` payloads and `apns-*` headers (spec §5.1).
- `internal/httpapi/apple.go` — replace the Phase 2 stub with the real send.
**Implementation notes.**
- **JWT** (spec §6.1, reference §1.1): ES256 only; header `{"alg":"ES256","kid":<10-char Key ID>}`, claims `{"iss":<10-char Team ID>,"iat":<epoch s>}`. Cache one JWT behind a mutex; regenerate on a ~50-min timer, never <20-min apart; eager regenerate + **single in-process retry** on `403 ExpiredProviderToken`; back off / slow refresh on `429 TooManyProviderTokenUpdates`. **Per-team pool** keyed by team even though v1 has one team.
- **Single-flighted JWT regeneration (stampede guard).** Under a burst of concurrent sends sharing one cached JWT, an expiry (or a `403 ExpiredProviderToken`) is observed by many in-flight goroutines at once; a naive per-goroutine regenerate-on-403 would trip `429 TooManyProviderTokenUpdates`. Regeneration must therefore be **single-flighted** (e.g. `golang.org/x/sync/singleflight` or a generation/epoch counter under the mutex): a goroutine seeing `ExpiredProviderToken` first checks whether the JWT was already refreshed since *its* request started and reuses the new token rather than triggering another refresh. Exactly **one** refresh happens per ~20-min window regardless of how many concurrent 403s arrive.
- **Endpoint** (spec §6.2): `production` → `https://api.push.apple.com`; `sandbox` → `https://api.sandbox.push.apple.com`. Both `api.sandbox.push.apple.com` and the older alias `api.development.push.apple.com` are **valid, equivalent sandbox hosts that still resolve** — we standardize on `api.sandbox.push.apple.com` (the current canonical name and `apns2` v0.25.0's `HostDevelopment`); `02`'s use of `api.development.*` is not a discrepancy to resolve, just the older alias. The external contract `"environment":"sandbox"` is unchanged. The upstream hosts are **hardcoded constants never derived from request input**. Port 443, 2197 fallback.
- **Connection** (spec §6.3, reference §3.3): one client/pool per process per env per team; never per push. Non-zero `ReadIdleTimeout`(~15s) + `PingTimeout`(~15s). Do not hardcode `SETTINGS_MAX_CONCURRENT_STREAMS`. Handle `GOAWAY` (drop+reconnect). Ensure system trust store includes USERTrust RSA (default in OS/Go pools).
- **Headers** (spec §5.1 table): `apns-topic` = allowlisted topic; `apns-push-type` = `alert` (private_alert) / `background` (background_wake); `apns-priority` = `10` / `5` (background **must** be 5; 10 is an error — reference §1.4.1); `apns-collapse-id` = `collapse_id` if present (≤64 bytes); `apns-id` omitted (Apple generates, echoed back). **`apns-expiration` ships with a finite default** (configurable) rather than 0/omitted: a few hours for `private_alert`, a short TTL for `background_wake`. Omitting it makes Apple store-and-retry an undelivered push for **up to 30 days** (reference §1.4) — for a notification whose content is fetched live on wake, a wake fired days later is useless or confusing. Mirror this on FCM with a finite `android.ttl` (default is otherwise ~4 weeks) for cross-provider parity. (Resolved open Q#5; the exact default values want product input but v1 ships a sane non-30-day default, not an omission.)
- **Payloads** (`pushwire`, spec §5.1): `private_alert` → `aps.alert` with `title-loc-key=SILO_NOTIFICATION_TITLE`, `loc-key=SILO_NOTIFICATION_GENERIC_BODY`, `sound=default`, plus `silo` dict (`v:1`, `wake:"notifications.changed"`, `server_device_id`, `delivery_id`); add `aps.badge` only if `badge` is present, non-null, and within a **bounded range (0–9999)** — out-of-range → `400 invalid_field` (it is the one numeric field that escapes the opaque-ID discipline, so it is bounded rather than copied verbatim). `background_wake` → `aps.content-available:1` + `silo` dict. Uncompressed JSON, far under 4 KB; defensive `400 payload_too_large` if it ever exceeds.
- **Response → caller** (spec §5.1): `200` → `{ "request_id", "apns_id" (Apple's apns-id header), "status":"accepted" }`. Error mapping per spec §6.4 (e.g. `BadDeviceToken`/**`InvalidToken`**/`DeviceTokenNotForTopic`/`MissingDeviceToken` → `422 bad_device_token`; `Unregistered`/`ExpiredToken` → `410 unregistered` with `timestamp` passed through in `error.message`; `403 InvalidProviderToken`/etc → `502 upstream_auth_error` + ops alert; `429 TooManyRequests` → `429 rate_limited` + `Retry-After`; 5xx → `503 upstream_unavailable`). **`InvalidToken` is treated equivalently to `BadDeviceToken` (token-purge, non-retryable)** — Apple has migrated some responses from `BadDeviceToken` to `InvalidToken`, and the `02` contract requires treating them equivalently; omitting it would let a dead device fall through to the catch-all and never be disabled. Add a **default/fallback rule**: any unrecognized 400/403/410 `reason` maps to a safe non-retryable `502 upstream_error` rather than being dropped. **Connection-severed sends** — a request whose HTTP/2 stream is terminated by `GOAWAY` or any connection reset **before** a final APNs response arrives → `503 upstream_unavailable` (retryable), the same as a network timeout; the relay does **not** transparently retry it (the caller's outbox does), preserving at-most-once semantics under the idempotency key. Tag the op-log outcome / metric label `connection_severed` so these are distinguishable. **Environment/token mismatch caveat:** a `sandbox`/`production` mismatch (e.g. a prod token sent with `environment=sandbox`) presents upstream as `BadDeviceToken` → `bad_device_token`, which instructs the caller to purge a perfectly good token; emit a distinct op-log/metric so a systemic misconfig (many `bad_device_token` from one account) is distinguishable from genuinely dead tokens. Network/timeout to APNs → `503 upstream_unavailable`. The relay does **not** retry transient upstream errors across caller requests (the caller's outbox does); the only in-process retry is the JWT-expiry one (single-flighted — see JWT note below).
**Tests.**
- Unit: JWT signs valid ES256 with correct header/claims; refresh-timer logic (≥20min, ≤60min, eager-on-expired); single retry on `ExpiredProviderToken`.
- Concurrency: force JWT expiry under a burst of concurrent sends; assert **exactly one** regeneration (single-flight) and **zero** `429 TooManyProviderTokenUpdates`.
- Unit (`pushwire`): golden-file payloads + headers for `private_alert` and `background_wake`, with and without `badge`/`collapse_id` (byte-exact vs spec §5.1). **Same golden files mirrored into `silo-server`.**
- Unit: every row of the spec §6.4 reason→error table maps correctly (table-driven), **including `InvalidToken` → `bad_device_token`** (equivalent to `BadDeviceToken`) and the **default/fallback** rule (unrecognized reason → `502 upstream_error`, non-retryable).
- Fake APNs upstream injecting a **`GOAWAY` / mid-stream reset** before a final response → caller gets `503 upstream_unavailable`, the op-log/metric is tagged `connection_severed`, and there is **no lost or double send** (the relay does not transparently retry).
- Integration (**Apple sandbox**, behind `-tags=integration`, requires `.p8`): real send to `api.sandbox.push.apple.com` returns 200 + `apns_id`; an intentionally bad token returns the expected `bad_device_token`/`unregistered` mapping.
- Integration: `relayctl ping-upstream --provider apns --env sandbox` signs+connects without sending content; optionally captures the sandbox-only `apns-unique-id` response header (reference §1.6) into the ping output for Push-Notifications-Console delivery-log correlation during client QA.
**Acceptance criteria.**
- [ ] `/v1/apple/send` builds the exact §5.1 payloads/headers and returns `{request_id, apns_id, status:"accepted"}` on success against sandbox.
- [ ] Background pushes always send `apns-priority: 5` and `apns-push-type: background`.
- [ ] JWT is cached, reused, and refreshed on the ~50-min cadence; `ExpiredProviderToken` triggers one regenerate+retry; concurrent 403s yield exactly one single-flighted regeneration.
- [ ] Every spec §6.4 mapping is covered by a passing table-driven test, **including `InvalidToken` and the unrecognized-reason fallback**; a `GOAWAY`/connection-severed send maps to `503 upstream_unavailable` with no double-send; the `pushwire` golden files match across both repos.
---
### Phase 4 — FCM client + `POST /v1/fcm/send` (size: L) · gated on Firebase provisioning
**Goal.** A production FCM HTTP v1 client (cached OAuth2 token, tuned HTTP/2, correct status→error mapping) and the live `/v1/fcm/send` endpoint building the §5.2 **data-only** payloads via `pushwire`, validated with FCM `validateOnly:true`.
**Files to create/modify.**
- `internal/fcm/client.go` — the `FCMClient` interface + concrete impl (`x/oauth2/google` path per §3).
- `internal/fcm/oauth.go` — `CredentialsFromJSONWithType(ctx, json, google.ServiceAccount, "https://www.googleapis.com/auth/firebase.messaging")` → cached/auto-refreshing `TokenSource`, one per project.
- `internal/fcm/transport.go` — tuned HTTP/2 client; endpoint `POST https://fcm.googleapis.com/v1/projects/{PROJECT_ID}/messages:send`.
- `internal/fcm/errors.go` — FCM `error.status` → caller `error.code` + retryability + Retry-After honoring (spec §7.4).
- `internal/pushwire/fcm.go` — build data-only `message` + `android` config (spec §5.2).
- `internal/httpapi/fcm.go` — replace the Phase 2 stub with the real send.
**Implementation notes.**
- **OAuth2** (spec §7.1, reference §2.2): least-privilege scope `firebase.messaging`; `TokenSource` caches + auto-refreshes (~1h tokens; never mint per request). On GCP prefer keyless Workload Identity / attached SA; off-GCP load SA JSON from the secret manager (open Q#4). One token source per Firebase project.
- **Endpoint** (spec §7.2): global host only — no regional endpoints (reference §2.3). The host `fcm.googleapis.com` is a **hardcoded constant never derived from request input**. `{PROJECT_ID}` is the allowlist-checked `project_id`, and `project_id`/`package_name` are validated against a **strict charset** (e.g. `^[a-z0-9-]+$` / reverse-DNS) **before** allowlist lookup and URL interpolation, so an allowlist row can never introduce a path-traversal or host-override (no SSRF / path injection).
- **Payload — data-only** (`pushwire`, spec §5.2): `message.token` = caller token; `message.data` is a **fixed, closed set of string-valued keys only** (`"v":"1"`, `"wake":"notifications.changed"`, `"server_device_id"`, `"delivery_id"`) — no caller-controlled key or value beyond the opaque IDs; **no `notification` block**; `message.android.priority` = `"HIGH"` (private_data) / `"NORMAL"` (background_wake); `message.android.collapseKey` set only if the caller sent `collapse_key`. **Field-casing reconciliation (reference §2.4):** the relay POSTs raw JSON to the real FCM HTTP v1 REST endpoint, whose AndroidConfig field is camelCase **`collapseKey`** (FCM v1 rejects unknown fields with `INVALID_ARGUMENT`, so a literal `collapse_key` inside `android` would fail every collapsible send). The **inbound caller request body field stays `collapse_key`** per `03`; only the relay-built upstream JSON uses `collapseKey`. Likewise `android.priority` is sent as canonical uppercase `"HIGH"`/`"NORMAL"` (reference §2.4 lists these as the enum values, with lowercase also accepted; the relay sends the canonical uppercase to be robust against future enum tightening). Honor the `03` contract's `priority: high`-equivalent (`"HIGH"`) for `private_data` (do not second-guess — spec §5.2 reconciliation note). Set a finite `android.ttl` (default ~4 weeks otherwise) for parity with the APNs `apns-expiration` default (above). Never set `topic`/`condition`. ≤4096 bytes. Use `validateOnly:true` (camelCase REST field — reference §2.5) only for the `relayctl` test path, never production.
- **`badge` has no data-only FCM home.** The relay-built FCM payload is strictly data-only (no `notification` block), so a caller-provided `badge` has nowhere legitimate to go. **Drop `badge` from the FCM accepted schema** (it is disabled-by-default and would otherwise have to be smuggled into the closed `data` map, weakening the allowlist). On the APNs side, bound `badge` to a sane range (e.g. 0–9999) and reject otherwise with `400 invalid_field` — it is the one numeric field that escapes the opaque-ID discipline.
- **Collapse-key coalescing is best-effort (reference §2.7).** FCM stores at most **4 distinct `collapseKey`s per device** while offline; a 5th evicts one nondeterministically. Since `collapse_key` is a per-series HMAC and a device may follow >4 series, collapsible coalescing is best-effort and excess distinct keys evict nondeterministically — acceptable because the durable inbox + sync (the caller's outbox) covers any dropped collapsible wake. APNs has no equivalent documented per-device collapse-id cap (note the asymmetry).
- **Response → caller** (spec §5.2): `200` → `{ "request_id", "fcm_message_name" (FCM v1 `name`), "status":"accepted" }`. Error mapping per spec §7.4 (e.g. `INVALID_ARGUMENT` → `422 invalid_argument`; `SENDER_ID_MISMATCH` → `403 sender_id_mismatch`; `UNREGISTERED` → `410 unregistered`; `QUOTA_EXCEEDED` → `429 rate_limited` honoring upstream `Retry-After` else default **60s**; `INTERNAL`/`UNAVAILABLE` → `503 upstream_unavailable` honoring `Retry-After` on 503; `UNSPECIFIED_ERROR` → `502 upstream_error`, no auto-purge). Classify on `error.status`/`FcmError.errorCode`, not the bare HTTP code. **Retry-After** forwarded to the caller as relative delay-seconds (reference §4.2). Add a **default/fallback rule** so any unrecognized FCM `error.status`/`errorCode` maps to a safe non-retryable `502 upstream_error` (never an auto-purge), rather than being dropped.
- **`THIRD_PARTY_AUTH_ERROR` (401) — re-classified (do NOT page as a relay-credential failure).** Per Firebase's error-codes page this means an invalid/missing **APNs cert/key or Web Push auth credential** and is returned only for iOS-via-APNs or Web Push registration tokens. `/v1/fcm/send` sends **only** Android registration tokens (`message.token`, package `com.continuum.app.android`, data-only), so a legitimate Android send can never produce `THIRD_PARTY_AUTH_ERROR`. If it ever appears, treat it as a **per-token/registration anomaly** → `502 upstream_error`, surface for investigation, and do **not** page on-call as a relay `.p8`/SA misconfig. The relay's **actual** OAuth2/service-account credential failures surface as a non-200 from `oauth2.googleapis.com/token` at mint time, or HTTP `401 UNAUTHENTICATED` / `403 PERMISSION_DENIED` (the `status`, not an `FcmError` code) on `messages:send` — **those** map to `502 upstream_auth_error` + ops alert.
- **Per-device rate exceedance (`DeviceMessageRateExceeded`, reference §2.7).** The Android per-device caps (240/min, 5,000/hr) are **distinct** from project-level `QUOTA_EXCEEDED` and surface defensively as a quota/`INVALID_ARGUMENT`-adjacent signal whose exact code/string is not firmly documented; detect on `error.status`/`errorCode` and map to a retryable `429 rate_limited` with a conservative default `Retry-After`, **not** a token purge.
- **`UNREGISTERED` caller-facing 410 is an intentional cross-provider normalization.** FCM natively returns `UNREGISTERED` as HTTP **404**; the relay remaps it to caller `410 unregistered` so the caller's device-disable ("purge dead token") logic is uniform with the APNs path (which natively uses 410). This is a deliberate divergence from FCM's native status, not a bug.
**Tests.**
- Unit: token source caches; no per-request mint; refresh margin.
- Unit (`pushwire`): golden-file data-only payloads for `private_data` and `background_wake`, with/without `collapse_key` (byte-exact vs spec §5.2; **all data values strings**; **no `notification` block**). Mirrored into `silo-server`.
- Unit: every row of the spec §7.4 status→error table (table-driven), including `Retry-After` honoring + 60s default, the unrecognized-`error.status` fallback (`502 upstream_error`), the per-device-rate (`DeviceMessageRateExceeded`) → `429 rate_limited` (no purge), and `THIRD_PARTY_AUTH_ERROR` → `502 upstream_error` (per-token anomaly, **not** paged as relay-credential).
- Unit/contract: the FCM upstream JSON uses camelCase `collapseKey`, uppercase `priority` (`"HIGH"`/`"NORMAL"`), and `validateOnly`; `badge` is rejected from the FCM schema; the `data` map is the closed key set (no extra keys).
- Integration (**FCM `validateOnly:true`**, `-tags=integration`, requires SA JSON): a valid request validates OK; a malformed token surfaces `invalid_argument`/`unregistered` mapping.
- Integration: `relayctl ping-upstream --provider fcm --env sandbox` mints a token + does a `validateOnly` send.
**Acceptance criteria.**
- [ ] `/v1/fcm/send` builds the exact §5.2 data-only payload (string values, no `notification`) and returns `{request_id, fcm_message_name, status:"accepted"}`.
- [ ] OAuth2 token is cached/refreshed, never minted per request; one source per project.
- [ ] Every spec §7.4 mapping is covered (incl. the unrecognized-status fallback, per-device-rate, and `THIRD_PARTY_AUTH_ERROR` as a non-paging per-token anomaly); `QUOTA_EXCEEDED`/`UNAVAILABLE` forward `Retry-After` (default 60s on 429).
- [ ] The upstream FCM JSON uses `collapseKey` (camelCase), `"HIGH"`/`"NORMAL"` priority, `validateOnly` (camelCase), a finite `android.ttl`, and the closed `data` key set; `badge` is not accepted on the FCM path.
- [ ] `validateOnly` test path works; production sends never set `validateOnly`.
---
### Phase 5 — Observability, redaction, metrics (size: M)
**Goal.** Production-grade redacted logging, op-log persistence, the full Prometheus metric catalog, and `/readyz`/`/metrics` wired and network-scoped.
**Files to create/modify.**
- `internal/observability/redact.go` — redaction middleware (strips `Authorization`/bearer, raw key, raw token, JWT, OAuth2 token, payload bodies).
- `internal/observability/metrics.go` — Prometheus registry + the spec §13.2 catalog.
- `internal/httpapi/health.go` — `/readyz` (PG `SELECT 1`, Redis `PING`; APNs and FCM reported **per-provider**, not as a single combined gate) and `/metrics` (`promhttp.HandlerFor`), both bound to the internal network.
- `internal/oplog/gc.go` + `cmd/relayctl` `logs --gc` subcommand (or a background sweeper in `cmd/relay`) — the retention GC deliverable; plus the §8.1 partitioning DDL (or a documented partition/offload decision).
- Wire `oplog.Write` into every send path with the redacted subset.
**Implementation notes.**
- **Redaction enforced in middleware** so no code path can leak (spec §11/§12/§13.1). Never log: bearer header, raw API key (log prefix or hash only), raw device tokens (log `SHA-256` truncated hash only), payload bodies, relay JWT, OAuth2 tokens. Do log: `request_id`, `account_id`, `status_code`, `error_code`, upstream reason, `token_hash`, `egress_ip`, `latency_ms`.
- **Op-log retention is a concrete Phase 5 deliverable** (spec §8.1/§13.1): every send writes one `relay_op_logs` row (redacted by construction). Ship a real GC — **add `relayctl logs --gc`** (to the §5.6 command table and Phase 1/5 file lists) **or** a built-in background sweeper in `cmd/relay` — with a definite retention query (`DELETE` success rows >14d, failure/reject rows >90d) on a schedule with an owner. Either add **time-partitioning DDL** to the §8.1 schema or document an explicit partition/offload decision here (do **not** defer it to an open question). Add a disk-usage / `relay_op_logs` table-growth alert (at 50k/day/account the table grows unbounded without GC — a classic "works for months then the disk fills" surprise).
- **Metrics catalog** (spec §13.2): `relay_requests_total`, `relay_request_duration_seconds`, `relay_upstream_requests_total`, `relay_upstream_duration_seconds`, `relay_auth_failures_total`, `relay_rate_limited_total` (incl. `kind="daily"`), `relay_rate_limit_degraded_total`, `relay_idempotency_total`, `relay_apns_jwt_refresh_total`, `relay_fcm_token_refresh_total`, `relay_redis_up`, `relay_pg_up`, plus `relay_provider_healthy{provider=...}`, `relay_credential_healthy{provider=...}`, and `relay_fcm_project_quota_headroom`. **Keep labels low-cardinality** (no token, no `request_id`).
- **`/readyz` — per-provider, not all-or-nothing.** Return `200` only when the **core deps** (PG + Redis) are reachable, and report **per-provider** APNs/FCM health separately in `checks` (a `relay_provider_healthy{provider}` gauge). A single provider's credential/init failure must **not** down the whole replica (that would pull a healthy APNs replica from the LB just because FCM creds are temporarily unresolvable, killing Apple push too); instead the send handler for an un-initialized/degraded provider returns `503 upstream_unavailable` for **that provider only**. Surface per-provider circuit-breaker state so operators see "APNs degraded, FCM healthy" rather than a binary not-ready. Body shape: `503 {"status":"not_ready","checks":{...}}` only when a core dep is down (spec §5.4).
- `/readyz` and `/metrics` bound to the internal/scrape network; only `/v1/*` and `/healthz` are public (spec §11/§14.1).
**Tests.**
- Unit: redaction middleware drops every disallowed field even when handlers attempt to log them (assert no token/bearer/JWT/OAuth2 string appears in captured log output).
- Unit: a success and a failure path each produce exactly one op-log row with the redacted subset and a `token_hash` (never raw token).
- Unit: metric counters increment with correct labels for first/replay/conflict idempotency, auth-failure reasons, rate-limit kinds, JWT/token refresh results.
- Integration: `/readyz` flips to `503` when PG or Redis is down; `/metrics` exposes the full catalog.
**Acceptance criteria.**
- [ ] No log line or op-log row can contain a raw token, bearer header, JWT, OAuth2 token, or payload body (asserted by tests).
- [ ] All spec §13.2 metrics are exported with low-cardinality labels.
- [ ] `/readyz` reflects real dependency + credential health; `/metrics` and `/readyz` are not publicly routable.
---
### Phase 6 — Hardening, load test, deploy (size: L) · gated on provisioning + secret manager
**Goal.** Production hardening (real secret-manager wiring, TLS, anti-abuse), a hundreds-of-users burst load/pacing test, idempotency-under-concurrency proof, container build, and rollout.
**Files to create/modify.**
- `internal/config/secrets_*.go` — real secret-manager integration (KMS/Vault/cloud secret manager); load `.p8`, SA JSON, pepper, DSNs at startup into memory; optional SIGHUP re-read.
- `deploy/Dockerfile` (distroless/minimal), deploy manifests, TLS/HSTS config.
- `test/load/` — k6/vegeta burst scenario + concurrency idempotency test harness.
- Alerting rules (Prometheus → alertmanager / SLO doc).
**Implementation notes.**
- **Secrets** (spec §11, reference §4.5): credentials loaded once at startup from the secret manager into memory; never on disk in the image/repo, never logged, never returned by any endpoint. On GCP prefer keyless FCM (Workload Identity). Rolling restart picks up rotated secrets.
- **TLS** (spec §11/§14.1): HTTPS/HTTP-2 only, TLS 1.2+ (prefer 1.3), HSTS on `relay.silo.app`. Terminate at LB or in-process.
- **Anti-abuse** (spec §8.2/§11): per-IP auth-failure rate limiting keyed on the **trusted-proxy-resolved client IP** (caller-supplied `X-Forwarded-For`/`X-Real-IP` ignored — see Phase 2 client-IP resolution; the trusted-proxy CIDR list is config); **soft** lockout (latency / `Retry-After`) to avoid CGNAT/VPN collateral; body size cap; explicit timeouts; graceful shutdown. Document a response runbook for the key-guessing alert.
- **Credential-health validation & rotation runbook** (spec §4.4/§14.3). Add a **startup + periodic synthetic credential check** that proves the loaded credential actually authenticates upstream — not just that a JWT signs / a token mints locally (which `/readyz` already checks). For FCM, a `validateOnly:true` send; for APNs, an auth-exercising send to a known-bad token that still distinguishes an auth-layer `403` (`InvalidProviderToken`) from a token-layer `400` (`BadDeviceToken`). Wire the result into a `relay_credential_healthy{provider=...}` gauge and into `/readyz`'s per-provider health (below). Schedule `relayctl ping-upstream` as a cron for continuous health, and **alert on the first `upstream_auth_error`** (§6.4) — Apple actively revokes leaked `.p8` keys and SA keys/bindings can be rotated out from under the relay. Document the **rotation runbook** (overlap window, dual-key support — APNs allows multiple keys and the relay pools by team).
- **Graceful-shutdown drain ordering** (spec §14.2). On `SIGTERM`: (1) flip `/readyz` to `503` so the LB stops routing **new** requests; (2) keep serving in-flight handlers; (3) bound `srv.Shutdown(ctx)` to **less than `lock_ttl`** so any marker left by a killed-mid-flight handler expires quickly — **and** release/downgrade the in-flight idempotency marker on handler context-cancellation (Phase 2), so a drained-but-incomplete send frees its lock immediately rather than forcing the caller's retry to eat a 30s `409 idempotency_conflict`. A clean drain must leave **no orphaned in-flight idempotency markers**; the relationship between the shutdown grace period and `lock_ttl` is explicit.
- **Load/pacing test** (spec §13 / testing strategy §5 below): simulate a hundreds-of-users burst (e.g. 300–500 concurrent device sends), assert the relay stays under the per-account cap, returns correct `429` + relative `Retry-After`, and one well-tuned upstream connection sustains the throughput (reference §1.3 — thousands/sec). Verify the FCM smooth-traffic guidance does not trip quota (reference §2.7).
- **Idempotency under concurrency**: fire N concurrent requests with the **same** `Idempotency-Key`; assert **at most one** upstream send (count via a fake upstream), the rest replay or `409`.
- **FCM quota-storm shedding**: when the shared project quota is exhausted, the relay-side project circuit breaker sheds with `503 upstream_throttled` before forwarding (Phase 2 / §6.4), rather than hammering FCM into a 429 stampede.
- **HA** (spec §14.2): multiple stateless replicas behind the LB; rolling deploys; kill-safety (a drained/killed replica is covered by the caller's outbox retry). Relay-down delays, never loses.
**Tests.**
- Load: hundreds-of-users burst (latency p50/p95/p99, error rate, `429` correctness).
- Concurrency: same-key idempotency → at-most-once upstream (assert against a counting fake).
- Security: secret-manager load path; no secret in image (`docker history`/scan); TLS config (min version, HSTS).
- Security: forged caller `X-Forwarded-For`/`X-Real-IP` does **not** change the resolved client IP / `egress_ip` (only the trusted-proxy header from an allowlisted peer is honored); per-IP throttle cannot be bypassed.
- Drain: rolling-restart drains in-flight requests via `srv.Shutdown` without dropping a completed send, `/readyz` flips to `503` before draining, and **no orphaned in-flight idempotency markers** remain afterward (an immediate caller retry does not get a spurious `409`).
- Resilience: FCM quota-exhaustion → relay sheds with `503 upstream_throttled` (project circuit breaker) instead of forwarding into a 429 storm.
- Credential health: the synthetic startup/periodic check flips `relay_credential_healthy` and `/readyz` when the loaded credential is rejected upstream (auth-layer `403`), distinct from a token-layer `400`.
**Acceptance criteria.**
- [ ] Secrets load exclusively from the secret manager; image/repo scans show no embedded `.p8`/SA JSON/pepper.
- [ ] A hundreds-of-users burst is handled within the per-account caps with correct `429`/`Retry-After`; one upstream connection sustains the load.
- [ ] Concurrent identical-key sends produce **at most one** upstream call.
- [ ] A rolling restart drains in-flight requests, flips `/readyz` to `503` first, and leaves **no orphaned in-flight idempotency markers**.
- [ ] The synthetic credential check gates `/readyz`/`relay_credential_healthy` on real upstream acceptance; the first `upstream_auth_error` pages ops; the rotation runbook is documented.
- [ ] Forged caller forwarding headers cannot poison `egress_ip` or bypass the per-IP throttle.
- [ ] TLS 1.2+ enforced, HSTS set; `/metrics`+`/readyz` internal-only; staging deployment validated before production rollout.
---
## 5. Testing Strategy
### 5.1 Layers
| Layer | What it covers | Provider creds? |
|---|---|---|
| **Unit** | Field validation, JWT/token lifecycle math, key hashing/compare, error-table mappings, redaction, metric labels | No |
| **Contract** | Exact `02`/`03` request/response shapes: `DisallowUnknownFields` rejects every disallowed field; success/error bodies are byte-exact; `pushwire` golden files | No |
| **Integration** | Real upstream behavior: APNs **sandbox** sends; FCM **`validateOnly:true`** validation; PG migrations; Redis rate-limit/idempotency | APNs `.p8` / FCM SA (sandbox) |
| **Redaction assertions** | No raw token/bearer/JWT/OAuth2/payload ever reaches a log or op-log | No |
| **Load / pacing** | Hundreds-of-users burst; per-account cap + `429`/`Retry-After`; upstream connection reuse sustains throughput | Optional (fake upstream) |
| **Idempotency-under-concurrency** | Same key, many concurrent requests → at-most-once upstream send | No (fake upstream) |
| **Failure-mode** | APNs `GOAWAY`/mid-stream reset mapping (no lost/double send); JWT-expiry single-flight (one regen, zero `TooManyProviderTokenUpdates`); Redis dropped mid-request (idempotency fail-closed, rate-limit bounded fail-open); graceful-drain leaves no orphaned idempotency lock; FCM quota-exhaustion shedding | No (fakes) |
### 5.2 Contract tests against the 02/03 shapes
For each endpoint, a golden corpus of requests/responses derived from spec §5.1/§5.2:
- **Valid** `private_alert` / `background_wake` (APNs) and `private_data` / `background_wake` (FCM) requests → assert the built upstream payload + headers are **byte-exact** vs the spec (the `pushwire` golden files).
- **Every disallowed field** (`title`, `body`, `image_url`, `media_id`, `username`, `server_url`, `notification`, …) → `400 unexpected_field`.
- **Every validation failure** → its exact `error.code` (`invalid_token`, `invalid_environment`, `invalid_mode`, `invalid_collapse_id`, `topic_not_allowed`, `project_not_allowed`, `package_not_allowed`, `missing_idempotency_key`, …).
- The same `pushwire` golden corpus is shared with `silo-server`'s `custom_apns`/`custom_fcm` tests. Because a copied package in one repo cannot fail the other repo's CI on its own, the mirror is a **checked-in generated artifact with a committed checksum** that **both** repos verify in CI against the canonical hash (see §3 sync runbook) — a divergence fails CI on both sides regardless of which repo changed first.
- **Closed-allowlist assertion (privacy guarantee):** a dedicated test asserts the FCM `data` map keys and the APNs payload key set are **exactly** the closed allowlist, so adding any key (e.g. smuggling content into `data`) fails CI even if golden files are regenerated. The content-free guarantee is enforced structurally, not only by golden equality.
### 5.3 Integration against sandbox / validateOnly
- **APNs sandbox** (`api.sandbox.push.apple.com`): a real send returns 200 + `apns_id`; a deliberately invalid token returns the mapped `bad_device_token` / `unregistered`. Run behind `-tags=integration`, only when `.p8` secrets are present (CI `integration` job).
- **FCM `validateOnly:true`** (reference §2.5): a valid request validates without delivering; a malformed token surfaces `invalid_argument`/`unregistered`. **Never** `validateOnly` on production sends.
### 5.4 Redaction assertions
Capture log + op-log output across success and every error path; assert via regex that none of: the bearer value, the raw `rk_` secret, a raw device token, the relay JWT, or an OAuth2 access token ever appears, and that `token_hash` is present where a token was involved (spec §12/§13.1).
### 5.5 Load / pacing test (hundreds-of-users burst)
Simulate a burst of several hundred concurrent device sends (one account, mixed APNs/FCM) against a **fake upstream** that records call counts and timing:
- Assert per-account rate limiting holds (10 req/s burst + 50k/day; spec §9.2) and over-limit responses are `429` with a **relative** `Retry-After`.
- Assert one upstream HTTP/2 connection is reused (no connection-per-push) and sustains the burst (reference §1.3/§3.1).
- Assert latency percentiles and zero double-sends.
### 5.6 Idempotency-under-concurrency test
Fire N (e.g. 50) concurrent requests sharing one `Idempotency-Key` at a fake upstream that increments a counter per call. Assert: **exactly one** upstream call; the other N−1 either replay the stored result or receive `409 idempotency_conflict`; a follow-up with a **different** payload but the same key returns `422 idempotency_key_reuse`. This proves at-most-once upstream send under the caller's concurrent outbox retries (spec §10.2).
### 5.7 Failure-mode tests (the exact modes on-call hits)
Beyond the happy path and the error-table mappings, the plan tests every failure mode that actually pages an engineer (all use fakes — no provider creds):
- **APNs `GOAWAY` / mid-stream reset.** A fake APNs upstream injects `GOAWAY` (or resets the stream) before a final response mid-burst → the caller-facing result is `503 upstream_unavailable`, the op-log/metric is tagged `connection_severed`, and a counting fake confirms **no lost or double send** (no transparent retry).
- **JWT expiry under concurrency.** Force the cached APNs JWT to expire under a concurrent burst → assert **exactly one** single-flighted regeneration and **zero** `429 TooManyProviderTokenUpdates`.
- **Redis dropped mid-request** (not just down at start) → idempotency fails **closed** (`503`); rate-limit degrades to **bounded** fail-open; a lock whose TTL expires mid-send cannot be stolen (upstream deadline `<` `lock_ttl`).
- **Graceful-drain idempotency.** A rolling restart during in-flight sends leaves **no orphaned in-flight idempotency markers** (an immediate caller retry does not eat a spurious `409`); `/readyz` flips to `503` before draining.
- **FCM quota-exhaustion shedding.** With the shared project quota exhausted, the relay-side project circuit breaker sheds with `503 upstream_throttled` instead of forwarding into a 429 storm.
---
## 6. Deployment
### 6.1 Container build
- `deploy/Dockerfile`: multi-stage; static `relay` (and `relayctl`) binaries on a distroless/minimal base. **No credentials baked in** (spec §11). Non-root user; read-only filesystem where possible.
- Image carries only the binary + CA roots (ensure USERTrust RSA present — default in `ca-certificates`; spec §6.3).
### 6.2 Secret provisioning
| Secret | Source | Notes |
|---|---|---|
| APNs `.p8` + Key ID + Team ID | secret manager (or mounted file) | per official team; never env/image (spec §14.3) |
| FCM SA JSON **or** Workload Identity | GCP keyless (on-GCP) / secret manager JSON (off-GCP) | open Q#4 (spec §15) |
| API-key HMAC pepper | secret manager | rotation is heavier (open Q#6) |
| Postgres DSN, Redis URL | secret manager | privileged `relayctl` DSN held only by operators |
Rotation: issue a new APNs key / FCM credential, deploy, retire the old; the relay loads secrets at startup so a rolling restart picks them up (spec §14.3). Optional SIGHUP re-read.
### 6.3 TLS, health, metrics wiring
- TLS 1.2+ (prefer 1.3), HTTP/2, HSTS on `relay.silo.app`; terminate at LB or in-process (spec §14.1).
- **Public:** `/v1/apple/send`, `/v1/fcm/send`, `/healthz`. **Internal-only:** `/readyz`, `/metrics` (spec §11/§14.1).
- Liveness probe → `/healthz`; readiness probe → `/readyz`; Prometheus scrapes `/metrics` on the internal network.
### 6.4 Monitoring / alerting
Alert on (from the §13.2 catalog):
- **First `upstream_auth_error` → page ops.** Alert on the caller-facing `upstream_auth_error` reason (the relay's genuine `.p8`/SA/OAuth2 credential failure — a non-200 from `oauth2.googleapis.com/token`, or `401 UNAUTHENTICATED`/`403 PERMISSION_DENIED`/`InvalidProviderToken` on send) > 0 → **credential misconfig, page ops**: never caller-fixable. **Do NOT** key this alert on `THIRD_PARTY_AUTH_ERROR`, which on this pure-Android path indicates a per-token/registration anomaly, not a relay-credential problem (see Phase 4 reclassification) — surface it for investigation at most, never page.
- **Proactive credential-health alert.** Alert if the periodic synthetic credential check (Phase 6: a scheduled `relayctl ping-upstream` / `validateOnly` FCM send + APNs auth-exercising probe, plus the `relay_credential_healthy` gauge wired into `/readyz`) reports the loaded `.p8`/SA/Workload-Identity credential is no longer accepted upstream — Apple actively revokes leaked `.p8` keys and SA keys/bindings can be rotated out from under the relay, so this must fire **before** production sends start failing.
- Sustained `relay_rate_limit_degraded_total` increase → Redis degraded (fail-open active; in-process per-account fallback limiter engaged).
- `relay_redis_up == 0` / `relay_pg_up == 0`; any provider showing `relay_provider_healthy{provider=...} == 0` → that upstream is degraded (per-provider, not a whole-replica outage).
- Elevated `relay_requests_total{status_code="5xx"}` rate; `relay_request_duration_seconds` p99 regression.
- `relay_upstream_requests_total{upstream_reason="QUOTA_EXCEEDED"}` and **low `relay_fcm_project_quota_headroom`** → shared FCM project quota pressure: the relay-side project limiter is shedding with `503 upstream_throttled` before hammering FCM (watch Cloud Console; spec §14.5). Treat as a multi-tenant fairness event — one noisy account must not exhaust the ~600k/min project quota shared across all accounts.
- Accounts approaching/hitting the **per-account daily cap** (`relay_rate_limited_total{kind="daily"}`) → a legitimate big-release burst can hit 50k/day; surface for an operator to consider a temporary cap bump.
- Anomalous `relay_auth_failures_total` rate from an egress IP → possible key-guessing (see §6.4 anti-brute-force; the per-IP limiter consumes the resolved client IP, not a caller-supplied header). Note: the fine-grained `reason` (unknown-prefix vs hash-mismatch) is internal-only and must not be exposed to tenants.
### 6.5 Rollout
- **Staging first** (spec §14.4): separate hostname + separate accounts so client QA never touches production quotas. Validate APNs sandbox + FCM `validateOnly` end-to-end.
- **HA / rolling** (spec §14.2): multiple stateless replicas behind the LB; rolling deploy with drain (`srv.Shutdown`); a killed replica is covered by the caller's outbox retry — **relay-down delays, never loses**.
- Single region in v1, close to PG/Redis (spec §14.5).
---
## 7. Provisioning Checklist (the long pole — start day 1)
> These gate the phases that touch live providers (Phase 3 APNs, Phase 4 FCM, Phase 6 production). Run them **in parallel** with Phases 0–2, which need no credentials.
### 7.1 Apple (gates Phase 3 + Phase 6)
- [ ] Apple Developer **Program account / team** with push entitlement (capture the 10-char **Team ID**).
- [ ] Generate an **APNs Auth Key (`.p8`)** in the developer portal; capture the **Key ID** (10 chars) and download the `.p8` **once** (Apple shows it once). Store in the secret manager (spec §14.3).
- [ ] Consider Feb-2025 **scoped keys** (team-scoped or topic-specific) for least privilege (reference §1.2).
- [ ] Register per-platform **bundle topics** for the official build config and seed the account allowlist (spec §8.4): `com.continuum.app.ios`, `com.continuum.app.tvos`, `com.continuum.app.macos`.
- [ ] Standardize on the **sandbox host** `api.sandbox.push.apple.com` (reference §1.3; `api.development.push.apple.com` is an equivalent still-resolving alias — no build-time resolution needed) and confirm the APNs server TLS root (**USERTrust RSA**, present in standard pools; spec §6.3 / reference §1.7).
- [ ] Decide `apns2` vs hand-rolled client (open Q#3) before Phase 3.
### 7.2 Firebase / Google (gates Phase 4 + Phase 6)
- [ ] Firebase **project(s)** for the official build config: `continuum-prod-android` (and a staging project if desired).
- [ ] Register the Android **package** `com.continuum.app.android`; seed the FCM allowlist `(project_id, package_name)` (spec §8.4).
- [ ] Provision a **service account** with the least-privilege `firebase.messaging` scope (reference §2.2). On GCP, prefer keyless **Workload Identity / attached SA**; off-GCP, download the **service-account JSON** and store it in the secret manager — never in repo/image (reference §2.2/§4.5; open Q#4).
- [ ] Confirm the global send host `fcm.googleapis.com` (no regional endpoints; reference §2.3) and the default ~600k msgs/min project quota (reference §2.7 / spec §14.5).
### 7.3 Cross-cutting
- [ ] Secret manager chosen and wired (KMS/Vault/cloud secret manager) for `.p8`, SA JSON, pepper, DSNs (spec §11 / Phase 6).
- [ ] DNS + TLS cert for `relay.silo.app` (and the staging hostname).
---
## 8. Risks, Mitigations, and Milestone Sequencing
### 8.1 Risks and mitigations
| # | Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|---|
| R1 | **Provider provisioning slips** (Apple/Firebase approvals) — the real long pole | Med | High (blocks Phase 3/4/6) | Start §7 on day 1 in parallel; Phases 0–2 + all unit/contract tests need no creds; fakes for upstream |
| R2 | `sideshow/apns2` is unmaintained (no release since Oct 2024) | Med | Med | Hide behind `internal/apns` interface; prototype hand-rolled `x/net/http2`+`golang-jwt/jwt/v5` in Phase 3; decide per open Q#3 |
| R3 | **JWT/token mishandling** (over-frequent refresh → APNs `429 TooManyProviderTokenUpdates`; per-request mint → throttling; concurrent-403 stampede) | Med | High | Cache + mutex; ~50-min APNs refresh (never <20min), eager-on-expired; **single-flighted regeneration** so a concurrent burst of `ExpiredProviderToken` yields exactly one refresh per ~20-min window; OAuth2 `TokenSource` auto-refresh; metrics `relay_apns_jwt_refresh_total`/`relay_fcm_token_refresh_total` |
| R4 | **Double-send** under concurrent caller retries (incl. lock-TTL expiry mid-send, and stale in-flight markers after a killed/drained replica) | Med | High | Redis `SET NX` idempotency, **fail-closed**; pin the upstream call deadline **strictly shorter than `lock_ttl`** (≈10 s upstream vs 30 s lock) and refuse to send if the deadline already passed the lock TTL; release/downgrade the in-flight marker on handler context-cancellation; at-most-once test (§5.6); drain test asserting no orphaned in-flight markers (§5.7); key includes `attempt_number` |
| R5 | **Redis outage / eviction** | Low | Med | Rate-limit **fails open but bounded** (in-process per-account fallback limiter, logged + metric); idempotency **fails closed** (`503`, retryable via caller outbox); idempotency keyspace requires a **non-evicting `maxmemory-policy` (`noeviction`)** or a dedicated Redis/db so a rate-limit eviction can never silently drop an in-flight idempotency lock (which would violate at-most-once) |
| R6 | **Content/identity leak** despite design | Low | Critical | Structural: `DisallowUnknownFields`, content-free schema, data-only FCM, badge-off-by-default, redaction in middleware, op-logs hold only `token_hash` |
| R7 | **Cross-tenant push** (one account targeting another's topic/token) | Low | High | Per-account allowlist enforced after auth; namespaced Redis keys; per-account-scoped allowlist cache |
| R8 | **Stolen relay API key** (incl. abuse-amplification during a Redis outage, and per-IP-throttle bypass via forged forwarding headers) | Low | Med | Revocable keys (`relayctl key revoke`), per-account rate caps + allowlist bound spend; **bounded** fail-open (in-process per-account cap when Redis is down — never unlimited); per-IP auth-failure throttling keyed on a **trusted-proxy-resolved client IP** (caller-supplied `X-Forwarded-For`/`X-Real-IP` ignored) |
| R9 | **`pushwire` drift** between relay and `silo-server` | Med | Med | One **canonical source repo**; mirror is a checked-in generated artifact with a committed **checksum** verified in CI on **both** sides; golden-file + closed-allowlist-key tests fail CI on divergence regardless of regeneration order |
| R10 | **APNs TLS root rotation** (USERTrust) | Low | Low | Use `api.sandbox.push.apple.com` (the `api.development.*` alias is equivalent and still resolves — not a risk); rely on the standard trust store (already includes USERTrust RSA) |
| R11 | **FCM high-priority deprioritization** over 7-day window (reference §2.8) | Low | Low | Honor `03`'s `priority:high` for `private_data` (app surfaces a notification); caller-side `priority:normal` fallback is the lever (open Q#10) |
| R12 | **Op-log volume** at 50k/day/account across many accounts (unbounded table → disk fills) | Med | Low | **Shipped** retention GC (`relayctl logs --gc` / background sweeper: 14d success / 90d failure) on a schedule with an owner; time-partitioning DDL (or a documented partition/offload decision); table-growth/disk alert |
### 8.2 Milestone sequencing & rough effort
| Milestone | Phases | Effort | Provider creds needed | Parallelizable |
|---|---|---|---|---|
| **M0 — Skeleton green** | Phase 0 | ~M (1 wk) | No | — |
| **M1 — Storage + admin** | Phase 1 | ~L (1 wk) | No | with §7 provisioning |
| **M2 — Request path (no upstream)** | Phase 2 | ~L (1 wk) | No | with §7 provisioning |
| **M3 — APNs live (sandbox)** | Phase 3 | ~L (1 wk) | **Apple `.p8`** | Phase 3 ∥ Phase 4 |
| **M4 — FCM live (validateOnly)** | Phase 4 | ~L (1 wk) | **Firebase SA** | Phase 4 ∥ Phase 3 |
| **M5 — Observability** | Phase 5 | ~M (3 d) | No | — |
| **M6 — Hardened + deployed** | Phase 6 | ~L (1 wk) | All + secret manager | — |
**Rough total:** ~6–7 engineer-weeks of code, with **§7 provisioning running in parallel from day 1** as the gating long pole for M3/M4/M6. Phases 3 and 4 can be split across two engineers once Phase 2 lands. The critical path is: M0 → M1 → M2 → (M3 ∥ M4) → M5 → M6, intersected with provisioning availability.
---
## 9. Open Questions (carried from spec §15)
These do not block starting; resolve at the noted phase.
1. **Per-account rate limits — real numbers** (resolve from logs post-M6; spec §9.2). v1 ships ~10 req/s burst + 50k/day.
2. **APNs sandbox hostname** (resolved — note only). Use `api.sandbox.push.apple.com`; `api.development.push.apple.com` is an equivalent, still-resolving sandbox alias. The two are interchangeable, so no build-time resolution is required — this is a standardization note, not an open question.
3. **`apns2` vs hand-rolled APNs client** — decide before Phase 3 (§3, reference §3.1).
4. **FCM credentials: GCP-keyless vs off-GCP JSON** — resolve in deployment design (Phase 6).
5. **`apns-expiration` / `android.ttl` defaults** (largely resolved). v1 ships a **finite** default (not omitted/30-day): a few hours for `private_alert`, short TTL for `background_wake`, with a matching finite FCM `android.ttl`; both configurable. Only the exact numeric values want product input.
6. **API-key hashing: HMAC pepper vs plain salted SHA-256** — plan chooses HMAC pepper; confirm rotation tradeoff (Phase 1).
7. **Idempotency-Key spec status** — header name + Stripe-style semantics are de-facto; re-check for an RFC.
8. **Op-log volume & retention** (resolved into a Phase 5 deliverable). Ship `relayctl logs --gc` (or a background sweeper): 14d success / 90d failure, on a schedule with an owner, plus time-partitioning DDL (or a documented partition/offload decision) and a table-growth alert — not deferred.
9. **Multi-region** — out of scope for v1; design later if latency to distant self-hosters matters.
10. **FCM priority deprioritization** — honor `03`'s `priority:high`; revisit only if metrics show systematic deprioritization.
@@ -0,0 +1,554 @@
# APNs & FCM 2026 Reference — Privacy-Preserving Push Relay
> **Provenance.** This document reflects **2026-current** Apple (APNs) and Google (FCM HTTP v1) developer documentation, plus the current Go ecosystem and OWASP guidance. Every non-obvious factual claim carries an inline citation in the form `(source: URL, date)`. Where a primary source disagreed with secondary/blog material, the primary source wins and the secondary claim is flagged as inaccurate. Where a fact remains uncertain, it is called out explicitly rather than overstated.
>
> **Compile date:** _<!-- PLACEHOLDER: set at compile time -->_
>
> **Commands assume the repository root is the cwd.** No local absolute filesystem paths appear in this document; all repo references are repository-relative.
## Scope
This is the factual foundation for a self-hosted, privacy-preserving push **relay** — a server that accepts notification requests from authenticated tenants and forwards them to Apple Push Notification service (APNs) and Firebase Cloud Messaging (FCM) on their behalf, without the providers (or other tenants) learning more than necessary. It captures the wire-level contracts (auth, endpoints, headers, payloads, errors, throttling), the recommended Go libraries and tuning, and the security/ops practices the spec and implementation plan are written against. It does **not** cover client-side SDK integration except where client behavior (token rotation, notification-permission gating) constrains relay design.
---
## 1. APNs (2026)
### 1.1 Provider authentication — token-based (JWT, ES256)
APNs supports **both** token-based and certificate-based provider authentication over HTTP/2 + TLS in 2026; Apple does **not** deprecate certificate-based auth. Both have dedicated docs and no deprecation notice (source: https://developer.apple.com/documentation/usernotifications/setting-up-a-remote-notification-server, 2026-06-13). The relay should use **token-based** (`.p8`) auth: the signing key does not expire (it can be revoked), one key covers sandbox + production and all/subset of a team's apps, and there is no yearly certificate renewal.
> **Correction vs. common blog claims:** Third-party posts claiming Apple is "deprecating / shutting down certificate-based auth in 2025" are **inaccurate**. The only confirmed 2025 changes are (a) the APNs **server** TLS root-CA rotation (§1.7) and (b) the optional token-key hardening features (§1.2). Neither deprecates provider auth (source: https://developer.apple.com/documentation/usernotifications/setting-up-a-remote-notification-server, 2026-06-13).
**JWT structure.** APNs supports **only the ES256** algorithm (ECDSA over P-256); no other algorithm is accepted (source: https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns, 2026-06-13).
```jsonc
// JWT header
{
"alg": "ES256", // ES256 ONLY
"kid": "ABC123DEFG" // 10-char Key ID from the developer account
}
// JWT claims
{
"iss": "DEF123GHIJ", // 10-char Team ID
"iat": 1437179036 // issued-at, seconds since Epoch (UTC)
}
```
The JWT is signed with the `.p8` ECDSA P-256 private key using ES256. It is sent on **every** notification request as an HTTP/2 header in Base64URL-encoded JWT form (source: https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns, 2026-06-13):
```http
authorization: bearer eyAia2lkIjog...MEYCIQDz...
```
**Lifecycle & regeneration cadence (critical):**
| Rule | Value | Source |
|---|---|---|
| Max token age (`iat`) | ≤ 1 hour, else `ExpiredProviderToken` (403) | (source: …/establishing-a-token-based-connection-to-apns, 2026-06-13) |
| Refresh **no more** often than | once every **20 minutes** (per connection) | (same) |
| Refresh **no less** often than | once every **60 minutes** | (same) |
| Over-frequent refresh → | `TooManyProviderTokenUpdates` (429) | (source: …/handling-notification-responses-from-apns, 2026-06-13) |
**Relay pattern:** Generate **one** ES256 JWT, cache it in memory behind a mutex, and reuse it across **all** senders/connections/topics. Regenerate on a ~50-minute timer (well inside the 1-hour window, never < 20 min apart), or eagerly on a `403 ExpiredProviderToken`. Never sign a fresh JWT per request.
**One key, many servers.** A single key/token can be reused across multiple provider servers, multiple connections, and all (or a subset) of a team's apps/topics — confirmed verbatim by Apple: "You can use the same token from multiple provider servers" and "You can use one token to distribute notifications for all or a subset of your company's apps" (source: https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns, 2026-06-13). **Caveat:** a connection cannot map to multiple teams — "If you manage APNs for multiple developer accounts, open separate connection pools for each of them" (same source). For a multi-tenant relay, this means **per-team connection pools**.
### 1.2 Token-key hardening (Feb 2025, optional)
On **2025-02-17** Apple added optional scoped token keys; the classic unrestricted team key still works, and "you don't have to update your keys unless you want to take advantage of the new capabilities" (source: https://developer.apple.com/news/?id=wy4tb0uo, 2025-02-17). Two new key types:
| Key type | Scope | Limit |
|---|---|---|
| **Team-scoped key** | Sandbox **or** Production only; all topics in the team | max **2 keys per environment** |
| **Topic-specific (topic-based) key** | Bound to specific bundle IDs (least privilege) | max **200 keys for Sandbox and 200 for Production, with up to 400 topics per topic-based key** |
(source: https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns, 2026-06-13 — verbatim scoped-key limit confirmed). For a multi-tenant relay these are a meaningful isolation lever: issue separately scoped `.p8` keys per environment/app rather than one all-powerful key.
### 1.3 Endpoints & HTTP/2 connection management
| Environment | Host | Ports |
|---|---|---|
| Production | `api.push.apple.com` | 443 or 2197 |
| Development / sandbox | `api.sandbox.push.apple.com` | 443 or 2197 |
(source: https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns, 2026-06-13). Port 2197 is functionally equivalent to 443 and exists so firewalls can allow APNs while blocking other 443 traffic — keep it as a fallback. `api.push.apple.com` (production) and `api.sandbox.push.apple.com` (sandbox) are the current canonical hosts named in Apple's docs. The older `api.development.push.apple.com` is a **still-valid equivalent alias for sandbox**: as of 2026-06-13 both names resolve to the same Akamai CNAME (`api-sand-vs.push-apple.com.akadns.net`) and the same IP set (verified by DNS, e.g. `17.188.168.x`), so they hit the identical sandbox backend. We **standardize on `api.sandbox.push.apple.com`** (it matches the canonical docs and `sideshow/apns2` v0.25.0's `HostDevelopment` constant), but the alias is not dead — see §5.
- **Transport:** HTTP/2 with **TLS 1.2 or later** (TLS 1.3 acceptable). `:method POST`, `:path /3/device/<device_token>` where the token is the hex bytes of the device token (source: …/sending-notification-requests-to-apns, 2026-06-13).
- **Connection reuse:** "Reuse a connection as long as possible. In most cases, you can reuse a connection for many hours to days. If your connection is mostly idle, you may send a HTTP2 PING frame after an hour of inactivity." Reuse reduces bandwidth/CPU; rapid connect/disconnect is treated as DoS-like behavior (source: …/sending-notification-requests-to-apns, 2026-06-13).
- **Stream multiplexing:** "APNs allows multiple concurrent streams for each connection, but don't assume a specific number of streams." Read the server's `SETTINGS_MAX_CONCURRENT_STREAMS`; do not hardcode. The exact number varies by server load and auth type (same source). A single well-tuned connection handles thousands of pushes/sec.
- **GOAWAY handling:** When APNs terminates a connection it sends an HTTP/2 `GOAWAY` frame whose payload includes JSON with a `reason` key. Apply RFC 9113 §6.8: stop opening new streams, finish in-flight streams with ID ≤ `Last-Stream-ID`, open a new connection for the rest; `NO_ERROR` = graceful shutdown, any other code = connection error (source: https://www.rfc-editor.org/rfc/rfc9113#section-6.8, 2022-06). The relay should drop a GOAWAY'd connection from the pool and transparently reconnect. Go's `net/http2` transport does most of this automatically when one `http.Client` is reused.
```mermaid
flowchart LR
R[Relay sender goroutines] -->|stream 1..N over 1 conn| C{HTTP/2 conn pool}
C -->|POST /3/device/token| A[api.push.apple.com:443]
A -. GOAWAY reason JSON .-> C
C -->|drop + reconnect| C2[new conn]
C2 -->|in-flight <= Last-Stream-ID finish on old conn| A
```
### 1.4 Request headers
`:method = POST`, `:path = /3/device/<device_token>`. Payload is **uncompressed** JSON.
| Header | Required | Allowed values / format | Notes |
|---|---|---|---|
| `authorization` | yes (token auth) | `bearer <ES256 JWT>` | Ignored on cert connections |
| `apns-topic` | yes | bundle ID, optionally with a type suffix (see §1.5) | `MissingTopic`/`BadTopic` (400) on error |
| `apns-push-type` | required on **watchOS 6+**, strongly recommended elsewhere | `alert`, `background`, `controls`, `fileprovider`, `liveactivity`, `location`, `mdm`, `pushtotalk`, `voip`, `widgets`, `complication` | Must match payload; invalid → `InvalidPushType` (400) |
| `apns-priority` | no (default 10) | `10`, `5`, `1` | See §1.4.1; invalid → `BadPriority` (400) |
| `apns-expiration` | no | UNIX epoch seconds (UTC) | `0` = deliver once, no store; nonzero = store & retry up to **30 days**; invalid → `BadExpirationDate` (400) |
| `apns-collapse-id` | no | ≤ **64 bytes** | Same value merges notifications on-device (latest wins); >64B → `BadCollapseId` (400) |
| `apns-id` | no | canonical UUID (8-4-4-4-12, lowercase hex) | Echoed in response; auto-generated if omitted; invalid → `BadMessageId` (400) |
(source: https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns, 2026-06-13). `apns-push-type` required on watchOS 6+ confirmed (source: https://developer.apple.com/documentation/usernotifications, 2026-06-13).
**`apns-topic` suffixes by push type** (source: …/sending-notification-requests-to-apns, 2026-06-13):
| Push type | `apns-topic` suffix |
|---|---|
| `complication` | `.h.complication` |
| `controls` | `.push-type.controls` |
| `fileprovider` | `.pushkit.fileprovider` |
| `liveactivity` | `.push-type.liveactivity` |
| `location` | `.location-query` |
| `pushtotalk` | `.voip-ptt` |
| `voip` | `.voip` |
| `widgets` | `.push-type.widgets` |
| `alert` / `background` | (none — bare bundle ID) |
#### 1.4.1 `apns-priority` semantics (corrected)
`apns-priority` is optional; if omitted, APNs sets priority to 10. Three values, with these exact semantics (source: https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns, 2026-06-13):
| Value | Meaning |
|---|---|
| `10` | Send the notification **immediately** (use for alert/sound/badge). |
| `5` | Send based on **power considerations** on the user's device. |
| `1` | Prioritize power over all factors and **do not wake the device** (lowest-power). |
- For `apns-push-type=background`, Apple's guidance is explicit and **narrower** than some sources state: "**Always use priority 5. Using priority 10 is an error.**" Apple does **not** list priority `1` as an accepted alternative for background pushes (source: same, 2026-06-13).
- The claim that priority `1` is a "new tier for Live-Activity-style delivery" is **not supported** by Apple's docs. Live Activity updates use `apns-push-type=liveactivity` (typically priority 10 for immediate or 5 for throttled), not a priority-1 mechanism. Priority `1` is simply the lowest-power option (source: same, 2026-06-13).
### 1.5 Payload shapes & size limits
**Size limits** (source: https://developer.apple.com/documentation/usernotifications/generating-a-remote-notification, 2026-06-13):
| Type | Max payload |
|---|---|
| Regular (alert/background/etc.) | **4096 bytes (4 KB)** |
| VoIP (PushKit) | **5120 bytes (5 KB)** |
Oversize → `413 PayloadTooLarge`. Payload must **not** be compressed. Live Activity payloads use the standard 4 KB limit.
**Payload root** = reserved `aps` dictionary + optional custom keys. `aps` keys (source: same, 2026-06-13): `alert`, `badge`, `sound`, `thread-id`, `category`, `content-available`, `mutable-content`, `target-content-id`, `interruption-level`, `relevance-score`, `filter-criteria`.
**Alert (user-facing) example:**
```json
{
"aps": {
"alert": { "title": "Title", "subtitle": "Sub", "body": "Body text" },
"badge": 1,
"sound": "default",
"thread-id": "thread-42",
"interruption-level": "active",
"relevance-score": 0.8
},
"custom-key": "opaque-relay-payload"
}
```
The `alert` sub-dictionary supports `title`/`subtitle`/`body` plus localization keys (`loc-key`/`loc-args`, `title-loc-key`/`title-loc-args`, `subtitle-loc-key`/`subtitle-loc-args`), `launch-image`, and the legacy `action-loc-key` (source: same, 2026-06-13).
`interruption-level` accepts exactly: `passive`, `active` (default), `time-sensitive` (needs Time Sensitive Notifications entitlement), `critical` (needs Critical Alerts entitlement, Apple-approved). Critical sound uses a **dictionary**: `{ "critical": 1, "name": "...", "volume": 0.0–1.0 }` (source: same, 2026-06-13).
**Background (silent) example** — must set `apns-push-type=background`, `apns-priority=5`, and an `aps` containing **only** `content-available: 1` (no `alert`/`badge`/`sound`):
```json
{ "aps": { "content-available": 1 } }
```
(source: https://developer.apple.com/documentation/usernotifications/pushing-background-updates-to-your-app, 2026-06).
**Live Activity** remote pushes use `apns-push-type=liveactivity`, `apns-topic=<bundleID>.push-type.liveactivity`, **token-based auth only**. The `aps` adds: `event` (`start`|`update`|`end`), `content-state` (decodes into the app's `ActivityAttributes.ContentState`), `timestamp`, `stale-date`, `dismissal-date` (UNIX seconds), optional `relevance-score` and `alert`. `attributes-type` + `attributes` are included **only** for `event: start` (push-to-start). For Live Activities, `relevance-score` is an **unconstrained Double** (Smart Stack ordering), not the 0–1 range used by `UNNotificationContent.relevanceScore` (source: https://developer.apple.com/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications, 2026-06).
### 1.6 Status codes & reason strings
**HTTP status codes** (source: https://developer.apple.com/documentation/usernotifications/handling-notification-responses-from-apns, 2026-06-13): 200, 400, 403, 404, 405, 410, 413, 429, 500, 503.
For unsuccessful requests the body is a JSON object `{"reason":"<String>"}`; for **410** it additionally includes a `timestamp` (Number, **milliseconds** since Epoch) indicating when the token became invalid for the topic (source: same).
| Status | Reason strings | Meaning / handling |
|---|---|---|
| 200 | — | Success. Echoed `apns-id` header; in **dev**, `apns-unique-id` header (response-only) for Push Notifications Console Delivery Log. |
| 400 | `BadCollapseId`, `BadDeviceToken`, `BadExpirationDate`, `BadMessageId`, `BadPriority`, `BadTopic`, `DeviceTokenNotForTopic`, `DuplicateHeaders`, `IdleTimeout`, `InvalidPushType`, `MissingDeviceToken`, `MissingTopic`, `PayloadEmpty`, `TopicDisallowed` | Bad request. **Do not retry.** `BadDeviceToken`/`DeviceTokenNotForTopic` ⇒ purge token. |
| 403 | `BadCertificate`, `BadCertificateEnvironment`, `ExpiredProviderToken`, `Forbidden`, `InvalidProviderToken`, `MissingProviderToken`, `UnrelatedKeyIdInToken`, `BadEnvironmentKeyIdInToken` | Auth/cert error. `ExpiredProviderToken` ⇒ regenerate JWT & retry once. `Invalid/MissingProviderToken` ⇒ config error (don't blind-retry). `UnrelatedKeyIdInToken` ⇒ open a new connection. |
| 404 | `BadPath` | Invalid `:path`. Don't retry. |
| 405 | `MethodNotAllowed` | Only `POST` allowed. Don't retry. |
| 410 | `Unregistered`, `ExpiredToken` | Token no longer active for topic. **Permanently stop sending** to that token (until app re-registers). Use the `timestamp` (ms): only purge if your registration is older than it. Not a connection error / no disconnection. |
| 413 | `PayloadTooLarge` | Payload exceeds 4 KB (5 KB VoIP). Don't retry. |
| 429 | `TooManyProviderTokenUpdates`, `TooManyRequests` | Back off. `TooManyProviderTokenUpdates` ⇒ slow JWT refresh (≤ once/20 min). `TooManyRequests` ⇒ per-device-token backoff. |
| 500 | `InternalServerError` | Transient. Retry after ~15 min with backoff. |
| 503 | `ServiceUnavailable`, `Shutdown` | Transient. Retry after ~15 min with backoff. |
(source: https://developer.apple.com/documentation/usernotifications/handling-notification-responses-from-apns, 2026-06-13).
**Retry policy summary.** Never retry permanent errors (`BadDeviceToken`, `Unregistered`, `ExpiredToken`, `PayloadTooLarge`, `DeviceTokenNotForTopic`, `Forbidden`); back off on 429; retry 5xx after ~15 min with backoff. 410 is **not** treated as a connection error (source: same, 2026-06-13). Always parse the JSON `reason`, not just the status code.
### 1.7 APNs server TLS root rotation (2025) — trust-store only
APNs migrated its **server** TLS certificate to a new SHA-2 root, **USERTrust RSA Certification Authority** (a Sectigo root): sandbox **2025-01-20**, production **2025-02-24** (source: https://developer.apple.com/news/?id=09za8wzy, 2024-10-17; effective per https://developer.apple.com/news/upcoming-requirements/?id=02242025a, 2025-02-24).
- This affects only how the relay **validates the APNs server TLS chain** — it does **not** require updating the APNs SSL provider certificates Apple issued you, nor app/account certs.
- Standard OS / Go system root pools (macOS/iOS, Linux `ca-certificates`, Go `crypto/x509` system pool) **already trust** USERTrust RSA, so no action is needed unless the relay **pins** or ships a **custom/minimal** trust store — in which case it must add this root or TLS handshakes fail after the cut-over dates.
- No newer APNs cert/infrastructure requirement is published on Apple's Upcoming Requirements page as of June 2026 (source: https://developer.apple.com/news/upcoming-requirements/?id=02242025a, 2025-02-24).
### 1.8 Background-push throttling
Background (`content-available`) pushes are best-effort and throttled. Apple's verbatim guidance: "The number of background notifications allowed by the system depends on current conditions, but don't try to send more than **two or three per hour**." The system may delay, coalesce, or drop them based on battery/network/usage (source: https://developer.apple.com/documentation/usernotifications/pushing-background-updates-to-your-app, 2026-06). The older "throttle disabled when a debugger is attached" behavior is Apple-Forums developer-experience guidance, **not** in the current spec — do not rely on it.
### 1.9 Legacy binary protocol — dead
The legacy binary (TCP, ports 2195/2196) APNs protocol is fully retired; Apple set the migration deadline to the HTTP/2 provider API at **March 31, 2021** (source: https://developer.apple.com/news/?id=c88acm2b, 2020-10-09; earlier announced https://developer.apple.com/news/?id=11042019a, 2019-11-04). **Do not implement it.** There is no binary fallback in 2026.
### 1.10 Broadcast push (Live Activity channels) — situational
Broadcast push for Live Activities (channels) is current (iOS/iPadOS 18+). You `POST /4/broadcasts/apps/<bundleID>` over HTTP/2 + TLS 1.2+. Required headers: `apns-channel-id` (base64), `apns-push-type=liveactivity`, `apns-priority` (10/5/1), `apns-expiration`; optional `apns-request-id` (UUID, echoed back). Channels are managed via a **separate** channel-management API (`/1/apps/<bundleId>/channels`, `/1/apps/<bundleId>/all-channels`); limit 10,000 channels per app per environment (source: https://developer.apple.com/documentation/usernotifications/sending-broadcast-push-notification-requests-to-apns, 2026-06-13).
> **Hostname nuance (correction):** Apple's broadcast **send** code samples use broadcast-specific hosts `api-broadcast.push.apple.com` (prod) / `api-broadcast.sandbox.push.apple.com` (sandbox) — **not** the plain `api.push.apple.com` used for `/3/device`. Channel **management** uses a third host pair `api-manage-broadcast(.sandbox).push.apple.com` on ports 2195/2196. Apple's own connection-section text confusingly lists the plain hosts (a documented inconsistency) (source: same, 2026-06-13). If broadcast is in scope, verify the exact host against Apple's code samples at build time.
---
## 2. FCM HTTP v1 (2026)
### 2.1 Legacy-API shutdown status
Sending with the legacy FCM HTTP and XMPP APIs was **deprecated 2023-06-20**, and **shutdown began 2024-07-22** — verbatim: "Sending messages (including upstream messages) with the FCM legacy APIs for HTTP and XMPP was deprecated on June 20, 2023, and shutdown begins on July 22, 2024" (source: https://firebase.google.com/docs/cloud-messaging/migrate-v1, 2025-05-29). The legacy HTTP endpoint (`fcm.googleapis.com/fcm/send`), XMPP (`fcm-xmpp.googleapis.com`), and legacy "server key" string auth are **gone**. Only **HTTP v1** remains.
The **batch send** endpoint (`fcm.googleapis.com/batch`) was also discontinued, shutdown date **2024-06-20** (source: same, 2025-05-29). For a raw-protocol relay there is **no batch endpoint** — send one `POST` per registration token and fan out via HTTP/2 multiplexing or a bounded concurrency pool.
### 2.2 OAuth2 service-account authentication
Auth is **OAuth2 service-account bearer tokens**. Recommended (least-privilege) scope: `https://www.googleapis.com/auth/firebase.messaging` (the broader `cloud-platform` works but is a superset) (source: https://firebase.google.com/docs/cloud-messaging/auth-server, 2025-05-29). Send the token as:
```http
Authorization: Bearer <oauth2_access_token>
```
**Token minting (RFC 7523 JWT-bearer grant):** build a JWT signed **RS256** with the service-account private key, then exchange it (source: https://developers.google.com/identity/protocols/oauth2/service-account, 2025):
```jsonc
// JWT claims for the token exchange
{
"iss": "<service-account-email>",
"scope": "https://www.googleapis.com/auth/firebase.messaging",
"aud": "https://oauth2.googleapis.com/token",
"iat": 1700000000,
"exp": 1700003600 // iat + up to 3600s (max 1 hour)
}
```
```http
POST https://oauth2.googleapis.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=<signed_JWT>
```
Response: `{ "access_token": "...", "token_type": "Bearer", "expires_in": 3600 }`. **Access tokens are valid 3600s (1 hour).** Cache and re-mint before expiry (≈5-minute safety margin); never mint per request (source: same, 2025). Prefer a maintained Google auth library over hand-rolling.
**Credential resolution (ADC):** `GOOGLE_APPLICATION_CREDENTIALS` env var (path to SA JSON) → platform attached/default SA → error. Firebase: the JSON "should be kept private. Do not add it to public repos!" (source: https://firebase.google.com/docs/cloud-messaging/send/v1-api, 2026-06). On GCP, prefer keyless auth (§4.5).
### 2.3 Endpoint & message JSON
```http
POST https://fcm.googleapis.com/v1/projects/{PROJECT_ID}/messages:send
Authorization: Bearer <oauth2_access_token>
Content-Type: application/json
```
The path accepts the project ID **or** project number. Host is global `fcm.googleapis.com`; **no** regional/data-residency send endpoints exist as of June 2026, and no new required top-level fields were introduced through 2025–2026 (source: https://firebase.google.com/docs/cloud-messaging/send/v1-api, 2026-06-12).
**Request body** = `{ "validateOnly"?: bool, "message": Message }`. `message` is **required** and must carry **exactly one** target: `token`, `topic`, or `condition` (source: https://pkg.go.dev/google.golang.org/api/fcm/v1, current). For a relay sending to one device, use `message.token`.
```jsonc
{
"message": {
"token": "<registration_token>",
"data": { "k1": "v1", "k2": "v2" }, // map<string,string> ONLY
"android": { "priority": "HIGH", "ttl": "0s", "collapseKey": "sync" },
"apns": { "headers": { "apns-priority": "10" }, "payload": { "aps": { /* ... */ } } }
}
}
```
**Data-only emphasis (relay-critical):** A message with a `notification` block is **auto-displayed** by the FCM SDK when the app is backgrounded/killed, and the app code does **not** run until the user taps it. A **data-only** message (no `notification` block) is **always** delivered to the app's handler (`onMessageReceived` / background handler) and never auto-displayed — full app control in foreground and background. **A relay that needs its own logic/custom display on every message should send DATA-ONLY messages** (source: https://firebase.google.com/docs/cloud-messaging/customize-messages/set-message-type, 2025-05-29).
**`message.data` is `map<string,string>` — all values MUST be strings** (no nested objects/numbers/booleans; JSON-stringify anything structured). Reserved/forbidden keys: `from`, `message_type`, `notification`, and any key prefixed `google.`/`gcm.` (or reserved words) → `INVALID_ARGUMENT`. `android.data` overrides top-level `data` for Android (source: https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages, 2025-05-29; https://firebase.google.com/docs/cloud-messaging/error-codes, 2026-06-12).
**Payload size:** max **4096 bytes** for token/device sends, **2048 bytes** for topic sends; over → `INVALID_ARGUMENT` (source: https://firebase.google.com/docs/cloud-messaging/error-codes, 2026-06-12).
### 2.4 AndroidConfig
`message.android` (REST camelCase) (source: https://pkg.go.dev/google.golang.org/api/fcm/v1, current):
| Field | Type / values | Notes |
|---|---|---|
| `priority` | `"NORMAL"` \| `"HIGH"` (lowercase also accepted) | Default: NORMAL for data messages, HIGH for notification messages. HIGH attempts immediate delivery and can wake a dozing device. |
| `collapseKey` | string | Group id; only the last queued message delivered on reconnect. **Not** a request-validation limit: FCM *stores* at most **4** distinct collapse keys per device while the device is offline; a 5th evicts one nondeterministically (a delivery/storage behavior — see §2.7). The API does **not** reject a request for using a 5th distinct key. |
| `ttl` | Duration string in seconds with `s` suffix, e.g. `"3s"`, `"3.000000001s"` | `"0s"` = deliver-now-or-drop. Range **0 – 2,419,200s (28 days)**; out-of-range → `INVALID_ARGUMENT`. |
| `restrictedPackageName` | string | Deliver only if the token's app package matches. |
| `directBootOk` | bool | Allow delivery during Direct Boot. |
| `data` | map<string,string> | Overrides `message.data` for Android. |
Newer **optional** fields exist (`bandwidthConstrainedOk`, `restrictedSatelliteOk`); admin-SDK support landed ~March 2026; both are documented Optional and do not change the required contract (source: https://firebase.google.com/docs/cloud-messaging/send/v1-api, 2026-06-12).
### 2.5 `validateOnly` (dry-run)
Set top-level `validateOnly: true` to fully validate the request (token format, payload size, field correctness) and return a message-id-shaped response **without delivering**. Default `false` (source: https://pkg.go.dev/google.golang.org/api/fcm/v1, current). Use in CI/tests.
### 2.6 Error model
Error body shape (source: https://firebase.google.com/docs/cloud-messaging/error-codes, 2026-06-12):
```json
{ "error": { "code": 404, "message": "...", "status": "UNREGISTERED",
"details": [ { "@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", "errorCode": "UNREGISTERED" } ] } }
```
Classify on `error.status` / the FcmError `errorCode`, not just the HTTP code.
| HTTP | `error.status` | Meaning | Handling |
|---|---|---|---|
| 400 | `INVALID_ARGUMENT` | Malformed payload **or** bad token (bad format, mismatched package, oversize, reserved data key, bad TTL). `details` = `google.rpc.BadRequest`. | **Do not retry.** Delete token **only** if payload is independently known-valid; otherwise fix payload. |
| 401 | `THIRD_PARTY_AUTH_ERROR` | Invalid/missing APNs cert/key or Web Push auth credential. | Fix credentials (ops alert). **Do not retry.** |
| 403 | `SENDER_ID_MISMATCH` | Authenticated sender ID ≠ sender that registered the token. | Send from the correct project; permanent. **Do not retry.** |
| 404 | `UNREGISTERED` | App unregistered (uninstall, APNs token invalid, 270-day Android inactivity, restore). | **Permanently delete the token.** Never retry. |
| 429 | `QUOTA_EXCEEDED` (RESOURCE_EXHAUSTED) | Project / device / topic rate quota exceeded. `details` = `google.rpc.QuotaFailure` naming which quota. | Retry with exponential backoff; min initial delay **1 minute** for project- and topic-rate cases. |
| 500 | `INTERNAL` | Unknown internal error. | Retry per "Handling retries"; **no** documented `Retry-After`. |
| 503 | `UNAVAILABLE` | Server overloaded / temporarily unavailable. | Retry with backoff + jitter; **honor `Retry-After`** if present. |
| (none) | `UNSPECIFIED_ERROR` | Catch-all; "No more information is available about this error." | Conservative: **do not** auto-delete token; surface for investigation. |
(source: https://firebase.google.com/docs/cloud-messaging/error-codes, 2026-06-12 / 2026-06-13).
> **Corrections / precision notes:**
> - `QUOTA_EXCEEDED` does **not** carry a `google.rpc.RetryInfo` per FCM's own page — only `google.rpc.QuotaFailure` is promised. General Google API guidance allows `RetryInfo`; treat its presence as optional and fall back to `Retry-After`/60 s (source: same, 2026-06-13).
> - `INVALID_ARGUMENT` (400) is **ambiguous**: it can mean malformed payload **or** bad token. Only treat it as a dead token when the payload is independently validated (source: same, 2026-06-12).
> - Strictly, `UNSPECIFIED_ERROR` is the `FcmError.errorCode` inside `details[]`, not the top-level google.rpc `status` — operationally equivalent (source: same, 2026-06-13).
### 2.7 Retry-After, backoff, quotas, throttling
**Retry classification at scale** (source: https://firebase.google.com/docs/cloud-messaging/scale-fcm, 2026-06-12):
- **Never retry** 400 / 401 / 403 / 404 — "abort, and do not retry."
- **429:** "retry after waiting for the duration set in the retry-after header. If no retry-after header is set, default to **60 seconds**."
- **500 / 503:** "retry with exponential back-off" (+ jitter; honor `Retry-After` on 503 if present).
- "Wait at least **10 seconds** before retrying a failed request"; set ≥10 s send timeout; cap retries; a request still failing after **60 minutes** is an outage/miscategorization, not something to retry forever.
**Exponential backoff must use jitter** ("varying the retry delays through a random process"). **Smooth traffic:** ramp 0→max RPS over ≥60 s, and "avoid sending messages within a 2 minute window of each of the :00, :15, :30, and :45 minute marks" to dodge synchronized spikes (source: same, 2026-06-12).
**Project quota.** Default per-project downstream quota is **600,000 messages per minute** ("covers over 99% of FCM developers"), enforced via a **600K-token 1-minute token bucket that refills to full at the end of each (non-clock-aligned) minute**. Over quota → `429 RESOURCE_EXHAUSTED` (`QUOTA_EXCEEDED`) until refill. **The quota measures messages, not requests.** Client errors (HTTP 400–499) count against quota **except 429s** (source: https://firebase.google.com/docs/cloud-messaging/throttling-and-quotas, 2026-06-13; corroborated https://firebase.google.com/docs/cloud-messaging/scale-fcm, 2026-06-12).
> **Correction:** The research claim that "different message types cost different token amounts" is **not** documented and is treated as inaccurate — quota is counted **per message** regardless of type; topics are merely more efficient because one request fans out to many subscribers (source: https://firebase.google.com/docs/cloud-messaging/scale-fcm, 2026-06-12).
**Per-device Android limits:** up to **240 messages/minute** and **5,000 messages/hour** to a single device (headroom for bursts, e.g. rapid chat). On iOS, FCM returns an error when APNs per-device limits are exceeded (source: https://firebase.google.com/docs/cloud-messaging/throttling-and-quotas, 2026-06-13).
> **Correction:** Exceeding the **per-device** Android caps is **not** reported as `QUOTA_EXCEEDED` (429). `429 / QUOTA_EXCEEDED` is the **project-level** per-minute quota. Firebase's throttling/quotas page does **not** specify a particular error code or status string for the per-device caps — it only says "For iOS, we return an error when the rate exceeds APNs limits." Secondary sources name the per-device signal `DeviceMessageRateExceeded`, but this string is **not** firmly documented on a primary Google page, so **classify it defensively** rather than matching an exact literal (source: https://firebase.google.com/docs/cloud-messaging/throttling-and-quotas, 2026-06-13).
>
> **Relay handling (per-device rate).** Because the exact code/string is uncertain, the relay should treat any per-device-rate signal — whether surfaced as an `INVALID_ARGUMENT`-adjacent error, a quota-style `RESOURCE_EXHAUSTED` that names a per-device (not project) quota in `details`, or a `DeviceMessageRateExceeded`-shaped string — as a **transient, retryable rate condition**: map it to a caller-facing **429 with a conservative default `Retry-After`** and **never** as a token purge (the token is healthy; only the rate is too high). Detect on `error.status` / the `FcmError.errorCode` and the `QuotaFailure` scope, not on a hardcoded string. The relay's whole purpose is fanning bursty per-device wakes, so this is a plausible real outcome that must have a defined mapping rather than falling through to `UNSPECIFIED_ERROR`/502.
**Collapsible-message throttling:** burst of **20 per app per device**, refill **1 every 3 minutes** (source: same, 2026-06-13).
> **Correction:** Exceeding the collapsible burst is a **silent delay** (battery protection), **not** a `QUOTA_EXCEEDED` error: "we delay messages to reduce the impact on a user's battery" (source: same, 2026-06-13).
**Distinct collapse keys:** FCM stores at most **4** different collapsible messages per device (one per distinct `collapse_key`); a 5th evicts a key nondeterministically. Default `collapse_key` = app package name (source: https://firebase.google.com/docs/cloud-messaging/customize-messages/collapsible-message-types, 2026-06-12).
> **Design implication (collapse-key cap vs. per-series keys).** If the relay derives `collapse_key` per notification series (e.g. a per-series HMAC), a single device that follows **more than 4 active series** will exceed FCM's 4-distinct-key store, and excess distinct keys evict each other nondeterministically — so **collapsible coalescing is best-effort**, not guaranteed, once a device crosses 4 series. This is acceptable only if a dropped collapsible wake is recoverable (e.g. the caller's durable inbox + sync covers it); otherwise the caller should cap distinct `collapse_key`s per device. **Asymmetry to note:** APNs has **no** documented per-device cap on the number of distinct `apns-collapse-id` values, so this 4-key eviction behavior is FCM-only — the two providers are not symmetric here, and the cross-provider design docs should reconcile this rather than assume parity.
**Non-collapsible offline storage (Android):** up to **100** messages stored while offline; beyond that, **all** stored messages are discarded and the device gets a single "contact your server" signal on reconnect. Do not treat FCM as a durable queue; the app must re-sync (source: same, 2026-06-12).
**Quota increases:** self-serve up to **+25%** in Google Cloud Console (gated on ≥80% usage for ≥5 consecutive minutes/day, <5% client-error ratio at peak, scale best practices). Larger/temporary bumps via Firebase Support: ≥15 days lead (≥30 days for >18M msgs/min), ≤2 events/year, ≤30-day total duration (source: https://firebase.google.com/docs/cloud-messaging/throttling-and-quotas, 2026-06-13).
### 2.8 High-priority data-message behavior & power management
- **Normal priority:** delivered immediately when awake; in Doze, batched to maintenance windows. **High priority:** FCM attempts immediate delivery, can wake a sleeping device, grants temporary network + a partial wakelock (source: https://developer.android.com/training/monitoring-device-state/doze-standby, 2024-07-28).
- **Deprioritization:** A high-priority message that does **not** produce a user-facing notification gets **deprioritized to normal** (and may be throttled): "If FCM detects a pattern in which messages don't result in user-facing notifications, your messages may be deprioritized to normal priority." FCM evaluates ~7 days of per-app-instance behavior (source: https://firebase.blog/posts/2025/04/fcm-on-android/, 2025-04-17). **Do not use high priority for silent data-only syncs; use normal priority and reserve high priority for messages that produce a notification.**
- **App Standby Buckets:** high-priority delivery is still subject to the device's standby-bucket quota; once exhausted, further high-priority messages are effectively treated as normal. Exact per-bucket numeric caps are **not published** by Google and are dynamic/OEM-dependent (source: https://firebase.blog/posts/2019/02/life-of-a-message/, 2019-02-26).
- **Background-restricted apps:** receive **no** FCM messages (any priority, any state) until launched to the foreground (source: https://developer.android.com/training/monitoring-device-state/doze-standby, 2024-07-28).
- **Android 13+ (`POST_NOTIFICATIONS`):** notifications require the runtime `POST_NOTIFICATIONS` permission; without it, delivered notification messages are silently dropped. If the app's first notification channel is created while in the background (which the FCM SDK does on first receipt), Android won't display or prompt until the next app open — so early notifications are **lost**. **Data-message delivery to `onMessageReceived` is unaffected** (source: https://developer.android.com/develop/ui/views/notifications/notification-permission, 2024-08-08).
### 2.9 Registration-token lifecycle
- **Rotation hook:** `onNewToken()` fires on initial generation and every refresh (including FCM-initiated refresh when "the security of the previous token had been compromised"). Tokens also change on reinstall, data-clear, device restore. Persist the token to your server inside `onNewToken` (source: https://firebase.google.com/docs/cloud-messaging/manage-tokens, 2026-06-13).
- **Android inactivity expiry:** "When stale tokens reach **270 days** of inactivity, FCM considers them expired." iOS/web rely on APNs/Web Push (no inactivity-based expiry), so cleanup there is driven by send-time error responses (source: same, 2026-06-13).
- **Freshness strategy:** store each token **with a server timestamp** (FCM SDKs don't provide one), refresh ~**monthly** (no benefit more often than weekly), skip tokens outside your staleness window before sending, and delete on `UNREGISTERED` (404) or on `INVALID_ARGUMENT` (400) **when the payload is verified valid** (source: same, 2026-06-13).
---
## 3. Go ecosystem (2026)
> **Version targeting:** `jackc/pgx v5.10` and `firebase.google.com/go/v4 v4.20` both require **Go 1.25+**, so target **Go 1.25+** for the whole relay (source: https://pkg.go.dev/github.com/jackc/pgx/v5, 2026-06-03; https://pkg.go.dev/firebase.google.com/go/v4/messaging, 2026-05-14).
### 3.1 APNs client
| Choice | Version | Rationale |
|---|---|---|
| `github.com/sideshow/apns2` | **v0.25.0** (tagged 2024-10-25 UTC; pkg.go.dev shows "Oct 24, 2024" due to TZ rounding) | De-facto Go APNs HTTP/2 client. Auto-manages ES256 JWT generation/refresh and HTTP/2 connection reuse. Host constants match Apple. **Hold ONE `*apns2.Client` per process/environment; never recreate per push** (TLS setup is slow). One client "can potentially do 4,000+ pushes per second." (source: https://github.com/sideshow/apns2/blob/v0.25.0/client.go, 2024-10-25) |
| Hand-rolled (alternative) | `net/http` + `golang.org/x/net/http2` (**v0.56.0**, 2026-06-09) + `golang-jwt/jwt/v5` (**v5.3.1**) | Full control of the HTTP/2 transport; mirror apns2: parse `.p8` EC key, sign one ES256 JWT, cache ~50 min, reuse one `*http2.Transport` with `ReadIdleTimeout`/`PingTimeout` set. (source: https://pkg.go.dev/github.com/golang-jwt/jwt/v5, 2026-01-01; https://pkg.go.dev/golang.org/x/net/http2, 2026-06-09) |
`apns2` internals (verified against v0.25.0 source): `HostProduction = "https://api.push.apple.com"`, `HostDevelopment = "https://api.sandbox.push.apple.com"`; `NewTokenClient(*token.Token{AuthKey, KeyID, TeamID})` / `NewClient(cert)`; chain `.Production()`/`.Development()`. `token.TokenTimeout = 3000` (50 min, deliberately under Apple's 1-hour window); `GenerateIfExpired()` regenerates under a mutex. Timeouts: `HTTPClientTimeout=60s`, `ReadIdleTimeout=15s`, `TCPKeepAlive=15s`, `TLSDialTimeout=20s`. `ClientManager` (MaxSize 64, MaxAge 10m) pools clients across multiple certs/keys (source: https://github.com/sideshow/apns2/blob/v0.25.0/client.go, 2024-10-25; token/token.go, same).
> **Maintenance caveat:** `apns2` has had no release since v0.25.0 (Oct 2024); bus-factor risk. Verify recent commit/issue activity before committing vs. the hand-rolled path (see §5, item 2).
### 3.2 FCM / OAuth2
Pick **exactly one** of these two FCM approaches, not both:
| Choice | Version | Rationale |
|---|---|---|
| `golang.org/x/oauth2/google` (lighter) | **v0.36.0** (2026-02-11) | Best for a **fixed/known payload**: `CredentialsFromJSONWithType(ctx, jsonKey, google.ServiceAccount, "https://www.googleapis.com/auth/firebase.messaging")` → `creds.TokenSource` already caches+auto-refreshes; POST your own JSON to the v1 endpoint with a tuned HTTP/2 client. Fewer deps; full control of connection reuse; **you implement retries/error-classification**. Note: `CredentialsFromJSON` is deprecated in favor of `CredentialsFromJSONWithType`. (source: https://pkg.go.dev/golang.org/x/oauth2/google, 2026-02-11) |
| `firebase.google.com/go/v4/messaging` (batteries-included) | **v4.20.0** (2026-05-14, Go 1.25+) | Auto-mints/caches OAuth token, auto-retries transient errors, rich error helpers: `IsUnregistered` (drop token), `IsQuotaExceeded`, `IsUnavailable`, `IsInvalidArgument`, `IsThirdPartyAuthError`, `IsSenderIDMismatch`. Use `Send` / `SendEach` / `SendEachForMulticast` (each = one HTTP call per message, ≤500). **`SendAll`/`SendMulticast` are deprecated (and removed in newer SDK majors)** — do not use. (source: https://pkg.go.dev/firebase.google.com/go/v4/messaging, 2026-05-14) |
For a privacy-preserving relay forwarding opaque tenant payloads, the **`x/oauth2/google` + custom HTTP/2 client** path is usually preferable: it avoids the heavy Admin SDK and gives direct control of connection reuse and error handling.
### 3.3 HTTP/2 tuning (both transports)
`golang.org/x/net/http2.Transport` (**v0.56.0**, 2026-06-09) knobs (source: https://pkg.go.dev/golang.org/x/net/http2, 2026-06-09):
- `ReadIdleTimeout` (~10–15 s): if no frame received for this duration, send a PING health check (0 = disabled).
- `PingTimeout` (~15 s, default): close the conn if no PING response arrives.
- `WriteByteTimeout`: close if no bytes can be written within the timeout.
- `StrictMaxConcurrentStreams` (bool): if `false` (default) new TCP conns open to stay under each conn's `SETTINGS_MAX_CONCURRENT_STREAMS`; if `true` the server limit is global and callers block.
**Setting a non-zero `ReadIdleTimeout` + `PingTimeout` is the single most important tuning for a long-lived relay** — it reaps half-open APNs/FCM connections. Do not hardcode max-concurrent-streams; let the transport read the server SETTINGS (the exact APNs/FCM advertised value is not a published primary-source constant — measure at runtime; see §5, item 3).
**Inbound server:** `net/http` gets HTTP/2 automatically over TLS and supports graceful shutdown. **Set explicit timeouts** — the zero value means no timeout: `ReadHeaderTimeout`, `ReadTimeout`, `WriteTimeout`, `IdleTimeout`, `MaxHeaderBytes` (default 1 MiB). On SIGTERM call `srv.Shutdown(ctx)` with a bounded context (source: https://pkg.go.dev/net/http, 2026-06-01).
### 3.4 Storage
| Choice | Version | Rationale |
|---|---|---|
| `github.com/jackc/pgx/v5` + `pgxpool` | **v5.10.0** (2026-06-03, Go 1.25+) | Recommended Postgres driver/pool. Use `pgxpool.New(ctx, dsn)` (not `pgx.Connect`) for a server — concurrency-safe, health-checked. Token store, API-key store, idempotency. (source: https://pkg.go.dev/github.com/jackc/pgx/v5, 2026-06-03) |
| `github.com/redis/go-redis/v9` | **v9.20.1** (2026-06-11) | Context-first Redis client with built-in pooling. Distributed rate-limiting, idempotency in-flight locks, dedup/queue state. (source: https://pkg.go.dev/github.com/redis/go-redis/v9, 2026-06-11) |
### 3.5 Observability
| Choice | Version | Rationale |
|---|---|---|
| `log/slog` (stdlib) | Go 1.21+ | Structured logging, no third-party dep. `slog.NewJSONHandler` for production aggregation; redaction middleware (§4.6). (source: https://pkg.go.dev/log/slog, 2026-06-01) |
| `github.com/prometheus/client_golang` | **v1.23.2** (2025-09-05) | Prometheus instrumentation; custom registry + `promhttp.HandlerFor` for `/metrics`. Instrument per-provider send latency (histogram) and outcome counters labeled by provider + status/reason. (source: https://pkg.go.dev/github.com/prometheus/client_golang/prometheus, 2025-09-05) |
### 3.6 Reference pattern (not a dependency)
`github.com/appleboy/gorush` is a maintained reference push **server** (built on `sideshow/apns2`, supports FCM) with a notification queue, worker pool, and pluggable stats storage — useful as a **pattern reference** for worker pools and retry/queue design, but it is a full server, not a library to embed (source: https://github.com/appleboy/gorush, 2025-11-20).
### 3.7 Pinned versions (summary)
```
github.com/sideshow/apns2 v0.25.0
github.com/golang-jwt/jwt/v5 v5.3.1
golang.org/x/net v0.56.0 // http2
golang.org/x/oauth2 v0.36.0
firebase.google.com/go/v4 v4.20.0 // only if using the Admin SDK path
github.com/prometheus/client_golang v1.23.2
github.com/jackc/pgx/v5 v5.10.0
github.com/redis/go-redis/v9 v9.20.1
golang.org/x/time (rate — see §4.2)
// Go 1.25+ required
```
---
## 4. Security & ops (2026)
### 4.1 API-key storage & comparison
- **Generate** 32 bytes from `crypto/rand`; format as `<prefix>_<base62/base64url>` where the **prefix** (8–12 chars, optionally tenant/env-tagged) is **non-secret**, stored in cleartext, and **indexed**. The remaining 32 bytes are the secret. This GitHub/Stripe-style design lets you `SELECT` the row by indexed prefix in O(1), then constant-time-compare the hash — no full-table scan, no SQL index over the secret (engineering consensus aligned with OWASP API2:2023, source: https://owasp.org/API-Security/editions/2023/en/0xa2-broken-authentication/, 2023).
- **Hashing:** Store **SHA-256** (or **HMAC-SHA-256** with a server-side pepper kept in the secret manager) of the secret — never plaintext. For **high-entropy random keys** (≥128, ideally 256 bits), a fast hash is acceptable; OWASP's slow-KDF warning is scoped to **low-entropy human passwords**, where "Fast hashing algorithms such as SHA-256 are not suitable" — that warning does **not** apply to 256-bit random API keys, and `argon2id` per-request would needlessly throttle the relay (source: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html, 2026-06). _Note: this passwords-vs-keys distinction is engineering consensus consistent with OWASP, not a single verbatim OWASP line — see §5, item 12._
- **If** you ever store a low-entropy password (not API keys), OWASP's current argon2id minimum is **m=19456 KiB (19 MiB), t=2, p=1** with a unique per-secret salt (source: same, 2026-06). Do **not** apply argon2id to per-request API-key verification.
- **Comparison:** use `crypto/subtle.ConstantTimeCompare(x, y []byte)` on the fixed-length (32-byte) hashes. Never use `==` or `bytes.Equal` on raw secret material (source: https://pkg.go.dev/crypto/subtle, current).
- **OWASP API2:2023:** "API keys should not be used for user authentication. They should only be used for API clients authentication." Never put keys in URLs/query strings; implement anti-brute-force on auth endpoints (source: https://owasp.org/API-Security/editions/2023/en/0xa2-broken-authentication/, 2023).
- **Metadata per key:** `account_id`, `key_hash`, `prefix` (indexed), `created_at`, `last_used_at` (written throttled, e.g. ≤ once/min, to avoid a DB write per request), `expires_at` (optional), `revoked_at`. Support **multiple live keys per account** for zero-downtime rotation; reject revoked/expired keys on every request (aligned with OWASP API2:2023, source: same, 2023).
### 4.2 Rate limiting
- **Single instance:** `golang.org/x/time/rate` token bucket — `NewLimiter(r Limit, b int)`. Use `Allow()` to drop/reject (return 429) on inbound; `Wait(ctx)`/`Reserve()` to pace outbound APNs/FCM calls. `Reservation.Delay()` gives the wait → use it to compute `Retry-After`. Per-account: `map[accountID]*Limiter` with eviction (per-instance only) (source: https://pkg.go.dev/golang.org/x/time/rate, current).
- **Multi-instance (behind LB):** per-instance limiters no longer enforce a global limit — use a **Redis-backed distributed limiter** with an **atomic Lua** script (refill → check → consume in one `EVAL`), state as a Redis HASH `{tokens, last_refill_ts}` + TTL for idle-account eviction. Alternatives: `redis-cell` (GCRA, `CL.THROTTLE` returns remaining quota + retry-after) or `redis-gcra` Lua scripts. Decide **fail-open vs fail-closed** when Redis is down (fail-open for availability; fail-closed for abuse-sensitive endpoints) (source: https://github.com/Losant/redis-gcra, current).
- **Response:** on limit exceeded, return **`429 Too Many Requests`** (RFC 6585 §4, 2012-04) with a **`Retry-After`** header. ABNF: `Retry-After = HTTP-date / delay-seconds`, `delay-seconds = 1*DIGIT` (RFC 9110 §10.2.3, 2022-06). **Common bug to avoid:** do **not** emit an absolute Unix epoch integer — `delay-seconds` is a **relative** count of seconds; an HTTP-date must use the full date format. Prefer an integer relative-seconds value from `Reservation.Delay()` (source: https://www.rfc-editor.org/rfc/rfc9110.html#section-10.2.3, 2022-06).
- **OWASP API4:2023** (renamed "Unrestricted Resource Consumption"): enforce rate limits, "Define and enforce a maximum size of data on all incoming parameters and payloads," cap per-account push volume, and "Configure spending limits for all service providers/API integrations" — directly relevant to capping per-tenant APNs/FCM spend (source: https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/, 2023).
### 4.3 Idempotency
Accept an **`Idempotency-Key`** header (UUIDv4, **scoped per account**). _Status note:_ this is an IETF **draft** (`draft-ietf-httpapi-idempotency-key-header-07`, 2025-10-15), **not** a ratified RFC, and that version **expired 2026-04-18** — treat the header **name** and pattern as the de-facto standard (Stripe uses the same header), but do not cite it as an RFC (source: https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header-07, 2025-10-15).
Status-code lifecycle from the draft:
| Situation | Response |
|---|---|
| First request | Process; record the response. |
| Duplicate, original **completed** | Replay the previously completed result (success or error). |
| Duplicate, original **in-flight** | **409 Conflict** (RFC 7807 body, type `.../idempotency`). |
| Same key, **different payload** | **422 Unprocessable Content**. |
| Missing key where required | **400 Bad Request**. |
(source: same, 2025-10-15).
**Stripe-style production model** (de-facto reference): header `Idempotency-Key`, value up to 255 chars, recommend UUIDv4; saved result (status + body) replayed for **≥24 hours** for both success and failure; same key + different params → error; concurrent in-flight → not saved, client may retry; applies to POST (GET/DELETE ignore it) (source: https://docs.stripe.com/api/idempotent_requests, 2026-06).
**Relay implementation:** atomic `SET idem:{account}:{key}` with **NX + TTL** as an in-flight lock. NX success → process, then overwrite the marker with the serialized `{status, body}` and a ~24 h TTL. NX fail + stored response → replay it with the original status. NX fail + still in-flight → return **409**. Same key + different payload → **422**. This prevents double-sends to APNs/FCM under concurrent retries.
### 4.4 Provider auth recap (security view)
- **APNs:** cache one ES256 JWT, regenerate on a ~50-min timer (never < 20 min apart) under a mutex, reuse across senders; refresh on `403 ExpiredProviderToken`; back off on `429 TooManyProviderTokenUpdates`. Prefer team-scoped + topic-specific `.p8` keys (Feb 2025) for least privilege. Ensure the TLS trust store includes USERTrust RSA / SHA-2 root (§1.7). (sources as cited in §1.)
- **FCM:** cache the OAuth2 access token (~1 h) rather than minting per message. On GCP, prefer keyless auth (§4.5). (sources as cited in §2.)
### 4.5 Secret management
OWASP Secrets Management ranking — **KMS/Vault/secret-manager (best) > tightly-permissioned mounted secret file (acceptable) > plain env var (discouraged) > in image/repo (forbidden)**. Verbatim: "environment variables are generally accessible to all processes and may be included in logs or system dumps. Using environment variables is therefore not recommended unless the other methods are not possible." Encrypt at rest (AES-256-GCM or ChaCha20-Poly1305); "You should not store keys next to the secrets they encrypt" (envelope encryption / KMS); least privilege; prefer dynamic/short-lived secrets (source: https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html, 2026-06). Store the APNs `.p8` and FCM SA JSON in a secret manager; never commit or bake into the image.
**Google keyless direction (2025):** prefer Workload Identity / service-account impersonation / attached SA over downloadable JSON keys; org policy `constraints/iam.disableServiceAccountKeyCreation` blocks new keys. If the relay runs **on GCP**, use an attached SA / Workload Identity. If **off-GCP**, a JSON key is sometimes unavoidable — then it **must** live in a secret manager, not a repo or plain env var (source: https://docs.cloud.google.com/iam/docs/keys-disable-enable, 2026-06).
### 4.6 Log redaction
OWASP Logging exclude-list (verbatim items): "Access tokens"; "Authentication passwords"; "Session identification values"; "Database connection strings"; "Encryption keys and other primary secrets"; payment/PII. Redact by remove/mask/sanitize/hash/encrypt (source: https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html, 2026-06).
For the relay specifically — **never log**: the `Authorization`/bearer header, the raw API key (log only the non-secret **prefix** or a hash), full APNs/FCM **device tokens** (truncate/hash), or **notification payload bodies**. **Do log**: request ID, `account_id`/tenant ID, status code, error `reason`/`status`, and Retry-After/idempotency decisions for triage. Implement redaction in logging middleware so it can't be bypassed.
### 4.7 Multi-tenant isolation
Combine **noisy-neighbor resource control** (per-tenant rate/volume quotas at gateway/service/data tiers) with **per-tenant scoping** of every stored API key, rate-limit bucket, idempotency key, cached provider token, and **log line**. "Attach a tenant ID to every request, log entry, metric, and trace." Namespace all shared-store keys by `account_id` to prevent cross-tenant collision/replay. Cap downstream APNs/FCM spend per tenant (OWASP API4:2023). For APNs, recall the per-team connection-pool constraint (§1.1) (source: https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/, 2023).
```mermaid
flowchart TB
T[Tenant request + API key + Idempotency-Key] --> AUTH[Auth middleware: prefix lookup + ConstantTimeCompare]
AUTH --> RL[Per-tenant rate limit: Redis Lua token bucket]
RL -->|429 + Retry-After| T
RL --> IDEM[Idempotency: Redis SET NX lock / replay / 409 / 422]
IDEM --> ROUTE{Provider}
ROUTE -->|APNs| APNS[ES256 JWT cached ~50m, per-team HTTP/2 pool]
ROUTE -->|FCM| FCM[OAuth2 token cached ~1h, v1 messages:send]
APNS --> STORE[(pgx: tokens / keys / idempotency)]
FCM --> STORE
AUTH -. redacted .-> LOG[slog JSON: tenant, req-id, status, reason]
```
---
## 5. Open questions / re-verify before implementation
1. **APNs token page exact wording.** `establishing-a-token-based-connection-to-apns` is a JS-rendered SPA; content was confirmed via Apple's JSON data endpoint and corroborating sources. Re-read the live page to confirm exact 20/60-minute refresh wording and the scoped-key limits ("200 keys per env, up to 400 topics per topic-based key") near build time.
2. **`apns2` maintenance / bus factor.** No release since v0.25.0 (Oct 2024). Check recent commit/issue activity before committing to it vs. a hand-rolled `net/http2` + `golang-jwt/jwt/v5` client.
3. **APNs `SETTINGS_MAX_CONCURRENT_STREAMS`.** Intentionally variable and not documented by Apple. Measure at runtime; do not hardcode connection sizing.
4. **APNs sandbox hostname alias (resolved — interchangeable).** `api.sandbox.push.apple.com` and `api.development.push.apple.com` are interchangeable sandbox aliases: as of 2026-06-13 both resolve to the same Akamai CNAME and IP set (DNS-verified, §1.3), so either reaches the same sandbox backend. We standardize on `api.sandbox.push.apple.com` (canonical in Apple's docs and `apns2`'s `HostDevelopment`). No build-time blocker — this is a naming preference, not a discrepancy.
5. **APNs broadcast hosts.** If Live Activity broadcast/channels are in scope, confirm the broadcast-specific hosts (`api-broadcast.*`, `api-manage-broadcast.*` on 2195/2196) against Apple's code samples; the connection-section text in Apple's docs is internally inconsistent.
6. **Future APNs requirements.** None published after Feb 2025 as of June 2026 — re-check Apple's Upcoming Requirements page (e.g. a future TLS-1.3 minimum or another CA rotation) close to deployment.
7. **FCM `RetryInfo` on 429.** FCM's error-codes page promises only `google.rpc.QuotaFailure`, not `RetryInfo`. Treat `RetryInfo` as optional; fall back to `Retry-After` / 60 s default.
8. **FCM data-residency endpoints.** None for FCM as of June 2026 (global `fcm.googleapis.com` only). Re-check release notes if EU/other data-residency requirements emerge.
9. **FCM project quota tiers above 600K/min.** Confirm a project's real ceiling via Google Cloud Console quotas rather than assuming a secondary-source tier.
10. **App Standby Bucket caps.** Per-bucket high-priority wake-up caps are unpublished and OEM/version-dependent; confirm via on-device testing (`adb shell am set-standby-bucket` / `get-standby-bucket`) if precise behavior matters.
11. **Android 270-day token expiry.** Currently documented (manage-tokens, 2026-06-13) but a platform constant that can change — re-verify at build time.
12. **API-key "fast hash OK" citation.** Engineering consensus consistent with OWASP's passwords-vs-keys threat model, but not a single verbatim OWASP line for API keys; if a hard citation is required, point to that distinction, and decide plain salted SHA-256 vs HMAC-SHA-256 pepper (pepper adds defense-in-depth but complicates rotation).
13. **Idempotency-Key draft status.** Draft 07 expired 2026-04-18 without becoming an RFC; re-check for an 08+/RFC before relying on its exact status-code semantics as "standard."
14. **GCP vs off-GCP for FCM credentials.** Whether the relay runs on GCP (keyless Workload Identity) or off-GCP (JSON key in a secret manager) changes the recommended FCM credential approach — resolve in the deployment design.
@@ -0,0 +1,268 @@
# Silo Push Relay — Decision Record
> **Provenance.** This is the decision record for the Silo Push Relay. It resolves the implementation decisions that the engineering spec ([`00-relay-spec.md`](./00-relay-spec.md)) left open in **§15 ("Open questions needing human / product input")**, building on the phased build plan ([`01-implementation-plan.md`](./01-implementation-plan.md)) and the cited 2026 factual foundation ([`02-apns-fcm-2026-reference.md`](./02-apns-fcm-2026-reference.md)). Each decision below is **firm** and **implementer-followable**: pick it, build it. The external wire contract in [`../02-apns-relay.md`](../02-apns-relay.md) and [`../03-fcm-relay.md`](../03-fcm-relay.md) is authoritative and unchanged — no decision here adds, renames, or repurposes a caller-visible field. Commands assume the repository root is the cwd.
These decisions reflect **2026-current** APNs and FCM behavior and a deliberately narrow v1 cut line: a single-region, self-hosted-ecosystem relay whose two priorities are **reliability** and **custody of the official Apple/Google push credentials**. Where the spec already leaned a direction, this record ratifies and operationalizes it; where it left a real fork, this record picks one decisively and says exactly what that commits the team to.
---
## Summary
| Decision | Recommendation (one line) | Confidence | Gates |
|---|---|---|---|
| APNs Go client (§15 #3) | Hand-roll on `net/http` + `x/net/http2` + `golang-jwt/jwt/v5` behind `internal/apns`; do **not** take a production dep on `sideshow/apns2`. | High | Phase 3 |
| FCM credentials (§15 #4) | Default **keyless** via ADC (attached SA on GCP, WIF off-GCP); SA-JSON-in-secret-manager is the explicit last-resort fallback. | High | Phase 4 / Phase 6 |
| `apns-expiration` policy (§15 #5) | Relay-set **finite** TTL: 4h `private_alert`, 1h `background_wake`, parallel FCM `android.ttl`; relay-owned config, **not** a caller field. | High | Phase 3 (APNs) / Phase 4 (FCM) |
| Per-account rate limits (§15 #1) | Ship **hard-coded global defaults** (config constants + env overrides); add per-account columns + `relayctl quota` only when logs prove need. | High | Phase 2 |
| API-key hashing (§15 #6) | `HMAC-SHA256(pepper, secret)` — fast keyed hash, pepper in secret manager; **not** argon2id, **not** salted SHA-256. | High | Phase 1 / Phase 2 |
| Op-log retention (§15 #8) | Native PostgreSQL **declarative RANGE partitioning** by day, retention by **partition DROP** via `pg_partman`; not bulk `DELETE`, not external offload. | High | Phase 1 / Phase 5 |
---
## Decision 1 — APNs Go client: hand-roll, do not depend on `sideshow/apns2`
**Decision.** Should the relay's APNs client adopt `sideshow/apns2`, or hand-roll the client on `net/http` + `golang.org/x/net/http2` + `golang-jwt/jwt/v5`? (Spec §15 #3; plan §3 "Decision to make before Phase 3".)
**Resolution.** **Hand-roll the APNs client** on `net/http` with `x/net/http2` and `golang-jwt/jwt/v5`, behind the existing `internal/apns` interface. Do **not** take a production dependency on `sideshow/apns2`. Mirror its verified internals — production host `api.push.apple.com`, sandbox host `api.sandbox.push.apple.com`, token TTL 3000 s with a 20-minute refresh floor, an HTTP/2 transport with `ReadIdleTimeout`/`PingTimeout` near 15 s, one client per process per (env, team) — and add the behavior `apns2` lacks: single-flighted JWT regeneration keyed by team, an (env, topic) → `.p8` key-selection map for Apple's Feb 2025 scoped keys with startup validation, a finite `apns-expiration` policy (Decision 3), and a connection-severed → `503` mapping with no transparent retry. Optionally keep `apns2 v0.25.0` as a throwaway Phase 3 oracle, then drop it before Phase 6.
**Alternatives considered.**
| Option | Pros | Cons |
|---|---|---|
| **Hand-roll (chosen)** | No dependency on a stalled library; relay owns its whole upstream transport. Surface is small and fully specified. `jwt/v5` and `x/net/http2` are maintained and already needed (FCM, HTTP/2 tuning). Enables single-flight JWT regen, scoped-key selection, finite expiration, and connection-severed mapping that `apns2` lacks. | More code/tests up front for the JWT lifecycle, HTTP/2 pool, GOAWAY handling, and reason mapping. Must re-earn `apns2`'s production hardening through tests. |
| Adopt `sideshow/apns2 v0.25.0` | Fastest path to a working client; de-facto library with verified internals, battle-tested at throughput, MIT, small, vendorable. | Maintenance drift: no release since Oct 2024, one untagged commit in twelve months, none in 2026; 25 open issues / 8 PRs with no maintainer response in 2025–2026, including an unaddressed connection-leak issue (#238). Lacks single-flight regen, scoped-key selection, expiration policy, connection-severed semantics — so it would be wrapped anyway. Bus-factor risk. |
| Adopt a fork (e.g. `ad-mos/apns2`) | In principle routes around the inactive upstream; adds Live Activity. | Less current than upstream (untagged pseudo-version from Jul 2024, no stable tags) — bus factor is worse. Live Activity is out of scope. |
**Rationale.** Three points decide it for a reliability-first, multi-year, self-hosted relay. First, **maintenance health is poor**: `v0.25.0` (Oct 2024) is still the newest tag (source: https://github.com/sideshow/apns2/releases, 2026-06-13), the only commit to master in twelve months is one untagged dep-bump PR #239 dated 2025-07-22 (source: https://github.com/sideshow/apns2/commits/master, 2026-06-13), and an open issue (#238, 2024-11-11) reports exactly the failure class that matters here — an HTTP/2 connection/goroutine leak in a process holding long-lived connections for days — with no maintainer response in 2025 or 2026 (source: https://github.com/sideshow/apns2/issues, 2026-06-13). Second, **relay requirements already exceed `apns2`**: single-flight JWT regen (to avoid `429 TooManyProviderTokenUpdates`), the (env, topic) → `.p8` scoped-key map with startup validation for Apple's Feb 2025 scoped keys, a finite `apns-expiration`, connection-severed → `503` with no transparent retry, and sandbox-only `apns-unique-id` capture — `apns2` has none of these, so it would be wrapped regardless, leaving only small JWT/HTTP-2 glue. Third, **that glue is low risk to own**: the 2026 reference verified every constant, `jwt/v5` and `x/net/http2` are maintained and already needed, and Go's `net/http2` handles GOAWAY and reconnect transparently on a reused client. Compatibility is a non-issue — `apns2` master pins `x/net v0.33.0` / go 1.18 but module MVS resolves up to the relay's `x/net v0.56.0` and `jwt v5.3.1` and builds under Go 1.25/1.26 (source: https://github.com/sideshow/apns2/blob/master/go.mod, 2026-06-13) — so the disqualifier is maintenance and missing behavior, not versions.
**Consequences (what this commits).**
- **Deps.** `go.mod` takes `golang-jwt/jwt/v5 v5.3.1` and `golang.org/x/net v0.56.0` as production deps (both already planned in plan §3). `apns2` is **not** a production dep — at most a Phase 3 prototype dropped before Phase 6.
- **Code (`internal/apns`).**
- `jwt.go` — ES256 `.p8` sign; in-memory cache; ~50-minute regen timer; 20-minute floor; eager regen on `403 ExpiredProviderToken` with a single retry; **single-flight regen keyed by team**.
- `transport.go` — per-(env, team) HTTP/2 transport with non-zero `ReadIdleTimeout`/`PingTimeout`, hardcoded host constants by environment enum, GOAWAY reconnect, USERTrust RSA via the system trust store.
- `client.go` — the `internal/apns` interface implementation.
- `errors.go` — the spec §6.4 reason table, including `InvalidToken` → `BadDeviceToken`, unrecognized reason → `502`, connection-severed → `503`.
- Key-selection map and startup validation live in `internal/config`.
- Everything else depends only on the `APNsClient` interface, so the choice is reversible.
- **Ops.** A CVE or HTTP/2 bug is patched by bumping `x/net` rather than waiting on an inactive repo. Test against a fake APNs upstream for GOAWAY and mid-stream reset, a JWT-expiry burst that must yield **exactly one** regen, plus the Apple sandbox integration test (Phase 3).
- **Schema.** None — upstream-client-only; no Postgres/Redis or external-contract change.
**Revisit if.** Reverse toward `apns2` only if, **before Phase 3 ships**, `sideshow/apns2` cuts a fresh tagged release newer than `v0.25.0` and shows sustained maintainer responsiveness — clearing the backlog and addressing the #238 leak class — over two to three months, resolving the bus-factor concern. Conversely, **re-validate the hand-rolled JWT/HTTP-2 internals** if Apple changes APNs provider auth (a new required header, a non-ES256 algorithm, a host change, or deprecating token auth), since the hand-rolled client now owns those mechanics.
**Sources.**
- `apns2` latest release/tag is `v0.25.0` (Oct 24–25 2024); no newer tag exists (source: https://github.com/sideshow/apns2/releases, 2026-06-13).
- Only commit to master in twelve months is untagged dep-bump PR #239 (2025-07-22); no 2026 commits (source: https://github.com/sideshow/apns2/commits/master, 2026-06-13).
- 25 open issues / 8 PRs; issue #238 (potential HTTP/2 connection/goroutine leak, 2024-11-11) has no maintainer response in 2025–2026 (source: https://github.com/sideshow/apns2/issues, 2026-06-13).
- pkg.go.dev flags `v0.25.0` as not the latest module version; newest released version is ~20 months old (source: https://pkg.go.dev/github.com/sideshow/apns2, 2026-06-13).
- `apns2` master `go.mod` declares go 1.18, pins `x/net v0.33.0` and `jwt v5.2.1`; MVS resolves to the relay's `x/net v0.56.0` / `jwt v5.3.1`, builds under Go 1.25/1.26 (source: https://github.com/sideshow/apns2/blob/master/go.mod, 2026-06-13).
- Fork `ad-mos/apns2` (Live Activity) is less current: untagged pseudo-version 2024-07-30, no stable tags (source: https://pkg.go.dev/github.com/ad-mos/apns2, 2026-06-13).
- `x/net v0.56.0` (HTTP/2 `ReadIdleTimeout`/`PingTimeout` tuning) published 2026-06-09, actively maintained (source: https://pkg.go.dev/golang.org/x/net, 2026-06-13).
- Verified `apns2 v0.25.0` internals to mirror: `HostProduction api.push.apple.com`, `HostDevelopment api.sandbox.push.apple.com`, `TokenTimeout 3000` under a mutex, `ReadIdleTimeout 15s`, one client per process per env (source: https://github.com/sideshow/apns2/blob/v0.25.0/client.go, 2026-06-13).
---
## Decision 2 — FCM credentials: default keyless via ADC, SA-JSON-in-secret-manager as last resort
**Decision.** Should the relay authenticate to FCM HTTP v1 via GCP keyless identity (Workload Identity Federation / attached service account) or via a service-account JSON key stored in a secret manager? (Spec §4.4, §15 #4; reference §4.5.)
**Resolution.** **Default to keyless.** The relay MUST authenticate via Application Default Credentials (ADC) using `golang.org/x/oauth2/google` with scope `https://www.googleapis.com/auth/firebase.messaging`, with the concrete credential **source** chosen by deployment:
1. **On GCP compute (GCE/GKE/Cloud Run):** use an **attached service account** — zero key material; the metadata server mints short-lived tokens.
2. **Off-GCP (self-hosted LXC/VM — the documented Silo home-hosting reality):** use **Workload Identity Federation** with a **non-secret** credential-configuration file plus an OIDC token from the host's own IdP — still no exported key.
3. **Only if WIF cannot be stood up** (no usable OIDC issuer for the host): fall back to a **service-account JSON key**, which MUST live in a secret manager loaded once at startup into memory — **never** as a literal-key env var, **never** baked into the image, **never** on disk in the repo.
In all three cases the code path is identical: `google.FindDefaultCredentials` / `DefaultTokenSource(ctx, "…/firebase.messaging")` returns an auto-caching, auto-refreshing `TokenSource`; the relay does **not** branch on credential type. This makes keyless the default and JSON-in-secret-manager the explicit last resort, matching spec §4.4 / reference §4.5 with one refinement: even the off-GCP case is keyless via WIF first.
**Alternatives considered.**
| Option | Pros | Cons |
|---|---|---|
| **Keyless via ADC — attached SA on GCP, WIF off-GCP; SA-JSON-in-secret-manager last resort (chosen)** | No long-lived exportable secret in the common path — eliminates the highest-severity leak vector for a service whose whole reason to exist is custody of the official Google push credential (spec G3). Aligns with Google's current guidance that SA keys are "an exception rather than the norm" and that orgs created on/after 2024-05-03 block key creation by default. WIF's credential-config file carries no private key and need not be confidential. Tokens are short-lived (~1h) and auto-rotated — no manual rotation, no rotation-induced outage mode. Single code path through `x/oauth2/google` regardless of source. Org-policy `iam.disableServiceAccountKeyCreation` can be enforced relay-wide with no impact. | WIF off-GCP requires the host to expose a usable OIDC token (or a supported IdP: AWS/Azure/GitHub/GitLab/K8s/AD/OIDC/SAML/X.509); a bare LXC with no IdP must stand one up or fall back to JSON. Slightly more one-time setup (pool + provider + attribute mapping). |
| Service-account JSON key in a secret manager (loaded at startup, never env/in-image) | Works on any host with no IdP or GCP compute — the universal fallback for a self-hosted ecosystem. Simple mental model. Same `x/oauth2/google` minting code via `CredentialsFromJSONWithType`. Secret-manager custody (vs env/in-image) is itself OWASP-aligned hardening, acceptable when keyless is genuinely unavailable. | A long-lived, exportable private key is exactly what Google now treats as an exception and blocks by default for 2024-05-03+ orgs — if the official Silo Firebase project lives in such an org, the policy must be relaxed just to create it. Single high-value theft target (full FCM send authority), manual rotation burden, rotation-induced outage window. The untyped `CredentialsFromJSON` loader is deprecated; must use the typed variant. Fallback, not default. |
| Use the Firebase Admin SDK and let it resolve ADC | Batteries-included: auto token mint/cache/refresh, rich error helpers, also resolves ADC (inherits keyless transparently). | Orthogonal to the credential decision and explicitly **not** the relay's chosen client — plan §3 / reference §3.2 deliberately pick the lighter `x/oauth2/google` path for a fixed payload and direct control of connection reuse and error classification. Adopting it to "solve credentials" re-opens a settled client-library decision for no credential benefit (ADC resolution is identical either way). |
**Rationale.** The relay's single most sensitive asset is the official Google push credential (spec G3, §4.4); a fully compromised relay must not hand an attacker a portable, long-lived FCM send key. Keyless removes that artifact in the common path: on GCP the key never exists; off-GCP, WIF replaces it with a non-secret credential-config file plus a short-lived host-issued OIDC token that Google STS exchanges for a ~1h access token. Google's own current guidance is unambiguous — SA keys are "an exception rather than the norm," and organizations created on or after 2024-05-03 enforce `iam.disableServiceAccountKeyCreation` / `disableServiceAccountKeyUpload` by default (source: https://docs.cloud.google.com/iam/docs/best-practices-for-managing-service-account-keys, 2026-06-13) — so a brand-new project should treat keyless as the baseline. Reliability-first reinforces this: long-lived keys add a manual-rotation outage mode and a "key silently revoked/expired" failure class that keyless does not have. Crucially, the **implementation cost of defaulting to keyless is near-zero**, because `x/oauth2/google` ADC already resolves all three sources — GCE/GKE metadata, `external_account` WIF config files, and SA JSON — behind one `TokenSource` with the same scope, so the relay does not branch (source: https://pkg.go.dev/golang.org/x/oauth2/google, 2026-02-11). JSON-in-secret-manager remains an honest fallback for a host with no IdP, but is the exception: secret-manager-held and loaded once into memory, never an env-var key or image layer (spec §4.4 ranking secret-manager > mounted file > env var > in-image). This is a refinement, not a contradiction, of spec §4.4 ("On GCP, prefer keyless …; off-GCP, the JSON lives in the secret manager") — the verified addition is that **off-GCP can and should try WIF (keyless) first**, because WIF explicitly targets non-Google-Cloud workloads and its config file carries no secret.
**Consequences (what this commits).**
- **Code.** `internal/fcm/oauth.go` builds the `TokenSource` exclusively through ADC — `google.DefaultTokenSource(ctx, "https://www.googleapis.com/auth/firebase.messaging")` (or `FindDefaultCredentials` + `creds.TokenSource`) — and does **not** special-case credential type. The fallback SA-JSON path uses `CredentialsFromJSONWithType(ctx, json, google.ServiceAccount, scope)` (**not** the deprecated `CredentialsFromJSON`) with the JSON sourced from the secret manager.
- **Config.** `internal/config` gains a single `fcm_credential_mode` resolution: attached-SA (no config) > WIF credential-config file path > secret-manager SA JSON, mapped onto ADC (set `GOOGLE_APPLICATION_CREDENTIALS` to a mounted, non-secret WIF config, or inject the SA JSON into the secret-manager-backed loader).
- **Ops/deploy.** On GCP: bind an attached SA with rights to send to the official Firebase project, and apply org policy `iam.disableServiceAccountKeyCreation`. Off-GCP: stand up a workload-identity pool + provider, configure the host's OIDC issuer, deploy the WIF `external_account` credential-configuration file (it may be mounted/baked because this specific file contains **no private key** and need not be kept confidential, per Google's WIF docs — **it is the only artifact exempt, and only because it carries no secret**). **This refines, not overrides, spec §11: the no-in-image rule still applies in full to every actual secret — the APNs `.p8`, the FCM service-account JSON, the HMAC pepper, and DB/Redis credentials remain strictly forbidden in the image under spec §11.** The startup credential health-check (`relayctl ping-upstream --provider fcm`, spec §5.4, plan Phase 4/6) validates the resolved `TokenSource` mints a token regardless of source. Removes the manual key-rotation runbook in the common path; documents the WIF pool/provider setup and the JSON-fallback secret-manager procedure for the exception case.
- **Schema/contract.** No DB schema change; no change to the external `02`/`03` contract or to `pushwire` payloads.
**Revisit if.** (1) The relay must deploy on a host with **neither** GCP attached-SA **nor** any WIF-compatible identity source — then the SA-JSON-in-secret-manager fallback becomes the de facto default for that deployment and its rotation runbook must be activated. (2) The official Silo Firebase project is created under / moved into a Google org that enforces `iam.disableServiceAccountKeyCreation` by default (org created on/after 2024-05-03), making the JSON fallback unavailable without a policy exception — then keyless is mandatory. (3) `x/oauth2/google` changes its ADC resolution order or deprecates the typed-credentials API, requiring a client-library re-verification.
**Sources.**
- Google: "consider the use of service account keys as an exception rather than the norm"; recommended constraints Disable SA key creation/upload; "If your organization was created on or after May 3, 2024, these constraints are enforced by default." (source: https://docs.cloud.google.com/iam/docs/best-practices-for-managing-service-account-keys, 2026-06-13).
- WIF lets external workloads (AWS/Azure/GitHub/GitLab/K8s/on-prem AD/OIDC/SAML 2.0/X.509) access GCP without SA keys via OAuth2/STS exchange; "eliminates the maintenance and security burden associated with service account keys." (source: https://docs.cloud.google.com/iam/docs/workload-identity-federation, 2026-06-13).
- For off-GCP workloads, WIF uses a credential-config file that "doesn't contain a private key and doesn't need to be kept confidential"; ADC auto-discovers it via `GOOGLE_APPLICATION_CREDENTIALS`; supported in Go (source: https://docs.cloud.google.com/iam/docs/workload-identity-federation-with-other-providers, 2026-06-11).
- `x/oauth2/google v0.36.0` `FindDefaultCredentials` / `DefaultTokenSource` resolves, in order: `GOOGLE_APPLICATION_CREDENTIALS` JSON (incl. WIF/`external_account`), gcloud user creds, then GCE/GKE metadata (attached SA); `CredentialsType` includes `ServiceAccount` and `ExternalAccount`; `CredentialsFromJSON` is deprecated — use `CredentialsFromJSONWithType`; same `TokenSource` regardless of source (source: https://pkg.go.dev/golang.org/x/oauth2/google, 2026-02-11).
- FCM HTTP v1 auth docs recommend ADC on Compute Engine/GKE/App Engine/Functions, warn that referencing the SA JSON file "should be done with extreme care," recommend `GOOGLE_APPLICATION_CREDENTIALS` as "more secure and … strongly recommended," cite Workload Identity as an alternative to downloaded keys, and specify scope `https://www.googleapis.com/auth/firebase.messaging` (source: https://firebase.google.com/docs/cloud-messaging/auth-server, 2026-06-13).
---
## Decision 3 — `apns-expiration` policy: relay-set finite TTL for WAKE pushes
**Decision.** Should the relay set a finite `apns-expiration` (and parallel FCM `android.ttl`) on `private_alert` WAKE pushes so a stale wake doesn't deliver days later, or omit it and accept Apple's ~30-day store-and-retry? What concrete default, and does the policy live in the relay or stay caller-controlled per the `02`/`03` contract? (Spec §5.1, §15 #5; resolves spec open question 5.)
**Resolution.** **Set a FINITE `apns-expiration` by default in the relay.** Ship `apns-expiration = now + 4 hours` for the APNs `private_alert` mode and `now + 1 hour` for `background_wake`, with a parallel FCM `android.ttl` keyed by the FCM wire mode — `"14400s"` for `private_data` (the FCM counterpart of the APNs `private_alert`) / `"3600s"` for `background_wake` — for cross-provider parity. Both values are **per-deployment config** (relay-owned defaults, not caller-supplied) and do **not** change the external `02`/`03` wire contract, which has no expiration field — the relay sets the upstream header itself. Do **not** use `apns-expiration=0` (single-attempt, no store) and do **not** omit it (Apple stores/retries up to ~30 days). This confirms and operationalizes spec open question 5; the firm refinement is to keep it relay-owned with config-tunable defaults rather than promoting it to a caller field.
**Alternatives considered.**
| Option | Pros | Cons |
|---|---|---|
| **Finite relay-set default (~4h `private_alert` / ~1h `background_wake`), config-tunable, parallel FCM `ttl` (chosen)** | A WAKE is only useful while it can still produce a near-real-time fetch; the inbox ([`../01-release-events-and-inbox.md`](../01-release-events-and-inbox.md)) is the source of truth, so a wake missed for hours is re-driven by the caller outbox or the next foreground sync, not by a stale push. Avoids a device buzzing for an event already read/deleted server-side days ago. Bounds APNs/FCM store-and-retry use. Keeps the content-free posture intact (no new caller field, no contract change). A multi-day-offline device just gets a clean expiry. With `collapse-id`, a finite TTL compounds correctly: a newer wake supersedes the old one and the old one self-expires. Config-tunable so product can re-tune without a code/contract change. | Bakes a partly product/UX call into infra: a device back online at hour 5 gets no wake and relies on its next foreground sync (acceptable — inbox is authoritative — but a real behavioral choice). One hardcoded default per mode cannot suit future urgency tiers. APNs (absolute epoch) vs FCM (duration) asymmetry means the relay must compute the APNs epoch from `now()+TTL` per request. |
| Omit `apns-expiration` / omit `android.ttl` (Apple ~30-day store-and-retry; FCM 4-week default) | Maximizes raw delivery probability; simplest code; matches provider default (no per-request epoch math). | Verified behavior: omitted `apns-expiration` → store/retry up to ~30 days; omitted FCM `ttl` → 4 weeks. For a content-free WAKE that exists only to trigger a live fetch, a wake fired days later is useless at best, confusing at worst. Spends store-and-retry budget on valueless pushes. Contradicts the "inbox is the source of truth" premise. Spec explicitly rejects the 30-day default. |
| `apns-expiration = 0` / `android.ttl = 0s` (deliver-once-now-or-discard) | Strongest guarantee against stale wakes; trivial constant. | `0` means no storage at all: a device merely briefly offline (locked, poor signal, Doze) loses the wake entirely, defeating the point. Background pushes are already APNs-throttled (~2–3/hr) and may be delayed; with `TTL=0` too many legitimate wakes drop. FCM `ttl=0` also disables collapsible-message throttling — a behavior change with no upside. |
| Promote expiration to a caller-controlled `02`/`03` field | Maximum flexibility: the self-hosted server, which alone knows true urgency, picks the TTL per push; future-proofs urgency tiers without a relay redeploy. | Violates the locked `02`/`03` contract (additive-only, not a listed v1 capability) and widens the content-free surface with a new caller-controlled numeric channel visible to Apple/Google — exactly what §5.1 bounds (cf. the badge field). Adds validation/clamping burden. Premature: v1 has exactly one wake semantic. Can be added later additively if a real urgency-tier requirement emerges. |
**Rationale.** The decision hinges on what a `private_alert` IS in Silo: a content-free **WAKE** whose only job is to make the device fetch fresh notifications from the user server, where the **inbox**, not the push, is the source of truth (spec §5.1, [`../01-release-events-and-inbox.md`](../01-release-events-and-inbox.md)). That framing makes a wake's value decay with time — once hours old, the device's own next foreground sync (or a fresher wake in the same collapse series) has already reconciled state, so a late wake yields no benefit and a small confusion cost. Both extremes are verified-wrong for this use case: omitting the header invokes Apple's ~30-day store-and-retry (verbatim: stored "for 30 days or less") and FCM's 4-week default — far too long; `expiration=0` / `ttl=0s` means deliver-once-or-discard with no storage, dropping wakes for a device merely briefly offline (especially bad given APNs already throttles background pushes). A finite few-hours TTL is the correct middle: long enough to ride out normal transient offline windows (Doze, locked phone, brief signal loss), short enough that a multi-day-offline device gets a clean expiry and relies on its authoritative next sync. `background_wake` gets the shorter ~1h TTL because it is even more time-sensitive and even more throttled (APNs requires `apns-priority 5` for background pushes; priority 10 is an error). The `collapse-id` interaction reinforces this: collapse already means latest-wins on-device, and a finite expiration is the complementary mechanism for the cross-series / no-newer-wake case, so the two together ensure stale wakes neither pile up nor fire late. Keeping it relay-owned and config-tunable (not a caller field) preserves the locked `02`/`03` contract and the structural content-free guarantee while letting product re-tune durations — which is how the spec already resolved open question 5.
**Consequences (what this commits).**
- **Code.** `internal/pushwire/apns.go` computes `apns-expiration = now()+TTL` as a UNIX epoch (UTC, seconds) per request from config, per APNs mode (`private_alert` vs `background_wake`). `internal/pushwire/fcm.go` sets `android.ttl` as the parallel duration string keyed by the **FCM** wire mode — `"14400s"` for `private_data` (the FCM counterpart of the APNs `private_alert`; spec §5.2 lists FCM modes as `private_data` | `background_wake`) / `"3600s"` for `background_wake`. Note the mode-name asymmetry: an implementer building `internal/pushwire/fcm.go` must key on `private_data`, not `private_alert`, on the FCM path. Because the value is `now()`-relative, the golden-file payload/header tests in Phase 3/4 must **inject a fixed clock** or assert the header is present and within tolerance rather than byte-exact; the mirrored `silo-server` `pushwire` copy and its checksum/golden tests must use the same clock-injection approach so the two repos do not drift.
- **Config.** Two new non-secret tuning values (`apns_expiration_private_alert`, `apns_expiration_background_wake`) in `internal/config`, with the FCM `ttl` paired/derived, defaulted in code, env/flag-overridable, loaded and validated at startup (reject values exceeding the APNs storage max or FCM 28-day max; reject `<=0` if omit/0 is disallowed by policy).
- **Contract.** No change to the `02`/`03` wire contract and no new caller field — preserving additive-only API rules and the content-free guarantee.
- **Ops.** A stale-wake or missed-wake-rate concern becomes a **config tune, not a logic redeploy**. No schema change, no new dependency.
- **Tests.** Assert `background_wake` never emits `apns-priority 10` alongside its shorter TTL, and that an out-of-range configured TTL fails startup validation.
**Revisit if.** (1) Silo introduces more than one wake semantic or an urgency/priority tier (today there is exactly one: `wake = notifications.changed`), at which point per-mode or per-request TTL — possibly an additive `02`/`03` caller field with a bounded max — becomes justified. (2) Production metrics show a material missed-wake rate attributable to the ~4h/~1h window being too short for the real offline distribution of Silo devices. (3) Apple or Google changes `apns-expiration` / `android.ttl` semantics or the storage-policy maximum (re-verify the cited pages near deployment). (4) The durable-inbox + foreground-sync assumption weakens (e.g., a future notification type whose value is NOT recoverable from the inbox), which would change whether a missed wake is truly harmless.
**Sources.**
- APNs verbatim: "If the value is 0, APNs attempts to deliver the notification only once and doesn't store it." and "If the value is nonzero, APNs stores the notification and tries to deliver it at least once, repeating the attempt as needed until the specified date." (source: https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns, 2026-06-13).
- Omitted `apns-expiration` → Apple stores per its storage policy: "it may store the notification for 30 days or less … APNs attempts to deliver … the next time the device activates and is available online." (source: https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns, 2026-06-13).
- `apns-expiration` carries UNIX epoch seconds (UTC); an invalid value yields `400 BadExpirationDate`; `apns-priority` for background pushes MUST be 5 (priority 10 is an error) (source: https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns, 2026-06-13).
- `apns-collapse-id` merges notifications on-device (latest of the same id wins), ≤ 64 bytes, independent of expiration — so finite expiration + collapse means a newer wake supersedes an older queued one AND an undelivered wake self-expires (source: https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns, 2026-06-13).
- FCM `android.ttl`: default when omitted is four weeks; max 2,419,200 s (28 days); `ttl=0` discards messages that can't be delivered immediately and disables collapsible-message throttling (source: https://firebase.google.com/docs/cloud-messaging/customize-messages/setting-message-lifespan, 2026-06-13).
- FCM collapse: with a collapse key, an offline-stored message is replaced by the newer one; without one, both are stored; at most 4 distinct collapse keys per device while offline (source: https://firebase.google.com/docs/cloud-messaging/customize-messages/setting-message-lifespan, 2026-06-13).
- Spec already resolved this as open question 5: v1 ships finite, configurable defaults (~4h `private_alert`, ~1h `background_wake`; parallel FCM `android.ttl`), relay-owned and tunable; the external `02`/`03` contract is unchanged (source: ./00-relay-spec.md, 2026-06-13).
---
## Decision 4 — Per-account rate limits: hard-coded global defaults in v1
**Decision.** Ship hard-coded global rate-limit defaults (~10 req/s burst, 50k/day), or add per-account quota columns (`rate_per_sec`, `burst`, `daily_quota`) on `relay_accounts` settable via `relayctl`, seeded with the documented defaults? (Spec §9.2, §15 #1.)
**Resolution.** **Ship hard-coded global defaults for v1**: ~10 req/s burst (capacity-20 token bucket), 50,000 req/day (fixed-UTC), coarse per-(account, token) ~1 req/3s — enforced as **named config constants with env-var overrides**, NOT per-account DB columns. Keep all per-account **enforcement** plumbing exactly as specified (Redis bucket keyed `rl:{account_id}`, daily counter `rl:day:{account_id}:{yyyymmdd}`, the §9.5 shared-FCM-project circuit breaker). Add per-account override columns only when production logs prove at least one account legitimately needs a different cap. This matches the relay's own stated lean in spec §9.2 ("v1 ships a single global default and tunes from logs") and open Q#1, so it ratifies the plan rather than reopening it.
Make the later per-account path cheap by: (1) reading the three limit values from a single typed struct `accountLimits{RatePerSec, Burst, DailyQuota}` resolved per request, defaulting to the global constants, so introducing a per-account source later is a one-function change, not a call-site sweep; (2) keeping the Redis bucket/daily keys labeled by `account_id` already (done). When the trigger fires, add columns `rate_per_sec int`, `burst int`, `daily_quota int` (all **NULLABLE**, NULL = inherit global) to `relay_accounts` in one timestamped Goose migration, plus a `relayctl quota set --account <id> [--rate-per-sec N] [--burst N] [--daily-quota N]` / `quota show` / `quota reset` subcommand — a backward-compatible, additive change.
**Alternatives considered.**
| Option | Pros | Cons |
|---|---|---|
| **Hard-coded global defaults (config constants + env overrides), no per-account columns in v1 (chosen)** | Matches the relay's own documented lean (spec §9.2, open Q#1). Zero new schema/CLI surface — less code, fewer tests, smaller attack surface for a reliability-first first release. The abuse-containment requirement (OWASP API4:2023) is **already** met: the global default is enforced per-account (keyed by `account_id` in Redis) plus the §9.5 shared-project circuit breaker, so a stolen/misbehaving key is capped whether the number is a constant or a column. Operators still adjust globally via env var. Adding columns later is genuinely cheap (PG metadata-only `ADD COLUMN`). | A single hot account legitimately exceeding 50k/day must be handled by raising the GLOBAL default (env var, affects all) or an emergency change — until override columns exist. Slightly less satisfying day-one operability. Mitigated by the §9.2 alert at ≥80% of the daily cap. |
| Per-account columns on `relay_accounts`, settable via `relayctl`, seeded with defaults — in v1 | Per-account tuning from day one; cleanest long-term shape; NULL-default inheritance is clean; avoids any future migration. | Builds an override surface against zero production evidence (the client already paces at 5 req/s, half the 10 req/s default, so real traffic won't press the limit). Adds schema + `relayctl quota` subcommands + tests + an inheritance/precedence rule and its edge cases to a reliability-first v1. More surface for a misconfiguration (`daily_quota=0`, absurd burst) to cause an outage or remove the abuse cap. The migration it "saves" is nearly free anyway. |
| Per-account columns now but NOT exposed via `relayctl` (DB-only `UPDATE`) | Future-proof schema without building/testing the CLI; emergency override via privileged DSN. | Worst of both: pays the schema + inheritance cost yet has no safe, audited workflow — raw `UPDATE`s bypass the `relay_op_logs` `admin.*` audit trail that `relayctl` provides (spec §5.6), contradicting the "all admin ops audited" posture. No advantage over adding columns + CLI together when the trigger fires. |
**Rationale.** Four things make global defaults the right v1 call. (1) The migration-cost argument for "add columns now to avoid pain later" does not hold: PostgreSQL `ALTER TABLE ADD COLUMN` with a **constant** default is a metadata-only operation (no row rewrite) since PG 11, and `relay_accounts` holds one row per opt-in installation — hundreds at most — so even a full rewrite is trivial; NULLABLE columns (NULL = inherit) avoid even needing a default, and adding columns + a `relayctl quota` subcommand later is backward-compatible (source: https://www.postgresql.org/docs/current/ddl-alter.html, 2026-06-13). (2) The security requirement (OWASP API4:2023, Unrestricted Resource Consumption) is satisfied by a per-account **cap**, which the global default already provides — every bucket is keyed by `account_id`, and the §9.5 shared-FCM-project circuit breaker bounds cross-tenant blast radius; OWASP recommends per-client throttling and business-tuned limits but does not require per-account **configurability** (source: https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/, 2026-06-13). (3) Real traffic won't press the default: the Silo server paces client-side at 5 req/s (`02`/`03`), half the 10 req/s relay backstop, so the relay limit exists to catch a misbehaving/compromised caller, not to be tuned per well-behaved tenant (source: ./00-relay-spec.md, 2026-06-13). (4) This is a reliability-first first release; the §15/§9 text already leans "single global default, tune from logs," so shipping that ratifies the plan and minimizes v1 surface. The correct sequencing is: ship one default, watch the ≥80%-of-daily-cap alert and per-account `429` metrics, and add targeted overrides only when a real account demands one — at which point the change is cheap and evidence-driven. The shared-project risk is unavoidable regardless of limit shape: FCM's default per-project quota is 600,000 messages/minute enforced **per project** (shared across all tenants using it), returning `429 RESOURCE_EXHAUSTED` over quota — which is exactly why the §9.5 circuit breaker is required no matter whether the per-account number is a constant or a column (source: https://firebase.google.com/docs/cloud-messaging/throttling-and-quotas, 2026-06-12).
**Consequences (what this commits).**
- **Schema.** NO change to the spec §8.1 v1 schema — `relay_accounts` ships **without** `rate_per_sec` / `burst` / `daily_quota` columns.
- **Code.** Limit values live as named config constants in `internal/config` (per plan §2.4, "non-secret defaults … live as constants with env overrides") with env-var overrides; the rate-limit middleware (`internal/ratelimit`) reads them through a single `accountLimits` struct resolved per request, defaulting to the globals, so a future per-account source is a one-function swap, not a call-site sweep.
- **Redis.** Keys remain account-scoped (`rl:{account_id}`, `rl:day:{account_id}:{yyyymmdd}`) — already specified, so enforcement granularity is unchanged.
- **CLI.** NO new `relayctl quota` subcommand in v1; the §5.6 command table is unchanged.
- **Ops.** Operators tune the SINGLE global default via env var + rolling restart; the §9.2 ≥80%-of-daily-cap alert and the `relay_rate_limited_total{kind=account|daily}` / `relay_daily_quota_used_ratio` metrics are the feedback loop. The §9.5 shared-FCM-project circuit breaker is unaffected and still required.
- **Commits the team** to a later additive migration + `relayctl quota set/show/reset` CLI **only if/when** the revisit trigger fires (columns NULL = inherit global).
**Revisit if.** Add the per-account columns + `relayctl quota set/show/reset` CLI as soon as ANY of: (a) production metrics show one or more **legitimate** accounts repeatedly hitting the 50k/day cap or 10 req/s burst (`relay_rate_limited_total{kind=daily|account}` or `relay_daily_quota_used_ratio >= 0.8` sustained for a real, non-abusive account) such that raising the GLOBAL default would over-provision everyone else and erode the abuse cap; (b) operators need to throttle or temporarily zero a SPECIFIC misbehaving account without affecting others (today only all-or-nothing revoke/disable exists); (c) the relay onboards tenant tiers with deliberately different quotas — at which point per-account quotas become a product requirement, not a tuning convenience.
**Sources.**
- PostgreSQL `ALTER TABLE ADD COLUMN` with a CONSTANT default is a fast metadata-only operation (no table rewrite; default materialized lazily); only a VOLATILE default forces a per-row update (source: https://www.postgresql.org/docs/current/ddl-alter.html, 2026-06-13).
- OWASP API4:2023 recommends rate limiting per client/operation and business-tuned limits; it does NOT prescribe per-client configurable quota columns or a specific multi-tenant quota architecture — a per-tenant cap satisfies the control (source: https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/, 2026-06-13).
- FCM default per-project downstream quota is 600,000 messages/minute, enforced PER PROJECT (shared across tenants) via a per-minute token bucket; over quota returns `429 RESOURCE_EXHAUSTED` — hence the shared-project circuit breaker is required regardless of per-account limit shape (source: https://firebase.google.com/docs/cloud-messaging/throttling-and-quotas, 2026-06-12).
- Spec §9.2 states the v1 intent: "These are tunable per account (future column on `relay_accounts`); v1 ships a single global default and tunes from logs," and lists per-account columns as open question #1 (source: ./00-relay-spec.md, 2026-06-13).
- The Silo server already paces relay calls client-side at a default 5 req/s shared token bucket (`02`/`03` "Relay pacing"), half the 10 req/s relay backstop (source: ./00-relay-spec.md, 2026-06-13).
---
## Decision 5 — API-key hashing: `HMAC-SHA256(pepper, secret)`
**Decision.** For high-entropy random relay API keys (`rk_<env>_<random>`, ≥128 bits), what is the correct at-rest hashing in 2026: `HMAC-SHA256(pepper)` vs salted SHA-256 vs argon2id? (Spec §8.2, §15 #6; plan Phase 1; reference §4.1.)
**Resolution.** **Store API keys as `HMAC-SHA256(pepper, secret)`** — a fast keyed hash with a server-side pepper held in the secret manager, separate from PostgreSQL. The spec/plan already lean this way (spec §8.2, plan Phase 1, reference §4.1); keep it. Concretely: generate the secret as ≥32 bytes from `crypto/rand`, format `rk_<env>_<base62/base64url>`; store a non-secret, uniquely-indexed prefix (`rk_<env>_<first 6–8 chars>`) cleartext for O(1) lookup; store `key_hash = HMAC-SHA256(pepper, presented_secret)` (32 bytes, `bytea`); verify with `crypto/subtle.ConstantTimeCompare` against the stored hash (and against a fixed decoy hash on prefix-miss to keep the path constant-time). Do **not** use argon2id/bcrypt here — they are for low-entropy human passwords and only add per-request latency and (for bcrypt) a 72-byte input-truncation hazard with no security gain against a 256-bit keyspace. No per-key random salt is required because the full-entropy secret already makes the hash input unique and rainbow tables infeasible; the pepper is the deliberate, shared defense-in-depth lever instead.
**Rotation note (load-bearing):** a pepper change cannot be done in place — it requires re-hashing every live key (impossible without the plaintext, which is never stored) or, the workable path, a **dual-pepper verification window** (try the new pepper, fall back to the old) plus a `pepper_version` column, then re-issuing keys under the new pepper and retiring the old pepper once all live keys are re-issued. Because key secrets are unrecoverable, "re-hash all keys" is really "re-issue all keys," so v1 must (a) tag each `key_hash` row with a `pepper_version`, (b) support reading under N peppers during overlap, and (c) treat pepper rotation as a make-before-break, multi-key-reissue operation aligned with the existing credential-rotation runbook (spec §14.3). Pepper rotation should be rare (compromise-driven), not scheduled.
**Alternatives considered.**
| Option | Pros | Cons |
|---|---|---|
| **`HMAC-SHA256(pepper, secret)` — fast keyed hash with secret-manager pepper (chosen)** | Fast: sub-microsecond verify, no per-request CPU/RAM tax on the hot auth path (the only synchronous DB touch is the prefix lookup). Correct for high-entropy keys: a 256-bit random secret is ~2^256 keyspace, infeasible to brute-force regardless of hash speed. Defense-in-depth: if PostgreSQL leaks but the pepper does not, the stolen `key_hash` values are inert to an offline attacker. Matches OWASP's pepper guidance (shared secret, stored separately, applied via HMAC-SHA256). Plays naturally with prefix-indexed O(1) lookup + constant-time compare. Already the spec/plan choice — zero rework. | Pepper rotation is heavier: cannot re-hash in place, so it needs a dual-pepper overlap window + `pepper_version` column + key re-issuance. The pepper is a single shared secret whose compromise affects all keys at once (mitigated: secret-manager-only, never in DB/image). Slightly more moving parts than plain SHA-256. |
| Per-key salted SHA-256 (fast hash, random salt per key, no pepper) | Also fast and correct for high-entropy keys; trivially simple; no pepper to provision/rotate; salt defeats cross-key precompute (near-moot at 256-bit). | No defense-in-depth on a DB-only leak: with salt+hash an attacker can verify guesses offline — but at 256-bit entropy that offline attack is itself infeasible, so the practical delta is small. The real loss is the cheap, high-value "DB leak but pepper intact ⇒ stolen hashes inert" property, which is exactly this design's headline threat (hostile/compromised relay operator with full DB, spec §3.5). For a credential-custody-focused service holding official Apple/Google push credentials, paying the modest rotation cost to keep that property is the right trade. |
| argon2id (or bcrypt) — slow memory-hard KDF | Industry-default "correct" answer for password storage; unimpeachable if these were human-chosen secrets; brute-force resistance for low-entropy inputs. | Wrong tool. OWASP's slow-KDF requirement is scoped to passwords ("Fast hashing algorithms such as SHA-256 are not suitable for password storage…") — the threat is low password entropy, which does not exist for a 256-bit `crypto/rand` key. Adds real per-request cost (argon2id at OWASP's m=19 MiB, t=2 is tens of ms and tens of MB per verification — a self-inflicted DoS/memory-amplification vector on an auth endpoint). bcrypt additionally truncates input at 72 bytes (needs an HMAC/SHA pre-hash anyway). No security benefit: cannot make an already-infeasible 2^256 search more infeasible. Spec and reference explicitly reject it. |
**Rationale.** The decision turns on one fact: the secret is **high-entropy and machine-generated, not a human password**. Slow KDFs (argon2id/bcrypt) exist to compensate for low password entropy by making each guess expensive — their entire value is throttling a feasible offline guessing attack. Against a ≥128-bit (here 256-bit) `crypto/rand` secret the guessing attack is not feasible to begin with (~2^256 ≈ 10^77 candidates), so the slow hash protects against nothing while imposing real cost; the GPU gap that justifies slow KDFs for an 8-char password (≈180 billion SHA-256 h/s vs ≈1000 argon2 h/s) is irrelevant when the keyspace itself is unreachable (source: https://www.thesslstore.com/blog/what-is-256-bit-encryption/, 2026-06-13). OWASP confines the fast-hash prohibition to "password storage" (source: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html, 2026-06-13), and OWASP API2:2023 treats API keys as a distinct category requiring anti-brute-force at the endpoint — which the relay supplies via per-IP throttle + per-account rate limits, not a slow hash (source: https://owasp.org/API-Security/editions/2023/en/0xa2-broken-authentication/, 2026-06-13). So a **fast** hash is the correct family. Within fast hashes, the pepper-vs-salt choice is a defense-in-depth-vs-rotation-cost trade, and the pepper buys a property that maps directly onto this service's threat model: the headline adversary is a compromised relay operator / DB leak (spec §3.5), and the relay holds the official Apple `.p8` and Google SA credentials for the whole ecosystem. With the pepper in the secret manager (never in PG, never in the image), a Postgres-only compromise yields `key_hash` values that are inert without the pepper key — an attacker cannot even mount the (already-infeasible) offline search, and cannot forge or recognize valid keys. OWASP defines a pepper as a secret "shared between stored passwords," stored separately, applied as an HMAC where "the pepper is acting as the HMAC key" — exactly this design (source: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html, 2026-06-13). The rotation cost is real but bounded and rare (compromise-driven), and reuses the existing make-before-break credential-rotation runbook (spec §14.3). Salted SHA-256 is a defensible simpler fallback if the team judges pepper rotation not worth it, but it forfeits the DB-leak-resistance property that is this design's reason to care; argon2id is the only genuinely wrong choice and both spec and reference already reject it.
**Consequences (what this commits).**
- **Schema/DB — amends spec §8.1 / plan Phase 1.** `relay_api_keys` stores `key_hash bytea = HMAC-SHA256(pepper, secret)` (32 bytes), a cleartext uniquely-indexed `key_prefix`, and `account_id` / `created_at` / `last_used_at` / `expires_at` / `revoked_at` (spec §8.1). No salt column. **This decision adds one column the reproduced §8.1 `relay_api_keys` table does not have: `pepper_version smallint NOT NULL DEFAULT 1` (a `key_id` column is an acceptable equivalent), included in the Phase 1 init migration from the start** so dual-pepper rotation is possible later without a migration under pressure. This is an **additive, backward-compatible deviation** from the §8.1 schema reproduced in the spec and the plan's "exact schema from spec §8.1" Phase 1 statement — an implementer must add the column up front rather than discovering the gap during pepper rotation.
- **Code.** `internal/accounts/keys.go`: `crypto/rand` secret generation; HMAC-SHA256 hashing. `internal/httpapi/middleware_auth.go`: `crypto/subtle.ConstantTimeCompare` for verification; a fixed decoy-hash compare on prefix-miss to keep the auth path constant-time (no enumeration oracle); verification tries the active pepper and, during a rotation window, falls back to prior pepper(s) keyed by `pepper_version`.
- **Ops / secret management.** The pepper is a first-class secret in the secret manager (KMS/Vault/cloud), never in PG, never in the image/env where avoidable, loaded once at startup (spec §11, reference §4.5); it must be **backed up / escrowed** because losing it invalidates every live key.
- **Rotation runbook.** Pepper rotation = make-before-break (provision new pepper alongside old, verify under both, re-issue all keys under the new pepper, retire old) — reuse the §14.3 credential-rotation pattern; document that "re-hash all keys" is really "re-issue all keys."
- **Performance.** Auth stays a sub-microsecond hash + one indexed lookup, preserving the stateless-fast hot path (spec §4.1). Commits to NOT pulling argon2/bcrypt onto the request path.
- **Tests.** Unit tests asserting the hash is `HMAC-SHA256(pepper, ·)`, constant-time compare, plaintext never stored, and unknown-prefix vs wrong-secret are timing-indistinguishable (plan Phase 1/2 acceptance criteria).
**Revisit if.** (1) The relay ever accepts a LOW-entropy or human-chosen secret (an operator passphrase, basic-auth admin password, shortened/customer-supplied key) — then a slow KDF (argon2id at OWASP's m=19 MiB, t=2, p=1 minimum) becomes mandatory for that credential class. (2) The pepper would have to live in the same trust boundary as PostgreSQL (same host/backup, in the DB) — its defense-in-depth value evaporates and plain salted SHA-256 becomes the honest, simpler choice. (3) A hard, citable, API-key-specific standard later prescribes a different at-rest scheme. (4) Pepper rotation proves operationally painful and pepper-compromise risk is judged low — drop to per-key salted SHA-256. (5) Key entropy is ever reduced below ~128 bits.
**Sources.**
- OWASP: "Fast hashing algorithms such as SHA-256 are not suitable for **password** storage because they allow attackers to perform large numbers of guesses quickly." (scoped to passwords) (source: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html, 2026-06-13).
- OWASP argon2id recommended configs (e.g. m=19456 KiB, t=2, p=1) are a per-verification CPU/RAM tax that would hit every relay auth if wrongly applied to API keys (source: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html, 2026-06-13).
- OWASP defines a pepper as a secret "shared between stored passwords," "stored separately from the password database," applied via HMAC where "the pepper is acting as the HMAC key" (source: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html, 2026-06-13).
- OWASP API2:2023 flags weakly-hashed credentials, requires anti-brute-force mechanisms on auth endpoints, and states "API keys should not be used for user authentication … only for API clients authentication." (source: https://owasp.org/API-Security/editions/2023/en/0xa2-broken-authentication/, 2026-06-13).
- A 256-bit random key has ~2^256 (~10^77) values — computationally infeasible to brute-force regardless of hash speed; security derives from key entropy, not hash slowness; the ~180-billion-vs-~1000 h/s gap is irrelevant when the keyspace is unreachable (source: https://www.thesslstore.com/blog/what-is-256-bit-encryption/, 2026-06-13).
- bcrypt truncates input at 72 bytes (a long `rk_` key would be silently shortened, needing an HMAC/SHA pre-hash anyway), making a slow KDF not merely unnecessary but operationally hazardous for long high-entropy keys; fast HMAC-SHA256/SHA-256 is appropriate for quick auth checks on high-entropy tokens (source: https://mojoauth.com/compare-hashing-algorithms/hmac-sha256-vs-bcrypt, 2026-06-13).
---
## Decision 6 — `relay_op_logs` retention: PostgreSQL daily RANGE partitioning, retention by partition DROP
**Decision.** For `relay_op_logs` at ~50k/day/account scale with 14-day-success / 30–90-day-failure retention, use native PostgreSQL declarative time partitioning (with partition drop), a simple indexed table with a periodic `DELETE` job, or offload to a log pipeline (Loki/ELK) — and what is the exact retention mechanism for v1? (Spec §8.1, §15 #8; plan Phase 5.)
**Resolution.** **Ship v1 with native PostgreSQL declarative RANGE partitioning** of `relay_op_logs` by `occurred_at`, using **DAILY** partitions, and enforce retention by **DROPPING whole partitions** on a schedule — not by row-level `DELETE`, and not by offloading to a log pipeline. Pre-create partitions ahead of time and drop those older than the retention horizon. Run the maintenance (create-ahead + drop-old) with **`pg_partman 5.4.3`** invoked from a scheduled `relayctl logs --gc` / cron `SELECT run_maintenance()`, configured to **DROP (not just DETACH)** expired partitions. Keep retention simple in v1: a single uniform **90-day** drop horizon for the whole table (the failure-retention ceiling), so no partition straddles two policies. The differential 14-day-success vs 90-day-failure trim is then a cheap row-level `DELETE` **within the youngest partitions only** (success rows older than 14 days), where the working set is tiny and bloat is bounded — the bulk reclamation that matters comes free from dropping 90-day-old partitions. This is the partition-drop mechanism the spec already calls "the recommended store strategy" (§8.1) and resolves open question §15 #8 in favor of partitioning over a plain `DELETE` job or external offload.
**Alternatives considered.**
| Option | Pros | Cons |
|---|---|---|
| **Native declarative RANGE partitioning by `occurred_at` (daily), retention by partition DROP via `pg_partman`/cron (chosen)** | Retention is metadata-only: `DROP TABLE` on a day partition "can very quickly delete millions of records" and "entirely avoids the VACUUM overhead caused by a bulk DELETE." No dead-tuple/index churn from the append workload — the exact failure mode a daily bulk-DELETE on a high-write table hits. Stays inside the existing Postgres dependency (no new store), honoring "operational simplicity that still scales." `pg_partman` (5.4.3, PG14+) automates create-ahead + drop and runs from plain cron via `run_maintenance()` with no background worker, fitting the Goose + scheduled-job model. Inserts still target one parent table; the redacted-by-construction write path is unchanged. Scales linearly; old partitions drop in O(1). | Schema change: a partitioned table's PRIMARY KEY must include the partition key, so the current `id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY` becomes composite `(occurred_at, id)`. Adds `pg_partman` (or hand-rolled DDL) plus a create-ahead job whose failure (no future partition) would block inserts — needs a monitor. Differential 14d/90d retention is no longer one query; a small DELETE runs inside recent partitions. `DROP TABLE` on a partition briefly needs an ACCESS EXCLUSIVE lock on the parent (mitigated by `DETACH … CONCURRENTLY` first, or accepting a sub-second lock on a low-contention audit table). |
| Simple indexed table + periodic `DELETE` job (the spec's `relayctl logs --gc` baseline, as the only mechanism) | Zero schema change — keeps the §8.1 table and surrogate bigint PK. Trivially expresses differential policy in one statement. Already a Phase 5 deliverable, so lowest-effort and a correct *fallback*. Fine at low account counts. | At 50k/day/account across accounts this is the anti-pattern the PG docs warn against: a large recurring DELETE leaves dead tuples and index bloat that standard VACUUM marks reusable but does not return to disk, so the table and its two `occurred_at` indexes bloat and slow the very append path. Reclaiming space needs `VACUUM FULL` (ACCESS EXCLUSIVE) or `pg_repack` — ongoing toil. The delete competes with hot-path inserts for I/O and locks. Works for months then the disk-growth surprise hits — exactly risk R12. |
| Offload to a log pipeline (Loki/ELK), Postgres holds only recent rows | Purpose-built for high-volume append-only logs with cheap time-based retention and rich ad-hoc querying; removes write pressure from Postgres; scales far past 50k/day/account. | Violates v1's operational-simplicity / self-hosted brief: a whole new stateful system to deploy, secure, scope per-account, and back up for a single-region relay. `relay_op_logs` is an audit/forensics table queried by `relayctl` and joined to `relay_accounts` (FK `account_id`); moving it out breaks the in-Postgres admin/audit model and the single-point redaction guarantees. Over-engineered for the stated scale; reintroduces a deliberately-avoided dependency. The right answer later, not for v1. |
**Rationale.** The workload is unambiguously a high-write, append-only, time-ordered table whose only deletes are time-based retention — the textbook case for declarative range partitioning. PostgreSQL's own current (v18) docs state the decision plainly: dropping a partition "is far faster than a bulk operation" and "entirely avoids the VACUUM overhead caused by a bulk DELETE," and recommend sizing partitions to match the retention cadence (source: https://www.postgresql.org/docs/current/ddl-partitioning.html, 2026-06-13). Reliability-first means the hot push path must not contend with a daily bulk DELETE or pay for its dead-tuple/index bloat; partition DROP makes retention a metadata operation that touches neither autovacuum nor the live indexes — whereas recurring large DELETEs accumulate bloat that VACUUM marks reusable but does not shrink on disk, requiring `VACUUM FULL` or `pg_repack` to reclaim (source: https://www.tigerdata.com/learn/how-to-reduce-bloat-in-large-postgresql-tables, 2026-06-13). **Daily** (not weekly/monthly) granularity is chosen because the success horizon is only 14 days and the failure horizon 90 days — daily children keep each partition small, make the 90-day drop precise, and keep the create-ahead window short. The plain-DELETE option is kept as the differential-trim tool inside young partitions and as a degraded fallback, not as bulk reclamation. A log pipeline is the correct answer at much larger multi-region scale, but for a single-region self-hosted v1 it adds a new stateful dependency that contradicts the "stays in Postgres" posture and severs the FK/audit/redaction model that lives in one place. `pg_partman` is the pragmatic automation: de-facto standard, current (5.4.3, 2026-03-05), PG14+, **defaults to DETACH** (so retention must be explicitly configured to DROP via `retention_keep_table = false`), and runs from ordinary cron via `run_maintenance()` so no background worker is required — matching the relay's Goose + scheduled-job model (source: https://github.com/pgpartman/pg_partman, 2026-06-13). The spec already names partitioning "the recommended store strategy"; this decision firms that into "partition + drop, daily, single 90-day horizon."
**Consequences (what this commits).**
- **Schema (§8.1) — amends the plan.** `relay_op_logs` becomes `PARTITION BY RANGE (occurred_at)` with daily child partitions; the surrogate `id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY` must become a composite PRIMARY KEY `(occurred_at, id)` (id remains identity for ordering/correlation; `occurred_at` carries the partition key). The two indexes — `(account_id, occurred_at DESC)` and `(occurred_at DESC)` — are created on the partitioned parent and propagate to children; the `account_id` FK with `ON DELETE SET NULL` is unaffected. **This decision explicitly supersedes spec §8.1's non-partitioned `relay_op_logs` DDL (its single-column `id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY`) and the plan's "exact schema from spec §8.1" Phase 1 statement: the partitioned-from-birth design only works if the structural DDL lands in the Phase 1 init migration.** Converting an already-populated non-partitioned table to partitioned later is exactly the painful full-table rewrite that partitioning exists to avoid, so it must **not** be deferred to Phase 5.
- **Migrations — Phase 1 (structural, amending the §8.1 reproduction).** The Phase 1 init migration creates the partitioned parent + an initial window of daily partitions **in place of** the non-partitioned spec §8.1 `relay_op_logs` DDL and its single-column PK; `pg_partman` is added via `CREATE EXTENSION` + a `part_config` row (parent table, daily interval, premake N future days, `retention = '90 days'`, `retention_keep_table = false` to DROP). The composite `(occurred_at, id)` PK and the `CREATE EXTENSION pg_partman` + `part_config` row are part of this Phase 1 init migration, not a later Phase 5 add-on.
- **Code / ops.** The oplog writer is unchanged (still `INSERT` into the parent). A scheduled job — `relayctl logs --gc` or cron `SELECT run_maintenance()` — must run at least daily to (a) pre-create upcoming day partitions and (b) drop partitions older than 90 days; its **failure** (missing future partition) would reject inserts on the hot path, so it needs a dedicated alert in addition to the existing table-growth/disk alert (R12). The differential 14d-success trim runs as a bounded `DELETE` within recent partitions only.
- **Deps.** Adds `pg_partman 5.4.3` (PG14+) as a Postgres extension dependency; if the deployment cannot install extensions, fall back to hand-rolled create-ahead/drop DDL in `relayctl` (same partition design, no extension).
- **Locking.** Schedule drops at low-traffic times and/or `DETACH … CONCURRENTLY` then `DROP` to avoid the brief ACCESS EXCLUSIVE lock on the parent.
- **Phase split (amends the plan).** The plan's Phase 5 "partitioning DDL (or documented partition/offload decision)" line item is **resolved** by this decision, but the structural DDL itself moves **into Phase 1** (per the Schema/Migrations items above). **Phase 5 owns only** the operational pieces: the scheduled `run_maintenance()` / `relayctl logs --gc` create-ahead+drop job, the create-ahead-failure alert (a missing future partition rejects inserts on the hot path), and the bounded 14d-success differential `DELETE` within recent partitions. The plan's Phase 1 "exact schema from spec §8.1" line and Phase 5 "partitioning DDL" deferral are both superseded.
**Revisit if.** (a) Sustained ingest materially exceeds the v1 envelope — e.g. tens of accounts pushing near 50k/day each so `relay_op_logs` exceeds ~100M live rows, or Postgres shows I/O/bloat pressure from op-log writes — at which point offloading to a dedicated log pipeline (Loki/ELK/ClickHouse) with Postgres keeping a short hot window becomes worth its added ops cost. (b) The deployment target cannot install `pg_partman` AND hand-rolled partition DDL proves error-prone. (c) The differential success/failure retention needs to grow into many distinct per-event horizons such that a single uniform partition-drop horizon no longer fits. (d) Operators need rich ad-hoc/full-text forensic search over op-logs beyond indexed Postgres queries.
**Sources.**
- PostgreSQL: "Dropping an individual partition using DROP TABLE, or doing ALTER TABLE DETACH PARTITION, is far faster than a bulk operation. These commands also entirely avoid the VACUUM overhead caused by a bulk DELETE." (PG 18) (source: https://www.postgresql.org/docs/current/ddl-partitioning.html, 2026-06-13).
- PostgreSQL: dropping the no-longer-needed partition "can very quickly delete millions of records because it doesn't have to individually delete every record"; `DROP TABLE` on a partition needs ACCESS EXCLUSIVE on the parent, while `DETACH PARTITION CONCURRENTLY` needs only SHARE UPDATE EXCLUSIVE (source: https://www.postgresql.org/docs/current/ddl-partitioning.html, 2026-06-13).
- A unique/primary-key constraint on a declaratively partitioned table must include all partition-key columns — so partitioning by `occurred_at` requires the composite key (source: https://www.postgresql.org/docs/current/ddl-partitioning.html, 2026-06-13).
- `pg_partman` latest is 5.4.3 (2026-03-05); requires PostgreSQL ≥ 14; third-party extension; can run maintenance from external cron via `run_maintenance()`; by default DETACHes rather than DROPs expired partitions, so drop-on-retention must be configured (`retention_keep_table = false`) (source: https://github.com/pgpartman/pg_partman, 2026-06-13).
- For high-churn tables, recurring large DELETEs accumulate dead tuples and index bloat; VACUUM marks them reusable but does not shrink files, requiring `VACUUM FULL` or `pg_repack`; partition DETACH+DROP needs no vacuum at all (source: https://www.tigerdata.com/learn/how-to-reduce-bloat-in-large-postgresql-tables, 2026-06-13).
- `pg_partman` retention: "By default, partitions are not dropped (DROP), but are detached (DETACH)"; the configured interval drops partitions older than that interval; pg_partman never drops the last remaining child (source: https://www.keithf4.com/partman-retention/, 2026-06-13).
---
## What this unblocks
Each decision retires a spec §15 open question and clears a specific implementation-plan phase to proceed without further product/architecture input:
| Decision | Retires | Unblocks |
|---|---|---|
| 1 — APNs client (hand-roll) | §15 #3, plan §3 "decision before Phase 3", risk R2 | **Phase 3** — `internal/apns` (`jwt.go`/`transport.go`/`client.go`/`errors.go`) and `POST /v1/apple/send` can be built directly against `net/http2` + `jwt/v5` with no library evaluation gating the work; the Apple provisioning checklist item "decide `apns2` vs hand-rolled" (plan §7.1) is closed. |
| 2 — FCM credentials (keyless-first) | §15 #4 | **Phase 4** (`internal/fcm/oauth.go` builds the ADC `TokenSource`; `POST /v1/fcm/send`) and **Phase 6** (secret-manager wiring, the GCP attached-SA / off-GCP WIF deploy paths, and the `relayctl ping-upstream --provider fcm` credential-health gate). |
| 3 — `apns-expiration` policy (finite, relay-owned) | §15 #5 (spec open question 5) | **Phase 3** (APNs `apns-expiration` epoch in `internal/pushwire/apns.go`) and **Phase 4** (parallel FCM `android.ttl` in `internal/pushwire/fcm.go`); the two new `internal/config` TTL values and their clock-injected golden tests are now specified. |
| 4 — Rate limits (global defaults) | §15 #1 | **Phase 2** — `internal/ratelimit` ships the global constants + env overrides and the `accountLimits` resolution struct with no new schema, no `relayctl quota` CLI, and no inheritance-precedence edge cases; spec §8.1 schema and §5.6 command table are unchanged. |
| 5 — API-key hashing (HMAC-pepper) | §15 #6 | **Phase 1** (`relay_api_keys` schema with `key_hash bytea` + a `pepper_version smallint NOT NULL DEFAULT 1` column added to the reproduced §8.1 table; the pepper as a first-class secret-manager secret) and **Phase 2** (`internal/httpapi/middleware_auth.go` constant-time verify with decoy-hash and dual-pepper fallback). |
| 6 — Op-log retention (daily partition + DROP) | §15 #8 | **Phase 1** (the structural DDL — partitioned `relay_op_logs` parent, composite `(occurred_at, id)` PK, `CREATE EXTENSION pg_partman` + `part_config` row — lands in the init migration, **amending** the plan's "exactly spec §8.1" Phase 1 statement and superseding the non-partitioned §8.1 DDL) and **Phase 5** (only the `relayctl logs --gc` / `run_maintenance()` schedule, the create-ahead-failure alert, and the bounded differential 14d-success trim). |
@@ -0,0 +1,71 @@
# Silo Push Relay — Design Index
> **Provenance:** Authored 2026-06-13 from a research-and-review workflow (live primary-source research against Apple Developer / Firebase / Google / RFC / pkg.go.dev docs, adversarial fact-checking of time-sensitive claims, draft, multi-lens review, and revision). Reflects **2026-current** APNs and FCM behavior. These design docs live here in `silo-server` alongside the `02`/`03` notification contracts purely for **co-location** — the relay **code** lives in its own repository (provisional name `silo-push-relay`).
This folder specifies the **Silo Push Relay**: the small Silo-operated service that holds the official Apple (`.p8` APNs auth key) and Google (Firebase service-account JSON) push credentials and forwards **opaque, content-free** push requests to APNs and FCM on behalf of opted-in self-hosted Silo servers.
It is the **server counterpart** to the relay-client contracts in [`../02-apns-relay.md`](../02-apns-relay.md) and [`../03-fcm-relay.md`](../03-fcm-relay.md). Those documents define the wire contract from the self-hosted Silo server's perspective (`POST /v1/apple/send`, `POST /v1/fcm/send`); this folder specifies the service that answers it. **That external contract is authoritative and reproduced byte-for-byte; these docs add only the server-internal design (`02`/`03` deliberately left it "to a separate repo").**
## Reading order
1. **[`00-relay-spec.md`](./00-relay-spec.md)** — Engineering spec. Scope, trust/threat model, architecture, the full public API contract (both send endpoints + health/metrics + the `relayctl` admin CLI), the upstream APNs and FCM clients, the PostgreSQL account/key/allowlist schema, rate limiting, idempotency, security, privacy, observability, deployment.
2. **[`01-implementation-plan.md`](./01-implementation-plan.md)** — Phased Go build plan. Repo bootstrap and layout, pinned dependency choices with rationale, seven implementation phases (each with files, tasks, tests, acceptance criteria), testing strategy, deployment, and the Apple/Firebase provisioning checklist.
3. **[`02-apns-fcm-2026-reference.md`](./02-apns-fcm-2026-reference.md)** — The cited 2026 factual foundation. APNs (token/ES256 auth, endpoints, headers, payloads, status/reason tables, throttling) and FCM v1 (legacy-shutdown status, OAuth2 auth, endpoint, message JSON, error model, quotas) plus the recommended Go ecosystem and security/ops practices. Every non-obvious claim carries an inline source + date.
4. **[`03-decisions.md`](./03-decisions.md)** — Decision record resolving the spec's open implementation questions (APNs client, FCM credentials, `apns-expiration`, rate limits, API-key hashing, op-log retention), each with a firm recommendation, alternatives, rationale, consequences, and cited current sources.
## Status
| Doc | Status |
|---|---|
| 00-relay-spec | Draft (implementation-grade) |
| 01-implementation-plan | Draft (implementation-grade, phased) |
| 02-apns-fcm-2026-reference | Reference (2026-current, cited) |
| 03-decisions | Decided (6 resolutions, high confidence) |
**Implementation status:** Phases 0–2 are built in the separate `silo-push-relay` repository. Phase 0 = the service skeleton (`/healthz` + `/readyz`, config, JSON logging, §5.5 envelope, graceful shutdown). Phase 1 = storage + admin (PostgreSQL schema via embedded Goose migrations, the `accounts` data-access layer with `HMAC-SHA256(pepper)` key hashing, the `relayctl` admin CLI with audited actions). Phase 2 = the full request pipeline on `POST /v1/apple/send` + `/v1/fcm/send` — bearer auth (constant-time compare with decoy, throttled `last_used_at`, soft per-IP brute-force cap, trusted-proxy client-IP resolution), Redis token-bucket rate limiting (per-account + daily + coarse per-token) with bounded in-process fail-open, `DisallowUnknownFields` decode/validate/allowlist, and fail-closed idempotency (NX lock + nonce, replay/409/422, compare-and-set). Upstream APNs/FCM calls are **stubbed** pending Phases 3–4. Two Phase-2-listed items were deferred to Phase 4 because they need the live FCM send path: the shared FCM-project circuit breaker (§9.5) and anti-spike smoothing. Phases 3–6 not started; provider-touching phases gated on Apple/Firebase provisioning (see `01` §7). `relay_op_logs` partitioning (Decision 6) is a dedicated later migration so migrations run on stock PostgreSQL.
## Key design decisions
- **Faithful external contract.** `POST /v1/apple/send` and `POST /v1/fcm/send` — request fields, validation, response shapes, relay-built payloads, and APNs headers — are reproduced exactly from `02`/`03`. Only the relay's server-internal design is newly specified.
- **Content-free by construction.** The send structs admit only the opaque `02`/`03` fields; JSON is decoded with `DisallowUnknownFields`, so any unexpected key is a hard `400`. FCM is always data-only (no `notification` block); badge omitted by default.
- **Stateless request path.** The only synchronous DB touch is an indexed API-key prefix lookup plus a throttled (`≤1/min`) `last_used_at` write; idempotency and rate-limit state live entirely in Redis, so replicas scale horizontally and are kill-safe.
- **Admin = CLI, not HTTP.** Account/key/allowlist administration is `relayctl` writing directly to PostgreSQL; there is **no public admin HTTP API** in v1 (minimal attack surface).
- **API keys.** `rk_<env>_<random>` with a non-secret indexed prefix + `HMAC-SHA256(pepper, secret)` stored, constant-time compare, revocable, multi-live-key rotation; the full token is shown exactly once.
- **Upstream mechanics (2026-correct).** One cached ES256 JWT per APNs team (regenerate ~50 min, never < 20 min apart, eager refresh on `ExpiredProviderToken`); one cached OAuth2 token per FCM project (~1 h); HTTP/2 connection reuse with PING health checks.
- **Idempotency fails closed; rate limiting fails open.** If Redis is unavailable, sends are refused (`503`) rather than risk a double-send; rate limiting degrades to allow (availability over perfect enforcement), with a metric.
- **"Relay-down delays, never loses."** The self-hosted server-side outbox (`push_delivery_attempts` pending rows in `../01-release-events-and-inbox.md`) guarantees re-delivery, so ordinary stateless-replica HA suffices.
### Reconciliations where the 2026 reference corrects the 2026-04 contracts
These are upstream-only details; the **external `02`/`03` contract is unchanged**:
- **APNs sandbox host** is `api.sandbox.push.apple.com` (the canonical name); `02`'s `api.development.push.apple.com` is a still-valid alias (same backend). Internal detail; the external `"environment":"sandbox"` field is untouched.
- **FCM 429** honored with `Retry-After` (60 s fallback) while still surfacing `Retry-After` to the caller as `03` expects.
- **FCM data values** forced to strings (`map<string,string>`).
- **FCM `priority:high`** honored for `private_data`; provider deprioritization is the caller's concern.
## Decisions (resolved in `03-decisions.md`)
The spec's open implementation questions are now decided (all high confidence):
1. **APNs client** — **hand-roll** on `net/http` + `x/net/http2` + `golang-jwt/jwt/v5` behind an `internal/apns` interface; do **not** take a production dependency on `sideshow/apns2` (no release since Oct 2024). Optionally keep apns2 as a throwaway Phase-3 oracle.
2. **FCM credentials** — **keyless by default** via Application Default Credentials: attached service account on GCP, Workload Identity Federation off-GCP; service-account JSON in a secret manager only as a last resort. Identical code path in all three cases.
3. **`apns-expiration`** — relay sets a **finite TTL** (4 h for `private_alert`, 1 h for `background_wake`) with a parallel FCM `android.ttl`; relay-owned config, external `02`/`03` contract unchanged.
4. **Per-account rate limits** — **hard-coded global defaults** for v1 (~10 req/s burst, 50k/day), resolved through a typed `accountLimits` struct so per-account override columns are a cheap additive migration later.
5. **API-key hashing** — `HMAC-SHA256(pepper, secret)` with a `pepper_version` column and a make-before-break dual-pepper rotation window. No argon2id (keys are high-entropy).
6. **Op-log retention** — native PostgreSQL **daily RANGE partitioning**, retention by **partition DROP** (pg_partman), uniform 90-day horizon with a cheap row-level success-trim inside young partitions.
## Confirmed (2026-06-13)
- **Module org** — `github.com/Silo-Server/silo-push-relay` (matches the real org in `silo-server`'s `go.mod`; the plan's `silo-app` was provisional).
- **Deployment target** — the relay is **not** deployed to GCP. Per Decision 2 this selects the off-GCP credential path: **Workload Identity Federation** (keyless, non-secret credential-config + OIDC token) preferred, with **service-account JSON in a secret manager** as the last-resort fallback. No attached-GCP-SA path.
## Still needs human / operational input
1. **Provider provisioning** (the real long pole) — Apple Developer account + APNs `.p8` key + per-platform bundle topics; Firebase project(s) + the WIF binding (or SA JSON in a secret manager). Gates Phases 3/4/6. See `01` §7.
2. **Off-GCP OIDC issuer for WIF** — confirm the relay host has a usable OIDC issuer for Workload Identity Federation; if none, fall back to SA-JSON-in-secret-manager (Decision 2).
3. **Multi-region** — out of scope for v1 (single region); revisit a regional Redis/Postgres strategy if latency to distant self-hosters matters.
## Relationship to the parent notifications design
This relay corresponds to phases 7–8 of the parent plan ([`../README.md`](../README.md) "Phasing"): `notifications.apple_push_enabled` and `notifications.android_push_enabled`. Those phases require Silo to provision Apple Developer + Firebase accounts and operate this service. The self-hosted server side (registration, dispatcher, pacing, retry, the dispatch outbox) is specified in `../02-apns-relay.md`, `../03-fcm-relay.md`, and `../01-release-events-and-inbox.md`.
@@ -0,0 +1,248 @@
# Unified Literary Works Design
## Goal
Silo should expose a server-owned literary work identity that links multiple format-specific catalog items for the same book, especially ebooks and audiobooks, without collapsing those items into one `media_items` row. Clients can then render one detail page with format tabs/actions while existing item detail, playback, reading, download, and catalog APIs keep working.
The implementation should target the ebook stack head, currently `work/ebook-reader-ruler-profiles`, which includes the ebook foundation, #98 local-cover/grouping work, and #99 reader profile/ruler work. That stack already has ebook scanning, ebook series metadata, ebook reader progress, and ebook detail extensions. The work layer should sit above those existing format-specific systems.
## Existing Context
`media_items.content_id` is the stable identity for each catalog item. Audiobooks are `media_items.type = 'audiobook'`; ebooks are introduced as `media_items.type = 'ebook'` in the ebook stack. These identities must remain independent because each format has different files, progress, playback/reader behavior, external compatibility, and library membership.
Audiobooks already store author and narrator credits in `item_people`, series data in `audiobook_series`, file data in `media_files`, and listening progress in existing playback/watch progress tables. The ebook stack adds ebook author credits, `ebook_series`, `ebook_reader_progress`, reader state/config, and ebook file serving. #98 also groups sibling ebook file formats by book identity during scan; that is an intra-ebook item grouping behavior, not the cross-format work identity.
The current catalog detail endpoint is `GET /api/v1/catalog/items/{id}` through `CatalogResourceHandler.HandleGetItemDetail`, backed by `catalog.DetailService.GetItemDetail`. Catalog browse/search remains item-oriented by default through catalog query/browse code.
## Data Model
Add a neutral work layer:
```sql
CREATE TABLE literary_works (
work_id TEXT PRIMARY KEY,
canonical_title TEXT NOT NULL,
sort_title TEXT,
normalized_title TEXT NOT NULL,
primary_author_key TEXT NOT NULL DEFAULT '',
primary_cover_content_id TEXT REFERENCES media_items(content_id) ON DELETE SET NULL,
description TEXT,
published_date DATE,
publisher TEXT,
genres TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE literary_work_items (
work_id TEXT NOT NULL REFERENCES literary_works(work_id) ON DELETE CASCADE,
content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE,
format_type TEXT NOT NULL,
link_source TEXT NOT NULL,
confidence DOUBLE PRECISION NOT NULL DEFAULT 1,
confirmed_at TIMESTAMPTZ,
ignored_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (work_id, content_id),
UNIQUE (content_id),
CHECK (format_type IN ('ebook', 'audiobook', 'comic', 'manga')),
CHECK (link_source IN ('manual', 'external_id', 'metadata_match', 'series_match', 'scan_seed'))
);
CREATE TABLE literary_work_match_decisions (
source_content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE,
target_content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE,
decision TEXT NOT NULL,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (source_content_id, target_content_id),
CHECK (source_content_id <> target_content_id),
CHECK (decision IN ('confirmed', 'ignored'))
);
```
Every ebook or audiobook can belong to at most one work through `UNIQUE (content_id)`. A work can contain many format-specific items. Manual/admin actions update `literary_work_items` and `literary_work_match_decisions`; automatic matching must not override an ignored pair or move a manually linked item without an explicit admin request.
Work IDs should be generated by the existing server ID generator, not derived directly from title text. Matching keys can change when metadata improves, but the `work_id` remains stable.
## Matching Rules
Matching runs in a `literaryworks` service after scan/enrichment or as an admin-triggered repair job. It considers literary item types only: `ebook`, `audiobook`, and later `comic`/`manga`.
Authority order:
1. Manual confirmed links and manual unlinks/ignored pairs.
2. Shared external IDs from `media_item_provider_ids`, excluding format-specific aliases that are not true work identity.
3. Normalized title plus normalized author.
4. Normalized series name plus series index plus author.
5. Publication year/date and publisher as supporting evidence only.
Automatic matching should be conservative. Same title alone is not enough. Same title plus different primary author should not link. Same title plus same author can link unless there is strong conflicting series/index evidence. Same series/index plus same author can link even if titles differ slightly due subtitle/edition formatting. Narrator is never required for ebook matching and must not prevent an ebook/audiobook work link; narrator remains audiobook-format metadata.
The matcher should produce scored candidates. Scores at or above the automatic threshold can create links when neither item has conflicting manual state. Lower scores are exposed as possible matches for admin review.
## Service Boundaries
Create an `internal/literaryworks` package with:
- `Repository`: reads/writes `literary_works`, memberships, decisions, and candidate rows.
- `Matcher`: normalizes item metadata, scores candidates, and applies conservative automatic links.
- `Service`: public orchestration used by handlers and background jobs.
The package depends on catalog repositories and the database pool, but catalog detail should depend on a small interface so item detail can include work data without creating a large import cycle.
The matcher should reuse existing item metadata:
- Titles from `media_items`.
- Authors from `item_people.kind = author`.
- Audiobook narrators from `item_people.kind = narrator` only for display inside audiobook format details.
- Series from `audiobook_series` and `ebook_series`.
- External IDs from `media_item_provider_ids`.
- File metadata from `media_files`.
## API Contract
Add a work endpoint:
`GET /api/v1/works/{work_id}`
Response:
```json
{
"work_id": "work_123",
"work_title": "Project Hail Mary",
"authors": [{ "name": "Andy Weir", "person_id": "42" }],
"formats": [
{
"type": "ebook",
"content_id": "ebook_1",
"library_id": 1,
"available_files": [
{
"file_id": 10,
"original_filename": "Project Hail Mary.epub",
"format": "epub",
"mime_type": "application/epub+zip",
"size": 12345
}
],
"progress": {
"kind": "reading",
"progress": 0.42,
"updated_at": "2026-06-09T12:00:00Z"
}
},
{
"type": "audiobook",
"content_id": "audio_1",
"library_id": 2,
"available_files": [
{
"file_id": 20,
"original_filename": "Project Hail Mary.m4b",
"format": "m4b",
"mime_type": "audio/mp4",
"size": 987654321,
"duration_seconds": 58200
}
],
"progress": {
"kind": "listening",
"position_seconds": 1200,
"duration_seconds": 58200,
"updated_at": "2026-06-09T12:00:00Z"
}
}
],
"primary_cover_url": "/api/v1/images/...",
"metadata": {
"description": "...",
"series": { "name": "The Series", "index": 1 },
"genres": ["Science Fiction"],
"published_date": "2021-05-04",
"publisher": "Ballantine Books"
}
}
```
Extend `GET /api/v1/catalog/items/{content_id}` additively:
```json
{
"content_id": "ebook_1",
"type": "ebook",
"work_id": "work_123",
"work_title": "Project Hail Mary",
"work_formats": [
{ "type": "ebook", "content_id": "ebook_1", "library_id": 1 },
{ "type": "audiobook", "content_id": "audio_1", "library_id": 2 }
]
}
```
Existing fields and behavior remain unchanged. Clients that do not know about works can ignore the new fields.
## Catalog Grouping
Add optional work grouping for literary media:
`GET /api/v1/catalog?type=reading&group=work`
The grouped response should return one card per work when a linked work exists, with format badges such as `ebook` and `audiobook`. Unlinked literary items still appear as single-item cards with an implicit one-item work projection. Non-literary media remain item-based.
Do not change default catalog/search behavior. Work grouping must be opt-in so current clients and Jellyfin/ABS compatibility routes are unaffected.
## Progress and Files
Progress remains per format and per existing content identity:
- Ebook progress reads from `ebook_reader_progress`.
- Audiobook progress reads from existing playback/watch progress storage.
The work response aggregates those format-specific progress rows without merging them.
Available files come from `media_files`, grouped by format content ID. File metadata should include `file_id`, original basename, extension/container, MIME type when known, byte size, and duration for audio. Android can continue using existing file/download routes by `file_id`; the work endpoint only tells it what is available.
## Admin Primitives
Add admin endpoints:
- `GET /api/v1/admin/literary-works/items/{content_id}/matches`: list possible work/item matches with score and evidence.
- `POST /api/v1/admin/literary-works/link`: link two or more content IDs into one work.
- `POST /api/v1/admin/literary-works/{work_id}/unlink`: unlink one content ID from a work.
- `POST /api/v1/admin/literary-works/matches/ignore`: mark a candidate pair ignored.
- `POST /api/v1/admin/literary-works/matches/confirm`: confirm a candidate pair and link them.
Admin linking must validate that every content ID is a supported literary type and accessible to the admin. Unlinking the last item can delete the empty work.
## Backward Compatibility
All existing item detail, catalog, reader, playback, download, Jellyfin compatibility, and ABS compatibility routes remain valid. The new tables cascade from `media_items`, so library deletion and orphan cleanup continue to remove format items; work cleanup should delete empty works after membership deletion.
No caller is required to supply `work_id` for existing format actions. Work APIs are additive and read-oriented except for admin management endpoints.
## Testing Strategy
Repository and matcher tests should cover:
- Ebook and audiobook with same normalized title and author link to one work.
- Same title with different authors does not auto-link.
- Same author and series/index links despite title subtitle differences.
- Shared external ID links above fuzzy metadata.
- Manual ignored pair blocks automatic linking.
- Manual confirmed link wins over automatic scoring.
- Unlink removes one item without deleting the format media item.
- Existing item detail JSON still includes normal fields when no work exists.
- Existing catalog browse/search remains item-based unless `group=work`.
- Work endpoint returns separate ebook and audiobook progress.
- Work endpoint returns file metadata for EPUB/PDF/CBZ and M4B/MP3 without changing download behavior.
Handler tests should assert route status codes and response shape. SQL tests should include cascade/empty-work cleanup behavior.
## Rollout Plan
Implement the schema and service behind additive APIs first. Then wire automatic matching after scan/enrichment. Finally add grouped catalog support. This keeps default behavior stable while giving clients and admin tooling a safe way to adopt works.
@@ -0,0 +1,311 @@
# AI services: shared core, metadata translation, Whisper ASR — design
**Date:** 2026-06-10
**Status:** Draft, pending review
**Scope:** Extract the AI subtitle translation engine into a shared AI core, add AI translation of movie/series/season/episode descriptions into the existing localization tables, and add Whisper ASR subtitle generation (`transcribe`, `transcribe_translate`). One PR.
Commands assume the repository root is the cwd.
## Problem
`internal/subtitles/ai` ships an on-demand LLM subtitle translator. The LLM plumbing inside
it (OpenAI-compatible chat client with retry/backoff, batched indexed-JSON translation,
job lifecycle with idempotency/heartbeat/reaper/bounded concurrency) is generic, but it is
buried in a subtitle-specific package, so nothing else can use it.
Two features want exactly that plumbing:
1. **Metadata translation.** Libraries whose `metadata_language` the providers cannot fully
serve end up with overviews/taglines in the item's default language (usually English).
The localization tables (`media_item_localizations`, `season_localizations`,
`episode_localizations`) and the serving path (`LocalizeItemModel` and friends in
`internal/catalog/detail.go`) already exist — the gap is producing rows for languages the
providers don't have.
2. **Whisper ASR.** `subtitle_ai_jobs.kind` already reserves `transcribe` and
`transcribe_translate`; media with no usable text subtitle track (foreign audio with only
bitmap subs, or no subs at all) currently has no AI path to a subtitle track.
## Goals
- One shared AI core: a single OpenAI-compatible endpoint configuration (chat + audio
transcription), one retry/backoff implementation, one generic batched text translator,
one job-lifecycle runner — consumed by subtitle translation, metadata translation, and ASR.
- AI-translated overviews and taglines stored in the **existing** localization tables, so
web, Android, Apple, and jellycompat clients get them through the current serving path
with **zero client changes**.
- Provenance on AI-writable localization fields so refreshes never regress data quality:
`manual` beats `provider` beats `ai`. A later provider localization overwrites an AI
translation; the reverse never happens.
- Two metadata-translation triggers: a manual admin action, and an opt-in per-library
fallback ("provider had no localization for the library language → AI-translate") wired
into metadata ingestion.
- ASR jobs that produce ordinary `downloaded_subtitles` rows (provider `transcribed`),
with `transcribe_translate` chaining the existing LLM translator for a target-language
track; live cue streaming to the requesting playback session, same as translation today.
- Total AI load on the configured endpoint bounded by **one** shared semaphore.
## Non-goals
- **Episode/movie/series title translation.** Titles often have official localized forms
that are not literal translations; provider data only.
- **Collection names or descriptions.** Deferred: collections have no localization table
yet. The metadata translation service is written against content-ID + field-list inputs,
so a `collection_localizations` table can be added in a follow-up without reshaping it.
- **Genre translation.** If ever wanted, that is a static mapping, not an LLM call.
- **Per-profile metadata language.** Presentation language stays library-scoped
(`media_folders.metadata_language`); changing that is an orthogonal feature.
- **A localization editor UI.** The `manual` provenance value is honored by the write
rules but no UI sets it yet (the existing metadata editor edits base fields, not
localizations).
- **Whisper word-level timing, diarization, VAD-aligned chunking.** v1 uses fixed-length
chunks; boundary-word artifacts are an accepted limitation noted for follow-up.
## Architecture
### Package layout
```
internal/ai/llm Client: OpenAI-compatible /v1/chat/completions (moved from
internal/subtitles/ai/client.go) + new /v1/audio/transcriptions.
Shared retry/backoff/429 handling. Config for the endpoint(s).
internal/ai/translate Generic batched text translation: []Segment{ID, Text} in,
translated segments out. Indexed-JSON protocol, context
neighbors, malformed-response retries — moved from
internal/subtitles/ai/translator.go and de-subtitled.
internal/ai/jobrunner Generic job lifecycle: bounded dispatch off a shared semaphore,
heartbeat loop, stale-job reaper, cancel registry — extracted from
internal/subtitles/ai/service.go behind a small repo interface.
internal/subtitles/ai Stays: subtitle job semantics. Translate path becomes a thin
adapter (cues ↔ segments); gains the transcribe paths.
internal/metadata/translation
New: metadata translation job service + repo + provenance-aware
writes into the localization repos.
```
Package names: `llm`, `aitranslate`, `jobrunner` (import paths above; `aitranslate` avoids
a name collision when imported from package `ai`).
### Shared semaphore
`cmd/silo` builds one `chan struct{}` of size `ai.max_concurrent_jobs` and hands it to both
job services via `jobrunner`. Subtitle, metadata, and ASR jobs all draw from it, so the
operator's endpoint sees a bounded number of concurrent jobs regardless of job mix.
(An ASR job holds one slot for its whole pipeline: extract → transcribe → translate.)
## Settings
Connection settings move to a shared `ai.*` namespace. Because encrypted values are
GCM-bound to their setting key (see `internal/catalog/encrypted_settings_repo.go`), the
legacy rows are **not renamed**; the loader falls back to them, following the precedent of
`recommendations.embedding_auth_token` → `recommendations.openai_api_key` in
`internal/config/db_loader.go`.
| Key | Default | Fallback | Notes |
| --- | --- | --- | --- |
| `ai.base_url` | `https://api.openai.com` | `subtitle_ai.base_url` | |
| `ai.api_key` | `""` | `subtitle_ai.api_key` | sensitive |
| `ai.chat_model` | `gpt-4o-mini` | `subtitle_ai.chat_model` | |
| `ai.max_concurrent_jobs` | `2` | `subtitle_ai.max_concurrent_jobs` | shared semaphore size |
| `ai.asr_model` | `whisper-1` | — | |
| `ai.asr_base_url` | `""` (= `ai.base_url`) | — | separate local Whisper server |
| `ai.asr_api_key` | `""` (= `ai.api_key`) | — | sensitive |
| `subtitle_ai.enabled` | `false` | — | existing; subtitle translation toggle |
| `subtitle_ai.transcribe_enabled` | `false` | — | new; ASR toggle |
| `subtitle_ai.batch_size` | `40` | — | existing; cues per chat request |
| `subtitle_ai.context_neighbors` | `2` | — | existing |
| `metadata_ai.enabled` | `false` | — | new; metadata translation toggle |
`ai.api_key` and `ai.asr_api_key` join `sensitiveSettingKeys`. The admin UI reads through
the same fallback (effective values come from the loaded config) and writes the new keys.
Config structs: `AIConfig` (connection + concurrency), `SubtitleAIConfig` (slims down to
toggles + batch tuning), new `MetadataAIConfig`. Readiness: subtitle translate requires
`subtitle_ai.enabled` + chat config; ASR requires `subtitle_ai.transcribe_enabled` + ASR
config; metadata requires `metadata_ai.enabled` + chat config.
## Metadata translation
### What gets translated
| Target | Fields |
| --- | --- |
| movie / series (`media_item_localizations`) | `overview`, `tagline` |
| season (`season_localizations`) | `overview` |
| episode (`episode_localizations`) | `overview` |
Titles, sort titles, and artwork columns in those rows are never written by AI.
### Provenance
New columns (default `'provider'`, check-constrained to `provider|ai|manual`):
- `media_item_localizations.overview_source`, `media_item_localizations.tagline_source`
- `season_localizations.overview_source`
- `episode_localizations.overview_source`
Write rules, enforced in the localization repos (single-statement upserts with CASE):
| Writer | May overwrite | Sets source | Notes |
| --- | --- | --- | --- |
| provider ingestion | `provider`, `ai` | `provider` | a non-empty provider value always replaces an AI value; an **empty** provider value never blanks an existing one |
| AI translation | `ai`, empty | `ai` | with `force`, may also overwrite `provider` (admin explicitly re-translating) — never `manual` |
| manual (future editor) | anything | `manual` | |
Serving hardening: `LocalizeItemModel` / `LocalizeSeasonModel` / `LocalizeEpisodeModel`
must only override base fields with **non-empty** localization values, since AI-created
rows legitimately carry empty titles. (Verify current behavior; fix if it clobbers.)
### Job model
New table `metadata_translation_jobs`, mirroring `subtitle_ai_jobs` conventions
(status enum, progress, idempotency partial-unique index, heartbeat):
- `target_kind` (`item` | `season` | `episode`), `content_id`, `include_children`
- `target_language`, `source_language` (default language of the base row)
- `engine`, `model`, `status`, `progress`, `progress_message`, `fields_done`, `fields_total`
- `error_message`, `idempotency_key`, `requested_by`, `force`, timestamps, `heartbeat_at`
A job for a series item with `include_children` expands to: item overview + tagline, every
season overview, every episode overview. All fields become segments
(`item:overview`, `season:<content_id>:overview`, `episode:<content_id>:overview`, …) fed
to the generic translator in batches (metadata batch size constant, smaller than the
subtitle one — paragraphs, not cue lines), so episode descriptions share terminology in one
prompt context. The system prompt names the series/movie and year for grounding.
Skip logic in the run loop (not at enqueue): a field whose localization value is already
non-empty is skipped unless `force` — so the auto trigger costs zero LLM calls on repeat
refreshes. If every field is skipped the job completes immediately.
Idempotency key: `content_id | target_kind | target_language | model`, active-only partial
unique index (completed/failed rows never block a retry).
### Triggers
1. **Manual.** Admin action on the item detail metadata editor: pick target language
(defaults to the library's `metadata_language`), `include_children`, `force`. Gated by
the same permission as metadata editing.
2. **Auto fallback.** New `media_folders.auto_translate_metadata boolean NOT NULL DEFAULT
false`. At the end of a metadata refresh for an item (after provider localizations are
persisted in `internal/metadata/service.go`), if the folder has the flag, has a
`metadata_language`, that language differs from the item's default metadata language,
and the localization row for it is missing a translated field — enqueue a non-force job
for the item (children included for series). Enqueue is fire-and-forget; refresh latency
is unaffected.
### API
Admin-gated, mirroring the subtitle AI handler shapes:
- `GET /api/v1/metadata/ai/status`
- `POST /api/v1/metadata/ai/translate` `{content_id, target_kind, target_language, include_children, force}`
- `GET /api/v1/metadata/ai/jobs/{job_id}` / `POST …/cancel`
- `GET /api/v1/metadata/ai/jobs?content_id=…`
Progress UI polls the job endpoint (jobs are short; no websocket events in v1).
## Whisper ASR
### Kinds
`subtitle_ai_jobs.kind` gains its reserved values:
- `transcribe` — audio track → subtitle track in the spoken language.
- `transcribe_translate` — `transcribe`, then the existing LLM translation chained to
`target_language`. Both the transcript track and the translated track are stored (the
transcript is a cache: a second target language skips ASR entirely because the transcript
is now a selectable text source for a plain `translate` job). `result_subtitle_id`
points at the translated track.
For ASR kinds, `source_index` holds the **audio** track index (`-1` = default/first audio
track); `source_language` is an optional hint passed to Whisper, else taken from the
detected `verbose_json.language`.
### Pipeline
1. Resolve the audio track from `media_files`; reject files with no audio.
2. Extract once with ffmpeg into fixed chunks in a job temp dir
(`-vn -map 0:a:<idx> -ac 1 -ar 16000 -c:a pcm_s16le -f segment -segment_time 600`):
16 kHz mono WAV, 10-minute chunks (~19 MB, under typical 25 MB API limits). Temp dir
removed on every exit path.
3. Per chunk: `POST /v1/audio/transcriptions` (multipart: file, model,
`response_format=verbose_json`, optional `language`, `temperature=0`) via the shared
`llm.Client` with the shared retry/backoff. Segment timestamps are offset by the chunk
start and merged.
4. Cue building: one cue per Whisper segment; text wrapped to at most two lines
(~42 chars/line). Empty/whitespace segments dropped.
5. Store as `downloaded_subtitles` (provider `transcribed`, SRT, release name
`"<Language> (AI transcribed)"`), notify `SubtitleReady`.
6. `transcribe_translate`: feed the cues to the existing `Translator`; store the translated
track (provider `translated`) and complete.
Chunks are processed starting from the chunk containing the viewer's playhead, then
wrapping — same UX as translation — and cues stream live to the requesting session through
the existing notifier. Progress bands: extract 0–10 %, transcribe 10–70 % (per chunk),
translate 70–95 %.
Known v1 limitation: fixed chunk boundaries can clip a word at the seam; acceptable, noted
for a silence-aligned follow-up.
### API / UI
The existing enqueue endpoint `POST /api/v1/subtitles/ai/translate` gains an optional
`kind` (default `translate`; backward compatible). `GET /api/v1/subtitles/ai/status` gains
`transcribe_enabled`. The player's translate modal gains a "Generate from audio" mode that
lists audio tracks and a target language — the natural path when the only subtitle tracks
are bitmap (today's hard error) or none exist.
## Frontend
- **AI Services settings page** (`web/src/pages/admin-settings/`): connection card
(base URL, API key, chat model, ASR model, optional ASR base URL/key), features card
(subtitle translation, ASR, metadata translation toggles; concurrency; batch tuning).
The AI card currently inside `SubtitlesSettings.tsx` moves here; subtitle settings keep
the download-provider config.
- **Metadata editor**: "Translate with AI" action in `EditMetadataDialog.tsx` (language,
include children, force), with job-poll progress and query invalidation on completion.
- **Library settings**: `auto_translate_metadata` toggle next to the metadata-language
field.
- **Player**: "Generate from audio" mode in `SubtitleTranslateModal.tsx`.
## Client (Android / Apple) impact
None required. Translated overviews/taglines arrive through the existing localized detail
responses; ASR/translated tracks arrive as ordinary downloaded subtitles. Optional
follow-ups in the client repos: surface "Generate from audio" in their subtitle menus
(the jobs API is shared) and the library auto-translate toggle in their admin surfaces.
## Reliability
- All jobs: idempotency dedup of in-flight work, heartbeat + stale reaper (crash-safe,
multi-instance safe), terminal-state handling on cancel/shutdown — one implementation in
`jobrunner`, no copy-paste between the two services.
- Provider-wins/manual-wins provenance is enforced in SQL (single-statement upserts), not
in racy read-modify-write Go code.
- The shared semaphore bounds total endpoint load; ASR temp space is cleaned on success,
failure, cancel, and shutdown.
- Auto-translate re-runs are free: skip-if-filled happens before any LLM call.
## Migrations
One Goose migration (`make migrate-create NAME=ai_metadata_translation_and_asr`):
1. `metadata_translation_jobs` table + active-idempotency / content / status indexes.
2. Provenance columns on the three localization tables (+ check constraints).
3. `media_folders.auto_translate_metadata`.
Down: drop the table, columns, and constraint.
## Risks / open questions
- **Whisper-endpoint compatibility.** OpenAI, Groq, faster-whisper-server/speaches all
speak `verbose_json`, but segment quality varies; the cue builder must tolerate missing
segment arrays (fall back to one cue per chunk worth of `text` is *not* acceptable —
fail the job with a clear message instead).
- **`LocalizeItemModel` empty-field semantics** must be verified before AI rows (empty
titles) ship; this is called out as an explicit plan task.
- **Long-running ASR on slow local Whisper servers** can exceed the 10-minute HTTP client
timeout per chunk; the transcription call uses a per-request timeout sized for chunk
duration rather than the client default.
- **Settings sprawl.** The `ai.*`/legacy fallback leaves old `subtitle_ai.*` connection
rows in place indefinitely; harmless, but a future settings-GC could prune them once the
new keys are written.
@@ -0,0 +1,78 @@
# On-view description translation + per-profile metadata language — design
**Date:** 2026-06-10
**Status:** Approved direction (per-profile preference; auto with button fallback; any user behind an opt-in setting)
**Builds on:** `docs/superpowers/specs/2026-06-10-ai-translation-and-asr-design.md`
Commands assume the repository root is the cwd.
## Problem
Metadata translation currently triggers from the admin editor or the per-library
refresh fallback, always targeting the library's metadata language. A viewer whose
language differs from the library's (or whose library was never auto-translated) has
no way to get descriptions in their language, and there is no per-person language
preference at all — presentation language is library-scoped.
## Design
### Per-profile preferred metadata language
- New column `user_profiles.preferred_metadata_language text NOT NULL DEFAULT ''`
(empty = inherit the library's metadata language). Threaded through the profile
model/repo/API exactly like the existing `language` (UI language) field, plus a
picker in the profile settings UI with an "Inherit library default" option.
- `access.Scope` gains `PreferredMetadataLanguage`, populated when the access
resolver loads the profile, so every request already carries it.
- `catalog.AccessFilter` gains `ProfilePreferredLanguage`, and presentation language
resolution becomes: explicit `PresentationLanguage` (request param) →
**profile preference** → library `metadata_language`. Applied wherever the scope
is available (native API and jellycompat), so all clients get profile-localized
metadata server-side.
### "Not in your language" signal
`ItemDetail` gains `pending_translation_language` (omitted when empty): set when the
resolved presentation language differs from the item's default metadata language,
the base overview is non-empty, and no localization overview exists for that
language. It is pure data — clients combine it with the AI feature status to decide
what to render.
### On-view trigger
- New setting `metadata_ai.on_view`: `off` (default) | `button` | `auto`, surfaced
on `GET /api/v1/metadata/ai/status` as `on_view`.
- New authenticated endpoint `POST /api/v1/items/{id}/translate-description`
`{target_language}` — any profile, gated by `metadata_ai` readiness +
`on_view != off`, item access enforced with the same `EnsureAccessible` check the
watched-state endpoints use. The client echoes back the language the server
reported in `pending_translation_language`.
- The endpoint goes through a new `Service.RequestOnView`, which adds one guard on
top of the existing pipeline: if the latest job for (content, language) failed
within a cooldown window, return it instead of enqueuing — a broken endpoint must
not be retried on every page view. Existing guarantees do the rest: in-flight
idempotency collapses concurrent viewers onto one job, skip-if-filled makes
repeats free, and the shared semaphore caps endpoint load.
### Web UX
On the detail page, when `pending_translation_language` is set:
- `on_view=auto`: fire the request on view (once per item), pulse-animate the
description while waiting, and poll the detail query until the flag clears (the
job's first batch translates the item's own overview, so this lands in seconds
even when a series job continues episodes in the background). Time out after ~45 s
and show the original text — no error states in the viewer's face.
- `on_view=button`: a small "Translate" chip near the description triggers the same
flow on click.
No job-status endpoint for viewers: completion is observed as the flag clearing on
refetch; failures surface only as the shimmer timing out (the cooldown stops
re-triggering).
## Non-goals
- Per-profile language in the Android/Apple UIs (server-side serving means they
already *receive* localized metadata; surfacing the trigger there is a client
follow-up using the same endpoint).
- Translating titles, or any admin-side behavior changes.
@@ -0,0 +1,96 @@
# Server-side Kindle→EPUB conversion (MOBI / AZW / AZW3) — design
**Goal:** Let the silo-server render Kindle-family ebooks (mobi, azw, azw3) in the Android in-app reader by converting them to EPUB **server-side, in-process, on demand**, served transparently through the existing read endpoint. No external converter binary, no cgo.
**Status:** Approach approved by the user: convert via **libmobi**, run **built-in** to the server as **WASM executed by `wazero`** (a pure-Go runtime). On-demand + cached, admin-gated, DRM-free only. This is the MOBI half of the "all 9 server formats in-app" goal; the CBR (client-side) half is a separate Android spec.
> **Linchpin PROVEN (build spike, 2026-06-17, linux/amd64).** libmobi + `mobitool` cross-compile to `wasm32-wasi` (wasi-sdk 25, libmobi `9062742`, zlib 1.3.1 built to wasm); `-e` (create EPUB) is present; the module converts real MOBI6 / KF8-hybrid / HUFF-CDIC / unicode samples to **well-formed EPUB** (`mimetype`=`application/epub+zip` first, `META-INF/container.xml`, `OEBPS/*`). Verified end-to-end under **wazero** (the prod runtime) with WASI preopen + argv + `_start` + exit handling. Reproducible build stage committed at `tools/mobitool-wasm/Dockerfile` (with a smoke-conversion gate). Build-config gotcha resolved: build libmobi against a **wasm-compiled zlib** — `--with-zlib=no` makes libmobi vendor miniz that collides with mobitool's own miniz at `wasm-ld`. **DRM finding:** `mobitool` prints `Document is encrypted` to **stdout** but still **exits 0** and writes a (garbage) EPUB — so detect failure by capturing stdout + validating output, never by exit code alone.
## Why this shape
- Pure-Go MOBI/KF8 *parsers* don't exist at usable quality (the Go MOBI packages only *write*). Calibre is the gold standard but ~300MB+ of Python/Qt — rejected as too heavy. **libmobi** (mature C library behind the `mobitool` CLI) handles MOBI6 + KF8/AZW3 + AZW, decompression, and EPUB generation.
- Rather than shell out to an external `mobitool` binary (a deploy moving part) or bind via cgo (forces `CGO_ENABLED=1`, a C toolchain, painful cross-compile, and an LGPLv3 static-link obligation), we compile **`mobitool` to a WASI `.wasm`** once and **run it in-process with `wazero`**.
- This keeps the main Go build **`CGO_ENABLED=0`** and pure; one **architecture-independent** `.wasm` runs on amd64/arm64 servers alike; the `.wasm` is `go:embed`-ed into the binary (self-contained); libmobi's LGPL is cleanly isolated as a separate embedded artifact; and — a real bonus — **untrusted ebook bytes are parsed inside the WASM sandbox**, so a memory-safety bug in libmobi on a malicious file cannot reach the host (only the mapped temp dir).
## Components
### 1. The WASM module (build-time artifact)
- Compile libmobi + its `mobitool` tool to `wasm32-wasi` using the **wasi-sdk** (clang). Output: `mobitool.wasm`.
- Build libmobi **without libxml2** (`--with-libxml2=no` / `--disable-xmlwriter` as available) so the module has no external native deps; mobitool's EPUB writer uses a bundled miniz, which is portable C and compiles to WASI cleanly. (Feasibility of the no-libxml2 build for the `-e` path is the #1 thing to verify in the build spike — see Risks.)
- Build it in a **dedicated Docker build stage** (or a committed `Makefile`/script under `tools/mobitool-wasm/`). **Commit the resulting `mobitool.wasm`** into the repo so the normal Go build does not need the wasi-sdk — it only `go:embed`s the prebuilt artifact. Rebuild the `.wasm` only when bumping libmobi; record the libmobi version + build flags next to it.
### 2. `internal/ebookconvert` (new package) — the in-process converter
```go
// Converter converts a Kindle-family ebook file to EPUB bytes, in-process via wazero.
type Converter interface {
// Convert reads srcPath (mobi/azw/azw3) and returns the path to a converted .epub.
// Returns ErrDRMProtected / ErrUnsupported / ErrConversionFailed as appropriate.
Convert(ctx context.Context, srcPath string) (epubPath string, err error)
}
```
- `//go:embed mobitool.wasm` → compile the module **once** at startup with `wazero.NewRuntime` + `CompileModule` (the compiled module is cached and reused; only cheap instantiation happens per conversion).
- **wazero command-module specifics** (from Codex review): `wasi_snapshot_preview1.MustInstantiate` the host module once; `Runtime.CompileModule` once at startup; per conversion instantiate with `ModuleConfig.WithArgs(...)` (argv ≈ `mobitool -e -o <outdir> <input>`), `WithFSConfig`, `WithStdout`+`WithStderr` (capture **both** — `mobitool.c` prints many failures with `printf` to stdout, not stderr), and the default `_start`. Use `WithName("")` (or a unique name) for repeated instantiation. Non-zero exits surface as `*sys.ExitError`. Honor ctx timeout via `RuntimeConfig.WithCloseOnContextDone(true)`.
- **Filesystem sandboxing** (from Codex review): wazero's `WithDirMount`/`WithReadOnlyDirMount` still permit relative `../../` traversal *within host permissions*, so **never preopen the library directory or the cache root**. Per conversion: copy the source into a private scratch tree, mount input **read-only via an `fs.FS`** (`WithFSMount(os.DirFS(in), …)` — confined, no `..` escape) and a dedicated empty **writable** output dir (`WithDirMount`). **The WASM sandbox is a memory-safety boundary, not a hard filesystem jail** — a writable dir mount can still be traversed by a compromised guest within the server user's reach. Filesystem isolation therefore depends on a **deployment requirement: run silo-server as a constrained, non-root service user (the container already does), so the converter's blast radius is minimal.** This is documented here and in `internal/ebookconvert/converter.go`.
- Enforce a wall-clock **timeout** (ctx), a **max source size**, and a **concurrency semaphore** (N simultaneous conversions). WASM memory is bounded by wazero config. **Resource note:** the defaults are `DefaultMaxMemoryPages` = 1 GiB linear memory per conversion × `DefaultConcurrency` = 2, i.e. up to ~2 GiB of wasm memory under saturation (plus the 256 MiB source / 512 MiB output caps). Generous for a small server; tune `ebookconvert.Options` for tight deployments.
- Map mobitool's exit code / captured stdout+stderr to typed errors. **Deterministic** verdicts (cacheable): DRM-protected input → `ErrDRMProtected`; corrupt / unconvertible layout / oversize-output → `ErrConversionFailed`. **Transient** verdicts (never negatively cached, retried next read): the per-call timeout firing → `ErrConversionTimedOut`; source over the size cap → `ErrSourceTooLarge`; caller cancellation/deadline → the context error propagated verbatim.
### 3. Conversion cache (on-demand, keyed)
- Cache converted EPUBs on disk under a configured cache root, keyed by **strong source identity + converter identity** — `file_id + size + mtime_ns + content hash` (use the scanner checksum if one exists) **plus** the converter **module fingerprint** (`moduleVersion`: a sha256 of the embedded `mobitool.wasm`, which already pins the libmobi commit, build flags, and wasi-sdk used to produce it). (Plain `path|size|mtime` can false-hit when a file is replaced with preserved mtime/size — Codex.) A stale key misses and reconverts.
- **Singleflight** per key so concurrent reads of the same just-opened book convert once. The shared conversion runs on a **detached context** (decoupled from any one caller), so a single caller cancelling its request never aborts the work for the others.
- **Negative caching**: remember only **deterministic-bad** sources — DRM (`ErrDRMProtected`) and conversion failures (`ErrConversionFailed`) — so every reader open does not reconvert a known-bad file. Transient outcomes (timeout, cancellation, oversize source) are **not** cached so they retry.
- A cache **hit refreshes the entry's mtime**, so the mtime-ordered budget eviction acts as a real LRU; eviction never deletes the entry it is handing back or another conversion's in-flight temp.
- Bounded by an LRU / total-size budget with opportunistic eviction (mirrors the existing reader-file/image cache conventions in the codebase).
### 4. Wiring into the read path
- `serveEbookInline` (`internal/api/handlers/ebook_reader.go:602`) currently `os.Open`s `file.FilePath` and `http.ServeContent`s it with `ebookMimeType(...)`.
- Change: when the source format is **kindle-family AND conversion is enabled**, resolve the converted EPUB via the cache (convert-on-miss), then serve **that** file with `Content-Type: application/epub+zip`. All other formats are served exactly as today (raw, byte-range, unchanged).
- Byte-range/`http.ServeContent` semantics are preserved — we serve the cached EPUB `*os.File` the same way, so resume/range still works.
- **Response headers** (from Codex review): `Content-Type: application/epub+zip`, `Content-Disposition` with an `.epub` filename, a strong `ETag` derived from the conversion cache key, and — because the *same URL* can return raw or converted bytes depending on the setting/failure — `Cache-Control: private, max-age=0, must-revalidate` (or `no-store`) to prevent stale client/proxy caching of the wrong representation.
- **HEAD is cache-only.** A `HEAD /read` must never trigger a (possibly minute-long, ~1 GiB) conversion. It answers from the cache: a hit serves the real converted headers + `Content-Length`; a negatively-cached source serves the `failed` contract; a miss advertises the converted headers (`Content-Type`, the cache-key `ETag`, `X-Silo-Ebook-Conversion: converted`) **optimistically with no body and no conversion** — the subsequent `GET` produces the body and the authoritative DRM/failure verdict, which then populates the cache that later HEADs read. *(Client note: a HEAD miss intentionally omits `Content-Length`; coordinate with the Android/Apple clients if any relies on it.)*
### 5. Admin gate + client capability
- An **admin-toggleable setting** (`ebook.kindle_conversion_enabled`, default off) gates the whole feature, via the existing server settings mechanism (`internal/api/handlers/settings.go` / `admin.go` `HandleUpdateSetting`). The flag is read with a short TTL cache so the hot read path (and the capability endpoint clients poll) does not hit the DB per request, while toggles still apply within seconds without a restart. When off: kindle-family files serve raw as today and the capability is not advertised.
- **Two gates to advertise the capability** (from Codex review): advertise conversion availability to clients **only when** (a) the admin flag is on **and** (b) the embedded WASM module compiled + instantiated successfully at startup. The Android client flips mobi/azw/azw3 from `ExternalOnly`→`InApp` **only when the flag is advertised**; otherwise it keeps today's external-open fallback. (Android-side change is small and tracked separately; this spec is server-side.)
- **Capability endpoint (implemented):** `GET /api/v1/ebooks/capability` → `{enabled, source_formats:["mobi","azw","azw3"], served_format:"epub", header:"X-Silo-Ebook-Conversion", header_failed_value:"failed"}`. `enabled` is true only when both gates hold (`EbookConversion.active`). The read response carries `X-Silo-Ebook-Conversion: converted|failed` so the client knows whether the body is EPUB or the raw original (→ external-open). ETag on the converted response is derived from the **exact conversion cache key** (`SourceKey.CacheKey()`) so a validator can never disagree with the cache; the failed/raw fallback is `Cache-Control: no-store` since the same URL flips representations with the flag.
## Data flow (read of a `.azw3`, conversion enabled)
1. `GET /api/v1/ebooks/{cid}/files/{fid}/read` → authorize → `file` is `azw3`, conversion enabled.
2. Cache lookup by source identity+version. Hit → serve cached `.epub`. Miss → singleflight: `Converter.Convert` (wazero runs `mobitool.wasm` on a temp copy) → store under key.
3. `http.ServeContent` the `.epub` with `application/epub+zip`, byte-range supported.
4. Client sees EPUB → existing reflow `EpubReader` renders it. Reader progress stays keyed to the **source** `fileId` (the EPUB is a derived view of the same file), so progress/annotations are unaffected.
## Error handling — explicit client failure contract
Transparent raw fallback is **not** safe on its own: once the server advertises conversion and the Android in-app EPUB path opens `/read`, returning raw MOBI/AZW bytes hands the EPUB reader a non-EPUB body (Codex). So failures need a contract the client can detect:
- On conversion failure (DRM, corrupt, timeout, oversize) the read endpoint returns a **structured signal** the Android client maps to its external-open fallback — preferred: a distinct status (e.g. `422 Unprocessable` / `415`) with an error code, **or** serve raw bytes **plus** an explicit `X-Silo-Ebook-Conversion: failed` (with the original MIME) header that the client checks. Pick one and make both sides honor it. Never silently hand raw bytes to the EPUB path.
- `ErrDRMProtected` vs `ErrConversionFailed` are distinguished in logs + (optionally) the error code, so DRM books get the "open externally" message and transient failures can be retried.
- Module compile/instantiate failure at startup (corrupt embed) → capability **not advertised** (gate b), kindle files serve raw as today, logged loudly.
## Testing
- `internal/ebookconvert` unit tests with **small DRM-free MOBI6, AZW, and KF8/AZW3 fixtures**: `Convert` produces a valid EPUB (zip with `mimetype` first + `META-INF/container.xml` + OPF + at least one content doc), deterministic across runs; corrupt input → `ErrConversionFailed`; oversize → rejected before invocation; timeout honored (ctx cancel).
- Cache tests: miss→convert→hit; key changes on source mtime/size and on module-version bump; singleflight collapses concurrent conversions.
- Handler tests: enabled+kindle → `application/epub+zip` body is a valid EPUB; disabled → raw bytes + original MIME; non-kindle → unchanged; DRM/failed → graceful fallback path.
- A build-stage check (CI) that the committed `mobitool.wasm` matches a rebuild from the pinned libmobi (or at least that it instantiates + converts a fixture), so the artifact can't silently rot.
## Sequencing — spike first (Codex-recommended)
**The first PR is only the build spike + a tiny Go harness that converts fixtures** — no handler/cache/wiring until the linchpin is proven. Codex confirmed libmobi documents both deps as optional (`--with-zlib=no` → bundled `miniz.c`; `--with-libxml2=no` → internal xmlwriter) and that `mobitool.c` gates `-e` behind `USE_XMLWRITER`, not `HAVE_LIBXML2` — so the no-libxml2 wasm build is *plausible*, but the spike must **assert** `mobitool -h` exposes `-e` and a fixture actually converts. Only then design the handler/cache/wiring PR.
## Risks / spike-first items
1. **libmobi+mobitool compiles to `wasm32-wasi` with the `-e` (EPUB) path working without libxml2.** The linchpin. If libxml2 turns out mandatory for `-e`, fall back to compiling libxml2 to wasm too (heavier) or assembling the EPUB in Go from libmobi's `MOBIRawml` parts.
2. **wazero WASI preopen file IO** for the file sizes involved (tens of MB) — confirm mapping + memory/time limits.
3. mobitool CLI surface as a wasm **command module** (argv + preopens) — confirm wazero runs it via `_start` and we capture exit status + stdout + stderr.
## Committed `mobitool.wasm` — guardrails (Codex)
Commit the artifact (so normal Go builds need no wasi-sdk) **with**: the pinned libmobi source commit, the Dockerfile/build script that produced it, its SHA256, the build flags, and the LGPL license notice. Add a **CI smoke test** that instantiates the embedded module and converts a fixture. Do not gate CI on byte-for-byte rebuild reproducibility — `mobitool -v` embeds `__DATE__`/`__TIME__`, so a rebuild won't match byte-for-byte unless normalized; rely on the fixture-conversion smoke + metadata hash instead.
## Process gate (Codex)
`docs/architecture/v1-scope.md` indicates v1 scope is not locked and new user-facing capabilities may need a proposal / project-tracking entry **before** a feature PR. Confirm this process step with the maintainer before opening the conversion PR. *(Decision deferred to the user.)*
## Out of scope
- CBR (separate Android client-side spec).
- DRM removal (never).
- Converting at scan/ingest time (on-demand only).
- Surfacing a separate "EPUB version" in the catalog (we serve transparently through `/read`).
- Reusing the WASM converter for other formats (later, if useful).
## Commit / deploy rules (project)
- silo-server changes go on a **feature branch, committed locally, PR-ready — NOT pushed** (per standing rule; server changes land via PR).
@@ -0,0 +1,711 @@
# Offline sync for mobile (downloads v2) — design
**Goal:** Let mobile clients (Android, Apple) download media for **fully offline** playback and keep
watch state consistent across reconnects. Three pillars: (1) a **device-scoped download registry** —
the server tracks what each device has downloaded; (2) an **offline playback manifest** — one bundle
giving the client everything it needs to play with no network (metadata, subtitle files, artwork,
chapter + intro/credits markers); (3) **offline progress reconciliation** — the client queues watch
progress while offline and the server merges it on reconnect (last-write-wins). Downloads are requested
through an admin-gated **quality** ladder (`original`, then bitrate presets), with server-side
**transcode-to-a-single-file** for bitrate requests and compatibility fallback for devices that cannot
play the source directly.
**Status:** Approved — implementing. This capability cleared the Silo v1 scope gate on
2026-06-18 (via the **v1 capability proposal** triage; template
`.github/ISSUE_TEMPLATE/v1-capability-proposal.yml`), and the offline-sync/downloads-v2 work is now
being implemented phase by phase against this design. Per the decision below, the
`/api/v1/downloads` contract is **reshaped, not just extended** — acceptable because the change lands
**before** scope lock (`docs/architecture/v1-scope.md`) and **before** any mobile client ships against
it. This document began as the design input to that proposal and now serves as the implementation
reference; it is kept in sync as the implementation lands.
Decisions locked with the requester:
- **Replace** the existing `internal/download` package with a new unified package (`internal/downloads`)
that absorbs today's download behavior and adds the three pillars.
- **The existing `/api/v1/downloads` contract may be reshaped, not just extended.** The only current
consumer is the first-party web app, which is updated in lockstep. So downloads v2 is a single
coherent surface: one device-aware `downloads` table and one `/downloads/*` endpoint family — *not* a
frozen legacy table plus a parallel registry. (This intentionally diverges from the v1 additive-only
API rule, which is acceptable here because the change lands **before** scope lock and **before** any
mobile client ships against it — exactly the right time to reshape. The proposal must call this out so
reviewers/clients know the download contract changed pre-lock.)
- **Quality is user-facing; delivery format is internal.** Clients request `quality` from the ordered
ladder `original > 20mbps > 10mbps > 5mbps > 2mbps > 1mbps`. `remux` is not a preset; it is a
server-selected `delivery_format` when `original` needs container/audio compatibility. Bitrate
presets are gated by a server setting **and** the per-user `download_transcode_allowed` flag.
- **Transcode-to-single-file is in v1.** (Today's transcode pipeline only emits ephemeral HLS segments.)
- **No expiry, no lease, no DRM.** Downloaded files persist on-device until the user deletes them; the
server can only revoke *future* downloads via `users.download_allowed`.
- **No cross-device visibility** in v1 (a device sees its own entries, not other devices').
**Implementation update (2026-06-20):** the current client-facing contract is documented in
`docs/downloads-api.md`. The public request field is `quality`, capabilities expose
`quality_presets`, rows expose `quality`, `effective_quality`, `delivery_format`,
`target_bitrate_kbps`, and `revision`, and batch manifests are implemented at
`GET /api/v1/downloads/batches/{batch_id}/manifests`.
> Commands and paths in this document are repository-relative; assume the repository root is the cwd.
## Why this shape
- **One device-aware table, not two.** The original plan kept today's `downloads` table
(`migrations/sql/042_downloads.sql`, keyed on `user_id`) frozen and added a separate device registry,
purely to avoid breaking the existing contract. With that constraint lifted, the cleaner model is a
single `downloads` table reshaped to carry `device_id` (and `profile_id`). `device_id` is **nullable**:
- `device_id IS NULL` → an **ephemeral / account-level** download (today's web-app browser download:
pick a version, pull the file; the row is a convenience record, not a managed library entry).
- `device_id` set → a **managed device-library entry** (a phone "has" this file; durable; unique per
`(user, profile, device, content, episode)`; the target of manifest + progress reconciliation).
One table, one endpoint family, and the format/transcode machinery is shared by web and mobile alike.
- **Device identity already exists in Postgres.** `migrations/sql/180_user_devices.sql` defines
`public.user_devices` keyed `(user_id, profile_id, device_id)` with FKs to `users` and
`user_profiles`. Device id arrives in the `X-Silo-Device-Id` header (see
`internal/api/handlers/settings.go:37`). Managed entries reference it; ephemeral rows leave it NULL.
- **The last-write-wins primitive already exists.** `userstore.UserStore.SetProgressIfNewer(... updatedAt) (bool, error)`
(`internal/userstore/store.go:26`, implemented in `internal/userdb/progress.go:131`) is exactly the
merge semantics offline reconciliation needs. Progress reconciliation is therefore **additive changes
to the existing progress endpoints**, not new machinery, and it deliberately stays in the watch-state
domain rather than being folded into the downloads package.
- **Manifests must never carry presigned URLs.** `catalog.ItemDetail` (`internal/catalog/detail.go`)
exposes `PosterURL`/`BackdropURL` as short-lived presigned S3 URLs — useless for an offline bundle
that may be opened days later. The manifest carries **stable** references (thumbhashes inline +
authenticated proxy endpoints the client fetches once at download time), so a stored manifest never
expires.
- **Remux and transcode both need a produced file, not a live pipe.** A clean, seekable, resumable
download requires a finalized file (`-movflags +faststart` relocates the moov atom in a finalization
pass — impossible over a pure pipe). `internal/playback`'s current `ServeRemux`/`StartTranscode`
stream ephemeral output. So remux and transcode share one **prepare-to-file** job pipeline; only
`original` is served straight from the source on disk.
## The replacement: `internal/downloads` (new package)
`internal/download` (singular) is replaced by `internal/downloads` (plural). The new package owns the
whole downloads domain; everything below lives in it unless noted.
```
internal/downloads/
model.go — Download (web + device, one type), Artifact, OfflineManifest,
quality/delivery constants, status constants, sentinel errors
repo.go — Postgres CRUD: downloads (reshaped), download_artifacts
service.go — orchestration: permission + quota checks, quality policy, create/list/serve
policy.go — DownloadQualityResolver (quality ladder + delivery decision + server/user gates)
bandwidth.go — BandwidthManager (ported from internal/download/bandwidth.go)
limiter.go — QuantityLimiter (ported from internal/download/limiter.go)
manifest.go — ManifestBuilder (assembles OfflineManifest; strips all presigned URLs)
artifacts.go — ArtifactManager (async prepare-to-file jobs: remux + transcode, dedup, cleanup)
serve.go — file serving for original source and prepared artifacts
```
**What it absorbs / reshapes** from `internal/download`: the `downloads` table and its endpoints, now
reshaped to be device-aware and quality/delivery-aware; `BandwidthManager`; `QuantityLimiter`; the `Service`
permission/quota/file-serving logic; and the `config.DownloadConfig` integration.
**What stays out of the package (by design):**
- **Progress reconciliation** is watch-state, not downloads. It is additive changes to
`internal/api/handlers/progress.go` + `internal/userstore`/`internal/userdb`, reusing
`SetProgressIfNewer`. Folding it into `internal/downloads` would couple two domains and duplicate the
LWW write.
- **The encode/remux primitive** stays in `internal/playback` (it owns ffmpeg). `internal/downloads`
calls a new `playback.PrepareFile(...)`; it does not shell out to ffmpeg itself.
**Cutover strategy:** because the web app is the only consumer and is updated in lockstep, Phase 0 is a
*reshape*, not a verbatim port: move the logic into `internal/downloads`, reshape the table + endpoints
(add device-awareness plus quality/delivery fields), update the web app's download hooks/components, delete
`internal/download`. The handler `internal/api/handlers/downloads.go` already depends only on a narrow
`DownloadService` interface, so the swap is mechanical; its request/response DTOs gain the new fields.
## Data model (Goose migrations)
Migrations are timestamped, created with `make migrate-create NAME=...` (never hand-numbered; never
`goose fix`).
### 1. Reshape `downloads` (device-aware, format-aware)
A single migration alters the existing table. Downloads ship default-off with little/no production data,
so widening it in place is safe.
```sql
-- +goose Up
-- +goose StatementBegin
ALTER TABLE public.downloads
ADD COLUMN profile_id text, -- NULL for ephemeral/web rows
ADD COLUMN device_id text, -- NULL = ephemeral; set = managed device entry
ADD COLUMN format text NOT NULL DEFAULT 'original',
ADD COLUMN artifact_id text; -- set for remux/transcode once prepared
ALTER TABLE public.downloads
ADD CONSTRAINT downloads_format_check CHECK (format IN ('original','remux','transcode'));
-- Widen the status enum to cover the managed-entry lifecycle alongside the existing serve states.
ALTER TABLE public.downloads DROP CONSTRAINT downloads_status_check;
ALTER TABLE public.downloads ADD CONSTRAINT downloads_status_check
CHECK (status IN ('queued','downloading','completed','failed','cancelled', -- existing
'registered','preparing','ready','revoked')); -- managed entries
-- One managed entry per (user, profile, device, content, episode). Ephemeral rows (NULL device_id)
-- are exempt via the partial index.
CREATE UNIQUE INDEX downloads_device_entry_uidx
ON public.downloads (user_id, profile_id, device_id, content_id, COALESCE(episode_id, ''))
WHERE device_id IS NOT NULL;
CREATE INDEX downloads_device_idx ON public.downloads (user_id, profile_id, device_id) WHERE device_id IS NOT NULL;
CREATE INDEX downloads_artifact_idx ON public.downloads (artifact_id) WHERE artifact_id IS NOT NULL;
-- Composite FK to user_devices. MATCH SIMPLE (the default) skips the check whenever any column is NULL,
-- so ephemeral rows (NULL device_id) are not constrained while managed entries are.
ALTER TABLE public.downloads
ADD CONSTRAINT downloads_device_fkey
FOREIGN KEY (user_id, profile_id, device_id)
REFERENCES public.user_devices(user_id, profile_id, device_id) ON DELETE CASCADE;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE public.downloads DROP CONSTRAINT IF EXISTS downloads_device_fkey;
DROP INDEX IF EXISTS downloads_artifact_idx;
DROP INDEX IF EXISTS downloads_device_idx;
DROP INDEX IF EXISTS downloads_device_entry_uidx;
ALTER TABLE public.downloads DROP CONSTRAINT IF EXISTS downloads_format_check;
ALTER TABLE public.downloads DROP CONSTRAINT IF EXISTS downloads_status_check;
ALTER TABLE public.downloads ADD CONSTRAINT downloads_status_check
CHECK (status IN ('queued','downloading','completed','failed','cancelled'));
ALTER TABLE public.downloads
DROP COLUMN artifact_id, DROP COLUMN format, DROP COLUMN device_id, DROP COLUMN profile_id;
-- +goose StatementEnd
```
Status lifecycle:
- **ephemeral / web** (`device_id IS NULL`): `queued → downloading → completed` / `failed` /
`cancelled` — exactly today's behavior, now also able to request `remux`/`transcode`.
- **managed entry** (`device_id` set):
- `original`: `registered → ready` immediately (client pulls the source), then client reports
`completed`.
- `remux` / `transcode`: `registered → preparing` (artifact job runs) `→ ready → completed`; or
`failed`.
- `revoked`: admin/permission revocation of *future* access. Cannot delete the on-device file (no DRM
by decision); it only stops the server serving new bytes.
The unique index uses `COALESCE(episode_id,'')` so movies (NULL `episode_id`) collapse to one managed
entry per `(device, content)` without NULL-comparison surprises.
### 2. `download_artifacts` — prepared (remux/transcode) files, deduplicated
```sql
-- +goose Up
-- +goose StatementBegin
CREATE TABLE public.download_artifacts (
id text NOT NULL,
media_file_id integer NOT NULL REFERENCES public.media_files(id) ON DELETE CASCADE,
format text NOT NULL, -- 'remux' | 'transcode'
params_hash text NOT NULL, -- sha256 of the encode parameters (see below)
container text NOT NULL DEFAULT 'mp4',
codec_video text NOT NULL DEFAULT '',
codec_audio text NOT NULL DEFAULT '',
resolution text NOT NULL DEFAULT '',
audio_track_index integer NOT NULL DEFAULT -1,
output_path text NOT NULL DEFAULT '', -- absolute path on the server's artifact volume
file_size bigint NOT NULL DEFAULT 0,
status text NOT NULL DEFAULT 'queued',
error_message text NOT NULL DEFAULT '',
-- Durable-queue / crash-recovery columns (see "Durable artifact queue" below).
attempts integer NOT NULL DEFAULT 0,
max_attempts integer NOT NULL DEFAULT 3,
lease_owner text, -- worker/node id holding the running lease
lease_expires_at timestamptz, -- NULL unless status='running'
next_retry_at timestamptz, -- backoff gate for re-enqueue after a failure
created_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz,
last_used_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT download_artifacts_pkey PRIMARY KEY (id),
CONSTRAINT download_artifacts_unique UNIQUE (media_file_id, format, params_hash),
CONSTRAINT download_artifacts_status_check CHECK (status IN ('queued','running','ready','failed'))
);
CREATE INDEX download_artifacts_lru_idx ON public.download_artifacts (last_used_at) WHERE status = 'ready';
-- Claimable work: queued rows whose backoff has elapsed, plus running rows whose lease has expired.
CREATE INDEX download_artifacts_claimable_idx ON public.download_artifacts (status, next_retry_at);
CREATE INDEX download_artifacts_lease_idx ON public.download_artifacts (lease_expires_at) WHERE status = 'running';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS public.download_artifacts;
-- +goose StatementEnd
```
`params_hash = sha256(format | container | codec_video | codec_audio | resolution | audio_track_index | subtitle_burn_in)`.
Two devices (or a web user and a phone) requesting the same 1080p H.264/AAC of the same source share one
artifact. `last_used_at` drives LRU cleanup; `output_path` is on a configured artifact volume. The
`attempts`/`lease_*`/`next_retry_at` columns make the table a **durable, recoverable job queue** (see the
"Durable artifact queue" subsection) so a crash mid-encode cannot strand a download in `preparing`.
### 3. Server settings (no DDL)
`server_settings` is key/value, so new keys need no migration — written when an admin saves them, parsed
in `internal/config/db_loader.go`:
- `download.transcode_enabled` (bool, default **false**) — server gate for transcode-to-file.
- `download.artifact_dir` (string) — artifact output volume; defaults under the existing transcode/temp root.
- `download.max_concurrent_prepares` (int, default 2) — encode/remux worker-pool size.
- `download.artifact_max_bytes` (int64, default 0 = unlimited) — LRU eviction budget for artifacts.
`config.DownloadConfig` (`internal/config/config.go:299`) gains the matching fields; they hot-reload via
the existing 30s-TTL cache (`internal/download/service.go:107` → ported), and the keys are registered as
non-restart in `internal/config/restart_keys.go`.
## Quality policy
Resolved in `internal/downloads/policy.go` at create time, for both web and device flows:
```go
const (
QualityOriginal = "original"
Quality20Mbps = "20mbps"
Quality10Mbps = "10mbps"
Quality5Mbps = "5mbps"
Quality2Mbps = "2mbps"
Quality1Mbps = "1mbps"
)
type QualityDecision struct {
RequestedQuality string
EffectiveQuality string
DeliveryFormat string // original | remux | transcode
TargetBitrateKbps int
RequiresArtifact bool
}
func (DownloadQualityResolver) Resolve(requested string, user *models.User,
cfg config.DownloadConfig, file *models.MediaFile, caps playback.ClientCapabilities,
artifactsAvailable bool) (QualityDecision, error)
```
Decision tree:
1. Empty quality defaults to `original`.
2. `original` direct-plays the source unless supplied device caps require a compatibility artifact.
Container/audio-only compatibility records `delivery_format=remux`; video transcode fallback records
`effective_quality=20mbps`, `delivery_format=transcode`.
3. Bitrate presets require `cfg.TranscodeEnabled` (server), `user.DownloadTranscodeAllowed`, and an
available artifact pipeline. Server gate off → `ErrTranscodeDisabled` (403). User flag off →
`ErrDownloadNotAllowed` (403). Missing artifact pipeline → `ErrQualityUnavailable` (501).
4. `remux` and `transcode` as request values are invalid public qualities.
Target codec/resolution for compatibility artifacts and bitrate transcodes is chosen by reusing `playback.Resolve` /
`playback.SelectVersion` (`internal/playback/resolver.go:60`) against the device's declared capabilities
and the admin transcode ceilings, so download encoding matches streaming decisions exactly (no
duplicated codec logic).
## Transcode-to-file pipeline (v1)
`internal/downloads/artifacts.go` — `ArtifactManager`. Remux and transcode share this; `original` never
enters it.
**On create of a remux/transcode download (web or device):**
1. Resolve target params (codec/res/container/audio track) → compute `params_hash`.
2. `INSERT ... ON CONFLICT (media_file_id, format, params_hash) DO NOTHING` into `download_artifacts`;
read the row. If `status = ready`, link `downloads.artifact_id`, set `status = ready`,
`file_size = artifact.file_size`, bump `last_used_at`. Done — no new encode.
3. Otherwise the download row is `preparing`; if the artifact is freshly `queued`, enqueue an encode job.
**Encode worker** (bounded pool of `download.max_concurrent_prepares`, hosted on the existing
`internal/taskmanager` worker machinery so jobs are tracked, observable in admin, and cancellable):
1. **Claim a job transactionally** (see "Durable artifact queue" below) — never just "enqueue freshly
queued rows," which loses work on restart.
2. Call a new `playback.PrepareFile(ctx, opts)` that builds **single-file** ffmpeg args and writes to
`output_path + ".part"`, then atomically renames to `output_path`:
- **remux:** `-map ... -c copy -movflags +faststart -f mp4` (fast, near disk-throughput).
- **transcode:** the existing encode args (reuse the `buildFFmpegArgs` logic / `TranscodeOpts` at
`internal/playback/transcode.go:30`, incl. HW-accel, audio-track selection, optional subtitle
burn-in) but targeting `-movflags +faststart -f mp4` to a file instead of `-f hls`.
`TranscodeOpts` already carries every needed field (`InputPath`, `TargetCodecVideo/Audio`,
`TargetResolution`, `HWAccel`, `AudioTrackIndex`, `SubtitleBurnIn`, `TotalDuration`).
3. On success: artifact → `ready` (`file_size`/`completed_at`, clear lease); flip every linked download
row → `ready`; publish a `user_state`/`downloads` event on the events hub (`internal/events`) so a
waiting client pulls without polling.
4. On failure: increment `attempts`, set `error_message`/`next_retry_at` (backoff). If
`attempts >= max_attempts` → artifact `failed` and linked rows `failed`; otherwise return it to
`queued` for retry. Either way, delete the stale `.part` file.
**Durable artifact queue (crash/restart recovery).** The artifact table is the queue of record, so a
process exit mid-encode cannot strand a download in `preparing` forever:
- **Transactional claim:** a worker claims a job with a single statement —
`UPDATE download_artifacts SET status='running', lease_owner=$me, lease_expires_at=now()+$lease, attempts=attempts+1
WHERE id = (SELECT id FROM download_artifacts
WHERE (status='queued' AND (next_retry_at IS NULL OR next_retry_at <= now()))
OR (status='running' AND lease_expires_at < now()) -- steal expired leases
ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED) RETURNING *`.
`FOR UPDATE SKIP LOCKED` makes concurrent workers (and multiple nodes) safe without double-encoding.
- **Lease heartbeat:** a running worker periodically extends `lease_expires_at`; if it dies, the lease
expires and another worker (or the startup sweep) reclaims the job.
- **Startup sweep:** on boot, reset `running` rows with an expired lease back to `queued` (bounded by
`max_attempts`), and re-link any `preparing` download rows to their artifact's real state. A `ready`
artifact whose `output_path` is missing on disk is reset to `queued` (the file was lost).
- **Idempotency:** the encode writes to `output_path + ".part"` and atomically renames; a reclaimed job
overwrites its own `.part`. `output_path` is deterministic from `(media_file_id, format, params_hash)`.
- **No silent stranding:** a download stuck in `preparing` past a bound is surfaced (admin task + a
`failed` transition with reason), never left invisibly hung.
**Serving** (`internal/downloads/serve.go`, `GET /downloads/{id}/file`):
- `original`: open `media_files.file_path`, `http.ServeContent` (Range/resume) wrapped in the ported
`BandwidthManager.ThrottledReader`. Same path as today's `serveFileDownload`.
- `remux`/`transcode`: require `status = ready`; serve `artifact.output_path` the same way (Range/resume
+ throttle); bump `last_used_at`.
**Cleanup:** an admin/cron job (mirroring `internal/playback/transcode_cleanup.go`) evicts `ready`
artifacts whose `last_used_at` is older than a retention window or when the total exceeds
`download.artifact_max_bytes` (LRU), but never one still linked by an active managed download row,
including completed rows that represent a device's local library state.
Whatever is dropped is logged (no silent truncation).
## Offline playback manifest
`internal/downloads/manifest.go` builds `OfflineManifest` from `catalog`'s existing detail path (reusing
`catalog.ItemDetail` assembly — markers, chapters, subtitle list, versions) and then **strips every
presigned URL**. The manifest is stable: a client can store it and re-read it offline indefinitely.
Manifest + asset endpoints are only meaningful for managed device entries (`device_id` set).
```go
type OfflineManifest struct {
DownloadID string `json:"download_id"`
ContentID string `json:"content_id"`
EpisodeID string `json:"episode_id,omitempty"`
Type string `json:"type"` // movie | episode
Revision int `json:"revision"`
Quality string `json:"quality"`
EffectiveQuality string `json:"effective_quality"`
DeliveryFormat string `json:"delivery_format"`
TargetBitrateKbps int `json:"target_bitrate_kbps"`
MediaFileID int `json:"media_file_id"`
FileSize int64 `json:"file_size"`
Title string `json:"title"`
Year int `json:"year,omitempty"`
Overview string `json:"overview,omitempty"`
Runtime int `json:"runtime,omitempty"`
ContentRating string `json:"content_rating,omitempty"`
Genres []string `json:"genres,omitempty"`
SeriesID string `json:"series_id,omitempty"`
SeriesTitle string `json:"series_title,omitempty"`
SeasonNumber *int `json:"season_number,omitempty"`
EpisodeNumber *int `json:"episode_number,omitempty"`
// Artwork: stable thumbhashes inline (immediate offline placeholder) + authenticated proxy URLs
// the client downloads ONCE at download time. Never presigned S3 URLs.
PosterThumbhash string `json:"poster_thumbhash,omitempty"`
BackdropThumbhash string `json:"backdrop_thumbhash,omitempty"`
ArtworkURLs struct {
Poster string `json:"poster,omitempty"`
Backdrop string `json:"backdrop,omitempty"`
Logo string `json:"logo,omitempty"`
} `json:"artwork_urls"`
// Playback metadata
Container string `json:"container"`
CodecVideo string `json:"codec_video"`
CodecAudio string `json:"codec_audio"`
Resolution string `json:"resolution"`
HDR bool `json:"hdr"`
Duration int `json:"duration_seconds"`
Chapters []OfflineChapter `json:"chapters,omitempty"`
Intro *Marker `json:"intro,omitempty"`
Credits *Marker `json:"credits,omitempty"`
Recap *Marker `json:"recap,omitempty"`
Preview *Marker `json:"preview,omitempty"`
Subtitles []OfflineSubtitle `json:"subtitles"`
// Stable identity so the client can re-resolve content_id after a server-side rescan
// (mirrors userstore.WatchIdentity at internal/userstore/types.go:130).
StableIdentity OfflineIdentity `json:"stable_identity"`
ManifestVersion int `json:"manifest_version"` // bump on DTO shape change
GeneratedAt string `json:"generated_at"` // RFC3339
}
type OfflineSubtitle struct {
Language string `json:"language"`
Format string `json:"format"` // srt | ass | vtt
Forced bool `json:"forced"`
HearingImpaired bool `json:"hearing_impaired"`
External bool `json:"external"`
FetchURL string `json:"fetch_url"` // authenticated proxy; client downloads at download time
FileSize int64 `json:"file_size,omitempty"`
}
```
**Artwork/subtitle proxies** (replace presigning):
- `GET /downloads/{id}/artwork/{kind}` (`kind` ∈ `poster|backdrop|logo`) reads the raw stored path and
**streams the bytes** through the existing image resolver instead of redirecting to a presigned URL.
The client caches these once.
- `GET /downloads/{id}/subtitles/{ref}` (`ref` encodes `external:{index}` or `downloaded:{id}`) streams
the subtitle file (external sub from disk; downloaded sub from S3 via the existing subtitle manager).
Both are authorized under the **full managed-entry rule** (see "Security & reliability invariants"):
the row must match `(user_id, profile_id, header device_id)`, and content/library access is **re-checked
for the requesting profile** before any bytes are served (a download id alone never authorizes access).
They are session-authenticated rather than time-limited tokens, so a manifest stored on-device stays valid.
## Progress reconciliation
Two **additive** changes. The naïve version (route the client's wall-clock straight into
`SetProgressIfNewer` and drive `?since=` off the same column) is **unsafe**: a skewed or malicious device
can submit a far-future timestamp, win LWW permanently over genuinely-newer server activity, and poison
the cursor (a future-dated row makes a reader advance its cursor past real writes). So the design
**separates client event time from server ordering**.
The watch-progress row gains two server-owned facets (LWW key vs. cursor ordering):
- **`event_at`** — the client-observed wall-clock for the progress event, used as the **LWW comparison
key**, but **bounded on ingest**: `event_at = min(client_updated_at, server_now + skew)` with a small
skew window (e.g. 2m); a value past the window is clamped to `server_now` (and logged). For an online
write `event_at = server_now`. Bounding means a client can at most claim "now" for its **own profile's**
progress — which it is already entitled to do — and can never lock in a far-future value.
- **`synced_seq`** — a **server-assigned monotonic** marker set on every write (a sequence, or
server-ingest timestamp with a tie-breaker). It is **never** client-influenced and is the sole basis
for the `?since=` cursor.
**Write — `POST /api/v1/sync/progress`** (`internal/api/handlers/progress.go:230`): add an optional
`updated_at` (RFC3339) per item = the client event time. The handler clamps it (above) and merges via an
extended `SetProgressIfNewer` whose comparison is on the **bounded `event_at`**, while the server stamps
`synced_seq` itself. Absent `updated_at` → behavior is exactly as today (server `now()`), so existing
callers (incl. watchsync) are unaffected. Completion is never inferred from the timestamp alone — the
existing watched-threshold logic still gates `completed`.
```go
type syncProgressItem struct {
MediaItemID string `json:"media_item_id"`
Position float64 `json:"position"`
Duration float64 `json:"duration"`
ForceOverwrite bool `json:"force_overwrite"`
UpdatedAt *string `json:"updated_at,omitempty"` // NEW: RFC3339; client EVENT time (server clamps it)
}
```
**Read — `GET /api/v1/progress?since=<cursor>`** (`internal/api/handlers/progress.go:86`): the `since`
cursor is an **opaque server token over `synced_seq`**, not a client timestamp. When present, only rows
with `synced_seq > cursor` are returned (use a sequence, or `>=` + id de-dup if a timestamp marker is
used, to avoid tie loss). The response carries the next cursor. This makes delta delivery immune to
client clock skew. Requires `UserStore.ListProgressSince(ctx, profileID, cursor)` in both backends
(`internal/userdb`, `internal/userstore/pgstore`) ordering/filtering on `synced_seq`. Absent `since` →
identical to today.
Adding a `UserStore` method (and the `event_at`/`synced_seq` columns to the per-user `watch_progress`
schema) touches every stub implementation; ship the implementations, the schema change, and the shared
store test suite update (`internal/userstore/storetest`) in the same PR.
**Net safety:** honest clients still get correct "most-recent actual watch wins" LWW; a dishonest clock
is bounded to claiming "now" for its own profile (no authority it didn't already have), and cursors —
the cross-device delivery mechanism — never depend on a client clock at all.
## API surface (one `/downloads/*` family; existing endpoints reshaped)
The web app is updated in lockstep, so existing endpoints are reshaped rather than frozen.
**Reshaped (existing):**
```
POST /downloads body {content_id, episode_id?, file_id?, quality?, series?, caps?};
X-Silo-Device-Id present → managed entry; absent → ephemeral/web row.
`quality` is original by default; bitrate presets are single-item only.
GET /downloads managed entries for the CALLING device (device from header); ephemeral rows otherwise
DELETE /downloads/{id} remove a row owned by (user, profile, header device)
GET /downloads/{id}/file serve original (source) or prepared artifact (Range/resume + throttle)
GET|HEAD /direct-download one-shot browser download, original-only (browser-friendly, token-in-URL)
```
The response DTO gains `device_id`, `quality`, `effective_quality`, `delivery_format`,
`target_bitrate_kbps`, `revision`, artifact-derived readiness, and the new statuses.
**`device_id` is authoritative only from the `X-Silo-Device-Id` header — never from the body or query**
(a body/query value could only ever be a display hint, and is not accepted as authority).
**Bitrate qualities are single-item only.** A series batch (`series:true`) and
`/direct-download` are **original-quality only**: `/direct-download` streams
synchronously and cannot wait on an async encode, and a series batch would otherwise
fan out N encode jobs. A client that wants prepared transcodes for a series requests
each episode individually. `DownloadQualityResolver` rejects bulk bitrate requests
with `ErrBulkQualityUnavailable` / HTTP 501.
**New — capability / feature detection** (pattern from `internal/api/handlers/notifications.go:324`):
```
GET /downloads/capability
→ { "enabled": bool, "download_allowed": bool,
"quality_presets": ["original","20mbps","10mbps","5mbps","2mbps","1mbps"],
"transcode_enabled": bool, "transcode_user_allowed": bool,
"season_download": bool, "series_monitoring": bool }
```
**New — managed-entry lifecycle + offline bundle:**
```
PATCH /downloads/{id} body {status:"downloading"|"completed"}
GET /downloads/{id}/manifest → OfflineManifest
GET /downloads/batches/{batch_id}/manifests → {manifests:[OfflineManifest]}
GET /downloads/{id}/artwork/{kind} → image bytes (poster|backdrop|logo)
GET /downloads/{id}/subtitles/{ref} → subtitle bytes
```
**Progress reconciliation:** additive fields on existing `POST /sync/progress` and `GET /progress`
(above). No new routes.
Routes mount in the existing authenticated group where the current download routes live
(`internal/api/router.go:2121`). **Every managed-entry endpoint — create, list, `PATCH`, `DELETE`,
`/file`, `/manifest`, `/artwork`, `/subtitles` — is profile-scoped (`RequireProfile`) and authorizes the
row on `(user_id, profile_id, header device_id)`** (not `user_id` alone — household profiles share a
`user_id`, so a user-only check would leak one profile's downloads to another). A managed call requires
`X-Silo-Device-Id` (missing header on a managed create → `400 device_id_required`); ephemeral web rows
omit it and stay account-scoped. Byte-serving and asset endpoints additionally **re-check per-profile
content/library access** (the existing access filter / `EnsureAccessible`) before serving, so a stale or
out-of-scope row — e.g. a child profile, or a profile that lost library access — cannot pull restricted
media by download id even if the row exists.
## Data flows
**Ephemeral web download (reshaped existing flow, quality-aware)**
```
POST /downloads {content_id, quality:"original", caps:{...}} (no device id → ephemeral)
→ Resolve quality/delivery · permission · quota
→ compatibility artifact: ArtifactManager.Ensure → preparing|ready ; direct original: ready immediately
client → GET /downloads/{id}/file → serveContent(source|artifact, Range, throttle)
```
**Register a managed bitrate download (mobile)**
```
POST /downloads {content_id, quality:"5mbps", caps:{...}} (+X-Silo-Device-Id, +X-Profile-Id)
→ Resolve: cfg.TranscodeEnabled && user.DownloadTranscodeAllowed → delivery_format=transcode
→ upsert downloads (device entry, status=preparing)
→ ArtifactManager.Ensure(media_file_id, params): ON CONFLICT dedup → enqueue encode if new
encode worker → playback.PrepareFile(... -movflags +faststart) → rename → artifact=ready
→ linked rows=ready → events hub notify
client (on notify) → GET /downloads/{id}/file → serveContent(artifact, Range, throttle)
→ GET /downloads/{id}/manifest → download ArtworkURLs.* and Subtitles[].FetchURL once
→ PATCH /downloads/{id} {status:"completed"}
```
**Offline progress, reconnect**
```
(offline) client queues {media_item_id, position, duration, updated_at} per stop
(online) POST /sync/progress {items:[... updated_at ...]} → SetProgressIfNewer per item
GET /progress?since=<cursor> → rows changed elsewhere since last sync
```
## Phased build sequence (each phase independently shippable)
- **Phase 0 — Reshape + scaffolding (coordinated web update).** Create `internal/downloads`; reshape the
`downloads` table (device plus quality/delivery columns, widened status) and the `/downloads` +
`/direct-download` endpoints to be quality-aware; port `BandwidthManager`/`QuantityLimiter`/serving;
extend `config.DownloadConfig` (+ new keys, default-off); add quality/delivery constants + sentinel
errors; add `GET /downloads/capability`. Update the web app's download hooks/components
(`web/src/hooks/queries/downloads.ts`, `web/src/components/DownloadVersionPicker.tsx`) to the reshaped
DTOs. Delete `internal/download`.
- **Phase 1 — Managed device entries.** Device-aware create/list/patch/delete; managed-entry serving for
`original`; `X-Silo-Device-Id` handling + the device-entry unique constraint. **Invariant 2** lands
here: profile+device authorization on every managed endpoint, header-only `device_id` authority.
*Acceptance tests:* a second profile on the same `user_id` is denied a managed row's `/file` and
`PATCH`/`DELETE`; a body/query `device_id` cannot override the header.
- **Phase 2 — Offline manifest.** `ManifestBuilder`; manifest + artwork + subtitle proxy endpoints, each
under the invariant-2 authorization + a per-profile content-access re-check before serving.
*Acceptance test:* a child/library-restricted profile is denied artwork/subtitle/manifest for an
out-of-scope download id.
- **Phase 3 — Prepare-to-file (remux + transcode) [v1-critical].** `download_artifacts` migration (incl.
lease/attempt columns); `ArtifactManager` as a **durable leased queue** (transactional claim, lease
heartbeat, startup sweep, `.part` cleanup) per **invariant 3**; `playback.PrepareFile`; encode worker on
`taskmanager`; ready-state serving; events notification; cleanup job. Turn `download.transcode_enabled`
on. *Acceptance test:* kill the process mid-encode → on restart the artifact re-enqueues and the linked
download reaches `ready` (no permanent `preparing`); concurrent workers never double-encode.
- **Phase 4 — Progress reconciliation.** `event_at` (clamped) + server-assigned `synced_seq` columns on
`watch_progress`; clamped `updated_at` on `POST /sync/progress`; server-token `?since=` on
`GET /progress`; `ListProgressSince` in both stores + store test suite, per **invariant 1**.
*Acceptance test:* a future-dated client `updated_at` is clamped (cannot win over a later real write)
and never advances another device's cursor.
Phase 0 precedes all and is the coordinated server+web reshape. Phases 1/2/4 are independent; Phase 3
depends on 1. Remux can ship in Phase 3 even if transcode stays admin-off.
## Client impact
- **Android (`silo-android`):** persist a stable `device_id` (UUID in app storage) and send
`X-Silo-Device-Id`; call `/downloads/capability` on login to gate UI; register downloads, poll/listen
for `ready`, pull the file, store the manifest + artwork + subtitle files locally; play the local file
with ExoPlayer; queue offline progress with timestamps in a Room table and flush via
`POST /sync/progress`, then pull deltas with `GET /progress?since=`.
- **Apple (`silo-apple`):** same flow; `device_id` from `identifierForVendor` (or stored UUID on macOS);
`URLSession` background download against `GET /downloads/{id}/file`; AVFoundation plays the local file;
offline progress queue drained on reconnect. The manifest's markers/chapters reuse the types already
used for online playback.
- **Web (`web/`):** updated in lockstep in **Phase 0** — the download hooks/components move to the
reshaped `/downloads` DTOs (quality/effective quality/delivery fields, new statuses). New admin additions: a
`download.transcode_enabled` toggle in admin settings, plus the existing per-user
`download_transcode_allowed` switch in `web/src/pages/AdminUserDetail.tsx`. No offline-library UI on web.
## Security & reliability invariants (must-hold)
These three are correctness requirements, not nice-to-haves; each ships with the acceptance test named in
the phase plan. They were hardened in response to an adversarial design review.
1. **Server-owned sync ordering.** Cross-device delta delivery (`?since=`) is driven only by the
server-assigned `synced_seq`; the client clock is bounded metadata (`event_at`, clamped to
`now + skew`) used only for LWW conflict resolution on the caller's own profile. A client-supplied
timestamp can never advance another reader's cursor or lock in a far-future "win."
2. **Full profile + device authorization.** Every managed-entry endpoint authorizes on
`(user_id, profile_id, header device_id)` and re-checks per-profile content/library access before
serving bytes/assets. A download id is never sufficient; `device_id` authority is the header only.
This holds the "no cross-device visibility" and "no cross-profile leakage" invariants given that
household profiles share a `user_id`.
3. **Durable artifact recovery.** Artifact preparation is a leased, attempt-counted, transactionally
claimed queue with a startup sweep, so no crash/restart can strand a download in `preparing` or
double-encode the same artifact.
## Risks & trade-offs
- **Coordinated server+web reshape (Phase 0).** Reshaping `/downloads` requires the web app to land in
the same milestone. Mitigated because both are first-party and the reshape is mechanical (added fields,
not removed behavior for the existing web path).
- **One table, two lifecycles.** A nullable `device_id` plus a wide status enum carries both ephemeral
and managed rows. Deliberate and managed (partial unique index + MATCH-SIMPLE FK keep the two modes'
invariants clean); the alternative two-table split was only justified by the now-lifted compat
constraint.
- **Artifact disk + CPU cost.** Mitigated by `(media_file_id, format, params_hash)` dedup (shared across
web/devices/users), a bounded encode pool, LRU/byte-budget cleanup, and transcode being **off by
default** (admin opt-in).
- **Remux is not free for a clean downloadable file.** `+faststart` needs a finalization pass, so even
remux is a prepare-to-file job. It is fast (`-c copy`) but not instant; `preparing → ready` + push
notification covers the UX.
- **`content_id` can change on rescan.** The manifest carries `StableIdentity` (provider IDs +
season/episode) mirroring `WatchIdentity` so the client re-resolves after a catalog rebind; the stale
row doesn't break the on-device file.
- **No expiry by decision.** A revoked `download_allowed` stops *future* serves (re-checked each serve,
as today at `internal/download/service.go:345`) but cannot remove an already-downloaded file. Call this
out in admin copy.
- **LWW vs. client clocks (resolved, see invariant 1).** Conflict resolution uses a clamped `event_at`
and delivery uses the server-owned `synced_seq`, so a skewed/malicious clock is bounded to "now" on its
own profile and cannot poison cursors. Residual: an honest-but-wrong client clock within the skew
window can still mis-order two of *its own* near-simultaneous events — acceptable.
- **Authorization breadth (resolved, see invariant 2).** The per-endpoint profile+device authorization
and pre-serve access re-check are load-bearing; the acceptance suite must include a cross-profile and a
cross-device denial test, since a regression here is a content-access leak, not just a bug.
- **`UserStore` gains a method + `watch_progress` gains columns.** `ListProgressSince` plus the
`event_at`/`synced_seq` columns touch every store implementor/stub and the per-user schema — all in the
Phase 4 PR.
- **Quota semantics.** v1: all download rows (web + device) count toward the **same** per-user
`QuantityLimiter`; revisit if too coarse.
## Out of scope (deferred)
Cross-device download visibility; download expiry/lease/DRM; cumulative per-user storage-byte quotas;
push-to-client "your transcode is ready" beyond the existing events hub; server-initiated deletion of
client-side files.
## Appendix — proposal-ready summary (maps to the v1 capability template)
- **Summary:** Mobile apps can download movies/episodes for fully offline playback — choosing original
or an admin-gated bitrate quality — and watch progress stays consistent when the device reconnects.
The web app's existing download feature is folded into the same reshaped contract. Remux remains an
internal delivery format, not a client-facing quality preset.
- **User value / why v1:** Offline playback is table-stakes for mobile media clients (commutes, flights,
spotty networks) and the client teams need the download + sync contract locked to build against.
Reshaping the existing download contract now — before lock and before any mobile client ships against
it — is the right time; the only current consumer is the first-party web app, updated in lockstep.
- **API surface:** the reshaped + new `/downloads/*` endpoints listed in "API surface" above (capability,
device-aware create/list/patch, file serving, manifest/artwork/subtitle proxies) plus
`updated_at`/`?since=` on the progress endpoints. **Note for reviewers:** this *changes* the existing
`/downloads` contract (additive-only is intentionally waived pre-lock for this first-party endpoint).
- **Client impact:** Android — full offline UI + sync; Apple — full offline UI + sync; Web — lockstep
Phase-0 DTO update + admin toggles.
- **Rough size (server-side):** **L** (new package replacing the old one, table reshape + artifacts
table, async encode pipeline, manifest assembly, additive progress sync).
@@ -0,0 +1,246 @@
# Personal Collection Catalog Filter Standardization - Design
- **Date:** 2026-06-23
- **Status:** Draft (rev. 3 after adversarial review). Incorporates three findings: `match: any` merge safety, display-query fragment normalization, and ebook misclassification under unscoped watched filters.
- **Scope:** `silo-server` backend and web collection UI. Client follow-ups in `silo-android` and `silo-apple` are optional unless they adopt the same collection editing surface.
- **Note:** All paths are repository-relative. Commands assume the repository root is the cwd.
## Current Branch State
This design lives on the in-flight `profile-scoped-collection-filters` branch, which has **already implemented** the first cut of the feature but has **not merged to `main` and has not shipped to any client**. Concretely, on this branch today:
- A migration adds `watch_filter` and `media_filter` as `text` columns on `user_personal_collections`, each with a CHECK constraint (`migrations/sql/20260623184858_user_collection_display_filters.sql`).
- `internal/catalog/user_collection_display_filters.go` implements a custom Go filter pass (`FilterUserCollectionDisplayItems`) called from `internal/sections/fetcher.go` and `internal/catalog/catalog_resolver.go`.
- Web controls in `web/src/components/collections/CollectionBuilder.tsx`, `web/src/pages/ImportedCollectionEditor.tsx`, and helpers in `web/src/lib/collectionDisplayFilters.ts` already emit the legacy enums.
Because nothing is merged or released, this is a **clean-replacement** opportunity, not a backward-compatibility problem. The migration can be reshaped before merge; no client depends on the legacy enums. This resolves Open Question #1 below.
## Problem
Personal collection display filtering is growing its own filter model alongside two pre-existing completion implementations. There are **three** places that answer "is this item played/read for this profile?":
- the custom watched/media pass in `internal/catalog/user_collection_display_filters.go` (exact user-collection reads),
- the personalized `watched` query rule in `internal/catalog/query_builder.go` (`userStateCompletionClause`),
- the item user-data path in `internal/api/handlers/user_state.go` (`allEpisodesCompleted`), which is the canonical played/read state surfaced to clients.
That creates more than one way to answer the same product question: "should this item be visible for this profile based on watched state?" If multiple paths persist, they will drift in semantics, performance, and bug fixes. The drift is already visible *and already duplicated in code*:
- The custom personal collection helper treats series/seasons as watched only when all child episodes are complete; `userCollectionAllEpisodesCompleted` is a near-verbatim copy of `allEpisodesCompleted` in `user_state.go`.
- The catalog `watched` rule, by contrast, checks completion for the row's own `content_id` only (`uwp.media_item_id = mi.content_id`), so a series row is effectively never "watched" because progress/history is keyed to episode IDs, not the series ID.
Consolidating onto the catalog predicate therefore removes one Go copy outright and requires teaching the catalog predicate the rollup the other two already perform.
## Goals
- Use the catalog query system as the canonical representation and executor for watched filters.
- Make personal collection display filtering, catalog filtering, and collection-builder UI speak the same `QueryDefinition` / `QueryRule` language.
- Preserve exact collection source order when a collection is rendered as a shelf or source-ordered list.
- Preserve existing access controls and profile scoping.
- Avoid persisting a second watched-filter vocabulary unless needed as a compatibility shim.
- Keep the implementation narrow enough to land as a refactor/standardization of an already intended collection-filter feature.
## Non-Goals
- No new broad smart-collection DSL.
- No change to external sync membership. Imported lists still sync exact item membership before display filtering is applied.
- No change to collection ownership, sharing, or profile visibility rules.
- No client lockstep requirement. Existing clients can ignore new display-filter capabilities until they adopt the collection editor.
- No attempt to optimize very large exact collections beyond the current exact-source behavior in this pass.
## Scope Gate
`docs/architecture/v1-scope.md` says v1 is not locked and the proposal window is open. This design should be treated as a standardization of the profile-scoped collection filter work already underway, not as a separate feature expansion. If the final implementation exposes a new public capability beyond personal collection display filtering, file a v1 capability proposal before opening a feature PR.
## Design Principle
One feature gets one semantic implementation:
```json
{ "field": "watched", "op": "is", "value": true }
```
is the only canonical expression for "show watched items"; `value: false` is the canonical expression for "show unwatched items".
UI controls may present simpler labels such as "All", "Watched", and "Unwatched", but those controls must translate into catalog query rules before execution. Backend code should not add another watched evaluator for personal collections.
## Canonical Semantics
The canonical `watched` filter should match the user-facing played/read state shown by item user data:
| Item kind | `watched is true` should mean |
|---|---|
| Movie / episode / video leaf | Completed progress or visible completed history exists for the active profile. |
| Series | At least one child episode exists, and every child episode is completed for the active profile. |
| Season | At least one child episode exists, and every child episode in the season is completed for the active profile. |
| Ebook | Reader progress is at or above `models.EbookFinishedProgressThreshold`. |
| Manga | Use the same read-state source the catalog uses for manga/chapters once manga catalog filtering is finalized; do not add a collection-only interpretation. |
This intentionally aligns with `internal/api/handlers/user_state.go`, where series and season user data are derived by `allEpisodesCompleted`. If this definition is too expensive for catalog queries, we should optimize the catalog predicate rather than keep a separate personal collection definition.
For this table to hold when a query is *unscoped* (mixed-media exact collections), the predicate must pick the completion source from each row's own type, not from a single query-wide `media_scope` — see Catalog Watched Predicate Work. Picking the source by `media_scope` alone misclassifies any row whose type differs from the scope (notably ebooks under an unscoped query).
## Storage Model
Recommended canonical storage for personal collection display filters:
- Add a dedicated JSON column named for display filtering, `display_query_definition`. Do **not** overload the existing `query_definition` field (see Risks: it already carries `library_ids` for live/smart collections and is fragile enough to have needed a recent sanitization fix).
- Store the standard catalog `QueryDefinition` *shape* in that field, normalized to a filter-only fragment (see normalization rules below).
- For the current "media filter" control, store a **`type` rule**, not `media_scope`:
- `all` -> no `type` rule
- `movie` -> `{ "field": "type", "op": "is", "value": "movie" }`
- `series` -> `{ "field": "type", "op": "is", "value": "series" }`
- For the current "watch filter" control, store a `watched` rule:
- `all` -> no watched rule
- `watched` -> `{ "field": "watched", "op": "is", "value": true }`
- `unwatched` -> `{ "field": "watched", "op": "is", "value": false }`
**Why `type` rule, not `media_scope`:** `media_scope` is a single string on `QueryDefinition`, not a rule, and there is no logic to intersect two scopes (only `intersectCatalogDefinitionLibraries` exists, for library IDs). A `type` rule composes as an ordinary AND group and avoids any collision with a smart collection's own `media_scope`. `type` is already a supported, executable query field, so this needs no new merge machinery. Reserve `media_scope` for the source query's own scoping.
**`display_query_definition` is a filter-only fragment, not a full query.** A full `QueryDefinition` carries fields that are meaningless — and actively dangerous — for a display overlay. In particular the executor honors `def.Limit` internally, so a persisted display query with `limit` set would cap the match set *before* the exact-collection source-order reorder, silently and permanently hiding matching items and producing wrong totals/pages. `sort` and `library_ids` are likewise the source/collection's concern, not the overlay's. Normalize on the API boundary before persisting and before executing:
- Persist only `match` and `groups` (with rules drawn from the allowed display vocabulary — initially `type` and `watched`).
- Reject or strip `limit`, `sort`, `library_ids`, and `media_scope` from the fragment. Prefer reject-with-error on write so clients learn the contract; strip defensively on read for forward-compat.
- Storing the `QueryDefinition` *shape* keeps the door open to a richer rule set later; it does not mean accepting the whole `QueryDefinition` surface.
Compatibility: none required. The legacy `watch_filter` / `media_filter` enums exist only on this unmerged branch (see Current Branch State), so they are dropped rather than aliased. If — and only if — a future build ships the enums to clients before this lands, revisit the compatibility shim; until then there is no legacy surface to preserve.
## Execution Model
### Smart/live collections
Smart and live collections already resolve through `CatalogResolver.resolveQuerySource`. Their stored query is already a catalog `QueryDefinition`. Display filters should be merged into that query before execution.
**Merge must guarantee `(source) AND (display)` — appending a group does not.** `QueryBuilder.Build` joins *all* top-level groups with a single joiner derived from the definition's top-level `match` (`query_builder.go`: `topJoiner` is ` OR ` when `match == "any"`). So a smart/live source with `match: any` and groups `G1, G2` becomes `G1 OR G2`; appending a display group `D` yields `G1 OR G2 OR D` — which *widens* the result past the collection source and lets contradictory watched rules return rows instead of none. The QueryDefinition model has only one top-level `match` and only two levels (groups of rules), so there is no in-model way to express `(G1 OR G2) AND D`.
Merge rule:
- Keep library scope, `media_scope`, limit, and sort from the collection's source query.
- Combine the source definition and the display fragment with a true conjunction that is independent of either side's `match`. The robust implementation is an **executor-level conjunction**: build the source predicate and the display predicate as two separate SQL fragments and join them with ` AND ` (each side already parenthesized), rather than merging group lists. This is net-new merge logic, not an existing capability.
- The append-as-group shortcut is correct **only** when the source's top-level `match == "all"`. When `match == "any"` it is incorrect. Do not special-case "all" and forget "any"; implement the general conjunction and test both.
- Because the media control is stored as a `type` rule (not `media_scope`), it ANDs in through the same conjunction. If the source query's `media_scope` already excludes that type (e.g. a movies-only smart collection plus a `type is series` display filter), the result is empty — no scope-intersection code is needed. (There is no existing media-scope intersection logic to lean on, which is the main reason the media control is modeled as a `type` rule rather than a second `media_scope`.)
- If both the source and display layers contain `watched` rules, the conjunction preserves both; contradictory rules naturally return no rows.
### Exact/manual/imported collections
Exact collections have a source item set from `user_personal_collection_items`. Display filtering is applied after membership is known but before pagination.
**This path replaces two existing filters, not one.** Today the source-ordered exact path runs the in-Go `filterCatalogItems` (a metadata-only matcher that cannot evaluate personalized fields — it returns `false` for `watched`) *and* the separate `FilterUserCollectionDisplayItems` Go helper for the watched dimension. Neither is the SQL executor. Routing through the executor folds both metadata and personalized filtering into one SQL path; `FilterUserCollectionDisplayItems` is deleted and `filterCatalogItems` is no longer used for these collections.
Recommended execution:
1. Load collection item IDs in source order (from `user_personal_collection_items`, `ORDER BY position`).
2. Build a catalog `QueryDefinition` from the normalized display fragment plus any request overlay. Force `Limit = nil` and drop any fragment-level `sort`/`library_ids` so the executor cannot truncate or reorder the match set out from under step 4. (Defense in depth: the fragment is already normalized at persistence per the Storage Model, but the executor call must not reintroduce a limit.)
3. Run the normal catalog executor with `AccessFilter.AllowedContentIDs = itemIDs` so all filtering, including `watched`, uses the catalog query path. (`resolveCandidateItemsWithQuery` already establishes this pattern.)
4. For source-ordered requests, retrieve the *full* matching set (no limit), collect the matching IDs into a set, reapply the original source order, and only then apply request offset/limit. Request pagination is applied after reordering, never inside the executor.
5. For requests that sort via the query, allow the catalog executor to sort normally. **Note:** exact user collections are hardcoded to source order today (`UseSourceOrder: true`); this branch exists for forward-compatibility and is not exercised by current callers. Mark it clearly so it is not mistaken for present behavior.
This preserves exact collection order without duplicating watched logic. Step 4 is a new shape (run the executor for the match-set, then reorder), distinct from the sorted-pagination shape `resolveCandidateItemsWithQuery` uses today — call out the differing limit handling in the implementation.
## Catalog Watched Predicate Work
The existing catalog `watched` rule is already implemented but needs one semantic hardening before it becomes the shared path:
- Extract a reusable played/read predicate for `QueryBuilder`.
- Keep the existing leaf-item checks for progress, completed history, hidden history, and ebook reader progress.
- Add series and season rollup branches so `watched` matches item user-data semantics.
- Add SQL tests that assert series/season predicates are present only when relevant.
- Reuse the same predicate for `last_watched` only if the semantics truly match; otherwise leave `last_watched` as a leaf/history timestamp filter and document the difference. (`last_watched` is a timestamp aggregate built from the `user_last_watched` CTE — different machinery from the completion predicate — so it most likely stays separate.)
**Episode-set parity is the load-bearing requirement, not the rollup branch itself.** The Go rollups (`allEpisodesCompleted` in `user_state.go` and its copy in the collection helper) define "all episodes complete" over the episode set returned by the episode repository (`ListBySeriesIDs` / `ListBySeasonIDs`). If the SQL rollup's notion of "child episodes" differs — specials, unaired, or unavailable episodes included or excluded differently — the catalog `watched` rule will *disagree* with item user-data, recreating the exact drift this design removes, just relocated into SQL. The hardening must therefore reproduce the repository's child-episode set exactly (same joins, same filters), and a test must pin that equivalence (see Testing Strategy). Treat Open Question #4 (all-available vs all-aired) as a question about *what that repository set already is*, and match it.
**Row-type branching is mandatory for unscoped queries, including ebooks.** The current predicate selects the ebook source (`ebook_reader_progress`) *only* when `mediaScope == "ebook"`; otherwise it uses video watch progress/history (`userStateCompletionClause` branches on `qb.mediaScope == "ebook"`). Exact collection membership is not type-restricted, so an exact collection can contain an ebook while the query runs unscoped (the `all` media control emits no `type` rule). Under today's code that ebook would be evaluated against video progress/history — i.e. classified as never-watched regardless of read state — reintroducing exactly the kind of drift this design removes. The hardened predicate must therefore branch by the row's own type when the query is unscoped:
- movie / episode / video leaf -> watch progress + completed history,
- series / season -> episode rollup (with repository-parity episode sets, above),
- ebook -> `ebook_reader_progress >= EbookFinishedProgressThreshold`,
- manga -> deferred (see Non-Goals / Canonical Semantics).
If row-type branching cannot land in the first cut, v1 must instead *enforce* a video-only scope at execution for exact collections (so ebooks are never silently classified by the video source) and say so explicitly — but row-type branching is the correct end state because it is what makes the canonical semantics table hold under unscoped queries.
The first implementation may still gate series/season rollup behavior by `mediaScope == "series"` and exact known season rows, but it must not assume a single item kind for unscoped/video queries that can return movies, series, and ebooks together.
## API Shape
Preferred API model:
- Add `display_query_definition?: QueryDefinition` to personal collection create/update/response types.
- Replace the branch's `watch_filter` / `media_filter` request and response fields with `display_query_definition`. Since the enums are unreleased (see Current Branch State), no aliasing or deprecation header flow is required.
- Keep collection capabilities additive:
- expose supported display-query fields using the existing catalog field vocabulary,
- optionally expose UI presets for "all/watched/unwatched" and "all/movies/series" as client convenience.
Do not add a separate `filter_watched` query parameter to catalog endpoints. Catalog already has the watched rule.
## UI Model
The web UI can keep simple controls:
- media segmented control: All / Movies / Series,
- watched segmented control: All / Unwatched / Watched.
Those controls should edit a `QueryDefinition` fragment using shared helpers, not a separate collection-only state model.
Recommended helper location:
- `web/src/lib/collectionDisplayFilters.ts` should convert between UI presets and catalog `QueryDefinition`.
- `web/src/components/collections/CollectionGuidedRulesEditor.tsx` remains the broader catalog-query editor.
- `web/src/components/collections/CollectionBuilder.tsx` and `web/src/pages/ImportedCollectionEditor.tsx` use the shared helper.
## Migration Strategy
The legacy `watch_filter` / `media_filter` enums exist only on this unmerged branch and have shipped to no client (see Current Branch State), so the clean-replacement path applies. There is no backfill, dual-read, or deprecation window:
- Reshape the existing migration `20260623184858_user_collection_display_filters.sql` rather than layering a second migration on top: drop the `watch_filter` / `media_filter` columns and their CHECK constraints, and add the canonical `display_query_definition jsonb` column (nullable; `NULL` / absent means "no display filter"). Because the branch is unmerged, editing the not-yet-applied migration in place is cleaner than a forward-then-backfill sequence.
- Update API and web types to use display-query-backed helpers; delete the legacy enum request/response fields.
- No server code reads or writes the legacy columns after this change.
A backfill/dual-read migration would only be warranted if a build ships the legacy enums to clients before this lands. If that happens, fall back to: add `display_query_definition`, backfill (`watch_filter = 'watched'` -> watched rule true; `'unwatched'` -> false; `media_filter = 'movie'`/`'series'` -> `type` rule; `all` omits the rule), read legacy as fallback, stop writing legacy, and remove later under the API deprecation flow. Treat this as a contingency, not the plan of record.
## Risks
- **Merge OR-leak (smart/live):** appending a display group to a `match: any` source query widens results past the collection source instead of constraining them. Mitigate with the executor-level conjunction (Execution Model) and a test that a `match: any` source plus a display filter never returns rows the source alone would exclude.
- **Display fragment truncation:** a persisted `limit` (or stray `sort`) on `display_query_definition` would cap or reorder the exact-collection match set before the source-order reorder, hiding items and corrupting totals. Mitigate by normalizing the fragment to filter-only on write and forcing `Limit = nil` on the match-set execution.
- **Ebook misclassification under unscoped queries:** an ebook in a mixed-media exact collection evaluated by an unscoped `watched` rule is classified by the video source today (`media_scope`-gated ebook predicate). Mitigate with row-type branching in the predicate, or enforce a video-only execution scope for v1 (Catalog Watched Predicate Work).
- **Episode-set divergence (highest risk):** if the SQL rollup's child-episode set differs from the repository's `ListBySeriesIDs` / `ListBySeasonIDs` listing, the catalog `watched` rule will disagree with item user-data and reintroduce drift in a new place. Mitigate by reproducing the repository's joins/filters exactly and pinning the equivalence with a test. This, not raw SQL cost, is the thing most likely to ship subtly wrong.
- **Series/season SQL cost:** all-episodes-complete checks are more expensive than leaf checks. Mitigate by only emitting rollup branches when item types can include series/seasons and by relying on indexes on episode parent IDs and progress/history media IDs.
- **Semantics change for catalog series filters:** extending catalog `watched` to match displayed played state can change existing catalog results for series (today they are effectively never "watched"). This is likely correct, but it must be called out in release notes or PR text.
- **Exact collection performance:** source-order rendering requires evaluating the full exact collection before pagination (the executor must return the complete match-set to reorder). This matches current behavior but should not regress further; very large exact collections remain out of scope this pass (see Non-Goals).
- **Field overloading (resolved → dedicated column):** code inspection confirms `query_definition` already carries `library_ids` for live/smart collections and recently needed a `library_ids` sanitization fix. Reusing it for display filters would entangle two concerns; use a dedicated `display_query_definition` column.
## Testing Strategy
Backend tests are justified because this changes shared personalized filtering semantics:
- **Equivalence test (highest value):** for the same series/season/profile fixtures, assert the catalog `watched` rule and `user_state.go`'s `Played` flag agree. This is the "one semantic" goal made executable and is the test most likely to catch episode-set divergence; it should fail loudly if the two definitions drift.
- `internal/catalog/query_builder_test.go`: `watched` emits leaf completion checks and series/season rollup checks as appropriate.
- `internal/catalog/query_executor_test.go` or focused resolver tests: exact collection filtering routes through catalog `watched` and preserves source order; the executor returns the full match-set so reordering is stable across pages.
- `internal/catalog/catalog_resolver_test.go`: contradictory display/source filters return no rows; a `type is series` display filter on a movies-only smart collection yields no rows without any scope-intersection code.
- **Merge safety:** a smart/live source with top-level `match: any` plus a display filter must never return rows the source alone excludes (guards against the OR-leak); a contradictory `watched` overlay on a `match: any` source returns zero rows.
- **Fragment normalization:** a `display_query_definition` carrying `limit`/`sort`/`library_ids` is rejected on write (or stripped on read); an exact collection larger than any plausible page returns the full match set in source order regardless of fragment contents.
- **Ebook under unscoped watched:** a mixed-media exact collection (movie + ebook) with an `unwatched`/`watched` filter classifies the ebook via `ebook_reader_progress`, not video history (guards against the misclassification finding).
- `internal/api/handlers/collections_test.go`: create/update round-trips `display_query_definition`; the removed `watch_filter` / `media_filter` fields are gone from request and response.
- Migration test only if the contingency backfill path is used (it is not, on the plan of record).
Frontend tests should stay targeted:
- helper tests for converting simple UI controls to/from `QueryDefinition`,
- one editor test proving the submit payload carries the canonical display query.
## Implementation Outline
1. Harden catalog `watched` semantics (series/season rollup with repository-parity episode sets) and land the equivalence test. This is independently valuable and reviewable; consider shipping it as its own PR first.
2. Reshape migration `20260623184858` to drop `watch_filter` / `media_filter` and add `display_query_definition jsonb`.
3. Add display-query types and normalization helpers in backend collection handling.
4. Replace both the custom `FilterUserCollectionDisplayItems` pass and the in-Go `filterCatalogItems` source-order path with catalog-query execution constrained by exact item IDs (match-set, then reorder).
5. Update web controls to emit catalog query fragments through shared helpers; delete the legacy enum fields.
6. Run focused Go tests for catalog/query/user-collection paths and targeted frontend helper tests.
Per the v1 process, link the capability sub-issue (`Part of #NNN`) and keep PRs to one concern — predicate-hardening (step 1) is a natural standalone PR separate from the storage/UI reshape (steps 2–5).
## Open Questions
- **Resolved.** The legacy enums exist only on this unmerged, unreleased branch, so it replaces them cleanly with no compatibility shim (see Current Branch State and Migration Strategy).
- **Decided:** the standardized *controls* expose video item scopes (movie/series/episode) for v1; manga read-state filtering is deferred until catalog manga filtering is finalized, then adopts the same catalog read-state source rather than a collection-only interpretation. Note this constrains the *control vocabulary*, not collection *membership* — exact collections can still contain ebooks, so the watched predicate must branch by row type (see Catalog Watched Predicate Work) regardless of what the control offers.
- **Decided:** first version exposes only the `type` and `watched` controls, stored as a **filter-only `QueryDefinition` fragment** (`match` + `groups`; no `limit`/`sort`/`library_ids`/`media_scope`). Keeping the fragment shape lets a richer rule set turn on later without a storage change, while normalization prevents the fragment from carrying execution-controlling fields (see Storage Model).
- Should catalog `watched` rollup for series mean "all available episodes complete" or "all aired episodes complete"? This is a question about *what the episode repository already returns* — match that set exactly (see Catalog Watched Predicate Work / episode-set parity). Only change the definition by changing the repository path and item user-data together, never the catalog rule alone.
- **New:** confirm no `silo-android` / `silo-apple` build has already consumed the branch's `watch_filter` / `media_filter` fields. The design assumes not (the branch is unreleased); if a client has pinned them, the contingency backfill path applies instead.
@@ -0,0 +1,220 @@
# Download limits & restrictions (downloads v2) — design
**Goal:** Extend downloads v2 with the restriction knobs admins actually need: (1) a **quality
ceiling** — server-wide and per-user — so a user can be allowed to download, even transcode, but
only up to a capped bitrate; (2) a **batch size cap** protecting the server from a single series
request fanning out into hundreds of rows and transcode jobs; (3) **per-user overrides** for the
existing global quantity and bandwidth limits, following the established `users.max_streams` /
`users.max_transcodes` pattern, so households can tier users without changing the global policy.
**Status:** Draft — design input for a v1 capability proposal (Silo v1 scope gate,
`docs/architecture/v1-scope.md`). All API changes are additive within `/api/v1`.
Decisions locked with the requester:
- **No device/user storage caps.** Device disk is the client's job (per-subscription
`max_storage_bytes` already covers the monitoring case); server disk is already covered by
`download.artifact_max_bytes`; serving volume is already covered by bandwidth + per-period quotas.
- **No expiry / lease / revocation of already-downloaded files.** Unchanged from the downloads v2
design (`docs/superpowers/specs/2026-06-18-offline-sync-mobile-design.md`).
- **Transcode restriction already exists** (`download.transcode_enabled` +
`users.download_transcode_allowed`) and is kept as-is; the quality ceiling composes with it
("transcode allowed, but only up to 5mbps").
- **Admin observability (downloads dashboard, admin list/revoke endpoints) is a separate effort**,
not part of this spec.
> Commands and paths in this document are repository-relative; assume the repository root is the cwd.
## Current state (what this builds on)
Enforcement today lives in `internal/downloads`:
- **Policy** — `policy.go`: `DownloadQualityResolver.Resolve` validates a requested quality from the
ladder `original > 20mbps > 10mbps > 5mbps > 2mbps > 1mbps` and gates bitrate presets on
`cfg.TranscodeEnabled && user.DownloadTranscodeAllowed`. `PresetsFor` advertises the fulfillable
ladder through `GET /api/v1/downloads/capability` (`quality_presets`), so clients render only what
the server will honor.
- **Quantity** — `limiter.go`: `QuantityLimiter.Check(ctx, userID, batchSize)` enforces the global
`download.max_concurrent_per_user` and `download.max_per_period` / `download.period_duration`
settings (0 = unlimited) against `CountActiveByUser` / `CountByUserSince`.
- **Bandwidth** — `bandwidth.go`: `BandwidthManager` token-buckets file serving with one server-wide
rate and one per-user rate (`download.server_bandwidth_mbps`, `download.user_bandwidth_mbps`),
caching one `rate.Limiter` per user id.
- **Per-user flags** — `users.download_allowed`, `users.download_transcode_allowed`
(`internal/auth/repository.go`), exposed on the admin users API (`internal/api/handlers/admin.go`)
and web UI (`web/src/pages/AdminUserDetail.tsx`).
- **Bulk** — `service.go` `createSeriesScoped` fans a series/season request into managed entries with
no upper bound; `resolveBulkQuality` intentionally keeps batches **original-only**
(`ErrBulkQualityUnavailable`). Subscription sync auto-registers in-scope episodes without
consuming the quantity limiter (by design).
- **Serve-time re-check** — `Service.ServeFile` re-resolves policy on every file request, so
disabling downloads or revoking a user flag takes effect immediately for anything not yet pulled.
Settings are freeform keys with defaults and validation in `internal/config/db_loader.go`, hot-
reloaded by `Service.loadConfig` (which also reloads the limiter and bandwidth manager on change),
and edited in `web/src/pages/admin-settings/DownloadSettings.tsx`.
## 1. Quality ceiling
### Semantics
- New server setting **`download.max_quality`** (string; `""` = no cap; otherwise one of the quality
ladder values). New nullable per-user column **`users.download_max_quality`** (`NULL` = inherit the
server setting; otherwise one of the ladder values).
- The **effective ceiling** for a user is the *lower* of the server setting and the user column on
the ordered ladder (`original` is the top). A ceiling below `original` deliberately forbids
`original` — that is the point: "this user gets at most a 5mbps transcode, never the full file."
- Enforcement is **presets-first**: `PresetsFor` truncates the advertised ladder at the effective
ceiling, so compliant clients never offer a forbidden quality and **no client changes are
required** (clients already read `quality_presets` from the capability endpoint).
- `Resolve` independently rejects any request above the ceiling with a new sentinel
`ErrQualityNotPermitted` (handler maps it alongside the existing limit errors as HTTP 403,
distinct from `ErrInvalidQuality`), so a non-compliant client cannot bypass the cap.
- The **original-compatibility fallback** inside `Resolve` (original → remux/transcode when the
device can't play the source) must clamp its chosen `EffectiveQuality`/bitrate to the ceiling.
In practice a sub-original ceiling makes the fallback moot — `original` is already rejected — but
the clamp keeps the invariant local to `Resolve` rather than depending on call-site ordering.
- **Serve-time**: the existing `ServeFile` policy re-check additionally denies rows whose
`effective_quality` exceeds the user's *current* ceiling. Consistent with today's revocation
semantics: lowering a cap stops future serving of higher-quality registered entries; files already
on-device are untouched (no expiry).
### Interactions (documented behavior, not bugs)
- **Ceiling below `original` + `download_transcode_allowed = false`** → the fulfillable preset list
is empty and the capability endpoint reports no presets; the user effectively cannot download.
This is legal admin configuration. The admin UI shows a hint on the per-user field when this
combination is selected ("a sub-original cap requires the transcode permission").
- **Series/season/monitoring downloads are original-only today** (`resolveBulkQuality`). A user
capped below `original` therefore cannot create series/season batches or receive subscription
auto-registrations; those paths return `ErrQualityNotPermitted` (create) or skip registration with
a `SkippedDownload` reason `quality_not_permitted` (subscription sync). This is accepted for now
and called out here so it is not rediscovered as a bug. Lifting it means bulk transcode — a
deliberate follow-up, not smuggled into this change.
### Implementation
- `Resolve`/`PresetsFor` gain the ceiling input. Rather than growing the parameter lists further,
introduce a small `PolicyInput`/`userPolicy` struct (user + cfg + artifactsAvailable) if the
signatures get unwieldy — implementer's choice; behavior is what is specified above.
- Ladder comparison helper in `internal/downloads/model.go` (e.g. `QualityAtMost(q, ceiling) bool`
using the `QualityPresets` order) — no string parsing of bitrates.
- Validation: `db_loader.go` rejects a `download.max_quality` value not on the ladder; the users
API rejects invalid `download_max_quality` the same way (shared validation via
`downloads.ValidQuality`, with `""`/`NULL` allowed).
## 2. Batch size cap
- New server setting **`download.max_batch_size`** (int; default **100**; 0 = unlimited): the
maximum number of managed entries one explicit series/season create may register, and the maximum
auto-registered per subscription-sync pass.
- **Explicit series/season create** (`createSeriesScoped`): if the in-scope episode count (after
access filtering and skip computation) exceeds the cap, **reject** the whole request with a new
sentinel `ErrBatchTooLarge` (HTTP 400, error body includes the cap so clients can message "try a
season at a time or subscribe"). Rejection is chosen over truncation because a silently partial
one-shot batch never self-heals — there is no later pass that picks up the remainder.
- **Subscription sync** (`subscription_service.go`): **truncate** each pass at the cap (stable
episode order). Truncation is correct here because sync recomputes in-scope-but-unregistered
episodes every pass, so the remainder registers on subsequent syncs — natural pagination, and one
monster series cannot monopolize a sync pass or the artifact queue.
- The capability response additively gains **`max_batch_size`** (int, 0 = unlimited) so clients can
preflight ("this series has 340 episodes; the server accepts 100 per request") instead of
discovering the limit by error.
## 3. Per-user quantity & bandwidth overrides
- Three new nullable columns on `users`, mirroring the `max_streams` pattern
(`internal/models/user.go`, `internal/auth/repository.go`):
- `download_max_concurrent int` — overrides `download.max_concurrent_per_user`
- `download_max_per_period int` — overrides `download.max_per_period` (window stays the global
`download.period_duration`; a per-user window is deliberately out of scope)
- `download_bandwidth_mbps int` — overrides `download.user_bandwidth_mbps`
- Semantics: `NULL` = inherit the global setting; **0 = explicitly unlimited** (matches the global
settings' zero-means-unlimited convention, and is the reason `NULL` and `0` must stay distinct).
- **QuantityLimiter**: `Check` gains the caller-resolved user overrides — e.g.
`Check(ctx, userID, batchSize, overrides Overrides)` where `Overrides` carries the two optional
ints. Every existing call site already has the `*models.User` loaded (the create paths load it
for policy), so resolution stays in `service.go` and the limiter remains storage-free.
- **BandwidthManager**: the per-user limiter cache currently assumes one global rate. Store the
rate alongside each cached limiter and recreate the entry when the effective rate for that user
changes (`ThrottledReader` gains the resolved per-user bps from the caller; `ServeFile` already
loads the user for its policy re-check). `Reload` keeps its existing clear-all behavior.
- **Admin API**: the three fields (plus `download_max_quality` from §1) are added to the admin user
create/update/response DTOs in `internal/api/handlers/admin.go` and to
`UpdateUserInput`/`CreateUserInput` in `internal/models/user.go` +
`internal/auth/repository.go` — the exact `max_streams` additive pattern.
## Data model (Goose migration)
One timestamped migration (`make migrate-create NAME=user_download_limits`):
```sql
ALTER TABLE users
ADD COLUMN download_max_quality text,
ADD COLUMN download_max_concurrent integer,
ADD COLUMN download_max_per_period integer,
ADD COLUMN download_bandwidth_mbps integer;
```
All nullable, no defaults, no backfill — `NULL` means "inherit global" for every column. The two new
settings keys (`download.max_quality`, `download.max_batch_size`) are settings rows, not schema.
## API surface (all additive)
- `GET /api/v1/downloads/capability` — `quality_presets` now reflects the effective ceiling
(existing field, narrowed values); new field `max_batch_size`.
- `POST /api/v1/downloads` — new 403 error case (`quality_not_permitted`) and new 400 error case
(`batch_too_large`, body includes the configured cap).
- `POST /api/v1/downloads/subscriptions/sync` — skipped registrations may carry the new
`SkippedDownload` reason `quality_not_permitted`.
- Admin users endpoints — four new optional request fields / response fields (§1, §3).
- Settings endpoints — two new keys; both hot-reloadable (no restart-required entry in
`internal/config/restart_keys.go`).
## Web admin UI
- `web/src/pages/admin-settings/DownloadSettings.tsx`: "Max Download Quality" (select over the
ladder + "No limit") under Quantity Limits or a new Restrictions group; "Max Batch Size" numeric
field with the 0-=-unlimited hint.
- `web/src/pages/AdminUserDetail.tsx` (+ the user edit dialog): rows for the four per-user fields,
each showing the inherited global value when unset (as the streams/transcodes fields do), plus the
§1 hint when a sub-original cap is combined with transcode-not-allowed.
## Enforcement summary
| Restriction | Hard/soft | Enforced at |
|---|---|---|
| Quality ceiling | Hard | `Resolve` (create), `PresetsFor` (capability), `ServeFile` re-check |
| Batch size cap | Hard | `createSeriesScoped` (reject), subscription sync (truncate per pass) |
| Concurrent / per-period overrides | Hard | `QuantityLimiter.Check` on every create path |
| Bandwidth override | Hard | `ThrottledReader` on every byte served |
Nothing in this spec is client-enforced; the client-pull architecture is unchanged.
## Client follow-up (silo-android, silo-apple)
- **Quality ceiling / presets:** zero work if the client renders `quality_presets` from the
capability endpoint (the downloads v2 contract). Audit for hardcoded ladders.
- **Batch errors:** handle `batch_too_large` with a useful prompt (download per season / subscribe
instead of a raw error toast). Handle empty `quality_presets` as "downloads unavailable for this
account", which the capability contract already implies.
- No manifest or sync-protocol changes.
## Testing
- `policy_test.go`: ceiling truncation of `PresetsFor` (server-only, user-only, both, ties at
`original`); `Resolve` rejection above the ceiling; fallback clamp; the
cap-without-transcode-permission → empty presets case.
- `limiter_test.go` (new cases): override precedence NULL/0/positive vs. global for both limits.
- `service_*_test.go`: batch reject at cap boundary (cap, cap+1); subscription sync truncation and
next-pass continuation; serve-time denial after a cap is lowered.
- Handler tests for the new error mappings and admin-user field round-trips — run in a
libvips-capable container (bare-host `go test ./...` skips `internal/api/handlers`).
## Out of scope / follow-ups
- Admin downloads dashboard + admin list/revoke endpoints (observability; separate spec).
- Bulk (series/season) transcode quality — prerequisite for capped users to use series downloads
and monitoring; lifts the `resolveBulkQuality` original-only rule.
- Profile-level download permission (parental-controls integration; downloads are already keyed by
`profile_id`, so the data model is ready).
- Per-user managed-device count cap — revisit only if storage/quantity limits prove insufficient.
+16 -13
View File
@@ -1,6 +1,6 @@
module github.com/Silo-Server/silo-server
go 1.26.3
go 1.26.4
require (
github.com/aws/aws-sdk-go-v2 v1.41.5
@@ -14,12 +14,13 @@ require (
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2 // indirect
github.com/redis/go-redis/v9 v9.18.0
golang.org/x/crypto v0.50.0
golang.org/x/sys v0.43.0
golang.org/x/crypto v0.52.0
golang.org/x/sys v0.45.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/abadojack/whatlanggo v1.0.1
github.com/go-chi/cors v1.2.2
github.com/gorilla/websocket v1.5.3
@@ -30,9 +31,12 @@ require (
github.com/oklog/ulid/v2 v2.1.0
github.com/pgvector/pgvector-go v0.3.0
github.com/pressly/goose/v3 v3.27.1
github.com/tetratelabs/wazero v1.12.0
github.com/wneessen/go-mail v0.7.3
github.com/zishang520/socket.io/v2 v2.5.0
go.n16f.net/thumbhash v1.1.0
golang.org/x/image v0.39.0
golang.org/x/image v0.41.0
golang.org/x/net v0.55.0
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
@@ -41,13 +45,13 @@ require (
github.com/PuerkitoBio/goquery v1.8.0 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/andybalholm/cascadia v1.3.1 // indirect
github.com/dunglas/httpsfv v1.1.0 // indirect
github.com/fatih/color v1.13.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/gookit/color v1.5.4 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
@@ -55,8 +59,9 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.53.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.60.0 // indirect
github.com/quic-go/webtransport-go v0.10.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
@@ -68,15 +73,11 @@ require (
github.com/zishang520/socket.io-go-parser/v2 v2.5.0 // indirect
github.com/zishang520/webtransport-go v0.9.1 // indirect
go.opentelemetry.io/otel/sdk/metric v1.41.0 // indirect
go.uber.org/mock v0.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/tools v0.43.0 // indirect
)
require (
github.com/Silo-Server/silo-plugin-sdk v0.5.0
github.com/Silo-Server/silo-plugin-sdk v0.8.1
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
@@ -101,8 +102,10 @@ require (
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sync v0.20.0
golang.org/x/text v0.36.0
golang.org/x/text v0.37.0
golang.org/x/time v0.14.0
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11
)
replace github.com/zishang520/webtransport-go => ./internal/compat/zishang520-webtransport-go
+92 -27
View File
@@ -2,8 +2,10 @@ entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ=
entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
github.com/Silo-Server/silo-plugin-sdk v0.5.0 h1:0yJpD0RP1AetGsWVCXoPih2ggLjN8hVp8blS+2RkIWU=
github.com/Silo-Server/silo-plugin-sdk v0.5.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/Silo-Server/silo-plugin-sdk v0.8.1 h1:6DLm2s/O3N9KTx2d09Nq3bbV8z4u9HJwO2gueQDPyPw=
github.com/Silo-Server/silo-plugin-sdk v0.8.1/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
@@ -44,7 +46,6 @@ github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -52,12 +53,12 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54=
github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk=
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
@@ -70,10 +71,12 @@ github.com/go-pg/pg/v10 v10.11.0 h1:CMKJqLgTrfpE/aOVeLdybezR2om071Vh38OLZjsyMI0=
github.com/go-pg/pg/v10 v10.11.0/go.mod h1:4BpHRoxE61y4Onpof3x1a2SQvi9c+q1dJnrNdMjsroA=
github.com/go-pg/zerochecker v0.2.0 h1:pp7f72c3DobMWOb2ErtZsnrPaSvHd2W4o9//8HtF4mU=
github.com/go-pg/zerochecker v0.2.0/go.mod h1:NJZ4wKL0NmTtz0GKCoJ8kym6Xn/EQzXRl2OnAe7MmDo=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -166,10 +169,14 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.53.0 h1:QHX46sISpG2S03dPeZBgVIZp8dGagIaiu2FiVYvpCZI=
github.com/quic-go/quic-go v0.53.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
github.com/quic-go/webtransport-go v0.10.0 h1:LqXXPOXuETY5Xe8ITdGisBzTYmUOy5eSj+9n4hLTjHI=
github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPrbh73MGoC6jd+V7ow=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -190,6 +197,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
github.com/uptrace/bun v1.1.12 h1:sOjDVHxNTuM6dNGaba0wUuz7KvDE1BmNu9Gqs2gJSXQ=
@@ -206,12 +215,15 @@ github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vb
github.com/vmihailenco/tagparser v0.1.2/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK0=
github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
github.com/zishang520/engine.io-go-parser v1.3.2 h1:aEVrhQVhfk99Ct6htNffgHydUBC4dGclO/OXPz5CSy0=
@@ -222,8 +234,6 @@ github.com/zishang520/socket.io-go-parser/v2 v2.5.0 h1:uGKTwcH2qrrs9uwzfCo3ems+q
github.com/zishang520/socket.io-go-parser/v2 v2.5.0/go.mod h1:GK9GIIs/KQbBKfnxgZJMBYSlsFemB2rL7EFUn0kmTfM=
github.com/zishang520/socket.io/v2 v2.5.0 h1:+KdZLbl4wWVzyI84RG+h21t8OY4ifz/Oo6qwgt9zHl8=
github.com/zishang520/socket.io/v2 v2.5.0/go.mod h1:+GyoPyakXDS6KsW81RAQpDA9+mJBXbcYcQ+Itx2D+rU=
github.com/zishang520/webtransport-go v0.9.1 h1:Y3gqPM8cIDvQILsTyXJ5G9fp2PYqGqLI2z+QXpgboQc=
github.com/zishang520/webtransport-go v0.9.1/go.mod h1:IgNAD6qLe3oWu7MSSkjusRNftpvjYxWjI4LmoH4VEyY=
go.n16f.net/thumbhash v1.1.0 h1:aBEvuAd4yiwzeQ7Sm4BZoHJYbrQ1ewjrmrRlCE79snk=
go.n16f.net/thumbhash v1.1.0/go.mod h1:mo9pP7WtfdV9ojIamGFR/Vc0PaPA2l0CUtmYQf/SweU=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
@@ -242,41 +252,96 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg=
+1
View File
@@ -73,6 +73,7 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro
scope.MaxContentRating = profile.MaxContentRating
scope.MaxPlaybackQuality = MinQuality(scope.MaxPlaybackQuality, NormalizePlaybackQuality(profile.MaxPlaybackQuality))
scope.PreferredMetadataLanguage = profile.PreferredMetadataLanguage
scope.AllowedLibraryIDs, scope.LibrariesRestricted = effectiveLibraries(user.LibraryIDs, profile)
scope.ProfileVerified = profile.PINHash == "" || input.SkipPINVerification
+20 -2
View File
@@ -86,9 +86,15 @@ func (s stubStore) GetProgress(context.Context, string, string) (*userstore.Watc
func (s stubStore) ListProgress(context.Context, string, string, int, int) ([]userstore.WatchProgress, error) {
panic("unused")
}
func (s stubStore) ListProgressFiltered(context.Context, string, string, []string, *int, int, int) ([]userstore.WatchProgress, error) {
panic("unused")
}
func (s stubStore) ListProgressByMediaItems(context.Context, string, []string) (map[string]userstore.WatchProgress, error) {
panic("unused")
}
func (s stubStore) ListProgressSince(context.Context, string, string) ([]userstore.WatchProgress, string, error) {
panic("unused")
}
func (s stubStore) AddHistory(context.Context, userstore.WatchHistoryEntry) error { panic("unused") }
func (s stubStore) AddHistoryIfMissing(context.Context, userstore.WatchHistoryEntry) (bool, error) {
panic("unused")
@@ -99,6 +105,9 @@ func (s stubStore) ListHistory(context.Context, string, int, int) ([]userstore.W
func (s stubStore) ListCompletedHistory(context.Context, userstore.CompletedHistoryQuery) ([]userstore.WatchHistoryEntry, error) {
panic("unused")
}
func (s stubStore) ListCompletedHistoryItems(context.Context, userstore.CompletedHistoryItemQuery) ([]userstore.CompletedHistoryItem, error) {
panic("unused")
}
func (s stubStore) RemoveHistoryItems(context.Context, string, []string, time.Time) error {
panic("unused")
}
@@ -127,9 +136,18 @@ func (s stubStore) ListFavorites(context.Context, string, int, int) ([]userstore
func (s stubStore) ListFavoritesByMediaItems(context.Context, string, []string) (map[string]bool, error) {
panic("unused")
}
func (s stubStore) IsFavorite(context.Context, string, string) (bool, error) { panic("unused") }
func (s stubStore) AddToWatchlist(context.Context, string, string) error { panic("unused") }
func (s stubStore) IsFavorite(context.Context, string, string) (bool, error) { panic("unused") }
func (s stubStore) AddToWatchlist(context.Context, string, string) error { panic("unused") }
func (s stubStore) AddToWatchlistAt(context.Context, string, string, time.Time) error {
panic("unused")
}
func (s stubStore) RemoveWatchedFromWatchlist(context.Context, string) (bool, error) {
return true, nil
}
func (s stubStore) RemoveFromWatchlist(context.Context, string, string) error { panic("unused") }
func (s stubStore) ReplaceWatchlistOrder(context.Context, string, []string) error {
panic("unused")
}
func (s stubStore) ListWatchlist(context.Context, string, int, int) ([]userstore.WatchlistEntry, error) {
panic("unused")
}
+5 -2
View File
@@ -9,8 +9,11 @@ type Scope struct {
LibrariesRestricted bool
MaxContentRating string
MaxPlaybackQuality string
PolicyRevision int64
ProfileVerified bool
// PreferredMetadataLanguage is the profile's metadata (presentation)
// language; "" inherits the library's metadata language.
PreferredMetadataLanguage string
PolicyRevision int64
ProfileVerified bool
}
// ResolveInput is the request input for resolving a viewer access scope.
+6
View File
@@ -89,6 +89,7 @@ func (l *PGLibraryRefreshItemLister) ListLibraryItems(ctx context.Context, libra
query := `
SELECT mi.content_id, COALESCE(mi.tmdb_id, ''), COALESCE(mi.tvdb_id, ''), COALESCE(mi.imdb_id, '')
FROM media_item_libraries mil
JOIN media_folders f ON f.id = mil.media_folder_id
JOIN media_items mi ON mi.content_id = mil.content_id
WHERE mil.media_folder_id = $1`
args := []any{libraryID}
@@ -123,6 +124,11 @@ func (l *PGLibraryRefreshItemLister) ListLibraryItems(ctx context.Context, libra
FROM stale_media_ids smi
WHERE smi.content_id = mi.content_id
)
OR (
COALESCE(mi.default_metadata_language, '') <> ''
AND LOWER(TRIM(mi.default_metadata_language))
<> LOWER(TRIM(COALESCE(NULLIF(f.metadata_language, ''), 'en')))
)
)`
}
@@ -0,0 +1,93 @@
package adminjob
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// TestQuickRefreshListsLanguageMismatchedItems covers the listing half of
// issue #211: quick-mode library refresh must include items whose stamped
// default_metadata_language differs from the library's configured metadata
// language, even when the item is otherwise complete (overview, poster,
// backdrop all present) — otherwise a library language change never revisits
// already-complete items.
func TestQuickRefreshListsLanguageMismatchedItems(t *testing.T) {
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("SILO_TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect test database: %v", err)
}
t.Cleanup(pool.Close)
suffix := time.Now().UnixNano()
mismatchID := fmt.Sprintf("lang-mismatch-%d", suffix)
matchedID := fmt.Sprintf("lang-matched-%d", suffix)
var folderID int
if err := pool.QueryRow(ctx, `
INSERT INTO media_folders (type, name, enabled, metadata_language)
VALUES ('movies', 'Lang Refresh Test', true, 'da')
RETURNING id
`).Scan(&folderID); err != nil {
t.Fatalf("seed folder: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = ANY($1)`, []string{mismatchID, matchedID})
_, _ = pool.Exec(ctx, `DELETE FROM media_folders WHERE id = $1`, folderID)
})
for _, row := range []struct {
id, lang string
}{
{mismatchID, "zh"},
{matchedID, "da"},
} {
if _, err := pool.Exec(ctx, `
INSERT INTO media_items (
content_id, type, title, status, genres, tmdb_id,
default_metadata_language, overview, poster_path, backdrop_path,
last_refreshed, refresh_failures, episode_metadata_incomplete
) VALUES ($1, 'movie', 'Complete Item', 'matched', '{}'::text[], '42',
$2, 'An overview', '/p.jpg', '/b.jpg', NOW(), 0, FALSE)
`, row.id, row.lang); err != nil {
t.Fatalf("seed media item %s: %v", row.id, err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO media_item_libraries (content_id, media_folder_id)
VALUES ($1, $2)
`, row.id, folderID); err != nil {
t.Fatalf("link media item %s: %v", row.id, err)
}
}
lister := NewPGLibraryRefreshItemLister(pool)
items, err := lister.ListLibraryItems(ctx, folderID, LibraryRefreshModeQuick)
if err != nil {
t.Fatalf("ListLibraryItems: %v", err)
}
var sawMismatch, sawMatched bool
for _, item := range items {
switch item.ContentID {
case mismatchID:
sawMismatch = true
case matchedID:
sawMatched = true
}
}
if !sawMismatch {
t.Errorf("quick refresh must include complete item with stamped language differing from the library language")
}
if sawMatched {
t.Errorf("quick refresh must not include complete item whose stamped language matches the library language")
}
}
+197
View File
@@ -0,0 +1,197 @@
// Package jobrunner owns the lifecycle mechanics shared by Silo's AI job
// services (subtitle translation/ASR, metadata translation): bounded dispatch
// off a semaphore shared across services, a heartbeat loop that keeps a row
// alive even while the job is queued, a stale-job reaper for rows orphaned by
// a crashed worker, and a per-job cancel registry. The services keep their own
// job tables, validation, and run logic; this package keeps them honest about
// concurrency and crash recovery without copy-pasting the trickiest code.
package jobrunner
import (
"context"
"log/slog"
"sync"
"time"
)
const (
// A running job refreshes its heartbeat every HeartbeatInterval; one whose
// heartbeat has not advanced for StaleJobThreshold is treated as orphaned
// by a crashed worker and reaped. The margin over HeartbeatInterval avoids
// reaping a job that is merely mid–LLM-call.
HeartbeatInterval = 30 * time.Second
StaleJobThreshold = 2 * time.Minute
// How often the background reaper scans for orphaned jobs.
ReaperInterval = time.Minute
)
// Store is the minimal persistence surface the runner needs. Both AI job
// repositories satisfy it.
type Store interface {
Heartbeat(ctx context.Context, id int64) error
// ResetStaleJobs marks pending/running jobs whose heartbeat predates
// `before` as failed with the given message. Returns rows reset.
ResetStaleJobs(ctx context.Context, before time.Time, message string) (int64, error)
}
// NewSemaphore builds the dispatch semaphore shared across runners, so the
// configured endpoint sees one global bound regardless of job mix. size <= 0
// falls back to 2.
func NewSemaphore(size int) chan struct{} {
if size <= 0 {
size = 2
}
return make(chan struct{}, size)
}
// Runner executes jobs with bounded concurrency, heartbeats, cancellation,
// and crash recovery. One Runner per job table; the semaphore may be shared
// across Runners.
type Runner struct {
// baseCtx is the application context; dispatched jobs and the reaper
// derive from it so they stop when the server shuts down.
baseCtx context.Context
sem chan struct{}
store Store
// label names the job family in log lines ("subtitle ai", "metadata translation").
label string
logger *slog.Logger
mu sync.Mutex
cancels map[int64]context.CancelFunc
wg sync.WaitGroup
}
// New wires a runner. A nil appCtx falls back to context.Background(); a nil
// sem gets a private default-size semaphore (used by tests; production passes
// the shared one).
func New(appCtx context.Context, sem chan struct{}, store Store, label string, logger *slog.Logger) *Runner {
if appCtx == nil {
appCtx = context.Background()
}
if sem == nil {
sem = NewSemaphore(0)
}
if logger == nil {
logger = slog.Default()
}
return &Runner{
baseCtx: appCtx,
sem: sem,
store: store,
label: label,
logger: logger,
cancels: make(map[int64]context.CancelFunc),
}
}
// Recover clears jobs orphaned by a crashed worker and starts a background
// reaper that keeps doing so. Reaping is heartbeat-based (not "every active
// job"), so it is safe when multiple instances share one database: a job
// still being heartbeat-updated by a live worker is never reset. Call once at
// startup; jobs and the reaper derive from the application context, so they
// stop on shutdown.
func (r *Runner) Recover() {
r.reapStaleJobs()
go r.reaperLoop()
}
func (r *Runner) reaperLoop() {
ticker := time.NewTicker(ReaperInterval)
defer ticker.Stop()
for {
select {
case <-r.baseCtx.Done():
return
case <-ticker.C:
r.reapStaleJobs()
}
}
}
func (r *Runner) reapStaleJobs() {
before := time.Now().Add(-StaleJobThreshold)
n, err := r.store.ResetStaleJobs(context.WithoutCancel(r.baseCtx), before, "interrupted by server restart")
if err != nil {
r.logger.Warn("failed to reset stale jobs", "jobs", r.label, "error", err)
return
}
if n > 0 {
r.logger.Info("reset stale jobs", "jobs", r.label, "count", n)
}
}
// Dispatch launches a bounded background goroutine for job id. run executes
// once a semaphore slot is acquired; onAbort runs instead if the job is
// cancelled (user cancel or shutdown) while still waiting for a slot. Both
// receive a context derived from the application context that is cancelled by
// Cancel(id) or server shutdown.
func (r *Runner) Dispatch(id int64, run func(ctx context.Context), onAbort func(ctx context.Context)) {
runCtx, cancel := context.WithCancel(r.baseCtx)
r.mu.Lock()
r.cancels[id] = cancel
r.mu.Unlock()
r.wg.Add(1)
go func() {
defer r.wg.Done()
defer func() {
r.mu.Lock()
delete(r.cancels, id)
r.mu.Unlock()
cancel()
}()
// Heartbeat for the whole lifetime — crucially including while queued
// behind the semaphore — so the stale-job reaper never reaps a job that
// is alive but merely waiting for a slot (which would otherwise mark it
// failed and let it resurrect itself on acquire, or admit a duplicate).
stopHeartbeat := make(chan struct{})
defer close(stopHeartbeat)
go r.heartbeatLoop(runCtx, id, stopHeartbeat)
select {
case r.sem <- struct{}{}:
case <-runCtx.Done():
if onAbort != nil {
onAbort(context.WithoutCancel(runCtx))
}
return
}
defer func() { <-r.sem }()
run(runCtx)
}()
}
// Cancel cancels the in-flight goroutine for id, returning false when none is
// registered (job on another node, or never dispatched) so the caller can
// fall back to a best-effort terminal transition in the database.
func (r *Runner) Cancel(id int64) bool {
r.mu.Lock()
cancel := r.cancels[id]
r.mu.Unlock()
if cancel == nil {
return false
}
cancel()
return true
}
// heartbeatLoop keeps a job's heartbeat_at fresh until the job ends or the
// context is cancelled, so the stale-job reaper only ever reaps jobs orphaned
// by a crashed worker.
func (r *Runner) heartbeatLoop(ctx context.Context, jobID int64, stop <-chan struct{}) {
ticker := time.NewTicker(HeartbeatInterval)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ctx.Done():
return
case <-ticker.C:
_ = r.store.Heartbeat(context.WithoutCancel(ctx), jobID)
}
}
}
+158
View File
@@ -0,0 +1,158 @@
package jobrunner
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
)
type fakeStore struct {
mu sync.Mutex
heartbeats int
resets int
lastBefore time.Time
}
func (s *fakeStore) Heartbeat(context.Context, int64) error {
s.mu.Lock()
defer s.mu.Unlock()
s.heartbeats++
return nil
}
func (s *fakeStore) ResetStaleJobs(_ context.Context, before time.Time, _ string) (int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.resets++
s.lastBefore = before
return 0, nil
}
func (s *fakeStore) snapshot() (int, time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
return s.resets, s.lastBefore
}
func TestRecoverReapsImmediatelyWithStaleCutoff(t *testing.T) {
store := &fakeStore{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
approxNow := time.Now()
New(ctx, nil, store, "test", nil).Recover()
resets, before := store.snapshot()
if resets < 1 {
t.Fatalf("Recover did not reap immediately: resets=%d", resets)
}
want := approxNow.Add(-StaleJobThreshold)
if diff := before.Sub(want); diff > 2*time.Second || diff < -2*time.Second {
t.Errorf("stale cutoff = %v, want ~%v", before, want)
}
}
// One shared semaphore bounds jobs across BOTH runners: with size 1, the
// second runner's job must not start until the first runner's job finishes.
func TestSharedSemaphoreBoundsAcrossRunners(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sem := NewSemaphore(1)
r1 := New(ctx, sem, &fakeStore{}, "one", nil)
r2 := New(ctx, sem, &fakeStore{}, "two", nil)
release := make(chan struct{})
aStarted := make(chan struct{})
var bStarted atomic.Bool
bDone := make(chan struct{})
r1.Dispatch(1, func(context.Context) {
close(aStarted)
<-release
}, nil)
<-aStarted
r2.Dispatch(2, func(context.Context) {
bStarted.Store(true)
close(bDone)
}, nil)
time.Sleep(50 * time.Millisecond)
if bStarted.Load() {
t.Fatal("job B ran while job A held the only slot")
}
close(release)
select {
case <-bDone:
case <-time.After(2 * time.Second):
t.Fatal("job B never ran after the slot freed")
}
}
// Cancelling a job that is queued behind the semaphore aborts it without
// running it, via the onAbort callback.
func TestCancelWhileQueuedCallsOnAbort(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sem := NewSemaphore(1)
r := New(ctx, sem, &fakeStore{}, "test", nil)
release := make(chan struct{})
defer close(release)
started := make(chan struct{})
r.Dispatch(1, func(context.Context) {
close(started)
<-release
}, nil)
<-started
var ran atomic.Bool
aborted := make(chan struct{})
r.Dispatch(2, func(context.Context) {
ran.Store(true)
}, func(context.Context) {
close(aborted)
})
if !r.Cancel(2) {
t.Fatal("Cancel(2) found no in-flight goroutine")
}
select {
case <-aborted:
case <-time.After(2 * time.Second):
t.Fatal("onAbort never ran")
}
if ran.Load() {
t.Fatal("cancelled queued job still ran")
}
}
func TestCancelUnknownJobReturnsFalse(t *testing.T) {
r := New(context.Background(), nil, &fakeStore{}, "test", nil)
if r.Cancel(99) {
t.Fatal("Cancel(99) = true for unknown job")
}
}
// The run context is cancelled by Cancel(id) so an in-flight job can stop.
func TestCancelRunningJobCancelsContext(t *testing.T) {
r := New(context.Background(), NewSemaphore(1), &fakeStore{}, "test", nil)
started := make(chan struct{})
stopped := make(chan struct{})
r.Dispatch(7, func(ctx context.Context) {
close(started)
<-ctx.Done()
close(stopped)
}, nil)
<-started
if !r.Cancel(7) {
t.Fatal("Cancel(7) found no in-flight goroutine")
}
select {
case <-stopped:
case <-time.After(2 * time.Second):
t.Fatal("running job did not observe cancellation")
}
}
+22
View File
@@ -0,0 +1,22 @@
package jobrunner
// Status is the lifecycle state shared by every AI job table.
type Status string
const (
StatusPending Status = "pending"
StatusRunning Status = "running"
StatusCompleted Status = "completed"
StatusFailed Status = "failed"
StatusCancelled Status = "cancelled"
)
// Terminal reports whether a status is final.
func (s Status) Terminal() bool {
switch s {
case StatusCompleted, StatusFailed, StatusCancelled:
return true
default:
return false
}
}
+314
View File
@@ -0,0 +1,314 @@
// Package llm provides the shared OpenAI-compatible API client used by every
// AI feature in Silo (subtitle translation, metadata translation, Whisper ASR).
// One endpoint configuration, one retry/backoff implementation; the operator
// can point it at OpenAI, Groq, a local Ollama/llama.cpp/faster-whisper
// server, etc.
package llm
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"sync/atomic"
"time"
)
// Config holds the connection settings for the shared OpenAI-compatible
// endpoints. The ASR fields are optional overrides for operators who run a
// separate Whisper-compatible server next to their chat endpoint; when empty,
// transcription uses the chat endpoint's base URL and key.
type Config struct {
BaseURL string // e.g. "https://api.openai.com" (no trailing /v1)
APIKey string // empty for keyless local servers
ChatModel string // chat-completions model used for translation
ASRBaseURL string // optional; empty = BaseURL
ASRAPIKey string // optional; empty = APIKey
ASRModel string // audio-transcription model, e.g. "whisper-1"
}
func (c Config) asrBaseURL() string {
if c.ASRBaseURL != "" {
return c.ASRBaseURL
}
return c.BaseURL
}
func (c Config) asrAPIKey() string {
if c.ASRAPIKey != "" {
return c.ASRAPIKey
}
return c.APIKey
}
// ChatConfigured reports whether chat completions are minimally configured.
func (c Config) ChatConfigured() bool { return c.BaseURL != "" && c.ChatModel != "" }
// ASRConfigured reports whether audio transcription is minimally configured.
func (c Config) ASRConfigured() bool { return c.asrBaseURL() != "" && c.ASRModel != "" }
// Client is a minimal OpenAI-compatible API client with shared retry/backoff
// conventions (429 with Retry-After, 5xx, transport errors, and gateway "200
// with embedded error object" responses are retried; other 4xx fail fast).
// The config is held behind an atomic pointer so admin settings changes apply
// to subsequent requests without rebuilding the client; each request snapshots
// the config once so its URL, key, and model stay consistent.
type Client struct {
cfg atomic.Pointer[Config]
// chatHTTP caps a single chat completion at 10 minutes. asrHTTP has no
// client-level timeout: a transcription upload's deadline is set per request
// (sized to the chunk duration), which a client timeout would silently cap.
chatHTTP *http.Client
asrHTTP *http.Client
}
// NewClient builds a client from the endpoint config.
func NewClient(cfg Config) *Client {
c := &Client{
chatHTTP: &http.Client{Timeout: 10 * time.Minute},
asrHTTP: &http.Client{},
}
c.UpdateConfig(cfg)
return c
}
// UpdateConfig swaps the endpoint config. Safe for concurrent use; in-flight
// requests finish with the config they started with.
func (c *Client) UpdateConfig(cfg Config) {
c.cfg.Store(&cfg)
}
// Config returns a snapshot of the current endpoint config.
func (c *Client) Config() Config {
return *c.cfg.Load()
}
// Message is one chat-completions message.
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatResponseFormat struct {
Type string `json:"type"`
}
type chatCompletionRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Temperature float32 `json:"temperature"`
ResponseFormat *chatResponseFormat `json:"response_format,omitempty"`
}
type chatCompletionResponse struct {
Choices []struct {
Message Message `json:"message"`
} `json:"choices"`
// Some OpenAI-compatible gateways (e.g. OpenRouter) return a 200 with an
// error object instead of an HTTP error status when an upstream provider
// fails. We surface and retry on it.
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
// Chat performs one chat completion and returns the first choice's content.
// When jsonObject is true it requests response_format=json_object; providers
// that ignore the field still work because the prompt itself demands JSON.
func (c *Client) Chat(ctx context.Context, messages []Message, jsonObject bool) (string, error) {
cfg := c.Config()
reqBody := chatCompletionRequest{
Model: cfg.ChatModel,
Messages: messages,
Temperature: 0.2,
}
if jsonObject {
reqBody.ResponseFormat = &chatResponseFormat{Type: "json_object"}
}
body, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshal chat request: %w", err)
}
url := endpointURL(cfg.BaseURL, "chat/completions")
var content string
err = c.doWithRetry(ctx, c.chatHTTP, "chat API",
func() (*http.Request, error) {
httpReq, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if reqErr != nil {
return nil, fmt.Errorf("create request: %w", reqErr)
}
httpReq.Header.Set("Content-Type", "application/json")
if cfg.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey)
}
return httpReq, nil
},
func(respBody []byte) error {
var parsed chatCompletionResponse
if err := json.Unmarshal(respBody, &parsed); err != nil {
return fmt.Errorf("decode chat response: %w", err)
}
if parsed.Error != nil && parsed.Error.Message != "" {
// 200 with an upstream error object — transient on gateways.
return fmt.Errorf("chat API error: %s", parsed.Error.Message)
}
if len(parsed.Choices) == 0 || parsed.Choices[0].Message.Content == "" {
slog.Warn("AI chat returned no choices, retrying",
"model", cfg.ChatModel, "response_bytes", len(respBody))
return fmt.Errorf("chat API returned no choices")
}
content = parsed.Choices[0].Message.Content
return nil
})
if err != nil {
return "", err
}
return content, nil
}
// SystemUserChat performs one chat completion from a system + user prompt
// pair, requesting a JSON object response. Its signature matches
// aitranslate.ChatFn so a client method reference wires straight in.
func (c *Client) SystemUserChat(ctx context.Context, system, user string) (string, error) {
return c.Chat(ctx, []Message{
{Role: "system", Content: system},
{Role: "user", Content: user},
}, true)
}
// permanentError marks a parse failure that retrying cannot fix (e.g. an
// endpoint that structurally does not support the requested response format).
type permanentError struct{ err error }
func (e *permanentError) Error() string { return e.err.Error() }
func (e *permanentError) Unwrap() error { return e.err }
// doWithRetry runs the shared request/retry loop: build constructs a fresh
// request per attempt, parse consumes a 200 body. Transport errors, read
// errors, 429 (honoring Retry-After), 5xx, and retryable parse errors back
// off and retry; other 4xx and permanentError parse failures return at once.
func (c *Client) doWithRetry(ctx context.Context, httpClient *http.Client, label string,
build func() (*http.Request, error), parse func(body []byte) error) error {
const maxAttempts = 6
var lastErr error
for attempt := 0; attempt < maxAttempts; attempt++ {
httpReq, err := build()
if err != nil {
return err
}
resp, doErr := httpClient.Do(httpReq)
if doErr != nil {
lastErr = fmt.Errorf("%s request failed: %w", label, doErr)
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
return waitErr
}
continue
}
respBody, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
// A truncated/failed read could otherwise be misparsed as a valid
// (empty) response; treat it as a retryable transport error.
lastErr = fmt.Errorf("read %s response: %w", label, readErr)
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
return waitErr
}
continue
}
switch {
case resp.StatusCode == http.StatusTooManyRequests:
wait := rateLimitBackoff(resp, attempt)
slog.Warn("rate limited by AI API, waiting", "api", label, "attempt", attempt+1, "wait", wait)
lastErr = fmt.Errorf("%s returned 429: %s", label, Truncate(string(respBody), 300))
if waitErr := sleepCtx(ctx, wait); waitErr != nil {
return waitErr
}
continue
case resp.StatusCode >= 500:
lastErr = fmt.Errorf("%s returned %d: %s", label, resp.StatusCode, Truncate(string(respBody), 300))
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
return waitErr
}
continue
case resp.StatusCode != http.StatusOK:
// 4xx other than 429: not retryable.
return fmt.Errorf("%s returned %d: %s", label, resp.StatusCode, Truncate(string(respBody), 300))
}
parseErr := parse(respBody)
if parseErr == nil {
return nil
}
var perm *permanentError
if errors.As(parseErr, &perm) {
return perm.err
}
lastErr = parseErr
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
return waitErr
}
}
if lastErr == nil {
lastErr = fmt.Errorf("%s: retries exhausted", label)
}
return lastErr
}
// endpointURL joins a configured base URL with an OpenAI API path,
// tolerating bases that already include the version segment (e.g.
// DeepInfra's https://api.deepinfra.com/v1/openai) alongside bare hosts
// (https://api.openai.com) and prefixed hosts (https://api.groq.com/openai).
func endpointURL(base, path string) string {
base = strings.TrimRight(base, "/")
if strings.Contains(base, "/v1") {
return base + "/" + path
}
return base + "/v1/" + path
}
// Truncate caps a string for inclusion in an error or log line.
func Truncate(s string, maxLen int) string {
if len(s) > maxLen {
return s[:maxLen] + "..."
}
return s
}
// sleepCtx waits for d or until ctx is cancelled.
func sleepCtx(ctx context.Context, d time.Duration) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(d):
return nil
}
}
// rateLimitBackoff returns how long to wait after a 429, honoring Retry-After
// and otherwise backing off exponentially (capped at 60s).
func rateLimitBackoff(resp *http.Response, attempt int) time.Duration {
if ra := resp.Header.Get("Retry-After"); ra != "" {
if secs, err := strconv.Atoi(ra); err == nil && secs > 0 {
return time.Duration(secs) * time.Second
}
}
wait := 10 * time.Second * (1 << attempt)
if wait > 60*time.Second {
wait = 60 * time.Second
}
return wait
}
+350
View File
@@ -0,0 +1,350 @@
package llm
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
func chatConfig(baseURL string) Config {
return Config{BaseURL: baseURL, APIKey: "test-key", ChatModel: "test-model"}
}
const chatOK = `{"choices":[{"message":{"role":"assistant","content":"hello"}}]}`
func TestChatSuccessSendsAuthAndModel(t *testing.T) {
var gotAuth, gotBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
buf := make([]byte, 4096)
n, _ := r.Body.Read(buf)
gotBody = string(buf[:n])
w.Write([]byte(chatOK))
}))
defer srv.Close()
c := NewClient(chatConfig(srv.URL))
out, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, true)
if err != nil {
t.Fatalf("Chat: %v", err)
}
if out != "hello" {
t.Errorf("content = %q, want hello", out)
}
if gotAuth != "Bearer test-key" {
t.Errorf("auth = %q", gotAuth)
}
if !strings.Contains(gotBody, `"model":"test-model"`) || !strings.Contains(gotBody, `"json_object"`) {
t.Errorf("request body missing model/response_format: %s", gotBody)
}
}
func TestChatRetriesOn429HonoringRetryAfter(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if calls.Add(1) == 1 {
w.Header().Set("Retry-After", "1")
w.WriteHeader(http.StatusTooManyRequests)
return
}
w.Write([]byte(chatOK))
}))
defer srv.Close()
start := time.Now()
c := NewClient(chatConfig(srv.URL))
if _, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, false); err != nil {
t.Fatalf("Chat: %v", err)
}
if calls.Load() != 2 {
t.Errorf("calls = %d, want 2", calls.Load())
}
if elapsed := time.Since(start); elapsed < time.Second {
t.Errorf("did not honor Retry-After: elapsed %v", elapsed)
}
}
func TestChatRetriesOn5xxAndEmbeddedErrorAndEmptyChoices(t *testing.T) {
responses := []func(w http.ResponseWriter){
func(w http.ResponseWriter) { w.WriteHeader(http.StatusBadGateway) },
func(w http.ResponseWriter) { w.Write([]byte(`{"error":{"message":"upstream sad"}}`)) },
func(w http.ResponseWriter) { w.Write([]byte(`{"choices":[]}`)) },
func(w http.ResponseWriter) { w.Write([]byte(chatOK)) },
}
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
responses[calls.Add(1)-1](w)
}))
defer srv.Close()
c := NewClient(chatConfig(srv.URL))
out, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, false)
if err != nil {
t.Fatalf("Chat: %v", err)
}
if out != "hello" || calls.Load() != 4 {
t.Errorf("out=%q calls=%d, want hello/4", out, calls.Load())
}
}
func TestChatFailsFastOnNon429ClientError(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
c := NewClient(chatConfig(srv.URL))
if _, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, false); err == nil {
t.Fatal("expected error")
}
if calls.Load() != 1 {
t.Errorf("calls = %d, want 1 (no retry on 401)", calls.Load())
}
}
const verboseJSON = `{"language":"english","text":"hi there","segments":[{"start":0.0,"end":1.5,"text":" hi"},{"start":1.5,"end":3.0,"text":" there"}]}`
func TestTranscribeParsesSegmentsAndMultipart(t *testing.T) {
var gotModel, gotFormat, gotLang, gotAuth, gotVAD string
var gotGranularities []string
var gotFile []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
if err := r.ParseMultipartForm(32 << 20); err != nil {
t.Errorf("parse multipart: %v", err)
}
gotModel = r.FormValue("model")
gotFormat = r.FormValue("response_format")
gotLang = r.FormValue("language")
gotVAD = r.FormValue("vad_filter")
gotGranularities = r.MultipartForm.Value["timestamp_granularities[]"]
f, _, err := r.FormFile("file")
if err == nil {
buf := make([]byte, 64)
n, _ := f.Read(buf)
gotFile = buf[:n]
f.Close()
}
w.Write([]byte(verboseJSON))
}))
defer srv.Close()
cfg := chatConfig(srv.URL)
cfg.ASRModel = "whisper-test"
c := NewClient(cfg)
tr, err := c.Transcribe(context.Background(), TranscribeRequest{
Filename: "chunk.wav", Audio: []byte("RIFFfake"), Language: "ja",
})
if err != nil {
t.Fatalf("Transcribe: %v", err)
}
if gotModel != "whisper-test" || gotFormat != "verbose_json" || gotLang != "ja" {
t.Errorf("fields model=%q format=%q lang=%q", gotModel, gotFormat, gotLang)
}
// Local (non-hosted) endpoint: faster-whisper VAD must be requested or
// segment timestamps stretch wall-to-wall across silence.
if gotVAD != "true" {
t.Errorf("vad_filter = %q, want true for a self-hosted endpoint", gotVAD)
}
if fmt.Sprint(gotGranularities) != "[segment word]" {
t.Errorf("timestamp_granularities[] = %v, want [segment word]", gotGranularities)
}
if gotAuth != "Bearer test-key" {
t.Errorf("auth = %q (should fall back to chat key)", gotAuth)
}
if string(gotFile) != "RIFFfake" {
t.Errorf("file payload = %q", gotFile)
}
if tr.Language != "english" || len(tr.Segments) != 2 || tr.Segments[1].Text != " there" || tr.Segments[1].End != 3.0 {
t.Errorf("unexpected transcription: %+v", tr)
}
}
func TestTranscribeOmitsVADFilterForStrictHostedEndpoints(t *testing.T) {
for url, wantVAD := range map[string]bool{
"https://api.openai.com": false,
"https://myorg.openai.azure.com": false,
"https://api.groq.com/openai": false,
"http://192.168.1.10:8000": true,
"https://whisper.example.com": true,
"https://api.deepinfra.com/v1/openai": true,
} {
got := !hostMatchesAny(url, strictHostedASRHosts)
if got != wantVAD {
t.Errorf("vad_filter for %s = %v, want %v", url, got, wantVAD)
}
}
}
func TestTranscribeParsesPerSegmentWords(t *testing.T) {
// speaches/faster-whisper shape: words nested inside each segment.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"language":"en","text":"hi there","segments":[
{"start":0.0,"end":3.0,"text":" hi there","words":[
{"start":0.2,"end":0.5,"word":" hi"},{"start":0.6,"end":1.0,"word":" there"}]}]}`))
}))
defer srv.Close()
cfg := chatConfig(srv.URL)
cfg.ASRModel = "whisper-test"
tr, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
if err != nil {
t.Fatalf("Transcribe: %v", err)
}
words := tr.Segments[0].Words
if len(words) != 2 || words[1].Text != " there" || words[1].Start != 0.6 || words[1].End != 1.0 {
t.Errorf("segment words = %+v", words)
}
}
func TestTranscribeAttachesTopLevelWordsBySegmentTime(t *testing.T) {
// OpenAI shape: words in a top-level array, segments without words.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"language":"en","text":"hi there friend","segments":[
{"start":0.0,"end":1.5,"text":" hi there"},{"start":1.5,"end":3.0,"text":" friend"}],
"words":[{"start":0.2,"end":0.5,"word":"hi"},{"start":0.6,"end":1.0,"word":"there"},
{"start":1.8,"end":2.2,"word":"friend"}]}`))
}))
defer srv.Close()
cfg := chatConfig(srv.URL)
cfg.ASRModel = "whisper-test"
tr, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
if err != nil {
t.Fatalf("Transcribe: %v", err)
}
if len(tr.Segments[0].Words) != 2 || len(tr.Segments[1].Words) != 1 {
t.Errorf("word distribution = %d/%d, want 2/1", len(tr.Segments[0].Words), len(tr.Segments[1].Words))
}
if tr.Segments[1].Words[0].Text != "friend" {
t.Errorf("segment 1 word = %+v", tr.Segments[1].Words[0])
}
}
func TestTranscribeEmptySegmentsIsNotAnError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"language":"english","text":"","segments":[]}`))
}))
defer srv.Close()
cfg := chatConfig(srv.URL)
cfg.ASRModel = "whisper-test"
tr, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
if err != nil {
t.Fatalf("Transcribe: %v", err)
}
if len(tr.Segments) != 0 {
t.Errorf("segments = %v, want empty", tr.Segments)
}
}
func TestTranscribeMissingSegmentsFieldFailsFast(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.Write([]byte(`{"text":"plain response without segments"}`))
}))
defer srv.Close()
cfg := chatConfig(srv.URL)
cfg.ASRModel = "whisper-test"
_, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
if err == nil || !strings.Contains(err.Error(), "verbose_json") {
t.Fatalf("err = %v, want verbose_json complaint", err)
}
if calls.Load() != 1 {
t.Errorf("calls = %d, want 1 (permanent error must not retry)", calls.Load())
}
}
func TestTranscribeUsesASROverrides(t *testing.T) {
var gotAuth atomic.Value
asrSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth.Store(r.Header.Get("Authorization"))
w.Write([]byte(verboseJSON))
}))
defer asrSrv.Close()
cfg := Config{
BaseURL: "http://chat.invalid", APIKey: "chat-key", ChatModel: "m",
ASRBaseURL: asrSrv.URL, ASRAPIKey: "asr-key", ASRModel: "whisper-test",
}
if _, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")}); err != nil {
t.Fatalf("Transcribe: %v", err)
}
if gotAuth.Load() != "Bearer asr-key" {
t.Errorf("auth = %q, want asr-key", gotAuth.Load())
}
}
func TestTranscribeRequiresConfig(t *testing.T) {
c := NewClient(Config{BaseURL: "http://x", ChatModel: "m"}) // no ASR model
if _, err := c.Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")}); err == nil {
t.Fatal("expected not-configured error")
}
}
func TestTranscribeChatOnlyGatewayGetsConfigHint(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":{"message":"invalid content-type: multipart/form-data","code":400}}`))
}))
defer srv.Close()
cfg := chatConfig(srv.URL)
cfg.ASRModel = "whisper-test"
_, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
if err == nil || !strings.Contains(err.Error(), "Whisper-compatible Transcription base URL") {
t.Fatalf("err = %v, want configuration hint", err)
}
}
func TestIsChatOnlyGateway(t *testing.T) {
cases := map[string]bool{
"https://openrouter.ai/api": true,
"https://openrouter.ai": true,
"openrouter.ai/api/v1": true,
"https://gateway.openrouter.ai": true,
"https://api.groq.com/openai": false,
"https://api.openai.com": false,
"http://localhost:8000": false,
"https://my-openrouter.ai.example.io": false,
"": false,
}
for in, want := range cases {
if got := IsChatOnlyGateway(in); got != want {
t.Errorf("IsChatOnlyGateway(%q) = %v, want %v", in, got, want)
}
}
}
func TestTranscribeRefusesChatOnlyGateway(t *testing.T) {
cfg := Config{BaseURL: "https://openrouter.ai/api", ChatModel: "m", ASRModel: "whisper-1"}
_, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
if err == nil || !strings.Contains(err.Error(), "cannot produce timestamped transcriptions") {
t.Fatalf("err = %v, want chat-only refusal", err)
}
}
func TestEndpointURLToleratesVersionedBases(t *testing.T) {
cases := map[string]string{
"https://api.openai.com": "https://api.openai.com/v1/chat/completions",
"https://api.groq.com/openai": "https://api.groq.com/openai/v1/chat/completions",
"https://api.deepinfra.com/v1/openai": "https://api.deepinfra.com/v1/openai/chat/completions",
"http://localhost:8969": "http://localhost:8969/v1/chat/completions",
"http://localhost:8969/v1": "http://localhost:8969/v1/chat/completions",
"https://api.openai.com/": "https://api.openai.com/v1/chat/completions",
}
for base, want := range cases {
if got := endpointURL(base, "chat/completions"); got != want {
t.Errorf("endpointURL(%q) = %q, want %q", base, got, want)
}
}
}
+287
View File
@@ -0,0 +1,287 @@
package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"net/url"
"strings"
"time"
)
// defaultTranscribeTimeout bounds a single transcription request when the
// caller does not size one to its chunk duration. Generous on purpose: local
// Whisper servers on modest hardware can run well below realtime.
const defaultTranscribeTimeout = 20 * time.Minute
// TranscribeRequest is one audio-transcription call. Audio is held in memory
// so the request can be rebuilt across retries; callers chunk long files
// (a 10-minute 16 kHz mono WAV is ~19 MB).
type TranscribeRequest struct {
Filename string // e.g. "chunk00001.wav"; the extension hints the container
Audio []byte
Language string // optional ISO-639-1 hint; empty lets the model detect
Timeout time.Duration // per-request deadline; 0 uses defaultTranscribeTimeout
}
// TranscriptionSegment is one timed segment of recognized speech, in seconds
// relative to the start of the submitted audio.
type TranscriptionSegment struct {
Start float64
End float64
Text string
// Words carries per-word timings when the endpoint honors
// timestamp_granularities[]=word; empty otherwise. Word-level times let
// cue building split paragraph-length segments and end cues when speech
// actually stops instead of when the next segment begins.
Words []TranscriptionWord
}
// TranscriptionWord is one recognized word with timing, in seconds relative
// to the start of the submitted audio.
type TranscriptionWord struct {
Start float64
End float64
Text string
}
// Transcription is the parsed verbose_json transcription result.
type Transcription struct {
// Language is the detected (or hinted) language as reported by the
// endpoint. OpenAI returns an English language name ("english"); other
// servers return ISO codes. Callers must normalize.
Language string
// Segments may legitimately be empty for speech-free audio (silence,
// music-only chunks).
Segments []TranscriptionSegment
}
type transcriptionWordJSON struct {
Start float64 `json:"start"`
End float64 `json:"end"`
Word string `json:"word"`
}
type transcriptionResponse struct {
Language string `json:"language"`
Text string `json:"text"`
// Segments distinguishes "verbose_json honored, no speech" (empty array)
// from "endpoint ignored verbose_json" (field absent) — the latter cannot
// produce timed cues and must fail rather than silently emit nothing.
Segments *[]struct {
Start float64 `json:"start"`
End float64 `json:"end"`
Text string `json:"text"`
Words []transcriptionWordJSON `json:"words"`
} `json:"segments"`
// Words is where OpenAI puts word timings; faster-whisper servers
// (speaches) nest them inside each segment instead.
Words []transcriptionWordJSON `json:"words"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
// chatOnlyGatewayHosts lists OpenAI-compatible gateways that cannot serve
// Silo's transcription needs: they either lack /v1/audio/transcriptions
// entirely or (OpenRouter) return plain text with no segment timestamps,
// which subtitle cues require. Matched by host suffix.
var chatOnlyGatewayHosts = []string{
"openrouter.ai",
}
// strictHostedASRHosts lists hosted transcription providers that reject
// multipart fields outside the OpenAI spec, so the faster-whisper-only
// vad_filter field must be omitted for them. Nothing is lost: hosted Whisper
// runs voice-activity detection server-side. Self-hosted servers (speaches,
// faster-whisper) need the explicit field — without it segment timestamps
// stretch wall-to-wall across silence. Matched by host suffix.
var strictHostedASRHosts = []string{
"api.openai.com",
"openai.azure.com",
"api.groq.com",
"api.mistral.ai",
}
// IsChatOnlyGateway reports whether baseURL points at a known chat-only
// gateway that cannot produce timestamped transcriptions. Used to validate
// the transcription settings and to disable ASR rather than fail jobs at
// runtime.
func IsChatOnlyGateway(baseURL string) bool {
return hostMatchesAny(baseURL, chatOnlyGatewayHosts)
}
// hostMatchesAny reports whether baseURL's hostname equals or is a subdomain
// of any of the given host suffixes.
func hostMatchesAny(baseURL string, hosts []string) bool {
baseURL = strings.TrimSpace(baseURL)
if baseURL == "" {
return false
}
if !strings.Contains(baseURL, "://") {
baseURL = "https://" + baseURL
}
u, err := url.Parse(baseURL)
if err != nil || u.Host == "" {
return false
}
host := strings.ToLower(u.Hostname())
for _, h := range hosts {
if host == h || strings.HasSuffix(host, "."+h) {
return true
}
}
return false
}
// Transcribe performs one audio transcription against the ASR endpoint
// (falling back to the chat endpoint's base URL/key when no ASR override is
// configured), using the OpenAI-compatible /v1/audio/transcriptions API with
// response_format=verbose_json for segment timestamps.
func (c *Client) Transcribe(ctx context.Context, req TranscribeRequest) (*Transcription, error) {
cfg := c.Config()
if !cfg.ASRConfigured() {
return nil, fmt.Errorf("transcription endpoint is not configured")
}
if IsChatOnlyGateway(cfg.asrBaseURL()) {
return nil, fmt.Errorf("the configured transcription endpoint (%s) cannot produce timestamped transcriptions; "+
"set a Whisper-compatible Transcription base URL under Admin Settings → AI Services", cfg.asrBaseURL())
}
if len(req.Audio) == 0 {
return nil, fmt.Errorf("no audio data to transcribe")
}
timeout := req.Timeout
if timeout <= 0 {
timeout = defaultTranscribeTimeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
url := endpointURL(cfg.asrBaseURL(), "audio/transcriptions")
var result *Transcription
doErr := c.doWithRetry(ctx, c.asrHTTP, "transcription API",
func() (*http.Request, error) {
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
fw, err := w.CreateFormFile("file", req.Filename)
if err != nil {
return nil, fmt.Errorf("create multipart file: %w", err)
}
if _, err := fw.Write(req.Audio); err != nil {
return nil, fmt.Errorf("write multipart audio: %w", err)
}
fields := [][2]string{
{"model", cfg.ASRModel},
{"response_format", "verbose_json"},
{"temperature", "0"},
// Word timings let cue building split paragraph-length
// segments and end cues when speech actually stops; segment
// granularity must be requested alongside or OpenAI omits it.
{"timestamp_granularities[]", "segment"},
{"timestamp_granularities[]", "word"},
}
if req.Language != "" {
fields = append(fields, [2]string{"language", req.Language})
}
if !hostMatchesAny(cfg.asrBaseURL(), strictHostedASRHosts) {
fields = append(fields, [2]string{"vad_filter", "true"})
}
for _, f := range fields {
if err := w.WriteField(f[0], f[1]); err != nil {
return nil, fmt.Errorf("write multipart field %s: %w", f[0], err)
}
}
if err := w.Close(); err != nil {
return nil, fmt.Errorf("finalize multipart body: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(buf.Bytes()))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
httpReq.Header.Set("Content-Type", w.FormDataContentType())
if key := cfg.asrAPIKey(); key != "" {
httpReq.Header.Set("Authorization", "Bearer "+key)
}
return httpReq, nil
},
func(respBody []byte) error {
var parsed transcriptionResponse
if err := json.Unmarshal(respBody, &parsed); err != nil {
return fmt.Errorf("decode transcription response: %w", err)
}
if parsed.Error != nil && parsed.Error.Message != "" {
return fmt.Errorf("transcription API error: %s", parsed.Error.Message)
}
if parsed.Segments == nil {
return &permanentError{err: fmt.Errorf(
"transcription endpoint did not return verbose_json segments (model %q); a verbose_json-capable Whisper endpoint is required", cfg.ASRModel)}
}
out := &Transcription{Language: parsed.Language}
for _, s := range *parsed.Segments {
out.Segments = append(out.Segments, TranscriptionSegment{
Start: s.Start, End: s.End, Text: s.Text, Words: wordsFromJSON(s.Words),
})
}
attachTopLevelWords(out.Segments, wordsFromJSON(parsed.Words))
result = out
return nil
})
if doErr != nil {
return nil, decorateTranscribeError(doErr)
}
return result, nil
}
func wordsFromJSON(words []transcriptionWordJSON) []TranscriptionWord {
out := make([]TranscriptionWord, 0, len(words))
for _, w := range words {
out = append(out, TranscriptionWord{Start: w.Start, End: w.End, Text: w.Word})
}
return out
}
// attachTopLevelWords distributes an OpenAI-style top-level word list onto
// segments by word midpoint, for endpoints that report words separately from
// segments. Segments that already carry their own words are left untouched.
func attachTopLevelWords(segments []TranscriptionSegment, words []TranscriptionWord) {
if len(segments) == 0 || len(words) == 0 {
return
}
for _, s := range segments {
if len(s.Words) > 0 {
return
}
}
si := 0
for _, w := range words {
mid := (w.Start + w.End) / 2
for si < len(segments)-1 && mid >= segments[si].End {
si++
}
segments[si].Words = append(segments[si].Words, w)
}
}
// decorateTranscribeError appends a configuration hint to the errors a
// chat-only gateway produces when it receives a transcription upload (no
// /v1/audio/transcriptions route, or multipart rejected). Operators routinely
// point the shared base URL at a chat-only provider; without the hint the raw
// 400/404 reads like a pipeline bug instead of "set a Whisper endpoint".
func decorateTranscribeError(err error) error {
msg := err.Error()
likelyUnsupported := strings.Contains(msg, "returned 400") ||
strings.Contains(msg, "returned 404") ||
strings.Contains(msg, "returned 405")
if !likelyUnsupported {
return err
}
return fmt.Errorf("%w — the configured endpoint likely does not support audio transcription; "+
"set a Whisper-compatible Transcription base URL (and model) under Admin Settings → AI Services "+
"(e.g. a self-hosted faster-whisper/speaches server, api.groq.com/openai with whisper-large-v3-turbo, or api.openai.com with whisper-1)", err)
}
+52
View File
@@ -0,0 +1,52 @@
package aitranslate
import "strings"
// languageNames maps common ISO 639-1 codes to English names. The model handles
// bare codes acceptably, but full names noticeably improve translation quality,
// so we resolve the common cases and fall back to the raw code otherwise.
var languageNames = map[string]string{
"ar": "Arabic", "bg": "Bulgarian", "bn": "Bengali", "cs": "Czech",
"da": "Danish", "de": "German", "el": "Greek", "en": "English",
"es": "Spanish", "et": "Estonian", "fa": "Persian", "fi": "Finnish",
"fr": "French", "he": "Hebrew", "hi": "Hindi", "hr": "Croatian",
"hu": "Hungarian", "id": "Indonesian", "it": "Italian", "ja": "Japanese",
"ko": "Korean", "lt": "Lithuanian", "lv": "Latvian", "ms": "Malay",
"nl": "Dutch", "no": "Norwegian", "pl": "Polish", "pt": "Portuguese",
"ro": "Romanian", "ru": "Russian", "sk": "Slovak", "sl": "Slovenian",
"sr": "Serbian", "sv": "Swedish", "ta": "Tamil", "th": "Thai",
"tr": "Turkish", "uk": "Ukrainian", "vi": "Vietnamese", "zh": "Chinese",
}
// LanguageCodeFromName maps an English language name back to its ISO 639-1
// code — Whisper endpoints report detected languages as names ("english")
// while local servers report codes. Returns "" when unknown.
func LanguageCodeFromName(name string) string {
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
return ""
}
for code, display := range languageNames {
if strings.ToLower(display) == name {
return code
}
}
return ""
}
// LanguageDisplayName returns a human-readable language name for a code, or
// the trimmed code itself when unknown. An empty code yields an empty string.
func LanguageDisplayName(code string) string {
code = strings.TrimSpace(code)
if code == "" {
return ""
}
base := strings.ToLower(code)
if i := strings.IndexAny(base, "-_"); i >= 0 {
base = base[:i]
}
if name, ok := languageNames[base]; ok {
return name
}
return code
}
+212
View File
@@ -0,0 +1,212 @@
// Package aitranslate implements generic batched text translation over an
// OpenAI-compatible chat model. Callers hand it ordered text segments and a
// domain-specific system prompt; it owns the wire protocol (indexed-JSON
// batches the model must echo back key-for-key), context carryover between
// batches, and retries on malformed model output. Subtitle cues and metadata
// descriptions both translate through this one implementation.
package aitranslate
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
)
// Segment is one unit of translatable text. ID is the caller's identifier and
// is never sent to the model (batches are keyed by position), so any stable
// string works.
type Segment struct {
ID string
Text string
}
// ChatFn performs one chat completion. Wiring this as a function keeps the
// package independent of the client and trivially testable.
type ChatFn func(ctx context.Context, system, user string) (string, error)
// Request describes one translation run.
type Request struct {
Segments []Segment
SystemPrompt string // domain-specific; must demand same-keys JSON output
// TargetName is the human-readable target language name used in the
// per-batch user message.
TargetName string
// EntryNoun names the units in the user message ("cues", "descriptions");
// empty defaults to "entries".
EntryNoun string
// BatchSize is the number of segments per chat request (required, > 0).
BatchSize int
// ContextNeighbors is how many preceding source segments are included,
// untranslated, for continuity across batch boundaries.
ContextNeighbors int
}
// maxFormatRetries is how many times a batch is re-asked after the model
// returns unparsable or incomplete JSON. Transport/API errors are retried
// inside the chat client and are not part of this count.
const maxFormatRetries = 2
// Translate translates req.Segments in order, preserving count and IDs and
// only rewriting Text. onBatch, when non-nil, is called after each batch with
// that batch's translated segments and overall progress (done/total
// segments). Implementations must honor ctx cancellation between batches.
func Translate(ctx context.Context, chat ChatFn, req Request, onBatch func(batch []Segment, done, total int)) ([]Segment, error) {
if chat == nil {
return nil, fmt.Errorf("chat function is nil")
}
if req.BatchSize <= 0 {
return nil, fmt.Errorf("invalid batch size: %d", req.BatchSize)
}
total := len(req.Segments)
if total == 0 {
return nil, fmt.Errorf("no segments to translate")
}
noun := req.EntryNoun
if noun == "" {
noun = "entries"
}
// Preserve IDs by copying input segments and only replacing Text.
out := make([]Segment, total)
copy(out, req.Segments)
for start := 0; start < total; start += req.BatchSize {
if err := ctx.Err(); err != nil {
return nil, err
}
end := min(start+req.BatchSize, total)
contextStart := max(0, start-req.ContextNeighbors)
translated, err := translateBatch(ctx, chat, req, noun, req.Segments[contextStart:start], req.Segments[start:end])
if err != nil {
return nil, fmt.Errorf("translate %s %d-%d: %w", noun, start+1, end, err)
}
for i, text := range translated {
out[start+i].Text = text
}
if onBatch != nil {
onBatch(out[start:end], end, total)
}
}
return out, nil
}
func translateBatch(ctx context.Context, chat ChatFn, req Request, noun string, contextSegs, batch []Segment) ([]string, error) {
texts := make([]string, len(batch))
for i, s := range batch {
texts[i] = s.Text
}
payload, err := buildIndexedJSON(texts)
if err != nil {
return nil, err
}
var user strings.Builder
if len(contextSegs) > 0 {
user.WriteString("Preceding lines for context only — do not translate or include them in your output:\n")
for _, s := range contextSegs {
user.WriteString(strings.ReplaceAll(s.Text, "\n", " "))
user.WriteByte('\n')
}
user.WriteByte('\n')
}
target := req.TargetName
if target == "" {
target = "the target language"
}
fmt.Fprintf(&user, "Translate these %d %s into %s. Respond with only the JSON object:\n%s", len(batch), noun, target, payload)
var lastErr error
for attempt := 0; attempt <= maxFormatRetries; attempt++ {
if err := ctx.Err(); err != nil {
return nil, err
}
content, err := chat(ctx, req.SystemPrompt, user.String())
if err != nil {
return nil, err // transport/API errors are already retried inside the client
}
obj, err := extractJSONObject(content)
if err != nil {
lastErr = err
continue
}
var m map[string]string
if err := json.Unmarshal([]byte(obj), &m); err != nil {
lastErr = fmt.Errorf("decode translation JSON: %w", err)
continue
}
out := make([]string, len(batch))
complete := true
for i := range batch {
v, ok := m[strconv.Itoa(i+1)]
if !ok {
complete = false
break
}
out[i] = v
}
if !complete {
lastErr = fmt.Errorf("model omitted one or more %s", noun)
continue
}
return out, nil
}
return nil, fmt.Errorf("invalid model response after %d attempts: %w", maxFormatRetries+1, lastErr)
}
// buildIndexedJSON renders texts as a JSON object {"1":..., "2":...} keyed by
// 1-based position, escaping each value safely. It is built by hand rather
// than json.Marshal'ing a map so the keys stay in numeric order — that reads
// more naturally for the model than the lexicographic order Go emits for maps
// ("1","10","11",...,"2"). Correctness doesn't depend on order (results are
// mapped back by key), but ordered input gives the model better context.
func buildIndexedJSON(texts []string) (string, error) {
var b strings.Builder
b.WriteByte('{')
for i, text := range texts {
if i > 0 {
b.WriteByte(',')
}
key, err := json.Marshal(strconv.Itoa(i + 1))
if err != nil {
return "", err
}
val, err := json.Marshal(text)
if err != nil {
return "", err
}
b.Write(key)
b.WriteByte(':')
b.Write(val)
}
b.WriteByte('}')
return b.String(), nil
}
// extractJSONObject pulls the first balanced-looking JSON object out of a
// model reply, tolerating ``` code fences and surrounding prose.
func extractJSONObject(s string) (string, error) {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "```") {
s = strings.TrimPrefix(s, "```")
if nl := strings.IndexByte(s, '\n'); nl >= 0 {
s = s[nl+1:]
}
if idx := strings.LastIndex(s, "```"); idx >= 0 {
s = s[:idx]
}
s = strings.TrimSpace(s)
}
start := strings.IndexByte(s, '{')
end := strings.LastIndexByte(s, '}')
if start < 0 || end < 0 || end < start {
return "", fmt.Errorf("no JSON object found in model response")
}
return s[start : end+1], nil
}
+153
View File
@@ -0,0 +1,153 @@
package aitranslate
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"testing"
)
// echoChat translates by upper-casing every value of the indexed JSON payload
// in the user message, mimicking a well-behaved model.
func echoChat(t *testing.T) ChatFn {
t.Helper()
return func(_ context.Context, _ string, user string) (string, error) {
obj, err := extractJSONObject(user)
if err != nil {
return "", fmt.Errorf("test chat: %w", err)
}
var m map[string]string
if err := json.Unmarshal([]byte(obj), &m); err != nil {
return "", fmt.Errorf("test chat decode: %w", err)
}
out := make(map[string]string, len(m))
for k, v := range m {
out[k] = strings.ToUpper(v)
}
b, _ := json.Marshal(out)
return string(b), nil
}
}
func segments(n int) []Segment {
out := make([]Segment, n)
for i := range out {
out[i] = Segment{ID: strconv.Itoa(i + 1), Text: fmt.Sprintf("line %d", i+1)}
}
return out
}
func TestTranslateBatchesAndPreservesIDs(t *testing.T) {
var batches []int
out, err := Translate(context.Background(), echoChat(t), Request{
Segments: segments(7),
BatchSize: 3,
}, func(batch []Segment, done, total int) {
batches = append(batches, len(batch))
if total != 7 {
t.Errorf("total = %d, want 7", total)
}
})
if err != nil {
t.Fatalf("Translate: %v", err)
}
if len(out) != 7 {
t.Fatalf("len(out) = %d", len(out))
}
for i, s := range out {
if s.ID != strconv.Itoa(i+1) {
t.Errorf("ID[%d] = %q, want %d", i, s.ID, i+1)
}
if want := strings.ToUpper(fmt.Sprintf("line %d", i+1)); s.Text != want {
t.Errorf("Text[%d] = %q, want %q", i, s.Text, want)
}
}
if fmt.Sprint(batches) != "[3 3 1]" {
t.Errorf("batch sizes = %v, want [3 3 1]", batches)
}
}
func TestTranslateSendsContextNeighborsUntranslated(t *testing.T) {
var secondBatchMsg string
call := 0
chat := func(ctx context.Context, system, user string) (string, error) {
call++
if call == 2 {
secondBatchMsg = user
}
return echoChat(t)(ctx, system, user)
}
_, err := Translate(context.Background(), chat, Request{
Segments: segments(4),
BatchSize: 2,
ContextNeighbors: 2,
TargetName: "French",
EntryNoun: "cues",
}, nil)
if err != nil {
t.Fatalf("Translate: %v", err)
}
if !strings.Contains(secondBatchMsg, "context only") ||
!strings.Contains(secondBatchMsg, "line 1\nline 2") {
t.Errorf("second batch missing context block:\n%s", secondBatchMsg)
}
if !strings.Contains(secondBatchMsg, "Translate these 2 cues into French.") {
t.Errorf("second batch missing instruction:\n%s", secondBatchMsg)
}
}
func TestTranslateToleratesCodeFences(t *testing.T) {
chat := func(_ context.Context, _, _ string) (string, error) {
return "Sure! Here you go:\n```json\n{\"1\":\"ok\"}\n```", nil
}
out, err := Translate(context.Background(), chat, Request{
Segments: segments(1),
BatchSize: 5,
}, nil)
if err != nil {
t.Fatalf("Translate: %v", err)
}
if out[0].Text != "ok" {
t.Errorf("Text = %q", out[0].Text)
}
}
func TestTranslateRetriesOmittedKeysThenFails(t *testing.T) {
calls := 0
chat := func(_ context.Context, _, _ string) (string, error) {
calls++
return `{"1":"only one"}`, nil // always omits key "2"
}
_, err := Translate(context.Background(), chat, Request{
Segments: segments(2),
BatchSize: 5,
}, nil)
if err == nil || !strings.Contains(err.Error(), "omitted") {
t.Fatalf("err = %v, want omission failure", err)
}
if calls != maxFormatRetries+1 {
t.Errorf("calls = %d, want %d", calls, maxFormatRetries+1)
}
}
func TestTranslateStopsOnCancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
calls := 0
chat := func(_ context.Context, _, _ string) (string, error) {
calls++
cancel() // cancel after the first batch completes
return `{"1":"a"}`, nil
}
_, err := Translate(ctx, chat, Request{
Segments: segments(3),
BatchSize: 1,
}, nil)
if err == nil || !strings.Contains(err.Error(), "context canceled") {
t.Fatalf("err = %v, want context cancellation", err)
}
if calls != 1 {
t.Errorf("calls = %d, want 1", calls)
}
}
+11 -25
View File
@@ -20,12 +20,13 @@ type autoscanQueuer = autoscan.Queuer
// RequestIntegrationLookup adapts the Requests repository to the autoscan
// connection resolver's RequestIntegrationLookup: it resolves a soft-linked
// Requests integration to its base URL and (write-only) api-key ref.
// Requests integration to its base URL and api key (the repo decrypts the key
// on read, so this returns plaintext).
type RequestIntegrationLookup struct {
Repo *mediarequests.Repository
}
func (l RequestIntegrationLookup) Get(ctx context.Context, integrationID string) (baseURL, apiKeyRef string, err error) {
func (l RequestIntegrationLookup) Get(ctx context.Context, integrationID string) (baseURL, apiKey string, err error) {
integration, err := l.Repo.GetIntegration(ctx, integrationID)
if err != nil {
return "", "", err
@@ -64,8 +65,8 @@ type PluginScanSourceAdapter struct {
Svc *plugins.Service
}
func (a PluginScanSourceAdapter) ScanSourceClient(ctx context.Context, installationID int, capabilityID string) (autoscan.PollChangesClient, error) {
return a.Svc.ScanSourceClient(ctx, installationID, capabilityID)
func (a PluginScanSourceAdapter) ScanSourceClient(ctx context.Context, pluginID, capabilityID string) (autoscan.PollChangesClient, error) {
return a.Svc.ScanSourceClientByPluginID(ctx, pluginID, capabilityID)
}
// scanSourceCapabilityType is the plugin capability type autoscan discovery
@@ -75,15 +76,6 @@ const scanSourceCapabilityType = "scan_source.v1"
// PluginScanSourceLister adapts the plugin installation store to
// autoscan.ScanSourceLister: it enumerates every installed scan_source.v1
// capability across ALL installed plugins, regardless of enabled state.
//
// Using List (not ListEnabled) is deliberate for orphan detection: a temporarily
// DISABLED-but-installed plugin must NOT be treated as orphaned. If we dropped
// disabled plugins here, PollOnce would prune their sources and skip them with no
// last_error — they'd vanish silently. Keeping them present means PollOnce still
// attempts them; the plugin client fails to load (not running) and the source
// gets a visible RecordError instead. Only a fully UNINSTALLED plugin (gone from
// List entirely) is a true orphan. The Add-source picker shares this all-installed
// set, which is fine — operators can bind sources against an installed capability.
type PluginScanSourceLister struct {
Store *plugins.InstallationStore
}
@@ -104,10 +96,9 @@ func (l PluginScanSourceLister) ListScanSources(ctx context.Context) ([]autoscan
continue
}
out = append(out, autoscan.DiscoveredSource{
InstallationID: c.InstallationID,
CapabilityID: c.ID,
PluginID: inst.PluginID,
DisplayName: scanSourceDisplayName(inst.PluginID, c),
PluginID: inst.PluginID,
CapabilityID: c.ID,
DisplayName: scanSourceDisplayName(inst.PluginID, c),
})
}
}
@@ -135,27 +126,22 @@ func scanSourceDisplayName(pluginID string, c *plugins.Capability) string {
}
}
// AutoscanSecretResolver resolves an encrypted api-key reference to plaintext.
// It is satisfied by the server settings repo (Get(ctx, key) (string, error)).
type AutoscanSecretResolver interface {
Get(ctx context.Context, ref string) (string, error)
}
// BuildAutoscanService wires the v2 autoscan engine from its concrete
// dependencies. Both the HTTP router (manual trigger) and the background poll
// task share this constructor so the adapter wiring lives in exactly one place.
// Credentials are now decrypted inline by the autoscan/requests repos, so there
// is no separate secret resolver to thread.
func BuildAutoscanService(
repo *autoscan.Repository,
pluginService *plugins.Service,
installationStore *plugins.InstallationStore,
requestsRepo *mediarequests.Repository,
secrets AutoscanSecretResolver,
folderRepo *catalog.FolderRepository,
queue autoscanQueuer,
redisClient *redis.Client,
) *autoscan.Service {
provider := autoscan.NewPluginProvider(PluginScanSourceAdapter{pluginService})
connRes := autoscan.NewConnectionResolver(RequestIntegrationLookup{requestsRepo}, secrets)
connRes := autoscan.NewConnectionResolver(RequestIntegrationLookup{requestsRepo})
svc := autoscan.NewService(
repo,
provider,
+33 -1
View File
@@ -1,6 +1,10 @@
package api
import "testing"
import (
"testing"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
)
// TestCheckRequestIntegrationUsable verifies the Fix 4 gate: a reused Requests
// connection is rejected when the integration is disabled or has no base_url, so
@@ -22,3 +26,31 @@ func TestCheckRequestIntegrationUsable(t *testing.T) {
}
})
}
// TestCheckRequestIntegrationUsableGatesReuse is the Task 17 regression test. It
// locks the autoscan→requests credential-reuse behavior after the
// request_integrations table was generalized into the two-tier connection
// registry (Task 9/10): autoscan must keep reusing the Sonarr/Radarr connection
// rows via the same enabled + base_url gate, regardless of the added
// capability/installation columns.
func TestCheckRequestIntegrationUsableGatesReuse(t *testing.T) {
// Enabled + non-blank base_url -> usable (autoscan can reuse the connection).
if err := checkRequestIntegrationUsable("int-1", true, "http://sonarr:8989"); err != nil {
t.Fatalf("enabled integration with base_url should be usable, got: %v", err)
}
// Disabled -> error (autoscan must not poll a disabled connection).
if err := checkRequestIntegrationUsable("int-1", false, "http://sonarr:8989"); err == nil {
t.Fatal("disabled integration must be rejected")
}
// Blank base_url -> error.
if err := checkRequestIntegrationUsable("int-1", true, " "); err == nil {
t.Fatal("blank base_url must be rejected")
}
}
// Compile-time guard: autoscan's RequestIntegrationLookup reads these fields off
// mediarequests.Integration. If any is renamed/removed, autoscan reuse breaks —
// fail the build here rather than silently at runtime.
var _ = func(in mediarequests.Integration) (string, string, bool) {
return in.BaseURL, in.APIKeyRef, in.Enabled
}
+103
View File
@@ -0,0 +1,103 @@
package api
import (
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/Silo-Server/silo-server/internal/api/handlers"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/ebookconvert"
)
// ebookKindleConversionSettingKey is the admin flag that gates Kindle->EPUB
// conversion. Off by default; toggled via PUT /admin/settings/{key}.
const ebookKindleConversionSettingKey = "ebook.kindle_conversion_enabled"
// buildEbookConversion wires the in-process Kindle->EPUB converter for the read
// handler. It compiles the embedded WASM module once at startup; if that fails
// the feature stays off (returns nil) and the raw original is served as before.
// The admin flag is read with a short TTL (see ebookFlagPredicate), so it can be
// toggled without a restart yet does not hit the DB on every read.
//
// The Converter (a wazero runtime, ~no external resources — per-conversion
// scratch dirs are cleaned as they go) is intentionally owned for the process
// lifetime: it is not Close()d on shutdown because process exit reclaims it and
// there is nothing to flush. Memory is bounded per conversion by
// ebookconvert.DefaultMaxMemoryPages × DefaultConcurrency (see the design doc's
// resource notes); tune via ebookconvert.Options if the deployment is tight.
func buildEbookConversion(deps Dependencies, settings catalog.SettingsStore) *handlers.EbookConversion {
if settings == nil {
return nil
}
converter, err := ebookconvert.NewConverter(context.Background(), ebookconvert.Options{})
if err != nil {
slog.Warn("ebook Kindle->EPUB conversion unavailable: converter init failed", "error", err)
return nil
}
cache, err := ebookconvert.NewCache(converter, ebookconvert.CacheOptions{Dir: ebookConversionCacheDir(deps.CurrentConfig())})
if err != nil {
slog.Warn("ebook Kindle->EPUB conversion unavailable: cache init failed", "error", err)
_ = converter.Close(context.Background())
return nil
}
slog.Info("ebook Kindle->EPUB conversion ready (admin-flag gated)", "setting", ebookKindleConversionSettingKey)
return &handlers.EbookConversion{
Converter: cache,
Enabled: ebookFlagPredicate(settings, ebookKindleConversionSettingKey),
}
}
// ebookFlagPredicate returns a cached boolean reader for a server setting. The
// read path (every ebook read + the capability endpoint, which clients may
// poll) would otherwise issue a DB query per request; a short TTL collapses
// bursts to one query while keeping admin toggles effectively instant. On a read
// error it keeps the last known value rather than flapping the feature off.
func ebookFlagPredicate(settings catalog.SettingsStore, key string) func(context.Context) bool {
const ttl = 3 * time.Second
var (
mu sync.Mutex
value bool
fetched time.Time
hasVal bool
)
return func(ctx context.Context) bool {
mu.Lock()
defer mu.Unlock()
if hasVal && time.Since(fetched) < ttl {
return value
}
v, err := settings.Get(ctx, key)
if err != nil {
return value // keep last known (false until the first successful read)
}
value = isTruthySetting(v)
fetched = time.Now()
hasVal = true
return value
}
}
// ebookConversionCacheDir derives the converted-EPUB cache directory, a sibling
// of the transcode dir so it lives alongside other derived media.
func ebookConversionCacheDir(cfg *config.Config) string {
base := os.TempDir()
if cfg != nil && strings.TrimSpace(cfg.Playback.TranscodeDir) != "" {
base = filepath.Dir(cfg.Playback.TranscodeDir)
}
return filepath.Join(base, "silo-ebook-epub")
}
func isTruthySetting(v string) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "true", "1", "yes", "on", "enabled":
return true
default:
return false
}
}
+39
View File
@@ -0,0 +1,39 @@
package api
import (
"context"
"sync/atomic"
"testing"
)
// stubSettings counts Get calls so we can assert the flag predicate caches.
type stubSettings struct {
value string
gets int64
}
func (s *stubSettings) Get(_ context.Context, _ string) (string, error) {
atomic.AddInt64(&s.gets, 1)
return s.value, nil
}
func (s *stubSettings) Set(context.Context, string, string) error { return nil }
func (s *stubSettings) GetAll(context.Context) (map[string]string, error) {
return map[string]string{}, nil
}
// The flag predicate must not hit the settings store on every call: the read
// path (every ebook read + capability poll) would otherwise issue a DB query
// per request.
func TestEbookFlagPredicate_CachesWithinTTL(t *testing.T) {
s := &stubSettings{value: "true"}
pred := ebookFlagPredicate(s, "ebook.kindle_conversion_enabled")
for i := 0; i < 50; i++ {
if !pred(context.Background()) {
t.Fatalf("call %d: predicate returned false for a truthy flag", i)
}
}
if got := atomic.LoadInt64(&s.gets); got > 2 {
t.Fatalf("settings.Get called %d times across 50 predicate calls; expected it to be cached", got)
}
}
+153 -26
View File
@@ -8,7 +8,9 @@ import (
"fmt"
"io"
"log/slog"
"math"
"net/http"
"net/url"
"slices"
"sort"
"strconv"
@@ -21,14 +23,18 @@ import (
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/adminjob"
"github.com/Silo-Server/silo-server/internal/ai/llm"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/cache"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/clientip"
"github.com/Silo-Server/silo-server/internal/config"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/markers"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/notifications"
subtitleai "github.com/Silo-Server/silo-server/internal/subtitles/ai"
"github.com/Silo-Server/silo-server/internal/userstore"
)
@@ -82,7 +88,9 @@ type AdminHandler struct {
accountProvisioner *auth.AccountProvisioner
DetailSvc *catalog.DetailService
StatsSource AdminStatsSource
Config *config.Config
EventBus cache.EventBus
EventsHub *evt.Hub
SettingsRepo ServerSettingsStore
JobRepo AdminJobCreator
ItemRefreshResolver ItemRefreshScopeResolver
@@ -92,6 +100,8 @@ type AdminHandler struct {
BootstrapSensitiveValues map[string]string
OnUserSessionsRevoked func(ctx context.Context, userID int)
OnServerSettingUpdated func(ctx context.Context, key, value string)
RestartStatus *ServerRestartStatusTracker
CatalogSearchStatus catalog.CatalogSearchStatusProvider
}
// NewAdminHandler creates a new AdminHandler backed by the given
@@ -1137,29 +1147,10 @@ func (h *AdminHandler) HandleUpdateItemMetadata(w http.ResponseWriter, r *http.R
// --- Server Settings endpoints ---
var sensitiveSettingKeys = map[string]bool{
"auth.jwt_secret": true,
"s3.public_access_key": true,
"s3.public_secret_key": true,
"s3.public_token_secret": true,
"s3.private_access_key": true,
"s3.private_secret_key": true,
"s3.user_db_access_key": true,
"s3.user_db_secret_key": true,
"redis.url": true,
"recommendations.openai_api_key": true,
"recommendations.embedding_auth_token": true,
"tmdb.api_key": true,
"introdb.api_key": true,
"mdblist.api_key": true,
"requests.radarr.api_key": true,
"requests.sonarr.api_key": true,
"subtitle_ai.api_key": true,
"watchsync.trakt.client_id": true,
"watchsync.trakt.client_secret": true,
"watchsync.simkl.client_id": true,
"watchsync.simkl.client_secret": true,
}
// sensitiveSettingKeys is the audited allowlist of secret-bearing settings
// keys, shared with the at-rest encryption decorator so redaction and
// encryption can never drift apart. See catalog.SensitiveSettingKeys.
var sensitiveSettingKeys = catalog.SensitiveSettingKeys
// HandleGetSettings handles GET /admin/settings.
func (h *AdminHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request) {
@@ -1234,6 +1225,9 @@ func (h *AdminHandler) HandleGetSensitiveStatus(w http.ResponseWriter, r *http.R
type adminSettingResponse struct {
Key string `json:"key"`
Value string `json:"value"`
// RestartRequired reports whether the saved value only takes effect
// after a server restart (set on update responses only).
RestartRequired bool `json:"restart_required,omitempty"`
}
type adminSettingsListResponse struct {
@@ -2120,6 +2114,136 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
} else {
req.Value = normalized
}
case "ai.asr_base_url":
if llm.IsChatOnlyGateway(req.Value) {
writeError(w, http.StatusBadRequest, "bad_request",
"This endpoint cannot produce timestamped transcriptions (chat-only gateway). "+
"Use a self-hosted Whisper server (faster-whisper/speaches), api.groq.com/openai, or api.openai.com.")
return
}
case "metadata_ai.on_view":
switch req.Value {
case "off", "button", "auto":
default:
writeError(w, http.StatusBadRequest, "bad_request",
"metadata_ai.on_view must be off, button, or auto")
return
}
case "subtitle_ai.transcribe_quota_jobs":
if n, err := strconv.Atoi(req.Value); err != nil || n < 0 {
writeError(w, http.StatusBadRequest, "bad_request",
"subtitle_ai.transcribe_quota_jobs must be an integer >= 0 (0 = unlimited)")
return
}
case "subtitle_ai.transcribe_quota_period":
if !subtitleai.ValidQuotaPeriod(req.Value) {
writeError(w, http.StatusBadRequest, "bad_request",
"subtitle_ai.transcribe_quota_period must be day, week, or month")
return
}
case notifications.SettingApplePushDeliveryEnabled:
enabled, err := strconv.ParseBool(strings.TrimSpace(req.Value))
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "notifications.apple_push_delivery_enabled must be true or false")
return
}
req.Value = strconv.FormatBool(enabled)
case notifications.SettingPushRelayURL, notifications.SettingPushRelayDeploymentID, notifications.SettingPushRelayAPIKey:
// The registration flow persists the relay URL, deployment id, and API
// key together; a direct write to any of them desyncs the stored URL
// from the credentials the relay minted for it (and feeds an arbitrary
// id into the next rotation request).
writeError(w, http.StatusBadRequest, "bad_request",
key+" is managed by the push relay registration flow; use POST /admin/notifications/push/relay/register")
return
case catalog.SearchSettingProvider:
switch strings.TrimSpace(strings.ToLower(req.Value)) {
case catalog.SearchProviderPostgres, catalog.SearchProviderMeilisearch:
req.Value = strings.TrimSpace(strings.ToLower(req.Value))
default:
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.provider must be postgres or meilisearch")
return
}
case catalog.SearchSettingMeilisearchURL:
value := strings.TrimSpace(req.Value)
if value != "" {
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.meilisearch.url must include scheme and host")
return
}
}
req.Value = value
case catalog.SearchSettingMeilisearchIndex:
req.Value = strings.TrimSpace(req.Value)
if req.Value == "" {
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.meilisearch.index is required")
return
}
case catalog.SearchSettingMeilisearchTimeoutMS:
n, err := strconv.Atoi(strings.TrimSpace(req.Value))
if err != nil || n <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.meilisearch.timeout_ms must be an integer greater than 0")
return
}
req.Value = strconv.Itoa(n)
case catalog.SearchSettingMeilisearchMatchingStrategy:
switch strings.TrimSpace(strings.ToLower(req.Value)) {
case "last", "all":
req.Value = strings.TrimSpace(strings.ToLower(req.Value))
default:
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.meilisearch.matching_strategy must be last or all")
return
}
case catalog.SearchSettingMeilisearchSyncBatchSize:
n, err := strconv.Atoi(strings.TrimSpace(req.Value))
if err != nil || n < 1 || n > catalog.MaxMeilisearchSyncBatchSize {
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.meilisearch.sync_batch_size must be an integer between 1 and 10000")
return
}
req.Value = strconv.Itoa(n)
case catalog.SearchSettingMeilisearchRebuildBatchSize:
n, err := strconv.Atoi(strings.TrimSpace(req.Value))
if err != nil || n < 1 || n > catalog.MaxMeilisearchRebuildBatchSize {
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.meilisearch.rebuild_batch_size must be an integer between 1 and 25000")
return
}
req.Value = strconv.Itoa(n)
case catalog.SearchSettingMeilisearchRebuildQueue:
n, err := strconv.Atoi(strings.TrimSpace(req.Value))
if err != nil || n < 1 || n > catalog.MaxMeilisearchRebuildQueueDepth {
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.meilisearch.rebuild_task_queue_depth must be an integer between 1 and 16")
return
}
req.Value = strconv.Itoa(n)
case catalog.SearchSettingMeilisearchIndexTypes:
itemTypes, err := catalog.NormalizeCatalogSearchIndexTypesValue(req.Value)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
req.Value = catalog.FormatCatalogSearchIndexTypesValue(itemTypes)
case catalog.SearchSettingMeilisearchSemanticEnabled:
enabled, err := strconv.ParseBool(strings.TrimSpace(req.Value))
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.meilisearch.semantic_enabled must be true or false")
return
}
req.Value = strconv.FormatBool(enabled)
case catalog.SearchSettingMeilisearchSemanticRatio:
ratio, err := strconv.ParseFloat(strings.TrimSpace(req.Value), 64)
if err != nil || math.IsNaN(ratio) || ratio < 0 || ratio > 1 {
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.meilisearch.semantic_ratio must be a number between 0 and 1")
return
}
req.Value = strconv.FormatFloat(ratio, 'f', -1, 64)
case catalog.SearchSettingMeilisearchEmbedder:
embedder, err := catalog.NormalizeCatalogSearchEmbedderName(req.Value)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
req.Value = embedder
}
if err := h.SettingsRepo.Set(r.Context(), key, req.Value); err != nil {
@@ -2134,10 +2258,13 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
if h.OnServerSettingUpdated != nil {
h.OnServerSettingUpdated(r.Context(), key, req.Value)
}
restartRequired := config.RestartRequired(key)
if restartRequired {
h.markServerRestartRequired("server_settings")
}
if sensitiveSettingKeys[key] {
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key})
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, RestartRequired: restartRequired})
return
}
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value})
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value, RestartRequired: restartRequired})
}
+279
View File
@@ -0,0 +1,279 @@
package handlers
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/notifications"
)
type AdminApplePushHandler struct {
system *notifications.System
settings ServerSettingsStore
client httpDoer
}
type httpDoer interface {
Do(*http.Request) (*http.Response, error)
}
func NewAdminApplePushHandler(system *notifications.System, settings ServerSettingsStore) *AdminApplePushHandler {
return &AdminApplePushHandler{
system: system,
settings: settings,
client: &http.Client{Timeout: 10 * time.Second},
}
}
type adminApplePushTestRequest struct {
ProfileID string `json:"profile_id"`
ServerDeviceID string `json:"server_device_id"`
}
type adminApplePushTestResponse struct {
AttemptID string `json:"attempt_id"`
PushDeviceID string `json:"push_device_id"`
ServerDeviceID string `json:"server_device_id"`
Outcome string `json:"outcome"`
RelayRequestID string `json:"relay_request_id,omitempty"`
UpstreamStatus *int `json:"upstream_status,omitempty"`
UpstreamReason string `json:"upstream_reason,omitempty"`
FailureMessage string `json:"failure_message,omitempty"`
}
type adminPushRelayRegisterRequest struct {
RelayURL string `json:"relay_url"`
}
type pushRelayRegisterRequest struct {
DeploymentID string `json:"deployment_id,omitempty"`
}
type pushRelayRegisterResponse struct {
RequestID string `json:"request_id"`
DeploymentID string `json:"deployment_id"`
APIKey string `json:"api_key"`
KeyPrefix string `json:"key_prefix"`
APNsTopics []string `json:"apns_topics"`
}
type pushRelayNestedError struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id"`
} `json:"error"`
}
type adminPushRelayRegisterResponse struct {
RelayURL string `json:"relay_url"`
DeploymentID string `json:"deployment_id"`
KeyPrefix string `json:"key_prefix"`
APIKeyConfigured bool `json:"api_key_configured"`
RelayRequestID string `json:"relay_request_id,omitempty"`
APNsTopics []string `json:"apns_topics,omitempty"`
}
// HandleTest handles POST /admin/notifications/push/apple/test.
func (h *AdminApplePushHandler) HandleTest(w http.ResponseWriter, r *http.Request) {
if h == nil || h.system == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Apple push delivery is not available")
return
}
var req adminApplePushTestRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
result, err := h.system.SendApplePushTest(r.Context(), req.ProfileID, req.ServerDeviceID)
if err != nil {
switch {
case errors.Is(err, notifications.ErrPushDeliveryInvalid):
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
case errors.Is(err, notifications.ErrPushDeliveryNotFound):
writeError(w, http.StatusNotFound, "not_found", "Apple push device not found")
case errors.Is(err, notifications.ErrPushDeliveryUnavailable):
writeError(w, http.StatusServiceUnavailable, "unavailable", "Apple push delivery is not available")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to send Apple push test")
}
return
}
writeJSON(w, http.StatusOK, adminApplePushTestResponse{
AttemptID: result.AttemptID,
PushDeviceID: result.PushDeviceID,
ServerDeviceID: result.ServerDeviceID,
Outcome: result.Outcome,
RelayRequestID: result.RelayRequestID,
UpstreamStatus: result.UpstreamStatus,
UpstreamReason: result.UpstreamReason,
FailureMessage: result.FailureMessage,
})
}
// HandleRegisterRelay handles POST /admin/notifications/push/relay/register.
func (h *AdminApplePushHandler) HandleRegisterRelay(w http.ResponseWriter, r *http.Request) {
if h == nil || h.settings == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Settings store is not available")
return
}
var req adminPushRelayRegisterRequest
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
relayURL, err := normalizePushRelayURL(req.RelayURL)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
existingDeploymentID, err := h.settings.Get(r.Context(), notifications.SettingPushRelayDeploymentID)
if err != nil {
writeError(w, http.StatusInternalServerError, "settings_error", "Failed to load relay deployment id")
return
}
relayResp, err := h.registerWithRelay(r, relayURL, strings.TrimSpace(existingDeploymentID))
if err != nil {
status, code, message := mapRelayRegistrationError(err)
writeError(w, status, code, message)
return
}
if strings.TrimSpace(relayResp.DeploymentID) == "" || strings.TrimSpace(relayResp.APIKey) == "" {
writeError(w, http.StatusBadGateway, "relay_bad_response", "Push relay returned an incomplete registration response")
return
}
if err := h.settings.Set(r.Context(), notifications.SettingPushRelayURL, relayURL); err != nil {
writeError(w, http.StatusInternalServerError, "settings_error", "Failed to save push relay URL")
return
}
if err := h.settings.Set(r.Context(), notifications.SettingPushRelayDeploymentID, relayResp.DeploymentID); err != nil {
writeError(w, http.StatusInternalServerError, "settings_error", "Failed to save push relay deployment id")
return
}
if err := h.settings.Set(r.Context(), notifications.SettingPushRelayAPIKey, relayResp.APIKey); err != nil {
writeError(w, http.StatusInternalServerError, "settings_error", "Failed to save push relay API key")
return
}
if h.system != nil && h.system.Settings != nil {
h.system.Settings.Invalidate(
notifications.SettingPushRelayURL,
notifications.SettingPushRelayDeploymentID,
notifications.SettingPushRelayAPIKey,
)
}
writeJSON(w, http.StatusOK, adminPushRelayRegisterResponse{
RelayURL: relayURL,
DeploymentID: relayResp.DeploymentID,
KeyPrefix: relayResp.KeyPrefix,
APIKeyConfigured: true,
RelayRequestID: relayResp.RequestID,
APNsTopics: relayResp.APNsTopics,
})
}
func (h *AdminApplePushHandler) registerWithRelay(r *http.Request, relayURL, deploymentID string) (*pushRelayRegisterResponse, error) {
body, err := json.Marshal(pushRelayRegisterRequest{
DeploymentID: deploymentID,
})
if err != nil {
return nil, err
}
httpReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, relayURL+"/v1/deployments/register", bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("User-Agent", "Silo-Server/PushRelayRegistration")
resp, err := h.client.Do(httpReq)
if err != nil {
return nil, relayRegistrationError{status: http.StatusBadGateway, code: "relay_unreachable", message: "Push relay could not be reached"}
}
defer func() { _ = resp.Body.Close() }()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<10))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var parsed pushRelayNestedError
_ = json.Unmarshal(data, &parsed)
code := parsed.Error.Code
if code == "" {
code = fmt.Sprintf("relay_http_%d", resp.StatusCode)
}
message := parsed.Error.Message
if message == "" {
message = http.StatusText(resp.StatusCode)
}
return nil, relayRegistrationError{status: resp.StatusCode, code: code, message: message}
}
var parsed pushRelayRegisterResponse
if err := json.Unmarshal(data, &parsed); err != nil {
return nil, relayRegistrationError{status: http.StatusBadGateway, code: "relay_bad_response", message: "Push relay returned invalid JSON"}
}
return &parsed, nil
}
type relayRegistrationError struct {
status int
code string
message string
}
func (e relayRegistrationError) Error() string {
return e.code
}
func mapRelayRegistrationError(err error) (int, string, string) {
var relayErr relayRegistrationError
if !errors.As(err, &relayErr) {
return http.StatusInternalServerError, "internal_error", "Failed to register push relay"
}
switch relayErr.status {
case http.StatusForbidden:
return http.StatusUnprocessableEntity, "relay_deployment_rejected", "Push relay rejected this deployment"
case http.StatusTooManyRequests:
return http.StatusTooManyRequests, "relay_rate_limited", relayErr.message
case http.StatusServiceUnavailable:
return http.StatusServiceUnavailable, "relay_unavailable", relayErr.message
default:
if relayErr.status >= 500 {
return http.StatusBadGateway, "relay_error", relayErr.message
}
return http.StatusBadGateway, relayErr.code, relayErr.message
}
}
// normalizePushRelayURLValue trims a relay URL and enforces https with a
// host. Empty input stays empty so callers choose their own default.
func normalizePushRelayURLValue(raw string) (string, error) {
value := strings.TrimRight(strings.TrimSpace(raw), "/")
if value == "" {
return "", nil
}
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
return "", errors.New("relay_url must be an https URL")
}
return value, nil
}
func normalizePushRelayURL(raw string) (string, error) {
value, err := normalizePushRelayURLValue(raw)
if err != nil {
return "", err
}
if value == "" {
return notifications.DefaultPushRelayURL, nil
}
return value, nil
}
@@ -0,0 +1,123 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/notifications"
)
func TestAdminApplePushHandlerUnavailableWithoutSystem(t *testing.T) {
h := NewAdminApplePushHandler(nil, nil)
rec := httptest.NewRecorder()
h.HandleTest(rec, httptest.NewRequest(http.MethodPost, "/admin/notifications/push/apple/test", strings.NewReader(`{}`)))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("HandleTest without system = %d, want 503", rec.Code)
}
}
func TestAdminApplePushHandlerRejectsInvalidJSON(t *testing.T) {
h := NewAdminApplePushHandler(&notifications.System{}, nil)
rec := httptest.NewRecorder()
h.HandleTest(rec, httptest.NewRequest(http.MethodPost, "/admin/notifications/push/apple/test", strings.NewReader(`{`)))
if rec.Code != http.StatusBadRequest {
t.Fatalf("HandleTest invalid JSON = %d, want 400", rec.Code)
}
}
func TestAdminApplePushHandlerRegistersRelayAndStoresKey(t *testing.T) {
settings := &fakeServerSettingsStore{values: map[string]string{
notifications.SettingPushRelayDeploymentID: "01EXISTING",
}}
var relayReq map[string]string
relay := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/deployments/register" {
t.Fatalf("relay path = %s", r.URL.Path)
}
if err := json.NewDecoder(r.Body).Decode(&relayReq); err != nil {
t.Fatalf("decode relay request: %v", err)
}
writeJSON(w, http.StatusOK, pushRelayRegisterResponse{
RequestID: "relay-request",
DeploymentID: "01RETURNED",
APIKey: "rk_live_raw-key",
KeyPrefix: "rk_live_raw",
APNsTopics: []string{"org.siloserver.silo"},
})
}))
t.Cleanup(relay.Close)
h := NewAdminApplePushHandler(&notifications.System{Settings: notifications.NewSettings(settings)}, settings)
h.client = relay.Client()
rec := httptest.NewRecorder()
h.HandleRegisterRelay(rec, httptest.NewRequest(http.MethodPost, "/admin/notifications/push/relay/register", strings.NewReader(`{
"relay_url":"`+relay.URL+`"
}`)))
if rec.Code != http.StatusBadRequest {
t.Fatalf("http relay URL status = %d, want 400 because HTTPS is required", rec.Code)
}
httpsRelay := httptest.NewTLSServer(relay.Config.Handler)
t.Cleanup(httpsRelay.Close)
h.client = httpsRelay.Client()
rec = httptest.NewRecorder()
h.HandleRegisterRelay(rec, httptest.NewRequest(http.MethodPost, "/admin/notifications/push/relay/register", strings.NewReader(`{
"relay_url":"`+httpsRelay.URL+`"
}`)))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d (%s), want 200", rec.Code, rec.Body.String())
}
if relayReq["deployment_id"] != "01EXISTING" || len(relayReq) != 1 {
t.Fatalf("relay request = %+v", relayReq)
}
if settings.values[notifications.SettingPushRelayURL] != httpsRelay.URL {
t.Fatalf("stored relay URL = %q", settings.values[notifications.SettingPushRelayURL])
}
if settings.values[notifications.SettingPushRelayDeploymentID] != "01RETURNED" {
t.Fatalf("stored deployment id = %q", settings.values[notifications.SettingPushRelayDeploymentID])
}
if settings.values[notifications.SettingPushRelayAPIKey] != "rk_live_raw-key" {
t.Fatalf("stored api key = %q", settings.values[notifications.SettingPushRelayAPIKey])
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v", err)
}
if _, ok := body["api_key"]; ok {
t.Fatal("response leaked raw api_key")
}
if body["api_key_configured"] != true || body["deployment_id"] != "01RETURNED" {
t.Fatalf("response = %+v", body)
}
}
func TestAdminApplePushHandlerMapsRelayRateLimit(t *testing.T) {
settings := &fakeServerSettingsStore{values: map[string]string{}}
relay := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusTooManyRequests, map[string]any{
"error": map[string]string{"code": "rate_limited", "message": "too many deployment registrations from this network"},
})
}))
t.Cleanup(relay.Close)
h := NewAdminApplePushHandler(&notifications.System{}, settings)
h.client = relay.Client()
rec := httptest.NewRecorder()
h.HandleRegisterRelay(rec, httptest.NewRequest(http.MethodPost, "/admin/notifications/push/relay/register", strings.NewReader(`{
"relay_url":"`+relay.URL+`"
}`)))
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("status = %d (%s), want 429", rec.Code, rec.Body.String())
}
if settings.values[notifications.SettingPushRelayAPIKey] != "" {
t.Fatal("api key was stored after relay rejected registration")
}
}
@@ -0,0 +1,30 @@
package handlers
import (
"net/http"
"github.com/Silo-Server/silo-server/internal/catalog"
)
func (h *AdminHandler) HandleGetCatalogSearchStatus(w http.ResponseWriter, r *http.Request) {
if h == nil || h.CatalogSearchStatus == nil {
writeJSON(w, http.StatusOK, catalog.CatalogSearchRuntimeStatus{
ConfiguredProvider: catalog.SearchProviderPostgres,
ActiveProvider: catalog.SearchProviderPostgres,
Meilisearch: catalog.CatalogSearchMeiliStatus{
Configured: false,
Healthy: false,
CircuitState: "not_configured",
},
Index: catalog.CatalogSearchIndexStateStatus{
ExpectedSchemaVersion: catalog.SearchMeilisearchSchemaVersion,
},
Tasks: []catalog.CatalogSearchTaskLink{
{Key: "sync_catalog_search_index", Name: "Sync Catalog Search Index", Href: "/admin/tasks/sync_catalog_search_index"},
{Key: "rebuild_catalog_search_index", Name: "Rebuild Catalog Search Index", Href: "/admin/tasks/rebuild_catalog_search_index"},
},
})
return
}
writeJSON(w, http.StatusOK, h.CatalogSearchStatus.Status(r.Context()))
}
@@ -6,8 +6,6 @@ import (
"net/http"
"sort"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/cache"
"github.com/Silo-Server/silo-server/internal/markers"
)
@@ -122,7 +120,11 @@ func (h *AdminMarkerProvidersHandler) HandleUpdateProvider(w http.ResponseWriter
writeError(w, http.StatusServiceUnavailable, "unavailable", "Marker providers are not configured")
return
}
provider := chi.URLParam(r, "provider")
provider, err := decodedURLParam(r, "provider")
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid provider ID")
return
}
existing, ok := h.Config.Get(provider)
if !ok {
writeError(w, http.StatusNotFound, "not_found", "Unknown marker provider")
@@ -179,7 +181,11 @@ func (h *AdminMarkerProvidersHandler) HandleValidateProvider(w http.ResponseWrit
writeError(w, http.StatusServiceUnavailable, "unavailable", "Marker providers are not configured")
return
}
provider := chi.URLParam(r, "provider")
provider, err := decodedURLParam(r, "provider")
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid provider ID")
return
}
var submitter markers.Submitter
for _, p := range h.Registry.Providers() {
if p.ID() == provider {
@@ -49,6 +49,34 @@ func TestValidateMarkerProviderUsesSnakeCaseStats(t *testing.T) {
}
}
type fakePluginMarkerSubmitter struct{ fakeMarkerStatsSubmitter }
func (fakePluginMarkerSubmitter) ID() string { return "plugin:6:introdb" }
// Browsers send encodeURIComponent-escaped provider IDs (plugin%3A6%3Aintrodb),
// and chi matches the raw path, so the handler must decode the route param.
func TestValidateMarkerProviderDecodesEncodedID(t *testing.T) {
reg := markers.NewRegistry(nil)
if err := reg.Register(fakePluginMarkerSubmitter{}); err != nil {
t.Fatalf("register provider: %v", err)
}
h := NewAdminMarkerProvidersHandler(reg, nil, nil, nil)
router := chi.NewRouter()
router.Post("/admin/markers/providers/{provider}/validate", h.HandleValidateProvider)
req := httptest.NewRequest(http.MethodPost, "/admin/markers/providers/plugin%3A6%3Aintrodb/validate", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"valid":true`) {
t.Fatalf("unexpected response: %s", rec.Body.String())
}
}
func TestMarkerProviderResponseIncludesPluginMetadata(t *testing.T) {
resp := toProviderConfigResponse(
markers.ProviderConfig{Provider: "plugin:4:markers", FetchEnabled: true, FetchPriority: 25},
+35 -18
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"log/slog"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
@@ -56,12 +57,13 @@ func NewAdminMatchHandler(
// --- Request/Response types ---
type matchSearchRequest struct {
Title string `json:"title"`
Year int `json:"year"`
ImdbID string `json:"imdb_id"`
TmdbID string `json:"tmdb_id"`
TvdbID string `json:"tvdb_id"`
LibraryID *int `json:"library_id,omitempty"`
Title string `json:"title"`
Year int `json:"year"`
ImdbID string `json:"imdb_id"`
TmdbID string `json:"tmdb_id"`
TvdbID string `json:"tvdb_id"`
ProviderIDs map[string]string `json:"provider_ids,omitempty"`
LibraryID *int `json:"library_id,omitempty"`
}
type matchSearchResponse struct {
@@ -119,7 +121,7 @@ func (h *AdminMatchHandler) HandleSearchItemMatchCandidates(w http.ResponseWrite
Title: req.Title,
Year: req.Year,
ContentType: item.Type,
ProviderIDs: make(map[string]string),
ProviderIDs: normalizeMatchProviderIDs(req.ProviderIDs),
}
if query.Title == "" {
query.Title = item.Title
@@ -129,15 +131,9 @@ func (h *AdminMatchHandler) HandleSearchItemMatchCandidates(w http.ResponseWrite
}
// Inject any provider IDs from the request.
if req.ImdbID != "" {
query.ProviderIDs["imdb"] = req.ImdbID
}
if req.TmdbID != "" {
query.ProviderIDs["tmdb"] = req.TmdbID
}
if req.TvdbID != "" {
query.ProviderIDs["tvdb"] = req.TvdbID
}
setMatchProviderID(query.ProviderIDs, "imdb", req.ImdbID)
setMatchProviderID(query.ProviderIDs, "tmdb", req.TmdbID)
setMatchProviderID(query.ProviderIDs, "tvdb", req.TvdbID)
candidates, err := h.metadata.SearchAndNormalize(r.Context(), query, folderID)
if err != nil {
@@ -165,7 +161,11 @@ func (h *AdminMatchHandler) HandleApplyItemMatch(w http.ResponseWriter, r *http.
return
}
if len(req.ProviderIDs) == 0 {
// Normalize keys and values the same way the search endpoint does so a
// caller-supplied key like "TMDB" or " tmdb " cannot bypass downstream
// provider-id handling (e.g. stale-ID suppression in metadata.Process).
providerIDs := normalizeMatchProviderIDs(req.ProviderIDs)
if len(providerIDs) == 0 {
writeError(w, http.StatusBadRequest, "bad_request", "At least one provider ID is required")
return
}
@@ -195,7 +195,7 @@ func (h *AdminMatchHandler) HandleApplyItemMatch(w http.ResponseWriter, r *http.
result, err := h.metadata.Process(r.Context(), metadata.ProcessRequest{
ContentID: contentID,
ProviderIDs: req.ProviderIDs,
ProviderIDs: providerIDs,
FolderID: folderIDStr,
Mode: metadata.ModeIdentify,
})
@@ -211,6 +211,23 @@ func (h *AdminMatchHandler) HandleApplyItemMatch(w http.ResponseWriter, r *http.
})
}
func normalizeMatchProviderIDs(input map[string]string) map[string]string {
out := make(map[string]string, len(input))
for provider, providerID := range input {
setMatchProviderID(out, provider, providerID)
}
return out
}
func setMatchProviderID(providerIDs map[string]string, provider string, providerID string) {
provider = strings.ToLower(strings.TrimSpace(provider))
providerID = strings.TrimSpace(providerID)
if provider == "" || providerID == "" {
return
}
providerIDs[provider] = providerID
}
// PoolFolderLookup implements MatchFolderLookup using a direct pgx pool query
// against the media_item_libraries table.
type PoolFolderLookup struct {
+117 -2
View File
@@ -199,6 +199,58 @@ func TestAdminMatchSearch_FallsBackToItemMetadata(t *testing.T) {
}
}
func TestAdminMatchSearch_AcceptsGenericProviderIDsAndLibraryID(t *testing.T) {
items := &fakeMatchItemLookup{
items: map[string]*models.MediaItem{
"ebook-1": {ContentID: "ebook-1", Title: "Example Book", Year: 2024, Type: "ebook"},
},
}
folders := &fakeMatchFolderLookup{
folderIDs: map[string][]int{"ebook-1": {7, 8}},
}
captureSvc := &capturingMetadataService{
inner: &fakeMatchMetadataService{searchResults: []metadata.MatchCandidate{}},
}
h := NewAdminMatchHandler(items, folders, captureSvc)
router := buildMatchRouter(h)
libraryID := 8
body, _ := json.Marshal(matchSearchRequest{
ProviderIDs: map[string]string{
" ISBN ": " 9780000000001 ",
"goodreads": " ",
},
TmdbID: "12345",
LibraryID: &libraryID,
})
req := httptest.NewRequest(http.MethodPost, "/admin/items/ebook-1/match/search", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if captureSvc.lastFolderID != 8 {
t.Fatalf("folderID = %d, want 8", captureSvc.lastFolderID)
}
if captureSvc.lastQuery.ContentType != "ebook" {
t.Fatalf("ContentType = %q, want ebook", captureSvc.lastQuery.ContentType)
}
if got := captureSvc.lastQuery.ProviderIDs["isbn"]; got != "9780000000001" {
t.Fatalf("ProviderIDs[isbn] = %q, want 9780000000001", got)
}
if got := captureSvc.lastQuery.ProviderIDs["tmdb"]; got != "12345" {
t.Fatalf("ProviderIDs[tmdb] = %q, want 12345", got)
}
if _, ok := captureSvc.lastQuery.ProviderIDs["goodreads"]; ok {
t.Fatalf("blank provider IDs should be ignored, got %v", captureSvc.lastQuery.ProviderIDs)
}
}
func TestAdminMatchApply_PreservesContentID(t *testing.T) {
items := &fakeMatchItemLookup{
items: map[string]*models.MediaItem{
@@ -274,6 +326,67 @@ func TestAdminMatchApply_RequiresProviderIDs(t *testing.T) {
}
}
func TestAdminMatchApply_NormalizesProviderIDKeys(t *testing.T) {
items := &fakeMatchItemLookup{
items: map[string]*models.MediaItem{
"item-5": {ContentID: "item-5", Title: "Test", Type: "movie"},
},
}
metaSvc := &fakeMatchMetadataService{
processResult: &metadata.ProcessResult{ContentID: "item-5", Updated: true},
}
h := NewAdminMatchHandler(items, nil, metaSvc)
router := buildMatchRouter(h)
body, _ := json.Marshal(matchApplyRequest{
ProviderIDs: map[string]string{
" TMDB ": " 157336 ",
"blank": " ",
},
})
req := httptest.NewRequest(http.MethodPost, "/admin/items/item-5/match/apply", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if got := metaSvc.lastProcess.ProviderIDs["tmdb"]; got != "157336" {
t.Errorf("Process provider_ids[tmdb] = %q, want 157336 (keys/values must be normalized)", got)
}
if _, ok := metaSvc.lastProcess.ProviderIDs[" TMDB "]; ok {
t.Errorf("Process received raw key %q, want normalized keys only: %v", " TMDB ", metaSvc.lastProcess.ProviderIDs)
}
if _, ok := metaSvc.lastProcess.ProviderIDs["blank"]; ok {
t.Errorf("blank provider IDs should be dropped, got %v", metaSvc.lastProcess.ProviderIDs)
}
}
func TestAdminMatchApply_RejectsAllBlankProviderIDs(t *testing.T) {
items := &fakeMatchItemLookup{
items: map[string]*models.MediaItem{
"item-6": {ContentID: "item-6", Title: "Test", Type: "movie"},
},
}
h := NewAdminMatchHandler(items, nil, &fakeMatchMetadataService{})
router := buildMatchRouter(h)
body, _ := json.Marshal(matchApplyRequest{
ProviderIDs: map[string]string{"tmdb": " ", " ": "123"},
})
req := httptest.NewRequest(http.MethodPost, "/admin/items/item-6/match/apply", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for all-blank provider_ids, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestAdminMatchApply_ItemNotFound(t *testing.T) {
items := &fakeMatchItemLookup{items: map[string]*models.MediaItem{}}
h := NewAdminMatchHandler(items, nil, &fakeMatchMetadataService{})
@@ -293,12 +406,14 @@ func TestAdminMatchApply_ItemNotFound(t *testing.T) {
// capturingMetadataService wraps a fake to capture the query passed to SearchAndNormalize.
type capturingMetadataService struct {
inner *fakeMatchMetadataService
lastQuery metadata.SearchQuery
inner *fakeMatchMetadataService
lastQuery metadata.SearchQuery
lastFolderID int
}
func (c *capturingMetadataService) SearchAndNormalize(ctx context.Context, query metadata.SearchQuery, folderID int) ([]metadata.MatchCandidate, error) {
c.lastQuery = query
c.lastFolderID = folderID
return c.inner.SearchAndNormalize(ctx, query, folderID)
}
@@ -179,7 +179,7 @@ func (h *AdminPlaybackControlHandler) handleSessionCommand(w http.ResponseWriter
fallback := func() {
h.playback.forgetRealtimeCommand(commandID)
_ = h.playback.stopPlaybackSessionByID(context.Background(), sessionID)
_ = h.playback.stopPlaybackSessionByID(context.Background(), sessionID, true)
}
h.playback.rememberRealtimeCommand(commandID, sessionID, name)
@@ -0,0 +1,240 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"time"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/notifications"
"github.com/go-chi/chi/v5"
)
// AdminServerChannelsHandler exposes admin CRUD for server notification
// channels (community broadcast destinations). All routes are mounted inside
// the admin-only group.
type AdminServerChannelsHandler struct {
system *notifications.System
}
// NewAdminServerChannelsHandler creates the handler.
func NewAdminServerChannelsHandler(system *notifications.System) *AdminServerChannelsHandler {
return &AdminServerChannelsHandler{system: system}
}
func (h *AdminServerChannelsHandler) service() *notifications.ServerChannelService {
if h == nil || h.system == nil {
return nil
}
return h.system.ServerChannels
}
// serverChannelResponse is the API view of a server channel. Like webhooks it
// never includes the destination URL (Discord webhook tokens are bearer
// credentials in the URL path) or the stored signing secret — only url_host.
type serverChannelResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
URLHost string `json:"url_host"`
Enabled bool `json:"enabled"`
NotifyNewMovies bool `json:"notify_new_movies"`
NotifyNewEpisodes bool `json:"notify_new_episodes"`
NotifyNewAudiobooks bool `json:"notify_new_audiobooks"`
NotifyNewEbooks bool `json:"notify_new_ebooks"`
NotifyRequestSubmitted bool `json:"notify_request_submitted"`
NotifyRequestApproved bool `json:"notify_request_approved"`
NotifyRequestDeclined bool `json:"notify_request_declined"`
NotifyRequestFulfilled bool `json:"notify_request_fulfilled"`
ConsecutiveFailures int `json:"consecutive_failures"`
DisabledReason *string `json:"disabled_reason"`
LastSuccessAt *time.Time `json:"last_success_at"`
LastFailureAt *time.Time `json:"last_failure_at"`
LastFailureStatus *int `json:"last_failure_status"`
LastFailureMessage *string `json:"last_failure_message"`
CreatedAt time.Time `json:"created_at"`
// SigningSecret is present only in create / rotate-secret responses.
SigningSecret string `json:"signing_secret,omitempty"`
}
func serverChannelToResponse(ch notifications.ServerChannel) serverChannelResponse {
return serverChannelResponse{
ID: ch.ID,
Name: ch.Name,
Type: ch.Type,
URLHost: ch.URLHost,
Enabled: ch.Enabled,
NotifyNewMovies: ch.NotifyNewMovies,
NotifyNewEpisodes: ch.NotifyNewEpisodes,
NotifyNewAudiobooks: ch.NotifyNewAudiobooks,
NotifyNewEbooks: ch.NotifyNewEbooks,
NotifyRequestSubmitted: ch.NotifyRequestSubmitted,
NotifyRequestApproved: ch.NotifyRequestApproved,
NotifyRequestDeclined: ch.NotifyRequestDeclined,
NotifyRequestFulfilled: ch.NotifyRequestFulfilled,
ConsecutiveFailures: ch.ConsecutiveFailures,
DisabledReason: ch.DisabledReason,
LastSuccessAt: ch.LastSuccessAt,
LastFailureAt: ch.LastFailureAt,
LastFailureStatus: ch.LastFailureStatus,
LastFailureMessage: ch.LastFailureMessage,
CreatedAt: ch.CreatedAt,
}
}
type serverChannelRequest struct {
Name *string `json:"name"`
URL *string `json:"url"`
Type *string `json:"type"`
Enabled *bool `json:"enabled"`
NotifyNewMovies *bool `json:"notify_new_movies"`
NotifyNewEpisodes *bool `json:"notify_new_episodes"`
NotifyNewAudiobooks *bool `json:"notify_new_audiobooks"`
NotifyNewEbooks *bool `json:"notify_new_ebooks"`
NotifyRequestSubmitted *bool `json:"notify_request_submitted"`
NotifyRequestApproved *bool `json:"notify_request_approved"`
NotifyRequestDeclined *bool `json:"notify_request_declined"`
NotifyRequestFulfilled *bool `json:"notify_request_fulfilled"`
}
func (r serverChannelRequest) toInput() notifications.ServerChannelInput {
return notifications.ServerChannelInput{
Name: r.Name,
URL: r.URL,
Type: r.Type,
Enabled: r.Enabled,
NotifyNewMovies: r.NotifyNewMovies,
NotifyNewEpisodes: r.NotifyNewEpisodes,
NotifyNewAudiobooks: r.NotifyNewAudiobooks,
NotifyNewEbooks: r.NotifyNewEbooks,
NotifyRequestSubmitted: r.NotifyRequestSubmitted,
NotifyRequestApproved: r.NotifyRequestApproved,
NotifyRequestDeclined: r.NotifyRequestDeclined,
NotifyRequestFulfilled: r.NotifyRequestFulfilled,
}
}
func writeServerChannelError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, notifications.ErrServerChannelsDisabled):
writeError(w, http.StatusForbidden, "server_channels_disabled", "Server channels are disabled")
case errors.Is(err, notifications.ErrServerChannelNotFound):
writeError(w, http.StatusNotFound, "not_found", "Server channel not found")
case errors.Is(err, notifications.ErrServerChannelLimit):
writeError(w, http.StatusUnprocessableEntity, "limit_reached", "Server channel limit reached")
case errors.Is(err, notifications.ErrServerChannelInvalid):
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Server channel operation failed")
}
}
// HandleList handles GET /admin/notifications/server-channels.
func (h *AdminServerChannelsHandler) HandleList(w http.ResponseWriter, r *http.Request) {
service := h.service()
if service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
return
}
channels, err := service.List(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list server channels")
return
}
responses := make([]serverChannelResponse, 0, len(channels))
for _, ch := range channels {
responses = append(responses, serverChannelToResponse(ch))
}
writeJSON(w, http.StatusOK, map[string]any{"channels": responses})
}
// HandleCreate handles POST /admin/notifications/server-channels. For generic
// channels the response carries the signing secret exactly once.
func (h *AdminServerChannelsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
service := h.service()
if service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
return
}
var req serverChannelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
ch, signingSecret, err := service.Create(r.Context(), apimw.GetUserID(r.Context()), req.toInput())
if err != nil {
writeServerChannelError(w, err)
return
}
response := serverChannelToResponse(*ch)
response.SigningSecret = signingSecret
writeJSON(w, http.StatusCreated, response)
}
// HandleUpdate handles PUT /admin/notifications/server-channels/{id}.
func (h *AdminServerChannelsHandler) HandleUpdate(w http.ResponseWriter, r *http.Request) {
service := h.service()
if service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
return
}
var req serverChannelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
ch, err := service.Update(r.Context(), chi.URLParam(r, "id"), req.toInput())
if err != nil {
writeServerChannelError(w, err)
return
}
writeJSON(w, http.StatusOK, serverChannelToResponse(*ch))
}
// HandleDelete handles DELETE /admin/notifications/server-channels/{id}.
// Idempotent.
func (h *AdminServerChannelsHandler) HandleDelete(w http.ResponseWriter, r *http.Request) {
service := h.service()
if service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
return
}
if err := service.Delete(r.Context(), chi.URLParam(r, "id")); err != nil {
writeServerChannelError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleRotateSecret handles POST /admin/notifications/server-channels/{id}/rotate-secret.
func (h *AdminServerChannelsHandler) HandleRotateSecret(w http.ResponseWriter, r *http.Request) {
service := h.service()
if service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
return
}
signingSecret, err := service.RotateSecret(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeServerChannelError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"signing_secret": signingSecret})
}
// HandleTest handles POST /admin/notifications/server-channels/{id}/test. The
// test send is synchronous and never touches the watermark or failure
// counters.
func (h *AdminServerChannelsHandler) HandleTest(w http.ResponseWriter, r *http.Request) {
service := h.service()
if service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
return
}
result, err := service.Test(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeServerChannelError(w, err)
return
}
writeJSON(w, http.StatusOK, result)
}
@@ -0,0 +1,82 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/notifications"
)
func TestWriteServerChannelErrorMapping(t *testing.T) {
cases := []struct {
err error
want int
}{
{notifications.ErrServerChannelsDisabled, http.StatusForbidden},
{notifications.ErrServerChannelNotFound, http.StatusNotFound},
{notifications.ErrServerChannelLimit, http.StatusUnprocessableEntity},
{fmt.Errorf("%w: name is required", notifications.ErrServerChannelInvalid), http.StatusBadRequest},
{fmt.Errorf("boom"), http.StatusInternalServerError},
}
for _, tc := range cases {
rec := httptest.NewRecorder()
writeServerChannelError(rec, tc.err)
if rec.Code != tc.want {
t.Errorf("writeServerChannelError(%v) = %d, want %d", tc.err, rec.Code, tc.want)
}
}
}
// The response shape must never leak the destination URL (a bearer credential
// for Discord webhooks) or the stored signing secret; signing_secret appears
// only when explicitly set by the create/rotate paths.
func TestServerChannelResponseNeverLeaksSecrets(t *testing.T) {
secret := "ciphertext-secret"
ch := notifications.ServerChannel{
ID: "ch-1",
Name: "Community",
Type: "discord",
URLCiphertext: "enc:v1:secret-url-material",
URLHost: "discord.com",
SigningSecretCiphertext: &secret,
Enabled: true,
}
body, err := json.Marshal(serverChannelToResponse(ch))
if err != nil {
t.Fatal(err)
}
encoded := string(body)
if strings.Contains(encoded, "secret-url-material") || strings.Contains(encoded, "ciphertext-secret") {
t.Fatalf("response leaked ciphertext material: %s", encoded)
}
if strings.Contains(encoded, "signing_secret") {
t.Fatalf("signing_secret must be omitted unless explicitly set: %s", encoded)
}
if !strings.Contains(encoded, `"url_host":"discord.com"`) {
t.Fatalf("url_host missing from response: %s", encoded)
}
response := serverChannelToResponse(ch)
response.SigningSecret = "shown-once"
body, err = json.Marshal(response)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(body), `"signing_secret":"shown-once"`) {
t.Fatalf("explicit signing_secret missing: %s", body)
}
}
// A handler with no notifications system reports 503 instead of panicking.
func TestServerChannelHandlersUnavailableWithoutSystem(t *testing.T) {
h := NewAdminServerChannelsHandler(nil)
rec := httptest.NewRecorder()
h.HandleList(rec, httptest.NewRequest(http.MethodGet, "/admin/notifications/server-channels", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("HandleList without system = %d, want 503", rec.Code)
}
}
@@ -0,0 +1,53 @@
package handlers
import (
"net/http"
"time"
"github.com/Silo-Server/silo-server/internal/jellycompat"
)
type adminServerStatusResponse struct {
StartedAt time.Time `json:"started_at"`
RestartRequired bool `json:"restart_required"`
RestartRequiredAt *time.Time `json:"restart_required_at,omitempty"`
RestartRequiredReason string `json:"restart_required_reason,omitempty"`
RestartRequested bool `json:"restart_requested"`
RestartRequestedAt *time.Time `json:"restart_requested_at,omitempty"`
}
// HandleGetServerStatus handles GET /admin/server/status.
func (h *AdminHandler) HandleGetServerStatus(w http.ResponseWriter, r *http.Request) {
snapshot := h.RestartStatus.Snapshot()
resp := adminServerStatusResponse{
StartedAt: snapshot.StartedAt,
RestartRequired: snapshot.RestartRequired,
RestartRequiredAt: snapshot.RestartRequiredAt,
RestartRequiredReason: snapshot.RestartRequiredReason,
RestartRequested: snapshot.RestartRequested,
RestartRequestedAt: snapshot.RestartRequestedAt,
}
if h.SettingsRepo != nil {
settings, err := h.SettingsRepo.GetAll(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load settings")
return
}
if jellycompat.WebComponentStatusForConfig(h.Config, settings).RestartRequired {
resp.RestartRequired = true
if resp.RestartRequiredReason == "" {
resp.RestartRequiredReason = "jellyfin_compat"
}
}
}
writeJSON(w, http.StatusOK, resp)
}
func (h *AdminHandler) markServerRestartRequired(reason string) {
if h == nil {
return
}
h.RestartStatus.MarkRequired(reason)
}
@@ -0,0 +1,87 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Silo-Server/silo-server/internal/config"
)
func TestAdminServerStatusClearsAfterProcessRestart(t *testing.T) {
t.Parallel()
restartStatus := NewServerRestartStatusTracker()
restartStatus.MarkRequired("settings")
handler := &AdminHandler{RestartStatus: restartStatus}
req := httptest.NewRequest(http.MethodGet, "/admin/server/status", nil)
rec := httptest.NewRecorder()
handler.HandleGetServerStatus(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp adminServerStatusResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if !resp.RestartRequired {
t.Fatal("RestartRequired = false, want true")
}
restartedHandler := &AdminHandler{RestartStatus: NewServerRestartStatusTracker()}
rec = httptest.NewRecorder()
restartedHandler.HandleGetServerStatus(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status after restart = %d, body = %s", rec.Code, rec.Body.String())
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response after restart: %v", err)
}
if resp.RestartRequired {
t.Fatal("RestartRequired = true after new process tracker, want false")
}
}
func TestAdminServerStatusDoesNotPromoteLiveJellyfinIdentitySettings(t *testing.T) {
t.Parallel()
cfg, err := config.LoadFromDB(map[string]string{
"jellyfin_compat.enabled": "true",
"jellyfin_compat.listen": ":8096",
"jellyfin_compat.public_url": "http://127.0.0.1:8096",
"jellyfin_compat.server_name": "Silo",
"jellyfin_compat.emulated_server_version": "10.11.0",
})
if err != nil {
t.Fatalf("LoadFromDB: %v", err)
}
handler := &AdminHandler{
Config: cfg,
RestartStatus: NewServerRestartStatusTracker(),
SettingsRepo: &fakeServerSettingsStore{values: map[string]string{
"jellyfin_compat.public_url": "https://compat.example.test",
"jellyfin_compat.server_name": "Silo Compat",
"jellyfin_compat.emulated_server_version": "10.11.6",
}},
}
req := httptest.NewRequest(http.MethodGet, "/admin/server/status", nil)
rec := httptest.NewRecorder()
handler.HandleGetServerStatus(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp adminServerStatusResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.RestartRequired {
t.Fatal("RestartRequired = true, want false for live Jellyfin identity settings")
}
}
@@ -12,6 +12,7 @@ import (
"github.com/redis/go-redis/v9"
"github.com/Silo-Server/silo-server/internal/cache"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/recommendations/embeddings"
"github.com/Silo-Server/silo-server/internal/s3client"
@@ -116,6 +117,8 @@ func (h *AdminHandler) HandleCheckSettingsConnection(w http.ResponseWriter, r *h
response = checkRedisConnection(r.Context(), cfg)
case "recommendations_embedding":
response = checkRecommendationsEmbeddingConnection(r.Context(), cfg)
case "meilisearch":
response = checkMeilisearchConnection(r.Context(), effectiveSettings)
default:
writeError(w, http.StatusBadRequest, "bad_request", "Unsupported connection check kind")
return
@@ -124,6 +127,22 @@ func (h *AdminHandler) HandleCheckSettingsConnection(w http.ResponseWriter, r *h
writeJSON(w, http.StatusOK, response)
}
func checkMeilisearchConnection(ctx context.Context, settings map[string]string) connectionCheckResponse {
searchSettings, err := catalog.CatalogSearchSettingsFromMap(settings)
if err != nil {
return connectionCheckResponse{Success: false, Message: err.Error()}
}
if searchSettings.MeilisearchURL == "" {
return connectionCheckResponse{Success: false, Message: "Meilisearch URL is required"}
}
searchSettings.Provider = catalog.SearchProviderMeilisearch
indexer := catalog.NewCatalogSearchIndexer(nil, nil)
if err := indexer.CheckConnection(ctx, searchSettings); err != nil {
return connectionCheckResponse{Success: false, Message: err.Error()}
}
return connectionCheckResponse{Success: true, Message: "Meilisearch connection successful"}
}
func (h *AdminHandler) effectiveSettingsForConnectionCheck(
ctx context.Context,
req adminSettingsConnectionCheckRequest,
@@ -11,6 +11,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/s3client"
)
@@ -326,6 +327,124 @@ func TestAdminUpdateSettingRedactsSensitiveSetting(t *testing.T) {
}
}
func TestAdminUpdateSettingReportsRestartRequired(t *testing.T) {
cases := []struct {
key string
value string
restartRequired bool
}{
// Infrastructure settings are captured at startup.
{key: "database.max_connections", value: "40", restartRequired: true},
{key: "s3.public_bucket", value: "assets", restartRequired: true},
{key: "jellyfin_compat.enabled", value: "false", restartRequired: true},
// Branding is read live from the settings repo per request.
{key: "branding.server_name", value: "Casa", restartRequired: false},
}
for _, tc := range cases {
t.Run(tc.key, func(t *testing.T) {
settings := &fakeServerSettingsStore{}
restartStatus := NewServerRestartStatusTracker()
handler := &AdminHandler{SettingsRepo: settings, RestartStatus: restartStatus}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings/"+tc.key,
strings.NewReader(`{"value":"`+tc.value+`"}`),
)
req = withChiParam(req, "key", tc.key)
rec := httptest.NewRecorder()
handler.HandleUpdateSetting(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp adminSettingResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.RestartRequired != tc.restartRequired {
t.Fatalf("restart_required = %v, want %v", resp.RestartRequired, tc.restartRequired)
}
snapshot := restartStatus.Snapshot()
if snapshot.RestartRequired != tc.restartRequired {
t.Fatalf("tracker restart required = %v, want %v", snapshot.RestartRequired, tc.restartRequired)
}
if !tc.restartRequired && snapshot.RestartRequiredReason != "" {
t.Fatalf("tracker reason = %q, want empty", snapshot.RestartRequiredReason)
}
})
}
}
func TestAdminUpdateCatalogSearchSemanticSettingsValidation(t *testing.T) {
valid := []struct {
key string
value string
want string
}{
{key: catalog.SearchSettingMeilisearchSemanticEnabled, value: "TRUE", want: "true"},
{key: catalog.SearchSettingMeilisearchSemanticRatio, value: ".30", want: "0.3"},
{key: catalog.SearchSettingMeilisearchEmbedder, value: "", want: catalog.DefaultMeilisearchEmbedder},
{key: catalog.SearchSettingMeilisearchEmbedder, value: "custom_embedder-2", want: "custom_embedder-2"},
}
for _, tc := range valid {
t.Run("valid "+tc.key, func(t *testing.T) {
settings := &fakeServerSettingsStore{}
handler := &AdminHandler{SettingsRepo: settings, RestartStatus: NewServerRestartStatusTracker()}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings/"+tc.key,
strings.NewReader(`{"value":"`+tc.value+`"}`),
)
req = withChiParam(req, "key", tc.key)
rec := httptest.NewRecorder()
handler.HandleUpdateSetting(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if settings.values[tc.key] != tc.want {
t.Fatalf("stored value = %q, want %q", settings.values[tc.key], tc.want)
}
})
}
invalid := []struct {
key string
value string
}{
{key: catalog.SearchSettingMeilisearchSemanticEnabled, value: "sometimes"},
{key: catalog.SearchSettingMeilisearchSemanticRatio, value: "-0.01"},
{key: catalog.SearchSettingMeilisearchSemanticRatio, value: "1.01"},
{key: catalog.SearchSettingMeilisearchSemanticRatio, value: "NaN"},
{key: catalog.SearchSettingMeilisearchEmbedder, value: "bad.name"},
}
for _, tc := range invalid {
t.Run("invalid "+tc.key, func(t *testing.T) {
settings := &fakeServerSettingsStore{}
handler := &AdminHandler{SettingsRepo: settings}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings/"+tc.key,
strings.NewReader(`{"value":"`+tc.value+`"}`),
)
req = withChiParam(req, "key", tc.key)
rec := httptest.NewRecorder()
handler.HandleUpdateSetting(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
if _, ok := settings.values[tc.key]; ok {
t.Fatalf("invalid setting was stored: %#v", settings.values)
}
})
}
}
func performSettingsCheckRequest(
t *testing.T,
handler *AdminHandler,
+44 -8
View File
@@ -22,7 +22,9 @@ type AdminStats struct {
TotalFiles int `json:"total_files"`
TotalUsers int `json:"total_users"`
TotalMovies int `json:"total_movies"`
TotalMovieFiles int `json:"total_movie_files"`
TotalShows int `json:"total_shows"`
TotalShowFiles int `json:"total_show_files"`
ActiveStreams int `json:"active_streams"`
TotalStorageBytes int64 `json:"total_storage_bytes"`
WatchProviderActivity WatchProviderActivity `json:"watch_provider_activity"`
@@ -127,13 +129,15 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
}
var (
totalUsers int64
totalItems int64
totalFiles int64
totalMovies int64
totalShows int64
activeStreams int64
totalStorage int64
totalUsers int64
totalItems int64
totalFiles int64
totalMovies int64
totalMovieFiles int64
totalShows int64
totalShowFiles int64
activeStreams int64
totalStorage int64
)
row := pool.QueryRow(ctx, `
@@ -151,8 +155,34 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
file_stats AS (
SELECT
COUNT(*)::bigint AS total_files,
COUNT(*) FILTER (WHERE file_kind = 'movie')::bigint AS total_movie_files,
COUNT(*) FILTER (WHERE file_kind = 'series')::bigint AS total_show_files,
COALESCE(SUM(file_size), 0)::bigint AS total_storage_bytes
FROM media_files
FROM (
SELECT
media_files.file_size,
CASE
WHEN lower(trim(COALESCE(NULLIF(media_items.type, ''), ''))) = 'movie'
THEN 'movie'
WHEN lower(trim(COALESCE(NULLIF(media_items.type, ''), ''))) = 'series'
THEN 'series'
WHEN episodes.content_id IS NOT NULL
THEN 'series'
WHEN lower(trim(COALESCE(NULLIF(media_files.base_type, ''), ''))) IN ('movie', 'movies')
THEN 'movie'
WHEN lower(trim(COALESCE(NULLIF(media_files.base_type, ''), ''))) IN ('series', 'tv', 'show', 'shows', 'tvshows')
THEN 'series'
WHEN lower(trim(media_folders.type)) IN ('movie', 'movies')
THEN 'movie'
WHEN lower(trim(media_folders.type)) IN ('series', 'tv', 'show', 'shows', 'tvshows')
THEN 'series'
ELSE ''
END AS file_kind
FROM media_files
JOIN media_folders ON media_folders.id = media_files.media_folder_id
LEFT JOIN media_items ON media_items.content_id = media_files.content_id
LEFT JOIN episodes ON episodes.content_id = media_files.episode_id
) classified_files
),
session_stats AS (
SELECT COUNT(*)::bigint AS active_streams
@@ -163,7 +193,9 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
item_stats.total_items,
file_stats.total_files,
item_stats.total_movies,
file_stats.total_movie_files,
item_stats.total_shows,
file_stats.total_show_files,
session_stats.active_streams,
file_stats.total_storage_bytes
FROM user_stats
@@ -176,7 +208,9 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
&totalItems,
&totalFiles,
&totalMovies,
&totalMovieFiles,
&totalShows,
&totalShowFiles,
&activeStreams,
&totalStorage,
); err != nil {
@@ -194,7 +228,9 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
TotalItems: int(totalItems),
TotalFiles: int(totalFiles),
TotalMovies: int(totalMovies),
TotalMovieFiles: int(totalMovieFiles),
TotalShows: int(totalShows),
TotalShowFiles: int(totalShowFiles),
ActiveStreams: int(activeStreams),
TotalStorageBytes: totalStorage,
WatchProviderActivity: activity,
+10 -3
View File
@@ -12,7 +12,6 @@ import (
"github.com/Silo-Server/silo-server/internal/subtitles/opensubtitles"
"github.com/Silo-Server/silo-server/internal/subtitles/subdl"
"github.com/Silo-Server/silo-server/internal/subtitles/subsource"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -57,7 +56,11 @@ func (h *AdminSubtitleHandler) HandleListProviders(w http.ResponseWriter, r *htt
// HandleUpdateProvider handles PUT /api/v1/admin/subtitle-providers/{provider}
func (h *AdminSubtitleHandler) HandleUpdateProvider(w http.ResponseWriter, r *http.Request) {
providerName := chi.URLParam(r, "provider")
providerName, err := decodedURLParam(r, "provider")
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid provider name")
return
}
var req updateSubtitleProviderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -83,7 +86,11 @@ func (h *AdminSubtitleHandler) HandleUpdateProvider(w http.ResponseWriter, r *ht
// HandleTestProvider handles POST /api/v1/admin/subtitle-providers/{provider}/test
func (h *AdminSubtitleHandler) HandleTestProvider(w http.ResponseWriter, r *http.Request) {
providerName := chi.URLParam(r, "provider")
providerName, err := decodedURLParam(r, "provider")
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid provider name")
return
}
cfg, err := h.repo.GetProviderConfig(r.Context(), providerName)
if err != nil {
+150
View File
@@ -0,0 +1,150 @@
package handlers
import (
"net/http"
"strconv"
"strings"
"github.com/Silo-Server/silo-server/internal/catalog"
)
type audiobookGroupResponse struct {
Name string `json:"name"`
ItemCount int `json:"item_count"`
TotalDurationSeconds int64 `json:"total_duration_seconds"`
InProgressCount int `json:"in_progress_count"`
FinishedCount int `json:"finished_count"`
PosterURLs []string `json:"poster_urls"`
}
type audiobookGroupsResponse struct {
Total int `json:"total"`
TotalExact bool `json:"total_exact"`
HasMore bool `json:"has_more"`
Groups []audiobookGroupResponse `json:"groups"`
}
// HandleGetAudiobookGroups — GET /api/v1/catalog/audiobook-groups
//
// Grouped browse for audiobook libraries: authors, narrators, or series with
// aggregate stats (book count, total duration, per-profile progress counts)
// and up to four poster URLs for cover stacks. Query parameters:
// library_id=<id> (required), group_by=author|narrator|series (required),
// sort=name|count|duration (default name), limit, offset, include_total, q.
//
// Group names round-trip into the corresponding catalog filter fields
// (author / narrator / series), which match case-insensitively.
func (h *CatalogHandler) HandleGetAudiobookGroups(w http.ResponseWriter, r *http.Request) {
if h == nil || h.itemsH == nil || h.itemsH.browseRepo == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured")
return
}
libraryID, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("library_id")))
if err != nil || libraryID <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "library_id must be a positive integer")
return
}
groupBy, ok := catalog.ParseAudiobookGroupBy(r.URL.Query().Get("group_by"))
if !ok {
writeError(w, http.StatusBadRequest, "bad_request", "group_by must be one of author, narrator, series")
return
}
sort := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("sort")))
switch sort {
case "", "name", "count", "duration":
default:
writeError(w, http.StatusBadRequest, "bad_request", "sort must be one of name, count, duration")
return
}
limit := 200
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
n, parseErr := strconv.Atoi(raw)
if parseErr != nil || n <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "limit must be a positive integer")
return
}
limit = n
}
offset := max(catalog.ParseIntParam(r.URL.Query().Get("offset")), 0)
includeTotal := true
if raw := strings.TrimSpace(r.URL.Query().Get("include_total")); raw != "" {
parsed, parseErr := strconv.ParseBool(raw)
if parseErr != nil {
writeError(w, http.StatusBadRequest, "bad_request", "include_total must be true or false")
return
}
includeTotal = parsed
}
result, err := catalog.ListAudiobookGroups(
r.Context(),
h.itemsH.browseRepo.Pool(),
catalog.AudiobookGroupsQuery{
LibraryID: libraryID,
GroupBy: groupBy,
SearchPrefix: strings.TrimSpace(r.URL.Query().Get("q")),
IncludeTotal: includeTotal,
Sort: sort,
Limit: limit,
Offset: offset,
},
h.itemsH.accessFilter(r),
)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list audiobook groups")
return
}
resolvedPosters := h.resolveAudiobookGroupPosterURLs(r, result.Groups)
resp := audiobookGroupsResponse{
Total: result.Total,
TotalExact: result.TotalExact,
HasMore: result.HasMore,
Groups: make([]audiobookGroupResponse, 0, len(result.Groups)),
}
for _, g := range result.Groups {
posterURLs := make([]string, 0, len(g.PosterPaths))
for _, path := range g.PosterPaths {
if resolved := resolvedPosters[cardThumbnailPath(path)]; resolved.URL != "" {
posterURLs = append(posterURLs, resolved.URL)
}
}
resp.Groups = append(resp.Groups, audiobookGroupResponse{
Name: g.Name,
ItemCount: g.ItemCount,
TotalDurationSeconds: g.TotalDurationSeconds,
InProgressCount: g.InProgressCount,
FinishedCount: g.FinishedCount,
PosterURLs: posterURLs,
})
}
writeJSON(w, http.StatusOK, resp)
}
func (h *CatalogHandler) resolveAudiobookGroupPosterURLs(r *http.Request, groups []catalog.AudiobookGroup) map[string]catalog.ResolvedImageURL {
if h == nil || h.itemsH == nil || h.itemsH.detailSvc == nil || len(groups) == 0 {
return map[string]catalog.ResolvedImageURL{}
}
paths := make([]string, 0, len(groups)*4)
seen := make(map[string]struct{}, len(groups)*4)
for _, group := range groups {
for _, path := range group.PosterPaths {
normalized := cardThumbnailPath(path)
if normalized == "" {
continue
}
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
paths = append(paths, normalized)
}
}
return h.itemsH.detailSvc.PresignURLsWithExpiry(r.Context(), paths, "card")
}

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