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.
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.
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.
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.
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.
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.
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.
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
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.
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: false | originEnabled: true | |
|---|---|---|
outputFormat: hls | the upstream playlist, URI-rewritten | a playlist we authored + our own segment paths |
outputFormat: ts | upstream segments concatenated per viewer | the same ring concatenated (decrypts); a demuxed source is woven into one program |
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-ENDLISTand 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
originRingMbis 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-TIMEis 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.
iopOne 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.
#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.#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.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 insteadiop 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.
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.
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.
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.
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.
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.
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.
#EXT-X-PROGRAM-DATE-TIME
window · ±½ segment
skew
→ one ring entry
→ hold, retry next poll
→ one ring entry
→ drop BOTH lanes
- 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-TIMEthe lookup falls back to the media-sequence index, and says so once in theioplog - 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
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.
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.
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:
| Field | Rewritten to | Why it is safe |
|---|---|---|
| PMT PID | 0x1000 | The 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 PID | 0x100 | |
| audio PIDs | 0x101…(max 4) | |
| PTS · DTS · PCR · OPCR | shifted by one offset | One 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 counters | repaired per pid | The remap merges pids that were distinct upstream, so their counters have to be re-derived or every player logs a discontinuity per packet. |
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.
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.
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 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.
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.
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.
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.
push_back(seg); while total > cap && ring.len() > MIN_SEGMENTS { pop_front() }
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.
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.
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.
reset_ring() bumps the generation and clears the deque — but deliberately never rewinds our sequence, because a backwards #EXT-X-MEDIA-SEQUENCE stalls players.
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.
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.
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.
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.
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.
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.
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.
- 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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
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.
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.
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.
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.
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.
iop vs oopIngress 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).
Node's telemetry ingest handles kind:"iop" and returns without ever calling noteBytes — ingestedBytes 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.
kind:"ring" every 2.5 s
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.
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:
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.
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.
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.
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
| Screen | Reads | Which side |
|---|---|---|
| Active Streams | Delivery — HLS / Raw TS / Mixed | Side-2 — what viewers receive |
| Active Streams | Ingest — <status> · N viewer(s) sharing | Side-1 — amber when the status isn't ok |
| Active Streams | Ring — N seg · N.N MiB (+ · N evicted) | the ring — now carries its own cap, so the number reads against a denominator |
| Active Streams | Upstream pulled — N seg · N.N MiB | Side-1 — one shared pull, not the bandwidth chart |
| Active Streams | Rendering — or the reason the ring isn't serving | the ineligible case, named in place |
| Active Streams | Ad break · Breaks seen | the cue frames — reported, never acted on |
| Dashboard | Memory pressure — total ring bytes, colour-coded | process-wide — every ring, including idle-grace ones |
Logs → proxy | tag iop / tag oop | which side a line came from |
“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.
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.
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.
| Knob | Default | Effect |
|---|---|---|
| originEnabled | off | “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.” |
| originRingMb | 25 (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. |
?pl= on the stream URL picks which config appliesThe 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.
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.
What to know before you switch it on
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.
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.
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.
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.
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.
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.
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.
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.
origin.rs · tsnorm.rs · tsweave.rs · tsseg.rs · tsmux.rs
Settings · Video Config → Video Proxy Engine
next → Users & access