masqueradarr
local origin · 08 republishing the stream

masqueradarr becomes the origin

Turn one switch on and the engine stops forwarding the provider's playlist. Instead, one ingest per channel — not per viewer — follows the upstream, decrypts and normalizes each segment into an in-memory ring, and both output shapes are rendered from that ring. What reaches your player is a stream we authored.

the one-line version

Remux-free is still true; “passthrough” no longer is. Nothing is ever re-encoded — but with originEnabled on, the bytes a player sees come out of our RAM, under our numbering, with the provider erased from every line of the manifest.

01 Start from zero

A rewriting proxy still shows you their timeline

Everything on the Video proxy engine page describes a rewriting proxy. The upstream playlist is fetched, its URIs are rewritten to point back through masqueradarr, and the rest is passed through. That hides hostnames — which is most of what people want from a proxy — but it leaves two things unsolved.

First, the client is still looking at the provider's timeline: their media sequence, their #EXT-X-KEY, even their vendor tags. You are relaying someone else's document. Second, every viewer is a separate trip upstream. Two people watching the same channel means two full pulls from the provider — the same bytes, fetched twice, because there was nowhere to put them.

● originEnabled: false

Forward their playlist

Fetch the upstream manifest, rewrite each URI to route back through us, pass everything else along. The player follows their timeline through our door.

One upstream fetch per viewer. Encrypted sources are relayed still encrypted — the player does the decrypting, using their key, fetched through us.

● originEnabled: true

Publish our own

One shared ingest follows the upstream, decrypts each segment, normalizes it onto one timeline, and pushes the plaintext into an in-memory ring. Every client is served from that ring.

One upstream fetch per channel. The manifest is a document we wrote: our sequence numbers, our segment paths, no key line at all.

it is a switch, not a mode

Both paths live in the same binary, and the choice is made per request from the playlist's proxy config. You can run one exported playlist as an origin and leave every other playlist — and the in-app player — on the rewriting path. Off is the default, and off is byte-for-byte the output you had before.

02 The shape of it

One ingest, a ring of pairs, two renderers

The design has a left half and a right half that run at different rates, and that is the whole claim. Everything on the left happens once per channel. Everything on the right reads the same cached bytes, as many times as there are viewers. The engine names them Side-1 (ingest, tagged iop) and Side-2 (serve, tagged oop) — and it tags every log line and every telemetry frame with which side it came from.

There is still exactly one ring. What changed is what an entry holds: on a demuxed source a single entry carries the video segment and its audio partner, so the two published playlists are identical by construction and a renderer can never serve half a pair.

local origin — one ingest, two renderers iop · ring · oop
one ingest the left lane runs once per channel; the right lane runs once per viewer — watch the two cadences, they never couple.
Side-1 · ingest · iop
Upstream
HLS — muxed or demuxed
or a bare TS socket
follow + fetch
poll · retry · pair the lanes
bare TS → PAT/PMT → cut at RAI
decrypt AES-128
key cached per rotation
ciphertext never enters the ring
normalize
canonical pids · one clock
both lanes, or neither
RING · RAM only
our own seq · originRingMb
oldest evicted · 3-seg floor
one entry · both lanes · evicted together
Side-2 · serve · oop
authored manifest
our MEDIA-SEQUENCE / EXTINF
demuxed → master + v/a.m3u8
raw TS · concat or weave
one continuous video/mp2t
demuxed → woven into one program
N viewers
a 2nd viewer costs
NO extra upstream

Ingress and egress are now INDEPENDENT: one ingest feeds N viewers, so ingest bytes are reported separately (kind:"iop") and never folded into egress counts.

Off by default — with originEnabled false the engine proxies the upstream playlist exactly as before and this whole path is skipped.

Discontinuity comes from three signals only — the upstream's own tag, a media-sequence gap, or a provider session renewal. Never guessed from URL shape, and often absorbed rather than published.

  • Upstream
  • Side-1 · ingest (iop)
  • Ring · RAM
  • Side-2 · serve (oop)
  • Clients
why the ring sits in its own column

It is the seam. One writer, two readers. The ingest never knows how many viewers there are, and neither renderer ever knows whether the upstream was a playlist or a socket, whether it was encrypted, or how many renditions it arrived on. All either side shares is a queue of decrypted, normalized entries in RAM — which is exactly why adding a viewer costs nothing upstream, and why every hard problem on this page is solved once, on the left, for both output shapes at the same time.

03 Two knobs, four outputs

originEnabled × outputFormat

The new switch is orthogonal to the one you already have. outputFormat decides the shape handed to the player; originEnabled decides where the bytes come from. Every combination is valid.

originEnabled: falseoriginEnabled: true
outputFormat: hlsthe upstream playlist, URI-rewrittena playlist we authored + our own segment paths
outputFormat: tsupstream segments concatenated per viewerthe same ring concatenated (decrypts); a demuxed source is woven into one program
read the bottom-left cell again

Per viewer appears in exactly one cell. That asymmetry is the bandwidth claim — everywhere else on the right-hand column, the work is per channel.

What the player actually receives

In origin mode the manifest contains no provider host, path, session id or query; no #EXT-X-KEY; no vendor tags; no proxy hop URLs. It is short enough to read in full:

# the authored media playlist — every line is ours
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6          # ceil() of the longest EXTINF in the window, min 1
#EXT-X-MEDIA-SEQUENCE:1042     # the seq of the ring's oldest held entry
#EXT-X-DISCONTINUITY-SEQUENCE:3 # splices that have already left the window
#EXT-X-PROGRAM-DATE-TIME:2026-08-05T20:14:07.812Z
#EXTINF:5.005,
/api/ext/v1/pluto/o/<enc-entry>/0-1042.ts?token=…&pl=…
#EXTINF:5.005,
/api/ext/v1/pluto/o/<enc-entry>/0-1043.ts?token=…&pl=…
#EXT-X-DISCONTINUITY   only at a real splice (see below)
#EXTINF:4.984,
/api/ext/v1/pluto/o/<enc-entry>/0-1044.ts?token=…&pl=…
  • No #EXT-X-ENDLIST and no #EXT-X-PLAYLIST-TYPE, so any standard player treats it as a live sliding window.
  • The window is the ring. There is no separate window length — the playlist lists whatever the ring currently holds. That means originRingMb is simultaneously the RAM budget and the advertised live-window length.
  • Every segment line carries the token. These paths are guessable by construction — a source, an entry, and two integers — so the token on each line is what keeps per-account governance meaningful. It is not a separate gate.
  • #EXT-X-PROGRAM-DATE-TIME is a real timestamp, emitted at the head of the window from that entry's ingest wall-clock — and again after every discontinuity, so a player never has to carry one anchor across a splice. It discloses when we ingested, not who we ingested from — but it is not nothing, and it is the only field in the document that isn't a counter.
  • On a demuxed source this is not what the entry URL returns. It answers with a small master we author instead, over two playlists of exactly this shape — one for video, one for audio, identical apart from the segment URIs.
  • Two numbers in the path. <generation>-<seq> is not redundant. That is its own section.
04 Side-1 · the ingest iop

One per channel, refcounted, self-reaping

An ingest is keyed by source + entry URL — not by viewer, not by token, not by playlist. Two people watching the same channel from different accounts and different playlists share exactly one ingest. Starting one is idempotent: whichever request gets there first spawns the task, and everyone after that just increments a subscriber count.

the ingest loop resolve · poll · fetch · decrypt · push
1
first viewer arrives
Subscribe, and maybe spawn
The registry is checked for this channel's key. On a miss, an ingest task is spawned; on a hit, the existing one is reused and only the subscriber count moves. concurrent first viewers can never start two ingests
2
resolve
Ask Node for the upstream, freshly
The ingest performs its own resolve rather than reusing the request's — deliberately bypassing the target cache, so mirror rotation and failover keep working, and a mid-session re-resolve stays on the failover-pinned candidate instead of snapping back to a dead parent.
3
peek the first chunk
Playlist, or a bare socket?
The response body is peeked, never buffered — the first 16 bytes decide. #EXTM3U → follow the playlist anything else → segment it locally Reading a bare TS socket to the end would never return, which is exactly why the peek exists.
4
poll
Follow the live edge
Cadence is half the target duration, clamped to 1–10 s (3 s if the playlist declares none). The first poll joins at the upstream's live edge rather than replaying its whole window. A master playlist is walked once and the highest-bandwidth variant is taken — an origin ingest is single-bitrate.
5
pair
Find the audio partner
On a demuxed source both playlists are refreshed on the same poll, and each video segment is matched to its audio partner on #EXT-X-PROGRAM-DATE-TIME. found → ring the pair not published yet → hold, retry next poll already rolled past → drop both lanes A muxed source skips this step entirely.
6
fetch + decrypt
Pull each new segment
Whole-segment fetch, then AES-128 decrypt if the source is encrypted. Every hop — segment and key alike — passes the same SSRF gate and grows the same allow-set. A failed fetch drops that one segment and leaves a gap the next one splices.
7
normalize
One timeline, canonical pids
With spliceNormalize on (the default), the plaintext is rewritten in place onto our own timeline: pids remapped, one clock offset applied to every stream, continuity counters repaired. any doubt → publish verbatim and signal the splice instead
8
push
Into the ring, plaintext
The entry is stamped with our sequence number, its duration, its wall-clock, and whether it opens a discontinuity — then appended, and the oldest evicted if the ring is over its cap. ciphertext never enters the ring
nothing new, five polls running
Declare a stall and re-resolve
Five consecutive polls that ingest nothing logs iop WARN “5 empty polls — re-resolving”, reports status stalled, and forces a fresh resolve. A dead-but-still-200 playlist is escaped in five polls instead of spinning forever.

When it stops — and the 30-second grace

There is no reaper task. The ingest watches itself: every 5 seconds it asks whether it has zero subscribers and nothing has read the ring for 30 seconds. If both hold, it stops — waking every parked reader, removing its own registry entry, and reporting a final closed.

● any read counts

Not just viewers

Rendering a manifest, serving a ring segment, or a raw-TS producer checking for new data all restart the idle clock. A player pulling segments without re-polling the manifest still keeps its channel alive.

● flip back within 30 s

Instant, warm

The ring is still full, so playback resumes with no re-resolve and no warm-up. This is the payoff for the grace window, and it is why channel-surfing feels different under origin mode.

● flip back after 30 s

A brand-new timeline

The registry entry is gone, so a fresh ingest is built: sequence numbering and the generation counter both restart at zero. It is a new session, not a continued one.

● the cost of the grace

RAM with nobody watching

For those 30 seconds the ring is still committed. An idle-grace origin has no row on Active Streams and is invisible per-channel — the process-wide memory-pressure reading is the only thing that sees it.

one asymmetry worth knowing

An HLS viewer holds its lease only for the length of each manifest poll — the grace window covers the gaps between polls. A raw-TS viewer holds one for the whole socket, because a continuous socket has no polling to renew a lease with. And a bare-TS ingest runs its socket session inline, so the idle check does not run while it is being consumed: such an ingest keeps pulling until the socket ends or errors, even after the last viewer leaves.

05 Demuxed sources

When the audio arrives on a rendition of its own

Some providers — pluto on every device cohort — publish no muxed variant at all. Every #EXT-X-STREAM-INF defers its audio to a separate #EXT-X-MEDIA rendition with its own URI=. Following the variant alone would ring, and serve, video only: a picture with no sound. The engine prefers a variant whose audio is muxed in wherever one exists, and only calls a source demuxed when every variant defers.

On such a source the ingest rings the pair. One ring entry holds the video segment and its audio partner — not a second queue alongside the first. Holding both on one entry is what makes the two published playlists identical by construction: same #EXT-X-MEDIA-SEQUENCE, same discontinuity sequence, same #EXT-X-PROGRAM-DATE-TIME anchors, one eviction. A renderer cannot serve half a pair, because half a pair is not a thing the ring can hold.

pairing the two renditions origin.rs · pair_audio() · Found / NotYet / RolledPast
one entry, two lanes four consecutive polls — watch 1042 appear twice: held once because its partner hadn't been published yet, then ringed when it was.
match on #EXT-X-PROGRAM-DATE-TIME window · ±½ segment skew
poll 1 1041 1041 Found
→ one ring entry
poll 2 1042 not published yet NotYet
→ hold, retry next poll
poll 3 1042 1042 Found
→ one ring entry
poll 4 1043 rolled past RolledPast
→ drop BOTH lanes
→ ring 1041 · v + a 1042 · v + a 4 polls in · 2 entries out
  • the pairing key is the wall-clock both lanes publish, not their sequence numbers — a session renewal can renumber either lane independently, and does
  • where one lane carries no #EXT-X-PROGRAM-DATE-TIME the lookup falls back to the media-sequence index, and says so once in the iop log
  • a correctly-paired source agrees to within ~11–20 ms; a mispair is a whole segment out, which is why half a segment is a safe window
why dropping the video too is the conservative choice

When the audio partner is already gone, publishing the video segment on its own would be the tempting move — you'd keep the picture. But the two playlists are rendered from the same entries, so an unpaired entry puts them on different windows for the rest of the session, and every subsequent segment inherits the skew. Dropping both costs one segment. Keeping one costs the channel.

what a demuxed channel actually publishes

The entry URL stops answering with a media playlist and answers with a small master we author — one #EXT-X-MEDIA naming the audio group, one #EXT-X-STREAM-INF naming the video — over two media playlists of our own, …/o/<entry>/v.m3u8 and …/o/<entry>/a.m3u8. Those two URIs deliberately carry no generation: a ring reset would otherwise 404 the live session permanently. The segments still do.

One affine offset is computed from the video lane's DTS and applied to both renditions, so the source's own A/V skew is translated rather than replaced — computing an offset per lane would manufacture a lip-sync error that was never in the source. A skew guard declines the pair outright if the two lanes ever drift more than half a second apart, and a declined pair publishes both lanes verbatim so they at least stay in sync with each other.

06 Splice normalization

The pid that moves, and the demuxer that doesn't follow

An ad creative is transmuxed just-in-time, by different machinery than the programme around it, and it arrives with its own arbitrary PSI. On live pluto the video PID itself moves across a single pod — 258 → 256 → 258. A demuxer does not follow an elementary stream to a new PID: it keeps rendering the one it latched onto and registers the new one as a stream nothing is displaying. The picture freezes until the PID happens to come back.

This is worth being precise about, because it is not the problem #EXT-X-DISCONTINUITY solves. That tag describes a timeline break. This is a stream identity break, and the only fix is to stop the identity from changing. So every ingested segment is republished onto one timeline with canonical pids:

what normalization rewrites proxy/src/tsnorm.rs · Splicer · PairSplicer
FieldRewritten toWhy it is safe
PMT PID0x1000The pids are the identity the demuxer latches onto. Fixing them is the entire point: a pod edge can change everything else about the creative and the player still sees one unbroken set of streams.
video PID0x100
audio PIDs0x101(max 4)
PTS · DTS · PCR · OPCRshifted by one offsetOne affine shift per segment, taken from the video lane's DTS and applied identically on every pid. A per-pid offset would invent an A/V skew that was not in the source.
continuity countersrepaired per pidThe remap merges pids that were distinct upstream, so their counters have to be re-derived or every player logs a discontinuity per packet.
● length-invariant

Nothing is inserted or dropped

Fields are overwritten in place. A normalized segment is byte-for-byte the same size as the one that arrived, so ring accounting, the cap and the eviction loop are all untouched by it.

● bail on doubt

Uncertain means verbatim

Anything the rewriter is not sure of returns nothing at all, and the segment is published exactly as it arrived — with the splice signalled the old way. A stream we passed through beats a stream we mis-rewrote.

● all or nothing

Both lanes, or neither

On a demuxed source the pair is rewritten together and declined together. Normalizing only the video would leave the audio track dying at every pod edge — the pids churn on both sides.

● the visible payoff

The tag stops appearing

Where a splice is genuinely absorbed onto the existing timeline, no #EXT-X-DISCONTINUITY is published at all: there is no longer a break to describe. The iop log says splice ABSORBED rather than discontinuity.

the switch is a kill switch, not an opt-in

spliceNormalize ships on, and it is labelled Smooth ad transitions in the UI. The un-normalized alternative is the bug it fixes, and originEnabled is already the opt-in above it — so a config document that predates the field reads as on, not off. Turn it off to test whether it is involved in a playback problem; that is the only reason to.

it does not gate the interleaving muxer

Turning spliceNormalize off stops splice absorption. It does not stop the weave: authoring one program out of two renditions requires the pid remap and the shared clock to exist at all, so raw TS on a demuxed source keeps applying them and logs that it has. If you want the two renditions published untouched, set outputFormat to hls.

07 The ring

RAM, not disk — and a cap that can be overruled

The ring is a queue of decrypted entries in memory, oldest first. It is never written to disk. Each entry holds six things: our sequence number, the duration, the plaintext video bytes, the paired audio bytes (on a demuxed source), whether a discontinuity opens before it, and the wall-clock at ingest. That is all a renderer ever needs — which is why neither renderer has to know anything about the upstream.

It is bounded per channel by originRingMb (default 25 MiB, roughly a minute at 3.3 Mbps). On each push, the oldest entries are evicted until the total is back under the cap — unless doing so would leave fewer than three. The floor always wins.

Both lanes count against the cap. The cap is a RAM budget and the pair is what occupies the RAM, so on a demuxed source a given originRingMb buys a marginally shorter window than it does on a muxed one — on pluto the audio rendition runs about 3 % of the video's bitrate, so the difference is small but real. It is the honest reading, and the one the floor-beats-cap warning below is judging.

the ring — 25 MiB cap vs the 3-segment floor proxy/src/origin.rs · push_back → evict loop
soft cap block width is the segment's bytes — watch the deque hit a hard stop at 3 and the total go over the line anyway.
phase A · ~3.3 Mbps · the byte cap binds phase B · same channel, high-bitrate source · the floor binds
ring3 seg · 31.6 MiB cap25 MiB evicted21
on every push push_back(seg); while total > cap && ring.len() > MIN_SEGMENTS { pop_front() }
floor · ring.len() = 3 · pop_front() refused
MIN_SEGMENTS = 3 · the floor always beats the cap over cap by 6.6 MiB · held anyway
iop WARN ring cap 25 MiB is too small for this bitrate — holding 3 segment(s) / 31.6 MiB to keep a playable window. Raise originRingMb. once per transition · warned_floor
iop quiet — every push pops back under 25 MiB, so the floor is never reached and nothing is logged.
serve · those 3 segments are the playable window block width ∝ segment bytes · the ring never touches disk
so the cap is a target, not a guarantee

On a high-bitrate channel the ring will legitimately exceed the MiB you configured, rather than serve a window too short to play. When that happens the engine says so — once, on the transition, not once per segment:

ring cap 25 MiB is too small for this bitrate — holding 3 segment(s) / 31.6 MiB to keep a playable window. Raise originRingMb.

Raising the dial takes effect on the next subscribe for that channel — no restart, no dropping viewers. It grows immediately and shrinks lazily, as later pushes evict against the new cap.

size the box yourself — there is no global ceiling

originRingMb bounds one channel. Nothing yet bounds the sum across channels, so worst-case sidecar RAM is concurrent origin channels × their caps — with an overshoot on top wherever the 3-segment floor is winning. A many-channel box wants a conservative per-channel value. The memory-pressure tile exists precisely because this number is currently un-enforced; watch it before you scale the channel count.

08 Two numbers in a path

Why <generation>-<seq> and not just a sequence

Our sequence number counts segments and never goes backwards for the life of an ingest — not across a mirror rotation, not across a failover. That matters because a backwards #EXT-X-MEDIA-SEQUENCE stalls players. But a failover onto a different upstream does invalidate the ring: those bytes came from somewhere else. So the ring gets a second counter.

generation · seq — two numbers on one path origin.rs · reset_ring() · serve_segment()
two numbers one is ours and only ever climbs; the other names the ring's timeline, and only moves when the ring is thrown away.
{mount}/{source}/o/{enc_entry}/{generation}-{seq}.ts  ?token=…&pl=…
client · segment URLs already queued
…/o/…/0-101.ts
in flightserved from ring
…/o/…/0-102.ts
in flight404 · stale segment
…/o/…/0-103.ts
in flight404 · stale segment
…/o/…/1-104.ts
in flight
serve_segment() compares the requested generation against the current one — a mismatch answers 404 · not found: stale segment, never another timeline's bytes.
re-resolve · failover onto a different upstream
generation
01
reset_ring() · +1
the ring deque — cleared on reset, refilled from the new upstream
#EXT-X-MEDIA-SEQUENCE
1012345
next_seq · untouched
monotonic · never rewinds — a backwards media sequence stalls players

reset_ring() bumps the generation and clears the deque — but deliberately never rewinds our sequence, because a backwards #EXT-X-MEDIA-SEQUENCE stalls players.

the reason both numbers are needed

When a re-resolve lands on a different upstream while the ring still holds segments, the ring is cleared and the generation is bumped — but the sequence deliberately is not. A client holding queued URLs from the old timeline gets a clean 404 · stale segment instead of a different stream's bytes, while the media sequence it is reading keeps climbing without a hitch. One resets for correctness; the other refuses to reset, for exactly the same reason.

09 Raw TS from a pair

Two renditions do not concatenate — so they are woven

HLS can publish a pair as two playlists. Raw TS is one socket, and two transport streams cannot simply be glued end to end. So outputFormat: 'ts' on a demuxed source used to decline — which meant the one source shape local origin exists for was exactly the shape raw TS could not serve.

It is now folded into one authored program on the way out, off the same ring entries the two HLS playlists are rendered from. The muxer is small because pairing and normalization already did the hard part — one shared clock, disjoint canonical pids, correct per-pid continuity counters — so the weave is transport-layer only: no decode, no re-encode, no timestamp rewriting.

the interleaving muxer proxy/src/tsweave.rs · PairWeaver::weave()
same packets, new order follow one access unit across — nothing is re-encoded, and nothing is re-stamped. Only the order changes, and the tables around it.
from the ringvideo lane from the ringaudio lane
weave drop each lane's PSI + null padding split into PES access units merge in decode order · video DTS, audio PTS emit ONE PAT + ONE PMT
one socketauthored program
→ client one continuous video/mp2t PAT + PMT re-emitted every 250 packets · ≈110 ms
decline A pair the weaver cannot carry is skipped — there is no verbatim option when the output is one socket. The reason is logged under oop, latched per distinct cause, and three consecutive declines end the socket cleanly so the client reconnects instead of watching a stream that is open but frozen.
  • No PCR is generated. The video lane's clock references were already shifted by the shared offset and the merge keeps that lane's relative order, so PCR stays monotonic and its spacing in stream time is unchanged. The published PMT names the video pid as PCR_PID.
  • The program is locked on the first woven pair and re-emitted byte-identically thereafter, on a ~110 ms cadence so a demuxer that resyncs finds it again quickly. A later pair whose stream set differs is declined rather than republished under a changed table — a PMT that changes shape mid-socket is itself a reconfiguration event.
  • Per-pid packet order survives by construction, because an access unit is contiguous within its pid. The merge only has to interleave whole units; it never reorders inside one.
  • The weave runs on egress, once per client socket — never at ingest. Weaving on the way in would put a third copy of every segment in the ring for the benefit of raw-TS viewers alone.
10 Bare MPEG-TS

Cutting a socket that has no playlist

A direct or hdhomerun upstream arrives as one endless socket with no playlist at all. There is nothing to poll and nothing that says where a segment ends. So the engine finds the boundaries itself — without an ffmpeg, and without decoding a single frame.

This is a content decision, not a per-adapter one: the engine sniffs what the entry actually returned. Anything that doesn't start #EXTM3U is segmented locally, whatever source it came from.

cutting a bare MPEG-TS socket proxy/src/tsseg.rs · TsSegmenter
the cut No playlist, no boundaries — just 188-byte packets behind sync byte 0x47. The segmenter reads PID 0 → PAT → PMT to learn the video PID, then cuts where the stream itself says a decoder can start cold.
ready waiting for PMT ready · cuts armed PID 0 → PAT → PMT → video PID
pcr Δ 33-bit PCR base · 90 kHz
segment length
run 1 a stream that signals random access

The first PAT hands over the PMT PID; the PMT hands over the first elementary PID whose stream type is video, and only then is the segmenter ready. The RAI at packet 7 arrives with the segment barely half its target, so it is walked past. The next RAI arrives with the clock at 5.0 s — both halves of the predicate are true, and that is the cut.

run 2 the same socket, no RAI anywhere

Nothing on this stretch sets the random-access-indicator, so the RAI half of the predicate never becomes true. The segment grows past target and keeps going — until elapsed >= max_duration forces a cut on an ordinary video packet, mid-GOP. A viewer joining exactly there gets nothing decodable until the next real keyframe; the code takes that over a segment that grows forever.

→ ring · emitted #EXTINF:5.000 cut at an RAI · target met #EXTINF:15.000 forced · no RAI ever came mid-GOP · no clean start
  • duration comes from the stream's own 33-bit PCR base at 90 kHz — never wall clock
  • keyframe detection depth is zero: it trusts the adaptation field's random-access-indicator bit
  • before the first PMT is parsed, nothing can be cut
● the PSI walk

PAT → PMT → video PID

Two hops through the stream's own tables to learn which packet stream carries the video. Until that lands, nothing can be cut — so no segment is ever published that a decoder cannot start cold.

● the cut

Trust the RAI bit

A cut needs a random-access-indicator packet on the video PID and a segment already at its target length. Keyframe detection depth is zero — no PES header, no NAL inspection. It trusts the muxer's own flag.

● the escape hatch

Hard cap at 3× target

A stream that never signals a random-access point would otherwise grow one segment forever. At three times the target the engine cuts anyway, mid-GOP — a deliberate trade against an unbounded segment.

● the clock

PCR, at 90 kHz

Durations come from the stream's own 33-bit program clock, wrap-corrected — never wall-clock. A source arriving faster than real time still produces correct #EXTINF values.

what this actually unlocks

Those locally-cut segments join the same ring as everything else, so a tuner republishes as ordinary HLS. They are the one shape that is never paired and never normalized — a bare socket already carries its audio inside the same program, and there is no upstream splice to absorb. HDHomeRun playback is no longer waiting on a TS→HLS remux — it is waiting on nothing; it needs originEnabled on for that playlist. Be aware this path is newer and has had far less live mileage than the HLS side, and it emits no #EXT-X-DISCONTINUITY ever: a bare socket is one continuous encode and carries no splice signal that could be honestly propagated.

the boundaries of the parser

Packets are assumed to be a strict 188 bytes — there is no handling for 192-byte M2TS or 204-byte FEC framing, so such a stream never syncs and produces no segments rather than bad ones. Only the first program and first video stream are followed, which is right for a single-channel tuner feed and wrong for a full multiplex. A PMT too large for one packet is not reassembled. And a transport stream carrying no PCR at all is never cut.

11 Encryption

AES-128 is decrypted at ingest — and what still declines

On the rewriting path, an encrypted source is relayed still encrypted: the player fetches the key through us and does the work. Under local origin the engine decrypts at ingest instead, so the ring holds plaintext and the authored manifest has no key line at all. It is pure Rust (no system OpenSSL), using hardware AES where the CPU has it.

● supported

METHOD=AES-128

Full-segment AES-128-CBC with PKCS7. The key is fetched through the same SSRF gate as any segment, and cached per ingest in a single slot keyed by the full key URL — so a source that rotates its keyfile mid-clip costs one fetch per rotation, shared by every viewer.

The IV is the explicit IV=0x… attribute when the playlist carries one; otherwise it is derived per RFC 8216 §5.2 from the segment's upstream media sequence. Using ours would decrypt to noise.

● declines

fMP4 and SAMPLE-AES

An #EXT-X-MAP init segment, or any METHOD other than NONE/AES-128 (SAMPLE-AES, FairPlay), makes the channel ineligible for origin ingest. So does an #EXT-X-MEDIA audio group that names no playable rendition. Each is refused with a WARN naming which one fired.

A decline is not a dead channel. The origin marks itself ineligible, the renderer answers nothing, and the request falls straight through to the ordinary rewriting path — which passes #EXT-X-MEDIA renditions through, so even a demuxed channel still plays, with sound, where the ring could only have served it silent.

this used to be a 503, and it is worth knowing why it no longer is

Before the origin could distinguish ineligible from still warming up, a shape mismatch was indistinguishable from a slow start: the renderer burned its full 20-second readiness timeout and then answered 503 · stream warming up: no playable window yet, over and over. Flipping originEnabled on for a playlist containing one fMP4 channel killed that channel outright.

An ineligible channel now degrades to the rewriting proxy on the first request instead. The explanation is still a single iop WARN — “fMP4 (#EXT-X-MAP) is not concatenable”, “unsupported encryption METHOD=SAMPLE-AES” — and Active Streams shows the reason under Rendering with the note that the rewrite path is serving this channel, not the ring. What you lose is the origin's benefits on that channel, not the channel.

a bad segment is a gap, not a failure

Every per-segment crypto failure — key fetch non-200, a key that isn't exactly 16 bytes, a decrypt that fails — drops that one segment with a named WARN and carries on. The next segment sees the sequence jump and opens a discontinuity. One bad segment degrades the picture briefly; it never kills the channel.

12 Discontinuity

Three triggers, and a fourth that was deleted

Ad-stitched sources splice. Where the engine cannot absorb the splice, the authored playlist emits #EXT-X-DISCONTINUITY — and that is in-spec output, not a leak. It says nothing about where the stream came from, and every player handles it. “Clean” here means no visible origin, no encryption and no hop URLs; it does not mean one unbroken timeline.

Detection and publication are two separate decisions, and it is worth keeping them apart. Detection has exactly three triggers, each independently sufficient:

● trigger 1

The upstream tagged one

The source's own #EXT-X-DISCONTINUITY, carried through positionally onto the segment that follows it. The parser explicitly steps over #EXT-X-DISCONTINUITY-SEQUENCE, which is a playlist header, not a splice — without that guard, every playlist carrying one would splice its first segment on every poll.

● trigger 2

A sequence gap proves it

The upstream's sequence jumped: this segment is not the previous one plus one. Something was missed — a failed fetch, a dropped key, a playlist that skipped — so the bytes on either side are not contiguous, whether or not anyone tagged it.

● trigger 3

The session was renewed

The provider ended our playlist with #EXT-X-ENDLIST and we re-resolved onto a new session on the same channel. Whether the bytes either side join cleanly is unknowable from here, so the join is signalled rather than assumed. This is the one boundary the engine introduces itself.

an ENDLIST is not always the end

Pluto's stitcher ends the playlist roughly every 25 seconds as a session artefact, not because the channel finished. Treating that literally would tear down a perfectly live ingest twice a minute. So the tag is checked against reality: the engine compares the segment URI list either side of it. An unchanged list means the stream genuinely finished; a different list means the session was renewed — keep the ring, dedupe the re-offered window, and mark the join as a splice. An empty list is excluded by name, because it is evidence of nothing.

Publication is the second decision, and with spliceNormalize on it usually goes the other way. Where the splice was genuinely absorbed onto an existing timeline, no tag is emitted at all — there is no longer a break to describe. Where normalization declined, or the segment opened a fresh anchor, the upstream's signal is published as before. The iop log names which happened — a published splice logs discontinuity (Tag), an absorbed one logs splice ABSORBED (Tag) and says why: rebased onto one timeline, no tag published.

the heuristic that was tried and removed

A fourth signal was implemented and then deleted: the segment URL's directory changed. It seemed obvious — ad clips live somewhere else. In practice it produced ~9 spurious splices per window and never once a true positive the upstream tag had missed, from two independent false-positive classes on live Pluto: a keyfile rotating within a clip (…keyfile_5 → _6 → _7, same directory), and CDN paths carrying a per-segment opaque token. Every false positive forces a needless decoder reset in the client. RFC 8216 requires the source to emit #EXT-X-DISCONTINUITY itself, and pluto does — so guessing from URL shape buys nothing and cannot be made reliable across CDNs. The state carried between segments is now a single field, the previous upstream sequence number: directory and key are not tracked at all, and two regression tests pin the two false-positive classes so the heuristic cannot come back by accident.

13 Observability · iop vs oop

Ingress and egress stopped being the same number

This is the part that changes how you read every existing screen. Once one ingest feeds N viewers, “the channel is stuttering” has two possible causes — the ingest can't keep up, or serving can't keep up — and they need telling apart. So every log line and every telemetry frame is tagged with which side produced it: iop for input operations (resolve, poll, fetch, decrypt, ring push and evict), oop for output ones (manifest render, segment serve, TS concat).

ingress ≠ egress — one ingest, N viewers iop · ring · oop
the split one ingest fills the ring; every viewer is served out of RAM — so upstream bytes follow the channel and client bytes follow the audience.
viewer roster · 1 → 2 → 5 → 0
iop · upstream pulled
One ingest per channel, so the slope is constant. It does not tilt when a viewer joins, and it keeps climbing after the last one leaves.
oop · delivered to viewers
Served from the ring, so the slope is the audience. It steepens on every join and goes flat the moment the last viewer leaves.
iop never noteBytes oop

Node's telemetry ingest handles kind:"iop" and returns without ever calling noteBytesingestedBytes measures the upstream pull, noteBytes measures viewer egress. Folding one into the other would over-count egress by roughly the viewer count and destroy the very attribution the split exists to provide.

ring footprint · kind:"ring" every 2.5 s
IDLE_GRACE 30 s IDLE_TICK 5 s
an origin inside its 30 s idle grace still holds its bytes with no viewer to hang them off — the process-wide kind:"ring" frame is the only thing that still sees them.

iop and oop both resolve to the proxy log category — the split is attribution inside the engine, not a new bucket.

they are tags, not categories

Both resolve to the existing proxy log category — the split is attribution within the engine, not a new bucket in the Logs drawer. Filter to proxy as you always did, then read the tag. One quirk worth knowing: the retry/backoff lines for origin's upstream hops are emitted under the proxy tag rather than iop, with labels like iop-segment and iop-key in the message body — so filtering strictly on the tag will miss them.

What the iop frame now carries

The per-channel ingest frame grew well past “how many bytes”. Four of its fields answer questions the earlier ones could not:

● a denominator

channelRingCapBytes

This channel's own cap, alongside its ring bytes — so “24.2 MiB” finally reads as a proportion rather than a number you have to remember a setting to interpret. ringSeconds sums the real #EXTINFs beside it, and floorBeatsCap says outright when the 3-segment floor is winning.

● two counts, never added

discSeq / discInWindow

Splices that have already left the window, and splices currently in it. They are disjoint — summing them double-counts nothing but means nothing either.

● what this channel is

demuxed · upstreamShape · encryption

Whether the ring is holding pairs; whether the upstream arrived as ts, an HLS master or an HLS media playlist; and which encryption fired. Three facts that used to require reading the log to recover.

● why the ring isn't serving

ineligible

Null in the ordinary case; otherwise the reason the origin declined and the rewrite path took over. suspect and suspectRetires sit beside it for a provider that returns clean 200s no decoder can render.

Where the two sides show up in the app

ScreenReadsWhich side
Active StreamsDeliveryHLS / Raw TS / MixedSide-2 — what viewers receive
Active StreamsIngest<status> · N viewer(s) sharingSide-1 — amber when the status isn't ok
Active StreamsRingN seg · N.N MiB (+ · N evicted)the ring — now carries its own cap, so the number reads against a denominator
Active StreamsUpstream pulledN seg · N.N MiBSide-1 — one shared pull, not the bandwidth chart
Active StreamsRendering — or the reason the ring isn't servingthe ineligible case, named in place
Active StreamsAd break · Breaks seenthe cue frames — reported, never acted on
DashboardMemory pressure — total ring bytes, colour-codedprocess-wide — every ring, including idle-grace ones
Logs → proxytag iop / tag oopwhich side a line came from
the misreading to expect

“Upstream pulled” will not match the bitrate chart above it, and that is correct. One is a single shared ingest; the other is egress summed across every viewer. With five viewers they differ by roughly five times. Reading them as the same number is the natural mistake — and the divergence is the feature doing its job. The engine enforces this on both sides of the seam: ingest bytes are never folded into the viewer byte counters, because doing so would over-count egress by a factor of N and destroy the attribution the whole split exists to provide.

grepping the engine log

Ingest lines carry a request id of the form iopts0, iopts1, … while the serving lines share the normal 8-hex per-channel id. They do not stitch together on one id — that is a consequence of the two sides genuinely being independent, not an oversight. The iop statuses you will see are ok, stalled, resolve_failed and closed, printed raw.

14 Turning it on

Three controls, Default or per playlist

All three knobs live in the proxyconfigs subsystem alongside the rest of the engine's settings, so each can be set globally or overridden for a single playlist. They sit deliberately directly above Output format, because local origin changes what that setting means. The two beneath Local origin are greyed out while it is off.

KnobDefaultEffect
originEnabledoff“Re-publish streams from masqueradarr instead of passing the provider's playlist through. One ingest per channel decrypts and caches segments in memory, and players receive a stream we authored — our own numbering, no encryption keys, no provider URLs. Extra viewers of the same channel then cost no additional upstream bandwidth. Off is exactly today's behaviour.”
originRingMb25 (1–4096)“How much memory one channel's live window may hold — bigger means a longer buffer against upstream hiccups. 25 MiB is roughly a minute at 3.3 Mbps. A minimum of 3 segments is always kept even if that exceeds the cap, and the engine logs a warning when it has to.” Inert while originEnabled is off.
spliceNormalize
“Smooth ad transitions”
on“Republishes the channel as one continuous stream, so a provider that switches encoder between ads cannot stall the player. Some providers change the stream's internal layout at every ad boundary, which many players cannot follow — they freeze until the layout happens to change back. Leave this on; turn it off only to check whether it is involved in a playback problem.” Inert while originEnabled is off, and it does not gate the weave.
where the switch lives default · custom · grant
Default
Settings → Video Config
“Video Proxy Engine (Default)” — applies to everything that hasn't detached
Custom
playlist drawer
“Proxy engine overrides” — a snapshot copy taken at detach; the Default stops propagating
Resolve
per request
the ?pl= on the stream URL picks which config applies
Grant
into Rust
Node resolves the knobs into each grant — Rust never reads MongoDB
there is no confirm step

The panel auto-saves — no Save button, about half a second of debounce. Flipping local origin on takes effect on new streams almost immediately. And because the in-app player never sends a ?pl=, it always resolves the Default config: enabling origin on one playlist's Custom override does not change what you see when you press play inside masqueradarr.

a security consequence of per-playlist config

Because ?pl= now selects which playlist's engine settings apply, the stream gate gained a rung: a non-admin passing a pl that isn't their own — and isn't the source id — is refused with 403 “Forbidden: you do not have access to this playlist”, checked against the union of their allowed playlists and allowed custom playlists. In edge mode the sidecar's auth cache is keyed on the playlist too, so one authorized pl can't mint a cached allow that a different one rides.

15 Honest scope

What to know before you switch it on

● off by default

Nothing changed until you act

With originEnabled false the engine proxies the upstream playlist exactly as before — the same output, byte for byte. Every behaviour on this page is opt-in, per playlist.

● first play is slower

Up to a 20-second warm-up

A cold ring will not be served. The first request waits for three segments and then answers 503 · stream warming up if they haven't arrived within 20 s. On a bare-TS source at the 5-second default that first play is around 15 s — uncomfortably close to the ceiling.

● no global RAM ceiling

You are the admission control

originRingMb bounds one channel. The sum across channels is unbounded, and the 3-segment floor can push any single channel over its own cap. Watch the memory-pressure tile.

● ineligible sources degrade

They fall back, they don't die

fMP4 and SAMPLE-AES channels can't be ingested, but they now drop to the rewriting path on the first request instead of 503-ing. You lose the origin's benefits on that channel, not the channel.

● reconnect after 30 s

Sequence restarts at zero

Past the idle grace the ingest is gone and a new one starts numbering from scratch. Players handle it as a new session, which it is — but it is not a continued timeline.

● demuxed costs a little more

Both lanes share one cap

On a demuxed source the ring holds pairs, and the audio counts. The same originRingMb buys a slightly shorter window — and a raw-TS viewer of such a channel runs a weaver per socket, which is the one piece of per-viewer work on Side-2.

● first play costs two resolves

A little extra load on Node

The request path resolves once to learn the policy, and the ingest then resolves again for itself. Only on a cold channel, and only once — but worth knowing if you are watching resolve load.

the honest summary

Local origin is the newest part of the data plane and it is doing genuinely more than the path beside it — holding decrypted media in RAM, parsing and rewriting transport streams, authoring manifests, and folding two renditions into one program. The HLS ingest is the well-trodden half; the bare-TS segmenter and the interleaving muxer are the newer ones. Turn it on for one playlist, watch the iop lines and the memory-pressure tile for an evening, and expand from there. Turning it back off is a single toggle and returns you to the previous behaviour immediately.

README · Local origin — republishing the stream proxy/ crate · origin.rs · tsnorm.rs · tsweave.rs · tsseg.rs · tsmux.rs Settings · Video Config → Video Proxy Engine next → Users & access
masqueradarr · local origin one ingest · a ring of pairs · two renderers