Commit Graph
5 Commits
Author SHA1 Message Date
854d07cf8f feat(playback): add protocol v3 planning and recovery (#398)
* docs(playback): plan protocol v3 server implementation

* docs(playback): incorporate protocol v3 review

* feat(playback): implement protocol v3 server

* fix(playback): persist empty route diagnostics

* feat(playback): harden protocol v3 HDR routing

* feat(playback): complete protocol v3 client contract

* fix(playback): harden protocol v3 recovery

* fix(playback): restore dovi_rpu strip filter for DV remuxes

The v3 work renamed the Dolby Vision strip recipe to a dovi_split=mode=bl
bitstream filter that does not exist in stock FFmpeg or jellyfin-ffmpeg;
the probe failed closed on every deployment, disabling the new validated
DV7-to-HDR10 route and regressing the previously working dovi_rpu=strip=1
remux path from main. Restore dovi_rpu across the probe, remux and HLS
copy arguments, and the recipe-card constant.

Also from review: validate the remux DV mode for every profile (garbage
modes on non-P7 sources silently no-opped), reject preserve mode for P7
outright (a base-layer-only remux cannot preserve dual-layer DV), tag
dvhe sample entries only for the explicit v3 preserve recipe so legacy
web/jellycompat remuxes keep their pre-v3 hev1 labeling, and honor the
token-frozen DV mode in the proxy remux path instead of legacy-auto.

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

* fix(playback): correct v3 planner policy and contract validation

Review fixes to the v3 planner and wire contracts:

- Bar Profile 7 sources from the non-strip progressive remux route: a
  base-layer-only remux can never deliver native dual-layer DV, so the
  planner no longer emits plans claiming validated Dolby Vision while
  the executed remux drops the enhancement layer.
- Accept the device-quirks feature flag from either capability location,
  matching every other dual-location feature check.
- Treat legacy hdr_unknown rows as HDR10 for HDR10-capable clients with
  a degradation warning instead of leaving them unplayable under v3.
- Honor bandwidth_cap_kbps as a hard ceiling in every quality mode and
  wire the previously dead Metered signal into conservative auto rungs.
- Degrade to the validated source-quality route instead of a terminal
  when only an implicit quality reduction demanded an unsupported
  transcode; explicit user-selected rungs keep terminal behavior.
- Bound inner capability lists and strings; compare attempt keys exactly
  instead of case-folded; make ParseTrackIDV3 strict about canonical
  numerics; accept dvdsub/pgssub/dvbsub aliases and stop promising
  burn-in for unknown subtitle codecs; probe every h264 encoder rather
  than requiring libx264; normalize the file-level bitrate fallback.
- Evaluate subtitle renderability against the engine each candidate
  route executes on, not always media3_direct.
- Pin the with-quirks attempt-key preimage arity in the cross-language
  fixture so the Kotlin client stays in lockstep.

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

* fix(playback): harden v3 control-plane reliability

Review fixes to the v3 session, store, and handler layer:

- Bound concurrent replans with a slot semaphore: each replan pins a
  pooled connection for its advisory lock while issuing further store
  queries from the same pool, so an unbounded recovery storm could turn
  every connection into a lock holder and deadlock the server.
- Make CompleteReplan a real compare-and-swap (base-revision predicate,
  ErrReplanSupersededV3) and map BeginReplan insert races to a replay
  instead of a raw unique violation.
- Fingerprint start requests (request_digest column): an attempt ID
  reused with different input is now a 409-style conflict rather than a
  silent replay, and both replay paths check session liveness so dead
  sessions surface as retryable terminals.
- Pre-delete expired attempt rows on SaveAttempt so a retry during the
  cleanup window cannot wedge on an unreachable conflict.
- Align the in-memory store's semantics with Postgres and add DB-backed
  planstore tests (SILO_TEST_DATABASE_URL), including a regression test
  inserting every route-event name against the real CHECK constraint.
- Session manager: v3 route-set updates own RemuxDVMode outright so a
  replan onto an SDR source clears a stale strip mode; replacement
  reservations survive unrelated legacy stream updates; replacement
  admission excludes the replaced session explicitly instead of
  decrementing totals it may no longer be part of; the admission CAS
  loop is bounded and decider errors are logged.
- Map transient store failures to 500s instead of terminal 404/403s;
  authorize route events via identity-only projections after the rate
  limiter; keep sanitized diagnostics deterministic.
- Merge the server-computed durable plan key into replan exclusions so
  unreproducible client history cannot re-select the failed route.
- Remap tracks only when the effective edition changes (a same-file
  replan no longer switches audio to a lookalike track) and remap
  ID-only subtitle selections on edition fallback.
- Cache the v3/shadow feature flags for five seconds instead of one
  settings SELECT per playback request; stop remote transports
  best-effort when the start call times out; carry dvm/tid claims and
  the transport-scoped job identity through the legacy audio-change
  re-mint; index playback_route_events(received_at) for the retention
  delete; run store maintenance for DB-less deployments too.

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

* fix(transcode): reap idle node jobs and gate WebVTT conversion

- Add an idle reaper to the transcode node: a job untouched by manifest
  or segment requests for ten minutes is closed and unregistered. After
  a v3 replan retires a transport ID, a stale in-flight stream token
  could resurrect the old job via reconstruct and encode to end-of-file
  for nobody; jobs waiting on readiness count registration as access
  and are never reaped mid-wait, and reaping keeps the recipe so a
  still-valid token reconstructs on the next hit.
- Reject bitmap subtitle tracks (PGS) on the .vtt conversion path with
  415 before headers are written instead of spawning an ffmpeg command
  that always fails mid-response, and make the extract-format override
  fall back to source-driven mapping for bitmap codecs.
- Drain error bodies on non-202 node responses so the HTTP transport
  can reuse connections.
- Pin the transcode-dir cleanup separator-boundary semantics with a
  regression test (a session ID sharing another's prefix must not
  retain foreign directories).

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

* fix(playback): close v3 planner policy gaps from review

- Clamp the final transcode bitrate to bandwidth_cap_kbps: the ladder has
  no rung below 480p/1500kbps, so lower caps were silently exceeded even
  though the cap is documented as a hard delivery ceiling.
- Treat video-only media as audio-compatible instead of forcing an AAC
  conversion (or an audio_conversion_unsupported terminal) onto a file
  with no audio stream. Tracks whose codec failed to probe keep the gate.
- Only promise a bitmap subtitle sidecar for embedded PGS with an engine
  that renders embedded bitmap: external/downloaded bitmap and embedded
  DVD/DVB published artifact URLs that always failed at fetch. They now
  fall through to burn-in or its terminal.
- Accept client_video_transformations_v1 from either client_features or
  the nested context when validating client-executor transformations,
  matching the planner's dual-source reads.

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

* fix(playback): probe and execute DV remuxes with one ffmpeg binary

The v3 transformation registry probed the configured playback.ffmpeg_path
while progressive remux execution resolved the process-global discovery
path, so a deployment where only one binary carries dovi_rpu could plan a
server_dv7_to_hdr10 route and then fail it at stream time. Resolution now
goes through a shared ResolveFFmpegPath (configured path first, discovery
fallback — the same rule the transcode pipeline already used), the
dovi_rpu probe is cached per binary path, and the stream handler and proxy
worker pass their configured path into ServeRemuxWithDVMode.

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

* fix(playback): harden v3 replan identity and control-plane limits

- Seed failure-replan track selections from the durable current plan
  before overlaying the request: after an alternate-version fallback the
  normalized request still carries requested-edition track IDs, so a
  replan omitting unchanged tracks was rejected as a track/file mismatch.
- Remap ID-only audio selections across edition changes (parse the ID to
  an index like the subtitle remap already does) instead of leaving a
  stale file-bound ID to fail validation.
- Release the node planner reservation when a prepared remote transport
  rolls back after the node accepted the job; repeated failed starts
  could otherwise pin max-job/bandwidth budgets for the full reservation
  age.
- Size the replan semaphore below the PostgreSQL pool via a store
  capacity advisor: with max_connections at or below the fixed bound,
  advisory-lock holders could starve the inner store queries they need
  to finish.
- Contain shadow-planner panics with a recover boundary; it runs on a
  bare goroutine where an escaped panic kills the process for what is
  telemetry-only work. Document why the memory store's session lock is
  deliberately a no-op.

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

* fix(transcode): serialize node job teardown against reconstructs

- Look up and touch manifest/segment sessions in one critical section so
  the idle reaper cannot unregister a job between the lookup and its
  liveness refresh.
- Re-validate each reap candidate under the per-session lifecycle lock
  before closing it: Close removes the output directory, and without the
  lock it could race a token reconstruct and wipe the segments the fresh
  ffmpeg is writing.
- Take the lifecycle lock in handleStop so a stop racing a RequireReady
  start's readiness wait blocks until registration and tears the job
  down, instead of 404ing and orphaning the ffmpeg until the reaper.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:51:27 -04:00
c1c110e3d2 feat(playback): improve web subtitles and track selection (#362)
* fix(player): keep text subtitles in sync across copy-mode restarts and sparse cue windows

- Rebase already-loaded cues in place when streamOriginSeconds changes
  (copy-mode session restart) instead of leaving them offset by the delta.
- Stop inferring end-of-input from where a window's cues stop; only the
  known media duration marks EOF, so a dialogue gap no longer silently
  ends prefetching for the rest of playback.
- Anchor the first window fetch to the intended start position (resume
  target or pending seek) while the element still reports currentTime=0,
  and reset coverage on forward seeks past the fetched window.

* fix(playback): align encoded transcode start with the declared segment boundary

A mid-segment start (resume, seek restart, audio switch) spawned ffmpeg at
the raw seek position while labeling its first segment with the grid number,
whose synthetic-manifest start is up to one segment earlier. hls.js aligns
the first fragment's content to that declared position, shifting the whole
session's timeline late by seek mod segment_duration (0-2s): subtitles
trail dialogue by a constant per-session offset and progress/resume
positions drift by the same amount.

Snap the ffmpeg start position down to the segment boundary for encoded
sessions so declared and produced timelines match exactly; the player
still seeks to the precise requested position. Copy-mode sessions serve
ffmpeg's real manifest and keep the raw seek.

* fix(api): forward http.Flusher through response-writer middleware wrappers

Streamed subtitle extracts (and any progressive response) flush per chunk
via an http.Flusher assertion, but none of the status-capturing middleware
wrappers implemented Flush, so the assertion failed and cues sat in Go's
response buffer until ffmpeg finished. On large remuxes where a 600s window
takes 20s+ to demux, captions appeared only when the whole window completed
instead of within the first seconds.

Give every wrapper a Flush() (satisfies plain assertions, including chi's
Compress) and Unwrap() (satisfies http.ResponseController). The jellycompat
image-proxy tag rewriter flushes only in passthrough mode since it buffers
JSON bodies for rewriting. Regression test asserts the API chain forwards
Flush end to end.

* feat(player): PGS subtitles honor size, position, and background settings

Port the tvOS/iOS bitmap-cue styling to the web player. libpgs now decodes
in worker mode but draws to a hidden source canvas on the main thread; a
compositor detects cue regions from the frame's alpha channel and re-places
them on a visible overlay canvas per the shared subtitle appearance
settings: size scale (with the 0.85 authored-size compensation), vertical
position preset (dialogue-band cues only — floating signs keep authored
placement, matching the Apple implementation), and the background box.
Font family, text color, and outline are baked into the source pixels and
remain inapplicable.

* fix(player): anchor PGS position presets to the text overlay's reference frame

The initial port used silo-apple's 30/1080 bottom margin and video-relative
lower-third/top anchors; the web text overlay anchors to a 16:9 reference
frame with 7%/18% offsets that extends into the letterbox for wide content.
Use the same anchors so PGS dialogue lands exactly where SRT text does.

* feat(player): size PGS cues to match the text subtitle line height

Replace the authored-size ladder (0.85 × font-size ratio) with per-cue
text-line matching: the region detector reports the tallest text line inside
each cue, and the compositor scales the cue so one line of bitmap text
renders at the same pixel height as the SRT overlay's font at the current
preset. Authored size differences between discs no longer leak through;
upscaling is capped at 2.5× to keep small bitmaps from going blurry.

* feat(subtitles): opt-in windowed PGS extraction to cut mid-file load latency

PGS extracts always demuxed the source from byte 0, so starting a large
remux mid-file meant minutes before the first bitmap cue. The web player
now opts in to windowed extraction (?windowed=1&position=&duration=) and
re-points libpgs at a fresh window on seeks and near coverage end; ffmpeg
input-side -ss with -copyts keeps absolute source timestamps. Without the
explicit opt-in the endpoint behaves byte-identically, so Apple/Android
and other single-fetch consumers are unaffected. ASS remains
unconditionally non-windowed (its header only exists at offset 0).

* feat(playback): cache extracted PGS subtitle tracks

Every selection of an embedded PGS track re-ran a full ffmpeg extract
that demuxes the entire source file from byte 0 — minutes for a large
remux — and responses were Cache-Control: no-store, so repeat
selections, re-watches, and multiple viewers all paid full price.

Add a disk cache for full-track .sup extracts under
<transcode_dir>/subtitle-cache, created lazily:

- Keyed by source path hash + subtitle stream ordinal + source
  mtime+size (encoded in the filename), so a replaced source file
  implicitly invalidates its entries; the source is stat'ed on every
  lookup.
- Cache miss: ffmpeg stdout is teed to the response (first viewer
  still streams progressively, first-byte latency unchanged) and into
  a temp file that is fsynced and atomically renamed into the cache
  on clean ffmpeg exit. Any error — ffmpeg failure, client disconnect,
  tee write failure, or the source changing mid-extract — discards
  the temp file, so a partial entry is never served.
- Cache hit: served via http.ServeContent (Range support,
  Content-Length, Last-Modified from the source mtime) with a
  revalidatable Cache-Control instead of no-store.
- Concurrent requests for the same in-flight track run their own
  uncached extract (mutex + in-flight key set) rather than blocking
  on another client's connection.
- Scan-on-commit LRU eviction under a 2 GiB cap (recency tracked by
  bumping entry mtime on hit; atime is unreliable under relatime),
  plus sweep of crash-orphaned .part temp files.
- Windowed PGS requests (?windowed=) bypass the cache in both
  directions: their output covers only a slice of the track.

Both the integrated API handler and the standalone proxy subtitle
path share the same playback.SubtitleCache.ServeSUPExtract helper.
VTT (already windowed and fast) and ASS (small) stay uncached. No
API surface change.

AI-use disclosure: implemented with Claude Code.

* fix(playback): check Close error returns in subtitle cache paths

Silence errcheck on the cache-hit defer and the test's simulated
disk-full Close.

AI-use disclosure: implemented with Claude Code.

* feat(playback): warm PGS cache in background and window from cached track

Windowed PGS requests bypassed the cache entirely, so every window fetch
re-demuxed the multi-GB original file. Now a windowed miss kicks off a
detached background warm (full-track extract into the cache, at most 2
concurrent server-wide, coalesced with client-driven fills), and once the
entry exists windowed extracts read the 15-80MB cached .sup instead —
seeks and re-enables become near-instant after the first load. Verified
empirically that ffmpeg preserves absolute PTS when windowing a sup input.

* feat(player): hold playback while PGS subtitle cues load

When a PGS track is enabled (or a seek lands outside the fetched window),
extraction takes seconds and dialogue could play unsubtitled. The player
now pauses until the renderer's parsed data covers the playhead — tracked
via libpgs' parsed-timestamp watermark, the exact predicate it renders
by — showing a "Loading subtitles…" indicator after 500ms. User
play/pause always wins over the hold, a 20s safety timeout prevents
stranding playback, and background prefetch never pauses. If future
libpgs versions reshape the observed internals the hook degrades to the
old play-through behavior.

* perf(player): shrink uncached PGS window to 600s

Draining a windowed extract from the source reads the full interleaved
container across the window (~1GB per 100s of remux on measured
hardware); a 3600s window cost ~12GB of reads per fetch while cold. Once
the server cache is warm a window costs milliseconds regardless of size,
so smaller windows only add trivially cheap re-fetches.

* feat(subtitles): burn in PGS/bitmap subtitles for the web player

The web player rendered PGS client-side via libpgs, which required
extracting the .sup track — a cold ffmpeg demux that took seconds even
windowed, since c:s copy still reads the whole interleaved container up
to the playhead. Every other server (Plex, Jellyfin default, Emby) burns
image subtitles into the video instead, and that is the only path with
no per-seek extraction cost.

Selecting a bitmap subtitle (PGS/DVD/DVB) now restarts the transcode
with subtitle_burn_in at the current aligned position, reusing the same
restart machinery as an audio/quality switch so the segment-boundary
timeline alignment holds. The server composites the decoded subtitle
onto the video with an overlay filter_complex graph (libass's subtitles=
filter is text-only); overlay runs at native resolution before any
target scaling, and hardware pipelines round-trip through CPU like the
text path. Burn-in forces a video encode, so copy-video recipes are
upgraded to h264 both client- and server-side.

Text subtitles keep the instant, styled, client-side path. The .sup
streaming endpoints, cache, and windowing are retained for the Apple
client, which renders PGS natively. The now-dead web PGS stack
(usePGSSubtitles, pgsPlacement, libpgs dep) is removed.

Tradeoff: bitmap subtitles no longer honor web appearance settings
(baked into the video) and toggling one restarts the transcode
(~1-2s buffering), matching Plex behavior.

* fix(player): rebuild text subtitle track when turning off PGS burn-in

Selecting an SRT track that turned off bitmap burn-in restarted the
transcode, and the client TextTrack built in that same moment was
orphaned when the <video> element reloaded, so the subtitles never
rendered (and a seek could not recover the dead track). Rebuild the
text track once the new stream settles, gated on the burn-in-off
transition so quality/audio switches and copy-mode seek restarts keep
their subtitles without a needless re-extract.

* fix(player): render web subtitles behind the control HUD

The text subtitle overlay sat at z-20, above the controls layer (z-10),
so cues painted over the bottom HUD and cluttered the control bar. Drop
it to z-[5] — above the video, below the controls — so the HUD paints
over the cues while it is visible. When controls are hidden the whole
controls layer is opacity-0, so cues remain fully visible.

* feat(player): lift web subtitles above the control bar while it's visible

Rather than hiding bottom-anchored cues behind the HUD, raise them just
above the control bar (measured height + a small gap) whenever the bar
is visible in the foreground player, then settle them back when it
hides. The bar is a roughly fixed pixel height while the cue offset
scales with the player, so the bar is measured via ResizeObserver
rather than hardcoded. Top-anchored cues never collide with the bottom
HUD, so they stay put. z-[5] is retained as a safety so any residual
overlap tucks behind the bar.

* fix(player): coalesce same-tick transcode restarts into one dispatch

Starting playback with a persisted bitmap subtitle fired transcode/start
twice within milliseconds: the auto-start effect dispatched before
subtitle auto-selection restored the burn-in, whose effect then forced a
second start. The first request was already on the wire (no abort signal
was passed to fetch), so the server spawned an ffmpeg only to kill it
for the second start — visible in production as an ffmpeg exit error
~1ms after every such session start, and slowing time to first frame.

Defer the network dispatch by one macrotask so back-to-back restart
calls in a tick collapse into a single request carrying the final
parameters; state updates stay synchronous. Pass the abort signal into
playerFetch so a superseded in-flight request is actually cancelled,
and drop any deferred dispatch on unmount so a stray transcode/start
cannot land after the session's exit DELETE.

* fix(catalog): resolve effective subtitle defaults for movie item details

Movie pre-play subtitle selectors were missing the effective defaults
(including per-item overrides saved from a previous play) that episodes
and watch payloads already resolve. Extract applyToItemDetail/
applyToWatchDetail helpers and apply defaults for movies in
buildMediaItemDetail. The SubtitlesPopover now also eagerly loads
downloaded subtitles when the saved preference points at one so the
closed trigger's Auto summary reflects the override.

* feat(player): scale subtitle font size with the rendered video

Replace fixed rem font sizes with px values defined at a 720px 16:9
reference height, scaled proportionally with the actually-rendered
video (object-fit: contain) so subtitles keep the same relative size
as the window grows or shrinks, with a 12px legibility floor. Rename
useSubtitlePositionStyle to useSubtitleLayout, returning both the
position style and the font scale, and add unit tests for the
appearance helpers.

* fix(player): satisfy strict index checks in transcode quality test

* feat(player): let the pre-play Auto option clear the saved subtitle override

A manual in-player subtitle selection persists as an 'always' override
for that movie/series, but nothing in the UI could undo it — auto
selection stayed pinned to the chosen track forever. Choosing 'Auto' in
the pre-play subtitles popover now also deletes the stored preference
(movie content ID / episode series ID) and invalidates item details so
profile-level auto selection applies again.

* feat(player): persist pre-play subtitle selections as the item override

Choosing a track (or Off) in the pre-play subtitles popover only lived
in component state: it applied to that playback session but vanished on
returning to the detail page. Persist it through PUT /subtitle-prefs —
the same 'always'/'off' override a manual in-player selection saves —
keyed by movie content ID or episode series ID, and invalidate item
details so the effective defaults reflect it immediately.

* feat(ui): show the saved subtitle override and richer pre-play pill summaries

A stored per-item override displayed as 'Auto: <language>', hiding both
that an override exists and which track it is. The pre-play subtitle
pill now shows the resolved track directly (name with (SDH)/(Forced)
markers plus format, skipping markers the name already carries), the
matching list row gets the checkmark instead of Auto, and the Auto row
reads 'Reset to profile defaults'. Subtitle, audio, edition, and
version pill summaries also truncate much later (max-w-44/sm:max-w-64).

* fix(player): recover text subtitles from stream reloads and failed window fetches

Three failure modes could silently freeze or stop web text subtitles:

- A stream restart (seek-triggered transcode restart, quality/audio
  switch) reloads the <video> element and can orphan the programmatic
  TextTrack — cuechange stops firing and the last cue freezes on screen.
  Only the PGS-burn-in-off transition rebuilt the track. Now every
  settled stream URL change bumps the generation, and the rebuild
  carries loaded cues (converted back to source time) and window
  coverage over so it costs no refetch.
- The sliding-window fetcher committed windowEnd before the fetch ran,
  so a failed or hung window counted as covered and was never retried —
  subtitles silently stopped for up to 10 minutes. Coverage now commits
  only after the window streams in fully; failures leave the range
  uncovered and retry after a 5s backoff.
- A hung extraction (one fetch in flight at a time, no deadline) blocked
  every future window for the session. Reads now arm a 30s stall timer
  that aborts a response which stops delivering chunks; slow-but-
  progressing streams keep resetting it.

Diagnosed from a session where ffmpeg took 69s to stream one subtitle
window and a transcode restart landed mid-fetch, freezing the active cue.

* fix(playback): keep subtitle selections stable across file changes

* fix(web): clarify subtitle labels and positioning

* fix(player): hide HUD when pointer leaves

* fix(web): tidy subtitle track badges

* fix(playback): remap audio tracks across file versions

* fix(web): tidy audio track labels

* docs(playback): clarify bitmap subtitle appearance

* fix(playback): preserve selection state on restart

* fix(http): preserve response state across flushes

* fix(web): preserve pending quality for burn-in

* fix(playback): preserve subtitle inventory identity

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-07-10 08:21:26 -04:00
203a18ae83 feat(observability): OpenTelemetry logs+traces with secret redaction and slog standardization (#290)
* feat(observability): OpenTelemetry logs+traces with secret redaction

Part of #265. Adds opt-in OpenTelemetry (logs + traces) alongside the existing
stderr + opslog pipeline, plus secret redaction on all sinks. Default-off: with
no OTEL_* / SILO_OTEL_ENABLED config, behavior is unchanged.

Bootstrap (internal/telemetry):
- Setup() builds one shared resource, a TracerProvider (parent-based trace-id
  ratio sampler), a LoggerProvider, and the W3C TraceContext+Baggage propagator
  from env. It installs NO MeterProvider — metrics stay on Prometheus, and the
  built-in no-op global MeterProvider keeps the trace instrumentation libs from
  double-emitting. Shutdown is deferred with a flush timeout.
- Logs are bridged via otelslog fan-out (slog.MultiHandler), level-gated by the
  shared LevelVar and best-effort so a failing collector can't break the console
  or DB branches. stderr + opslog stay untouched.

Secret redaction (internal/logredact):
- A slog.Handler masks secret-keyed attributes (password, token, api_key,
  authorization, cookie, ...) — including .With-bound attrs, nested groups,
  secret-keyed group subtrees, and values behind a LogValuer — on the console
  and OTLP sinks, with a no-op fast path when a record has no secret keys.
  opslog.shouldRedact delegates to logredact.SecretKey so all sinks share one
  marker list.

Rotation is infra-managed (no custom file sink): container runtime for stderr,
collector/backend for OTLP, opslog partition-pruning for the DB. Documented in
docs/architecture/observability.md.

Verification: go build ./..., go vet, gofmt -l — clean; go test
./internal/telemetry/ ./internal/logredact/ -race pass.

AI-use disclosure: implemented with AI assistance (Claude Code), including
adversarial reviews that hardened the bootstrap and fixed two redaction leak
paths; reviewed by the author.

* refactor(observability): slog context+component sweep, sloglint gate (phase 3)

Part of #265. Builds on the OTel bootstrap + redaction commit.

Standardizes every log call site onto the context-carrying slog variants so
records correlate with the active OpenTelemetry trace, and locks the standard
in with a machine gate so future code (human- or AI-authored) can't drift back.

- Call-site sweep: converted the remaining slog.<Level>(...) calls to the
  slog.<Level>Context(ctx, ...) form wherever a context.Context is in scope
  (background/init calls with no ctx are left as-is), across 183 files. Applied
  via a type-aware AST codemod. Log levels and message strings are preserved
  verbatim; a component attr (canonical per-package name) is added to direct
  package-level slog calls. Bound-logger calls keep their existing .With
  bindings. The main.go and telemetry package conversions rode with their file
  in the previous commit to keep each file within a single commit.
- Enforcement (.golangci.yml): enable sloglint with context=scope, static-msg,
  key-naming-case=snake, no-mixed-args. After the sweep all four report zero
  violations repo-wide (tests included), so make lint / CI now blocks any
  regression to the non-context form. The gate ships with the sweep because it
  cannot be green until the legacy sites are converted.

Metrics remain on Prometheus; no behavior change to /metrics or Grafana.

Verification: go build ./..., go vet ./..., gofmt -l — clean; sloglint (all 4
rules) 0 violations repo-wide; log levels verified unchanged.

AI-use disclosure: implemented with AI assistance (Claude Code), including the
codemod; reviewed by the author.

* fix(observability): honor per-signal OTLP protocol and secret WithGroup names

Two Codex review findings on PR #290:

- telemetry: OTEL_EXPORTER_OTLP_{TRACES,LOGS}_PROTOCOL now override the
  generic OTEL_EXPORTER_OTLP_PROTOCOL per signal, so mixed collector
  setups (e.g. HTTP logs + gRPC traces) build the right exporter.
- logredact: entering a group whose name is secret-bearing (e.g.
  WithGroup("authorization")) now masks every leaf in that subtree,
  matching how slog.Group("authorization", ...) is masked as a whole.

* fix(observability): address review feedback on telemetry bootstrap

- Telemetry setup failure no longer kills boot: Setup returns usable
  no-op providers alongside the error and main logs and continues with
  telemetry disabled, honoring the best-effort contract.
- Honor OTEL_TRACES_SAMPLER (always_on/off, traceidratio, parentbased_*
  variants); unsupported values fall back to parentbased_traceidratio.
- Attach node identity as semconv service.instance.id instead of the
  non-semconv node.name.
- Rename opslog retention-scope log attrs to target_component/target_level
  so they no longer collide with the canonical component routing key, and
  tag those lines with component=opslog.
- Fix stale levelGated comment casing; use WarnContext in the telemetry
  shutdown defer; document the LogValuer double-resolve on the redaction
  slow path.

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-07-09 08:53:52 -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
Silo Server Migration c085b12fd1 Initial Silo migration 2026-05-22 23:26:56 -04:00