* 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>
* 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>