MPEG-TS or HLS: what a live IPTV stream actually sends, and how each one fails
A portal live URL is one HTTP GET that never ends. An HLS URL is a text file that lists the next few seconds. Almost everything else follows from that.
var sourceKind = uri.Contains(".m3u8", StringComparison.OrdinalIgnoreCase) ? "hls" : "ts";
One line, and it is the whole of our transport detection on the playback path. It runs on every live media apply, at four call sites: zap, engine rebuild, and two failover handover paths. A substring test over the entire URL, with everything that is not HLS falling through to ts. Run it over the 2,203 entries of the two playlists we keep for parser work and it labels 1,930 of them hls and 273 ts. Exactly 7 of those 2,203 URLs end in .ts.
The label and the truth are different things, and so are the two transports it is trying to name. One is a single HTTP GET that never finishes. The other is a text file listing the next few seconds, which expects you to come back for more.
What the substring gets wrong, counted
Those two playlists are the corpus behind our parser work, curated directory-style files, and their overwhelming HLS share says nothing about what a portal serves. The portal path runs the other way, which is the part most write-ups get backwards: a live URL built by our client always asks for .ts. What comes back is the portal's business, and we never probe it.
What the corpus is good for is showing how badly a URL suffix describes a stream. Badly is the answer. Corpus-wide, 1,924 paths end .m3u8 and 7 end .ts. Among the 273 rows the rule calls ts, the suffixes break down like this:
- 147 with no readable extension, 16 of those because an ip and port sit in the path where a filename would be
- 75 ending
.php - 17 ending
.mpd, which is a DASH manifest and adaptive in exactly the way thetslabel denies - 14 ending
.htm - 7 ending
.ts, 6.m3u, 3.2ts, and one each of.smil,.dash,.mp4and.wmv
Six rows go the other way, classifying as HLS because .m3u8 sits in the query string rather than at the end of the path. That the rule reads the query at all is the only place it can disagree with a plain extension check, since no row in the corpus carries the token mid-path. Those disagreements, and the second URL-derived classifier in the Stream Information dialog that produces them, are covered in their own article along with the playlist file's own extension, which is a separate question from the extension on any URL inside it.
The saving grace is that the label barely does anything. It rides into a Debug line when a session arms and into the wd.fault Serilog event on every verdict, and no threshold, timeout or branch anywhere in the recovery machinery reads it. The typed record that was meant to carry it to a backend is declared and never constructed. The field's own doc comment says what it is for: telemetry, plus per-kind thresholds nobody has written. A classifier that mislabels at least 17 of 2,203 rows and is consulted by nothing is a strange thing to ship, and the reason it is tolerable is precisely that second half.
There is no better source available either. The live channel record carries a stream id, a portal base, an optional direct URL, quality fields and a catch-up flag, and nothing that names a container, a transport or a MIME type. Transport is not a property of a channel in our data model, so the URL is the only thing left to read.
Six URLs, and what the classifier calls each one
Every host below is a reserved example domain and every path is invented, but each row is assembled from shapes the corpus witnesses.
1 https://example.com/live/user/pass/1234.ts
2 https://example.com/live/user/pass/1234.m3u8
3 https://example.net/hls/channel.mpd
4 https://example.org/live/9001/?file=chan.m3u8&output=playlist.m3u8
5 https://example.net/watch.htm?cont=1&output=7&forceUserAgent=1
6 https://example.net/play/9001
Row 1 is what our portal builder emits, the one case where the label is correct and earned. Row 2 is the same channel asked for as HLS, with the substring where you would expect it.
Row 3 is a DASH manifest. It contains no .m3u8, so it is labeled ts, wrong in the most interesting direction: the stream is adaptive, it has segments, it has a manifest, and everything the label implies about it is false. Seventeen rows in the larger playlist end this way.
Row 4 is the reverse. The path ends in a slash and carries no extension at all, but .m3u8 sits inside the query twice over, once as a source hint and once as the value of a parameter called output. The substring rule reports HLS. Two real rows are shaped exactly like this, and playlist.m3u8 is a filename rather than a container name, so even the field that looks authoritative is not answering the question.
Row 5 is the shape that made me stop trusting the output parameter. Fourteen rows carry output=7, and all fourteen sit on a path ending .htm next to keys named cont and forceUserAgent. A field named output set to 7 tells a client nothing, and we have no way to learn what the 7 means: the co-occurring keys are not the Xtream export shape, so we cannot even assume it carries the output=ts or output=m3u8 meaning our own import parser documents. Meanwhile the extension the Stream Information dialog would read off that path is HTM.
Row 6 has nothing readable in it at all, and it is the majority of everything the rule calls ts: 147 of those 273 rows. It gets ts because ts is the default, not because anything was detected, which is what makes the whole classifier a naming convention rather than a measurement.
The portal path asks for .ts and never asks for anything else
Here is the entire live URL construction for a portal source, minus the escaping:
return string.Format(CultureInfo.InvariantCulture, "{0}/live/{1}/{2}/{3}.ts", baseText, user, pass, streamId);
No parameter, no branch, no options object. The only logic in that file beyond the format string is escaping the username and the password. The only fork on the resolve path is whether the row arrived from a playlist with a full URL already in it, in which case we use that URL untouched, or from a portal, in which case we build the line above.
Catch-up is built by a second function of the same shape, inserting a duration in whole minutes and a start stamp formatted yyyy-MM-dd:HH-mm, and its doc comment carries the one warning either builder needed: that stamp is the portal server's local time, the same clock the portal EPG runs on, not the device clock. It also ends in .ts.
Two things qualify that. Every Xtream source carries a persisted "Stream format" setting with MPEG-TS and HLS (m3u8) options, and it is read by nothing: the value round-trips through settings and the device goes on requesting .ts. That dead control, and the second builder on the catalog side that would have appended .m3u8 if anything called it, are documented where they belong. The other qualification matters more here. All of this is about the request. A portal is free to redirect a .ts URL somewhere else or to serve HLS from it, and we never look, so nothing in this article is evidence about what portals actually send.
A transport stream has cadence; what it lacks is a boundary the player hands us
The framing in the title is a category error. Dissolve it first. RFC 8216 section 3.2 lists MPEG-2 Transport Streams among the supported segment formats, and requires each such segment to contain a single program with a PAT and a PMT, so an HLS segment can be the same 188-byte packets cut into a file. How often it is, we have not measured, and the industry has been moving to fMP4 for years. What reliably differs is delivery, and delivery is where every behavioral difference below comes from.
A manifest gives a client four things an open socket does not:
- A request that can fail. Each segment is its own HTTP transaction, so a 404, a timeout or a truncated body is an event with a moment attached to it.
- A cadence the client can enforce. RFC 8216 requires a live client to reload the playlist and sets the wait from the target duration, so silence can be measured against something the format itself declares.
- An announced discontinuity, so a splice arrives as a marker rather than as a clock jump to be inferred.
- A position. A segment list and a target duration together say how far the client is from the live edge.
Progressive MPEG-TS over HTTP gives you one GET that never ends, with all the structure inside the bytes: packets of 188, a program association table, a program map table, elementary streams on their own PIDs, a program clock reference to pace them, and null packets on PID 0x1FFF as padding.
The sentence I first wrote here was that a byte stream has no boundary to hang a timeout on. I was wrong, and a broadcast engineer would have corrected it on sight: at the format level a transport stream has plenty of cadence. ISO/IEC 13818-1 constrains how far apart program clock references may sit, and DVB's monitoring standard, ETSI TR 101 290, builds its whole Priority 1 set out of exactly those boundaries: sync loss, a PAT that fails to repeat inside 500 ms, a continuity counter that skips, a PMT that goes missing. Racks of probes read those boundaries all day. Our own relay presupposes them: its nulls fault keeps sending PAT and PMT because a stream missing them is a different failure.
What a progressive stream does not have is a boundary the player surfaces to us. What libvlc hands over is a snapshot of cumulative counters, bytes read at the access layer, bytes consumed by the demuxer, pictures decoded and pictures displayed, and not one field in it is table timing. So the timeout has nowhere principled to hang and a stall gets inferred from a delta that went to zero. How those deltas are read, and why the engine keeps reporting healthy playback while the picture is frozen, is a separate article.
Our fault-injection relay makes the gap concrete. It is a developer-local harness, never deployed and never part of any service, that generates a synthetic single-program transport stream on loopback at roughly 4.6 Mbit/s and breaks it on command. Its freeze mode stops writing and leaves the socket established, and the measured result is the cleanest statement of the problem we have: the client's read-byte delta goes to zero and there is, in the relay's own words, "no event at all". The engine raises no error and reports no end of stream. The cause is that nothing is wrong at the HTTP layer, because the response is still open and still being delivered. The cost is that every stall detection we have for this transport rests on one counter and a four second window, and if that counter ever lies the product goes quiet with it.
Building the relay, the first thing that had to be defeated was our own web server. Kestrel enforces a minimum response data rate by default, 240 bytes per second with a five second grace, so a socket held open and silent is precisely what it is designed to kill. A freeze fault would have surfaced at the client as a socket close: an F1 access-stall test quietly running as an F2 end-of-stream test, with the whole matrix invalid and every case still passing. Three limits are cleared before the relay serves a byte.
Latency is your buffer on one transport and a floor on the other
RFC 8216 says a client SHOULD NOT start on a segment less than three target durations from the end of the playlist. With a six second target duration, a common choice rather than a measured one, that is eighteen seconds behind live before the client has buffered a frame of its own, and the source picked that number when it segmented the stream. The second-edition draft keeps three target durations as the default and adds a separate hold-back for Low-Latency Mode whose minimum is twice the part target duration, so the floor moves only when the source cuts the stream into parts. It is the source's decision either way, not the player's.
Progressive MPEG-TS has no such floor. Latency is whatever the client decides to hold, and our current tuning holds very little. The engine is constructed with a 400 ms network cache, and the live path applies 1200 ms of network caching over 800 ms of live caching. Multiview tiles drop to 350 and 200 when muted, 500 and 350 with audio. Remote on-demand playback gets 1500 ms, because scrubbing wants a deeper buffer than a live channel does. Those are tuning values. The next person to measure zap time will move some of them.
Small buffers are not free. That is the trade the transport hands you, not a preference. A byte stream with 1200 ms of cushion sits close to live and has 1200 ms of tolerance for a hiccup. An HLS client sitting three segments back has a great deal of tolerance and cannot spend it. When our rebuffer detector declares a loop, its remediation raises the caching floor by 1000 ms at the next restart, capped at 5000 ms, which is that same trade made at runtime instead of in settings.
Seeking comes from the length, not from the transport
Neither transport is seekable for live in our client, and the flag that decides it never consults the transport.
// A live stream with no known length is not seekable in any way the scrubber could honour,
// whatever the demuxer claims; both position and live edge stay unknown then.
var seekAvailable = isSeekable && durationMs > 0;
Two conditions, both read off the player. The test that pins this names the transport in its comment where the code does not: "Typical live TS: demuxer may claim seekable but there is no length to scrub against." It passes a seekable flag of true with a length of zero and asserts that no seek is offered. That is the codebase stating, in a test, that a progressive demuxer will happily claim a capability it cannot honor, and that the length is the field telling the truth.
What libvlc reports for the length of a live HLS window, we have not measured, and the rule above is written so that it does not matter. If a sliding window ever produced a positive length the scrubber would light up and the transport overlay would offer a seek across it. Whether that would work is an open question on our side, not a claim.
Catch-up is the honest version of seeking backward on a portal source, and it is not a seek at all: a second URL from a second builder, carrying a duration in minutes and a start stamp on the portal's clock, ending in .ts like everything else. Recording has the same limitation from the same cause, since capture progress cannot be a percentage of a duration the input never declares.
One threshold in the recovery machinery is HLS-shaped, and it is the only one
Every verdict window is transport-agnostic. Four seconds of zero bytes at the access layer, six seconds of bytes arriving while the demuxer produces nothing, four seconds of stalled video against live audio, four for both stalled together, fifteen on a channel measured as low frame rate. None of those has a second value for HLS, and none is selected by the source kind.
The exception is rebuffer counting, and the options file states the reason in place: episodes are counted only when the cache falls below a floor rather than on any sub-100 report, because "live HLS cache wobbles across segment boundaries and raw sub-100 counting would flag healthy streams". The hysteresis runs 62 percent to start an episode and 88 to end one, with four episodes in a rolling 60 seconds declaring a loop. The buffering scrim carries its own hysteresis for the same wobble, its comment putting the effect at roughly two seconds ahead of keyframes. Both exist because a manifest-driven stream's cache level oscillates by design and a raw threshold reads that oscillation as sickness.
The admission that belongs here is larger than one threshold. The whole counter model behind fault detection was calibrated on raw MPEG-TS, where the access layer and the demuxer sit in a clean upstream relationship and the gap between them means something specific. What an adaptive demuxer does to that relationship when it issues its own fetches, we have not measured, so I will not tell you the split is wrong on HLS, only that it was never checked. We carry a label that would let us branch. We have never written a single HLS-specific threshold, and with no fleet telemetry we cannot say what that costs. One asymmetry we handle without meaning to: the restart primitive disposes the media and re-applies the URL from scratch, which the plan notes regenerates tokens in URL-embedded HLS sessions.
The one capability a manifest genuinely buys us in shipped code is smaller than any of this. Rendition caps, applied to multiview tiles only, 640 by 360 for a muted tile and 1280 by 720 for the one with audio, plus a request for the lowest rendition on the muted profile. On progressive MPEG-TS those options are inert, because there are no renditions to cap, and the player accepts them silently rather than complaining. That is why they are gated on the tile profile rather than on the source kind: an option costing nothing when it does nothing is cheaper to apply unconditionally than to guard with a classifier we have already established mislabels at least 17 rows in 2,203. Full-screen live playback does not use them at all.
The honest limit: the two playlists measured here are the whole of the evidence, and we hold no corpus of portal live URLs at all. Everything above describes the request our client makes and the code that reacts to what comes back, never what portals send. Reading the URL your own source hands you remains the fastest way to find out which of the two shapes you are in. On the Windows and Xbox apps that stays a local operation: they open the address you point them at and parse it on the device, holding no channels of their own and fetching nothing on your behalf from anywhere else. The Stream Information dialog will show you the URL's own claim about itself, with the caveat set out earlier about what that row really means, and a free account is enough to go and look.
What this article measured35 claims, each with the evidence behind it
| Claim | Evidence | Counted |
|---|---|---|
| The only transport classifier on the playback path is a case-insensitive substring test for ".m3u8" over the whole URL, evaluated on every live media apply. Everything else defaults to "ts". | n = 1 | Aug 23, 2026 |
| No threshold, timeout or branch anywhere in the watchdog reads the source kind. Every occurrence is a declaration, an assignment, a copy or a log field. | n = 1 | Aug 23, 2026 |
| The source kind reaches a Debug line when a session arms and an Information-level wd.fault Serilog event on every fault verdict. The typed record meant to carry it to a backend is declared and never constructed. | n = 1 | Aug 23, 2026 |
| Run over the two playlists we keep for parser work, the shipped classifier labels 1,930 of 2,203 rows "hls" and 273 "ts". Exactly 7 of the 2,203 URLs end in ".ts". | n = 2203 | Aug 23, 2026 |
| Corpus-wide path suffixes: 1,924 .m3u8, 137 with no extension, 16 with no extension because an ip:port sits in the path, 75 .php, 17 .mpd, 14 .htm, 7 .ts, 6 .m3u, 3 .2ts, and one each of .smil, .dash, .mp4 and .wmv. Of the 273 rows the classifier calls "ts", 147 have no readable extension. | n = 2203 | Aug 23, 2026 |
| Six rows classify as HLS because ".m3u8" appears in the query string rather than at the end of the path. Every one of those six has a path with no extension. No row in the corpus carries the token mid-path, and no row anywhere carries an "out" query key. | n = 2203 | Aug 23, 2026 |
| Fourteen rows carry an output query parameter whose value is 7, and all fourteen sit on paths ending .htm alongside cont and forceUserAgent keys. Two rows set output to playlist.m3u8. Sixty rows have get.php as their last path segment and not one of them carries an output parameter at all. | n = 2203 | Aug 23, 2026 |
| A playlist file's own extension does not predict the suffixes of the URLs inside it. The .m3u file in our corpus points at 303 URLs ending .m3u8 and not one ending .ts. | n = 308 | Aug 23, 2026 |
| The two playlists measured here are curated directory-style files, not portal exports, and their HLS dominance says nothing about what a portal serves. | n = 2 | Aug 23, 2026 |
| The client's Xtream live URL builder hard-codes a .ts suffix with no parameter and no branch, and the catch-up builder does the same. This is what we request; nothing here observes what a portal returns. | n = 1 | Aug 23, 2026 |
| The Stream format control offered on Xtream sources normalizes, saves and reloads, and is read by no URL builder and no playback code. Whatever it is set to, the desktop client requests .ts. | n = 1 | Aug 23, 2026 |
| The Stream Information dialog derives Protocol from the URI scheme and Container from the path's file extension, falling back to "N/A" when the path has none. It is a second URL-derived classifier and it does not agree with the watchdog's. | n = 1 | Aug 23, 2026 |
| The live channel record carries no container, transport or MIME field, so transport is never a channel trait and can only be read off the URL. | n = 1 | Aug 23, 2026 |
| HLS delivers a Media Playlist that lists Media Segments under a declared target duration, and a client following a live presentation MUST periodically reload that playlist to learn what media is available.RFC 8216 section 4.3.3.1 (EXT-X-TARGETDURATION, REQUIRED) and section 6.3.4 (Reloading the Media Playlist File), verbatim: "The client MUST periodically reload a Media Playlist file to learn what media is currently available", and must wait at least the target duration between reloads that found a change. | Specification | Not applicable |
| RFC 8216 lists MPEG-2 Transport Streams among the supported Media Segment formats, so an HLS segment can be the same 188-byte packets cut into a file. How often it is, we have not measured.RFC 8216 section 3.1 (Supported Media Segment Formats) and section 3.2 (MPEG-2 Transport Streams), which requires a single MPEG-2 Program per segment and a PAT plus PMT in each segment absent an EXT-X-MAP tag. Sections 3.3 through 3.5 cover fragmented MPEG-4, packed audio and WebVTT. | Specification | Not applicable |
| A client playing a live HLS presentation SHOULD NOT choose a starting segment that begins less than three target durations from the end of the playlist.RFC 8216 section 6.3.3 (Playing the Media Playlist File), verbatim: "If the EXT-X-ENDLIST tag is not present and the client intends to play the media normally, the client SHOULD NOT choose a segment that starts less than three target durations from the end of the Playlist file. Doing so can trigger playback stalls." | Specification | Not applicable |
| The HLS second edition draft keeps three target durations as the default distance from the live edge and adds PART-HOLD-BACK for Low-Latency Mode, whose minimum is twice the Part Target Duration. Parts are much shorter than segments, which is how low-latency HLS gets inside one segment of the edge.draft-pantos-hls-rfc8216bis section 4.4.3.8 (EXT-X-SERVER-CONTROL). HOLD-BACK: "Its value MUST be at least three times the Target Duration ... Its absence implies a value of three times the Target Duration." PART-HOLD-BACK: "Its value MUST be at least twice the Part Target Duration." The draft header reads "Obsoletes: 8216 (if approved)". | Specification | Not applicable |
| A transport stream is not structureless in time. ISO/IEC 13818-1 constrains how far apart program clock references may sit, and DVB's monitoring standard turns table repetition and continuity into pass or fail checks.ISO/IEC 13818-1 (transport stream system layer, PCR interval constraint); ETSI TR 101 290 V1.4.1 (2020-06) Priority 1 measurements, which include TS_sync_loss, Sync_byte_error, PAT_error and PAT_error_2 on a 500 ms PAT repetition limit, Continuity_count_error, PMT_error and PMT_error_2, and PID_error. | Specification | Not applicable |
| What the player hands us is a snapshot of cumulative pipeline counters, and the state machine works exclusively on deltas between consecutive samples. No field in it is table timing. | n = 1 | Aug 23, 2026 |
| An MPEG-2 transport stream is a run of 188-byte packets carrying a program association table, a program map table, elementary streams on their own PIDs, a program clock reference, and null packets on PID 0x1FFF as padding.ISO/IEC 13818-1, transport stream packet syntax; mirrored in our own synthetic muxer at clients/windows/tools/FaultRelay/Media/TsMuxer.cs:15-20, which declares PacketSize 188, PatPid 0x0000, PmtPid 0x1000, VideoPid 0x0100, AudioPid 0x0101 and NullPid 0x1FFF. | Specification | Not applicable |
| The fault-injection relay is a developer-local test harness that generates synthetic video on loopback. It is never deployed, never added to any chart or compose file, and never referenced by a backend service. | n = 1 | Aug 16, 2026 |
| The relay generates a single-program transport stream at roughly 4.6 Mbit/s at its defaults, with PCR on the video PID and per-PID continuity counters. | n = 1 | Aug 16, 2026 |
| A relay fault that stops writing while leaving the socket established produces a zero read-bytes delta at the client and no engine event at all. | n = 1 | Aug 16, 2026 |
| A stream reduced to program tables plus null packets holds its byte rate while the demuxer starves. That failure shape belongs to the container, not to progressive delivery: an HLS presentation with transport-stream segments can produce it and one with fMP4 segments has no equivalent padding. | n = 1 | Aug 16, 2026 |
| The relay clears three Kestrel limits before serving a byte, because the default minimum response data rate would abort a byte-freeze fault and turn every F1 access-stall test into an F2 socket-close test. | n = 1 | Aug 16, 2026 |
| Kestrel's default minimum response data rate is 240 bytes per second with a five second grace period.Microsoft ASP.NET Core Kestrel options documentation, MinResponseDataRate default. | Specification | Not applicable |
| The live seek flag is the conjunction of the player's seekable flag and a positive length. The transport is not an input, and both position and live edge stay null when the conjunction fails. | n = 1 | Aug 23, 2026 |
| Recording progress cannot be reported as a percentage on a live capture because the input duration is unknown. | n = 1 | Aug 23, 2026 |
| The only per-transport options we ship are rendition caps, applied to multiview tiles only: 640 by 360 muted, 1280 by 720 with audio, plus a lowest-rendition selection on the muted profile. Full-screen live gets none of them. | n = 1 | Aug 23, 2026 |
| Current tuning: the engine is constructed with a 400 ms network cache, the live path applies 1200 ms network and 800 ms live caching, multiview tiles drop to 350/200 muted and 500/350 with audio, and remote on-demand uses 1500 ms. | n = 1 | Aug 23, 2026 |
| Every verdict window is transport-agnostic: 4 s of zero bytes, 6 s of a starved demuxer, 4 s of stalled video, 4 s of total freeze, 15 s on a low-frame-rate channel. | n = 5 | Aug 23, 2026 |
| The one threshold shaped by HLS is rebuffer counting: an episode is counted only below 62 percent cache and ends at 88, four episodes in a 60 s window declare a loop, and the remediation raises the caching floor by 1000 ms per verdict to a 5000 ms ceiling. | n = 1 | Aug 23, 2026 |
| The buffering scrim carries its own hysteresis for the same reason, and the code comment attributes the wobble to HLS segment boundaries at roughly two seconds ahead of keyframes. | n = 1 | Aug 23, 2026 |
| The counter model behind fault detection was calibrated against raw MPEG-TS, and no threshold we ship is HLS-specific. | n = 1 | Aug 23, 2026 |
| The restart primitive disposes the media and re-applies the URL from scratch, which the plan notes regenerates tokens in URL-embedded HLS sessions. | n = 1 | Aug 23, 2026 |