The remux-free Rust data plane
The old always-on ffmpeg engine is gone, replaced by a durable Rust sidecar (masq-proxy) that resolves each stream on demand and pipes it — with retry, mirror rotation, failover groups and read-ahead buffering — to the player. Node stays the brains; Rust moves the muscle.
Node control plane · Rust data plane
Video is split across two processes that ship in the same container. Everything stateful and provider-specific stays in TypeScript; the byte work is a small standalone Rust binary driven per-stream by a "grant".
streamState · ViewSession · the WS; proxyconfigs.m3u8 manifestsNode spawns and supervises masq-proxy as a child (auto-restart with backoff). A missing or crashed sidecar is non-fatal — the app keeps managing playlists / EPG / channels / users and serving M3U / XMLTV downloads; only live playback pauses until it's back.
One private loopback channel, four jobs
Node and Rust talk over one loopback channel — POST /api/internal/*, guarded by a shared x-masq-secret (the SPA never calls it; only the Rust engine does).
masterUrl, upstreamHeaders, allowHosts, relabel, resolved proxyConfig) that Rust replays for the whole stream. The request carries an attempt number — 0 is the channel itself, N is its Nth failover child — and the grant names the serving candidate's policySource. A 410 failover_exhausted ends the walk.proxy log category — the same "View logs" drawer.The telemetry + log responses both echo the current log level, so changing verbosity on the Settings screen reaches the sidecar within one flush — no restart.
Gate → relay → resolve → fetch → bytes
?token= and the ?pl= playlist id. /api/v1 = in-app; /api/ext/v1 = external clients (the mount the composed M3U emits).502/503/504 with bounded backoff; a dead master forces a fresh resolve → mirror rotation. still won't establish → walk the channel's failover children, attempt=1,2,… until one answers or the seam replies 410 failover_exhaustedSame video, different packaging
The engine can hand a stream to a player two ways. Same bytes underneath — different packaging. HLS is the universal default; raw TS is an opt-in for players that want one continuous pipe.
A list of short clips
The server hands the player a menu of tiny clips; the player fetches them one at a time, re-checking the menu every few seconds. The player drives timing and can adapt quality.
One continuous pour
The server opens one never-ending video/mp2t pipe and pours the video down it — segments welded by #EXT-X-MEDIA-SEQUENCE, no remux. The player just receives. Like tuning to a channel.
MPEG-TS is a river of self-describing 188-byte packets — you can glue two chunks end-to-end and it still plays. That's the property that lets masqueradarr build the raw-TS stream by pure concatenation: no re-encoding, no ffmpeg. fMP4 (#EXT-X-MAP) and AES-encrypted sources can't be welded, so they auto-fall-back to HLS.
| ● HLS | ● Raw TS | |
|---|---|---|
| Shape | A playlist + many short segments | One continuous video/mp2t stream |
| Who drives timing | The player pulls each clip | The server pushes the stream |
| Requests after start | One per segment, forever | Exactly zero — one open socket |
| Adaptive bitrate | Yes — player picks a quality | No — server fixes the top variant |
| Which mount | /api/v1 and /api/ext/v1 | /api/ext/v1 only |
| Telemetry model | Poll-recency (each poll = heartbeat) | Socket (open · sbytes · close) |
| Upstream limits | Handles fMP4 & AES too | Pure MPEG-TS only (else falls back to HLS) |
| Best for | Browsers, the in-app player, modern apps | Set-top / legacy clients that want one URL |
Keeping a stream alive on flaky upstreams
Transient failures
Transport errors and 502/503/504 retry with bounded backoff; definitive 4xx/5xx are forwarded verbatim (unless failoverOnDefiniteError routes them into the walk).
Rotate within one provider
A dead resolved master forces a fresh resolve, driving dlhd / dami to re-probe and rotate to a live mirror without dropping the viewer. Same channel, same provider.
Fall through to a backup
When a channel has configured backups and still won't establish, the engine walks its ordered children (attempt=1,2,… against the resolve seam) and serves the first live one under the parent's identity, then sticks to it for the session. Backups may be on other providers. See Playlist Failover.
Clean truncation
An idle read timeout (readTimeoutMs) turns a silent upstream into a clean end instead of a hang.
Smooth jitter
A bounded in-memory buffer (bufferSizeKb) smooths jitter and fixes the chunked / no-Content-Length byte undercount that used to fake buffering.
Off the hot path
Events are coalesced and posted off the byte path, so reporting never blocks the stream.
Flat TS pipe
With outputFormat: 'ts', the external mount serves one continuous video/mp2t (no remux); fMP4 / AES auto-fall back to HLS.
proxyconfigsTwo tiers, doc-level fallback
The engine's knobs live in the proxyconfigs collection, edited in the UI and resolved by Node into each grant — Rust never reads MongoDB. A per-install (Default) (_id:'app', Settings → Advanced) and an optional per-playlist (Custom) (_id:'app_<playlistId>', the playlist drawer) that fully replaces the Default for that playlist.
| Knob | Status | Effect |
|---|---|---|
| headerOverrides | live | Extra upstream headers, merged over the adapter's (operator wins). |
| connectTimeoutMs, maxRedirects | live | Per-config upstream HTTP client (cached in Rust). |
| readTimeoutMs, bufferSizeKb | live | Per-stream stall timeout + read-ahead buffer size. |
outputFormat (hls | ts) | live | Distribution shape (ts = continuous MPEG-TS, external mount only). |
| streamInfRedux | live | Opt-in (default off) non-destructive reorder of the HLS master so the first #EXT-X-STREAM-INF lands inside a strict player's manifest probe window. External mount only; off = byte-identical output. |
| failoverEnabled | live | Walk a channel's ordered failover children on an establish failure. Default on — configuring a group is the real opt-in. |
| failoverOnDefiniteError | live | Also treat a definitive upstream 4xx/5xx as a failover trigger. Default off — it changes forward-verbatim semantics. |
| segmentCacheTtlSec | reserved | Shipped in the grant, not yet enforced. |
Some players (VLC among them) only peek at the first few kilobytes of an HLS master before deciding it isn't video. When a source pads its master with long comments or many audio renditions, the first #EXT-X-STREAM-INF can fall outside that window. streamInfRedux reorders the master — moving the marker in, changing nothing else and deleting nothing — so a strict player recognizes the stream.
MASQ_EDGETake Node's event loop out of the byte path
By default Node is the public front door and Rust a loopback sidecar. MASQ_EDGE=1 inverts the topology: Rust binds the public port and serves streams in-process, reverse-proxying everything else (SPA, /api/*, downloads, WebSockets) back to Node on a loopback port. Same public port and DOMAIN; fully reversible.
Flip it for many concurrent or high-bitrate viewers (the main reason), to keep the management UI snappy under streaming load, or on constrained hardware (Pi / small VPS). Leave it off for a personal setup — the default sidecar path is simpler, keeps strictly per-request token revocation, and has a smaller blast radius. In edge mode the token gate becomes a per-request check against a Rust auth cache (revocation within a 30-second TTL), and the edge synthesizes client identity server-side, ignoring inbound x-masq-*.
Playback is live over the Rust engine (HLS + raw-TS). The part still pending is remux / transcode (e.g. HDHomeRun TS→HLS) — there is no ffmpeg, which is exactly why fMP4/AES fall back to HLS and HDHomeRun playback is dormant. The ?fmt=ts per-request override and the HDHomeRun-tuner / Xtream-panel consumers are on the roadmap; segmentCacheTtlSec ships in the grant but isn't enforced yet. Edge mode is build- and unit-verified; a full live-stack E2E pass is still pending — treat it as an opt-in scale topology.
masq-proxy · tsmux.rs · proxy.rs
.claude/artifacts · HLS vs Raw TS explainer
next → Users & access