m3u plus vs m3u: the attributes an IPTV playlist adds, and how to parse them
There is no M3U+ specification. There is a convention, a corpus, and a parser that has to survive both.
#EXTINF:-1 tvg-id="" tvg-logo="" group-title="News;General" tvg-chno="1",Example One
https://example.com/live/one.m3u8
Two lines. That row is assembled, not copied: the two playlists we keep for parser work name real broadcasters, so nothing here is lifted from them verbatim. Every shape in it is one those files do contain, and none of the values is distinctive. In the 1,895-entry file, 107 rows claim channel number 1, 20 carry an empty tvg-id, and 42 carry an empty tvg-logo. In the 308-entry file, 25 rows carry a blank group-title, 19 carry an empty tvg-logo, and 10 pack two group names into one attribute with a semicolon.
Almost everything on the first line that looks like structured data is absent, blank, or shared with a hundred unrelated channels. The one field that is both reliably present and close to distinct is the URL on the second line: 308 distinct URLs across 308 entries in the smaller file, 1,884 across 1,895 in the larger.
The player contains no channels and cannot supply any. What appears in it comes from the file you point it at, parsed on your own device, which means the behavior of that parse against imperfect input is most of what your channel list turns out to be.
There is no specification, there is a convention
RFC 8216 defines #EXTINF for HLS as a duration, a comma, and an optional human-readable title running to the end of the line. It says nothing about tvg-id, tvg-chno, group-title, tvg-logo, catchup-source, or any of the other tokens that real files pack between the duration and the comma.
Those tokens come from a later IPTV convention that has no agreed name, and both of the names people reach for are wrong. Extended M3U is the Winamp-era convention that introduced #EXTM3U and #EXTINF themselves; RFC 8216 cites it by name in section 4.3.1.1, and it contains no tvg- anything. M3U+ is not a format at all: m3u_plus is an output mode of Xtream Codes portal software, the query value that switches an export from bare URLs to rows carrying tvg-id, tvg-logo and group-title. One of those is a 1990s playlist convention, the other is a query parameter, and neither is a specification. The convention is defined by whatever emitters emit.
That distinction runs through everything below. What the convention allows, and what one parser accepts, are two different lists, and mixing them is how a parser ends up encoding one source's habits as if they were a rule. So: the grammar our parser implements is this, and no more.
line := "#EXTINF:" WS* duration WS* attributes "," displayName
duration := "-"? DIGIT+
The prefix match is ordinal, so #EXTINF: is case sensitive down to the colon, and the parser hard-codes the prefix at eight characters before handing the rest to a duration scanner. That scanner takes an optional single minus, then requires at least one digit, then consumes the digit run. It records where the digits ended and throws the value away. The duration is never read anywhere in the pipeline. All 2,203 #EXTINF lines across our two sample playlists carry -1, which is the convention's way of saying "live, no known length", and no code path would care if they said 7200.
Three consequences fall straight out of that scanner. +1 is rejected, because a plus is neither a minus nor a digit. .5 is rejected, because a digit must come before the dot. And a fractional duration such as #EXTINF:120.5,Example parses correctly by accident: the scanner stops at the dot, the leftover .5 lands inside the attribute region, and no attribute lookup ever matches it. RFC 8216 permits that fractional form and our parser handles it, but nobody implemented it, which is worth saying plainly instead of filing under "we support decimals".
One asymmetry inside that grammar is worth owning, though not in the direction it first appears. The tag prefix is matched case sensitively, which is what RFC 8216 section 4.1 requires of every tag beginning #EXT. Attribute names are matched case insensitively, which nothing requires. So #extinf: in lowercase gets skipped as an unrecognized directive while TVG-CHNO = "18" on the very same line parses fine, and the half that deviates from the RFC is the lenient one. The inconsistency arrived with a port from an older server-side parser and the source file still says so at the top. Neither sample playlist contains a lowercase tag, an uppercase attribute name, or a space around an equals sign, so nothing in our corpus has ever exercised either half of it.
The comma that is not a delimiter
Split an #EXTINF line on its first comma and you have broken 30 of the 308 entries in one of our two files.
The reason is that attribute values are quoted strings and quoted strings contain commas. Every one of those 30 rows carries a user-agent attribute holding a browser UA string, and all 30 of those strings contain the fragment KHTML, like Gecko. Twenty-eight of them wrap it in parentheses and two have had the parentheses stripped somewhere upstream, which changes nothing about the comma. Here is the actual comparison, run over the real file, with the source's hostnames removed and the channel's own name replaced:
naive first-comma split -> like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0",Example News (720p)
quote-aware split -> Example News (720p)
Nine point seven percent of that file gets a display name made of somebody's browser version string. The catalog still builds. Nothing errors. The channel list is wrong in a way that looks like a rendering bug and is not.
The fix is a hand-written scanner, not a regex: walk the attribute region character by character, flip an inQuotes flag on every double quote, and return the first comma seen while that flag is false. Everything to the left is the attribute region, everything to the right is the display name, both trimmed. It is nine lines of code and it is the single highest-value nine lines in the parser.
That scanner also decides what happens to a malformed row, and the decision is strict. If the attribute region holds an odd number of double quotes, for instance group-title="Movies,Example Four where the closing quote was dropped, the flag never returns to false, no unquoted comma is ever found, and the whole entry is rejected with a diagnostic. There is no fallback to the first comma and no attempt to guess where the quote should have been. We chose rejection over repair because a repaired row is silently wrong while a rejected row is loudly missing, and a loudly missing row is the one a reader can report.
Five attributes are read, and one file carries 1,895 rows of a sixth we ignore
The complete set of attributes our parser looks for is five spellings:
group-title, kept up to the first semicolon, blank folded to nothingtvg-logo, trimmedlogo, consulted only whentvg-logois entirely absenttvg-id, taken raw at parse time and trust-checked latertvg-chno, handed to a number parser that is allowed to return nothing
That is the whole list. Attribute names are matched case insensitively with optional whitespace on both sides of the equals sign, so TVG-CHNO = "18" and tvg-chno="18" are the same to us. Values must be double quoted; there is no unquoted-value path, and single quotes are not recognized anywhere.
Now the observed side, which is more interesting. In the 1,895-entry file, tvg-name appears on all 1,895 rows and we ignore every one of them. tvg-country appears on 1,642 rows and we ignore those too. The display name always comes from the text after the comma, never from tvg-name, even when a source supplies both on every row it publishes. That is a design decision rather than an oversight: the post-comma title is the field the grammar itself guarantees, tvg-name is optional, and one authoritative name source beats a preference order that behaves differently per file.
The smaller file shows the directive layer that sits between the two lines of an entry. It carries 30 user-agent attributes, 2 referrer attributes (spelled with two r's by that emitter, which is not the HTTP header's spelling), and 32 separate #EXTVLCOPT directive lines, 30 of them repeating the user agent the #EXTINF line already stated. We read none of these.
Other players read attributes we do not, and no two of their lists are the same list. The IPTV Simple add-on for Kodi documents catchup, catchup-source, catchup-days, tvg-shift, radio and an #EXTGRP directive. SS-IPTV, a player for smart televisions, documents aspect-ratio, which Kodi does not. tvg-language came from iptv-org, which has since dropped it along with tvg-country. Not one of those tokens appears anywhere in our sample and we read none of them, which produces one honest product limitation worth stating: our catch-up support exists, but it is built on the portal protocol, so an M3U source cannot expose catch-up to us no matter which attributes it writes.
Seven headers, three channels
Here is a fragment built to exercise every path at once. Every host in it is a reserved example domain and every name is invented. Most of the shapes it exercises do occur in our corpus, BS4K101 included, which is a real value lifted from the larger file. Five occur nowhere in it: the malformed duration, the unbalanced quote, the bare logo, the uppercase attribute name with spaces around its equals sign, and the name beginning ##. A hand-built fragment is the only way to reach those five paths, which is worth knowing before you read the walk-through below as evidence about real files.
#EXTM3U x-tvg-url="https://example.com/guide.xml"
#EXTINF:-1 tvg-id="one.example" TVG-CHNO = "22.1" group-title="News;General" tvg-logo="",Example One
https://example.com/live/one.m3u8
#EXTINF:-1 tvg-id="two.example" user-agent="Mozilla/5.0 (KHTML, like Gecko)",Example Two
#EXTVLCOPT:http-user-agent=Mozilla/5.0 (KHTML, like Gecko)
https://example.com/live/two.m3u8
#EXTINF:-1 tvg-chno="BS4K101" group-title="Local",## ===== LOCAL =====
https://example.com/live/divider
#EXTINF:x tvg-chno="7" group-title="Movies",Example Three
https://example.com/live/three.m3u8
#EXTINF:-1 group-title="Movies,Example Four
https://example.com/live/four.m3u8
#EXTINF:-1 tvg-chno="9",Example Five
rtmp://example.com/live/five
#EXTINF:-1 logo="https://example.com/six.png",Example Six
https://example.com/live/six.m3u8
Seven headers go in. Four rows come out of the parser, three of them reach the catalog, and five diagnostic strings are produced.
Line 1 is skipped. Our parser never looks for #EXTM3U at all; it falls through the generic "starts with a hash" skip along with everything else it does not recognize, and the x-tvg-url attribute on it is not read. RFC 8216 requires that header as the first line of a playlist. We do not require it, and a headerless file that opens directly with #EXTINF parses fine, because the shape check that runs on the device before parsing accepts either tag as the first thing in the file, after an optional byte-order mark and leading whitespace. Nothing else may precede it: a file opening with a comment line or an #EXTVLCOPT is rejected before the parser ever sees it.
Example One parses with the attribute name in the wrong case and spaces around the equals sign. Its group becomes News, because only the segment before the first semicolon is kept. Its number becomes 22.1 as a decimal, not 22 as an integer, which matters below. Its logo becomes an empty string where you would expect nothing at all, which also matters below.
Example Two is the quoted-comma case, and the #EXTVLCOPT line after it is the reason the generic hash skip must not clear the pending header. Directives legitimately sit between an #EXTINF and its URL. A parser that reset state on every unrecognized # line would drop this channel and every channel like it, which in the smaller sample file is 30 of 308 entries.
The divider row parses cleanly and is a channel as far as the grammar is concerned. Its tvg-chno value BS4K101 is rejected by the number parser and becomes nothing, costing the number but not the row. Its name starting with ## is what removes it, and that happens later, during name normalization, not at parse time. Keeping the two stages apart means the parser holds no opinion about what a channel name means, which is the right place for that boundary.
Example Three fails on the duration: x is neither a minus nor a digit, so the header is rejected with a diagnostic and all five pending fields are cleared. Its URL then arrives with no pending header and produces a second diagnostic. One malformed header costs two lines and produces two log entries, which is correct and worth knowing when you read the diagnostics of a real import.
Example Four is the unbalanced quote, and behaves identically: two diagnostics, nothing emitted.
Example Five has a perfectly valid header and an rtmp URL. That is an absolute URI, so it parses as one, but the scheme is neither http nor https and we reject it. It is the most dangerous row in the fragment, because if the reject path failed to clear the pending state, Example Six would silently inherit channel number 9. The path is not hypothetical. The larger sample file ends 6 of its 1,895 entries on an rtmp or rtsp URL, five and one, and those 6 rows are the only rows in the entire 2,203-entry corpus that our parser rejects.
Example Six carries a bare logo attribute with no tvg-logo and no group-title at all, so the logo fallback fires and the channel lands in a default group named after the source.
Every skip path has to clear the header
An M3U entry is two lines that share mutable state, and that is the whole difficulty of the format. The parser holds five pending fields between reading a header and reading a URL: name, group, logo, id, number. Seven distinct paths run through the loop, and the correctness of the parser is entirely a question of what each one does to those five.
Blank lines skip without clearing, correctly, because a blank line between a header and its URL should not destroy the entry. Unrecognized # directives skip without clearing, for the reason the fragment above demonstrates. A malformed header clears all five. A rejected URL clears all five. A successful emit clears all five.
The seventh path is the one most line-pair parsers get wrong, and the interesting part is that it is not handled by a reset at all. When a second #EXTINF arrives before any URL, the parser assigns all five fields unconditionally, with no if and no null-coalescing assignment. An attribute lookup that finds nothing returns nothing, and the number parser given nothing returns nothing, so a header carrying no tvg-chno writes emptiness over the previous header's number instead of leaving it in place. There is no path by which field N of entry K survives into entry K+1. That is a stronger property than "we remember to reset", and it is worth structuring a parser this way deliberately, because the reset version stays correct only as long as every future edit remembers to extend it.
One path deserves a note because it looks like a bug and is not. The "URL arrived with no pending header" path emits its diagnostic and clears nothing. That is safe by construction and not by luck, and the invariant is checkable by reading the assignments: the only place the other four fields are set is the header path, and that path always sets the name to a non-empty value on the same pass, falling back to the literal string Unnamed channel when the title is blank. So a null name implies the other four are already null, and the missing reset is unreachable work.
Every one of these paths needs its own regression test, and the nine we have do cover the leak paths: one asserts that a numbered entry does not leak its number onto the next one, another asserts that a malformed header does not either. What has no test at all is the quoted-comma scanner, the nine lines this article called the highest-value nine lines in the parser, along with the unbalanced-quote rejection, the #EXTVLCOPT-between-the-lines case, and the bare logo fallback. Nothing about that gap throws. The catalog builds. A hundred channels downstream of one bad row carry the wrong group.
The two fields that look like identifiers
tvg-chno is display data, and the measurement that settles it takes one line of shell work. In the 1,895-entry file, 1,355 entries carry the attribute. Those 1,355 numbers cover 263 distinct values. 107 entries claim number 1. The duplication is not an artifact of merging groups together, because it survives grouping: one group in that file holds 7 channels all numbered 1, and 33 of the 85 groups that carry any number at all have at least one duplicate inside them. That file has 92 groups in total, so 7 of them carry no numbers at all. The other file, 308 entries, has no tvg-chno anywhere. Nothing may be keyed on this value, nothing may assume it is unique, and every consumer that sorts by it needs a deterministic tie break.
Parsing it is a small exercise in refusing to be clever. The value is read as a floating point number under the invariant culture, not as an integer, and never under the device's locale. 56 of the 1,355 are rejected outright. Not one of the 56 is numeric:
- 49 are broadcast-system codes: 7 running
D011toD081, 19 in aBS101toBS4K101family, 22 in aCS055toCS363run, and a singleJCOM307 - 4 are a literal
#, apparently a placeholder the emitter never filled in - 3 are free text with a number buried in them, two reading
CH 106andCH 108, one a service name in Japanese ending in527
The parser also carries a ceiling of 99999, above which a value is treated as junk. It has never fired on this corpus. The largest number in the file is 4999, which is a useful reminder that a guard written for a hypothesis is not evidence the hypothesis was real.
The decimal parse is not a stylistic choice. 25 entries in that file use ATSC-style subchannel numbering, 24 distinct values running from 22.1 to 36.2, and an integer parse fuses 22.1, 22.2 and 22.3 into three channels all numbered 22. Invariant culture is not a stylistic choice either: honoring the device locale would make the same file produce different numbers on a machine configured for a comma decimal separator, which is a class of bug that only ever reproduces on someone else's computer.
tvg-id is worse, because its failures are not blank. In the 1,895-entry file the attribute is absent from 483 rows, empty on 20 more, and present but failing our shape rule on another 24, so 527 rows, 27.8 percent of the file, end up with no usable id. Those 24 are the uncomfortable ones. Seventeen of them look exactly like real ids and are rejected for one reason only, being shorter than six characters: M1.hu, E4.uk, 20.it, C8.fr, R9.at. The other 7 carry no dot. A minimum length is a blunt rule, it costs us those 17, and we would revisit the threshold if the count ever grew.
The reason that rule exists at all is a different corpus, one we hold for parser work, made of portal exports rather than the two curated files above. In two of its three sources the single most common tvg-id value was the literal string TS: 446 rows in one, 274 in the other. TS is the transport-stream container format, leaking out of an output-format field into the id column. Those 720 channels were mutually unrelated, a UK entertainment channel and an Arabic-language radio channel sitting side by side under one identifier. Because the channel key is derived from the id, all 720 collapsed into a single logical channel. The consequence is a favorite that appears on channels nobody marked, a parental lock that locks 720 things at once, and one multiview slot standing for every one of them.
The repair was a trust rule with one home, applied at the ingest boundary so that nothing downstream has to remember to re-check. An id is usable only if it is 6 to 128 characters, contains a dot, and is not TS, and what survives is lower-cased under the invariant culture, because sources genuinely disagree about the case of the same channel's id. Anything else normalizes to nothing, and "no id" is a state every consumer already handles, because most channels genuinely have none. The rule is necessary and not sufficient, and we know it: one id that passes the shape check turned out to be attached to 74 unrelated channels, so wherever sources are aggregated a build-time cardinality check runs on top of it. Neither of the two curated files in our sample contains a single TS id, which is the honest caveat on all of this. A clean corpus will never show you this failure. We learned about it from a bug report, not from a test.
With no usable id in most files, one has to be derived, and the derivation is where the trade-off gets uncomfortable. The stream URL is the only field always present and close to unique inside one source, so the synthetic id is a SHA-1 of that URL, first four bytes read little-endian, folded into a positive 32-bit integer. That leaves roughly 2^31 of key space, and by the birthday bound a 50,000-entry playlist carries about a 44 percent chance of at least one internal collision. We accepted that number with the arithmetic in front of us, on the grounds that a collision merges two channels inside one source, which is what already happens when a source lists the same channel twice. Our own corpus does it on 9 URLs covering 20 of the 1,895 rows in the larger file, and never once in the 308-entry one, so it is a real event but not a routine one, and that measurement weakens the argument more than it supports it. What actually settles the question is that widening the basis to include tvg-id, or moving to a wider hash, is unshippable: the derived id is written into saved data that other devices read back, so changing the derivation invalidates all of it at once. The collision rate is the price of a frozen wire format.
Blank is not absent, except in one field
Three attributes, three different policies for the empty string, and we did not notice until measuring.
group-title="" folds to nothing, because the group normalizer treats a blank as absent, and 25 rows in the smaller file take that path. tvg-id="" folds to nothing, because the trust rule turns it away. tvg-logo="" does not fold: it arrives as an empty string and travels all the way to the icon field of the catalog row. 19 rows in one sample file and 42 in the other carry an empty logo attribute, so 61 rows in a 2,203-row corpus hold an empty string where every other field would hold nothing. Logo is the odd one out for no reason beyond the order in which the three folds were written.
What that costs depends on a path we have not read. If the image loader treats an empty string and a null the same way, this is a wart and nothing more. If an empty string starts a request that fails, 61 rows are doing needless work on every render, and the wart is a small bug. Somebody should read that path before this page claims either.
What we have not measured
Some limits in this parser have no witness in our sample, and saying so is more useful than pretending they are tested. There is no unquoted-value path: tvg-chno=18 without quotes is invisible to us. Zero of the 2,203 #EXTINF lines we hold use an unquoted value, so it has never bitten us, and we do not know how common the shape is in the wild. The bare logo fallback has the same problem in reverse, since zero of our rows use it, so a fallback we ship has no test against real input.
Writing this page found a bug we had shipped, which is the most useful thing it did. Setting out the attribute matcher plainly enough to publish made it obvious that the pattern had no left boundary, so the pattern for logo matched inside any attribute name ending in logo. That includes provider-logo, a real attribute the IPTV Simple add-on for Kodi documents. A playlist carrying provider-logo and no tvg-logo took the wrong image, and the short-circuit did not save us, because it only fires when tvg-logo is present. The same hole let tvg-id match inside x-tvg-id, and group-title and tvg-chno inside anything ending in those.
It surprised us that the obvious fix is wrong. A word boundary does not work here: every attribute name in this convention is hyphen-joined, and - is not a word character, so there is already a boundary between the hyphen and the name and \btvg-logo still matches inside x-tvg-logo. The condition you actually want is that the name begins the attribute region or follows whitespace. In .NET that is a lookbehind of (?<=^|\s), which is portable in fewer engines than you would expect: Python refuses to compile it, because most engines require a fixed-width lookbehind and this one alternates. All four attributes now carry that boundary, and the six cases above are pinned by tests.
Our player runs all of this on your device against the playlist you supply, and every rejection produces a diagnostic string instead of an exception, because a file with 40 bad rows out of 3,000 has to import the other 2,960 and tell you what went missing. The Windows and Xbox apps do that work locally, and you point one at a source you already have.
If you are building a parser of your own, the measurements above will serve you better than the format documentation, mostly because there is not much format documentation. Count your entries. Count how often each attribute occurs, count how many distinct values it has, and compare those two numbers before you key anything on that field. That is one afternoon with a corpus you already have, and it is the difference between a parser that works on the file you tested with and one that works on the file you were sent.
What this article measured26 claims, each with the evidence behind it
| Claim | Evidence | Counted |
|---|---|---|
| RFC 8216 defines #EXTINF as a duration, a comma, and an optional title running to the end of the line.RFC 8216 section 4.3.2.1 | Specification | Not applicable |
| RFC 8216 permits a fractional #EXTINF duration.RFC 8216 section 4.3.2.1 | Specification | Not applicable |
| RFC 8216 requires #EXTM3U as the first line of a playlist.RFC 8216 section 4.3.1.1 | Specification | Not applicable |
| RFC 8216 says tags begin with #EXT and are case sensitive, so an ordinal prefix match is the conformant behavior.RFC 8216 section 4.1 | Specification | Not applicable |
| Extended M3U is the Winamp-era convention that introduced #EXTM3U and #EXTINF, and RFC 8216 cites it by name. It contains none of the tvg- tokens.RFC 8216 section 4.3.1.1, which reads: the EXTM3U tag indicates that the file is an Extended M3U [M3U] Playlist file | Specification | Not applicable |
| M3U+ is not a format name. m3u_plus is an output mode of Xtream Codes portal software, the query value that switches an export from bare URLs to rows carrying tvg-id, tvg-logo and group-title.Xtream Codes portal API, get.php?type=m3u_plus | Specification | Not applicable |
| The IPTV Simple add-on for Kodi documents catchup, catchup-source, catchup-days, tvg-shift, radio, provider-logo and an #EXTGRP directive.kodi-pvr/pvr.iptvsimple README, playlist attribute reference | Specification | Not applicable |
| aspect-ratio is documented by SS-IPTV, not by Kodi.SS-IPTV playlist attribute documentation | Specification | Not applicable |
| tvg-language came from iptv-org, which has since dropped it along with tvg-country.iptv-org/iptv repository, playlist field history | Specification | Not applicable |
| A 50,000-entry playlist over a ~2^31 key space carries about a 44 percent chance of at least one internal collision.Birthday bound, 1 - exp(-n(n-1)/2N) with N = 2^31 and n = 50,000, which evaluates to 0.4412 | Specification | Not applicable |
| Every #EXTINF line in the corpus carries a duration of -1. | n = 2203 | Aug 22, 2026 |
| Splitting on the first comma breaks 30 of the 308 entries in the smaller file; all 30 carry a user-agent value containing the fragment KHTML, like Gecko. | n = 308 | Aug 22, 2026 |
| 1,355 of 1,895 entries carry tvg-chno, over 263 distinct values, with 107 rows claiming number 1 and one group holding 7 channels all numbered 1. | n = 1895 | Aug 22, 2026 |
| The larger file has 92 groups; 85 of them carry at least one number, and 33 of those 85 contain a duplicated number. | n = 1895 | Aug 22, 2026 |
| 56 of the 1,355 tvg-chno values are rejected, and not one of them is numeric: 49 broadcast-system codes, 4 literal # placeholders, 3 free-text values. The parser ceiling of 99999 has never fired; the largest number in the file is 4999. | n = 1355 | Aug 22, 2026 |
| 25 rows use ATSC-style subchannel numbering, over 24 distinct values running from 22.1 to 36.2. | n = 1895 | Aug 22, 2026 |
| In the larger file tvg-id is absent from 483 rows, empty on 20 more, and present but failing the shape rule on another 24, so 527 rows (27.8 percent) end with no usable id. | n = 1895 | Aug 22, 2026 |
| 17 of those 24 shape failures are rejected only for being shorter than six characters, and look like real ids: M1.hu, E4.uk, 20.it, C8.fr, R9.at. The other 7 contain no dot. | n = 24 | Aug 22, 2026 |
| On a separate corpus of portal exports, TS was the most common tvg-id in two of three sources, 446 rows and 274 rows, and those 720 unrelated channels collapsed to one channel key. One id passing the shape check was attached to 74 unrelated channels. | n = 720 | Earlier corpus |
| Duplicate stream URLs are real and rare: 9 duplicated URL values covering 20 of the 1,895 rows in the larger file, and 0 of 308 in the smaller. | n = 2203 | Aug 22, 2026 |
| 6 rows in the larger file point at an rtmp or rtsp URL (5 and 1), and they are the only rows in the whole corpus the parser rejects. | n = 2203 | Aug 22, 2026 |
| 61 rows carry an empty tvg-logo (19 in the smaller file, 42 in the larger) and the empty string survives to the catalog row, while an empty group-title (25 rows) and an empty tvg-id both fold to nothing. | n = 2203 | Aug 22, 2026 |
| tvg-name appears on all 1,895 rows of the larger file and tvg-country on 1,642; the smaller file carries 30 user-agent attributes, 2 referrer attributes and 32 #EXTVLCOPT directive lines. | n = 2203 | Aug 22, 2026 |
| The corpus contains zero unquoted attribute values, zero lowercase #extinf tags, zero uppercase attribute names, zero spaces around an equals sign, zero unbalanced quote rows, and zero bare logo attributes. | n = 2203 | Aug 22, 2026 |
| The hand-built fragment in this article yields 4 parsed rows, 3 catalog rows and 5 diagnostics. | n = 7 | Aug 22, 2026 |
| The parser has 9 unit tests, including two that assert fields do not leak between entries. The quoted-comma scanner, the unbalanced-quote rejection, the #EXTVLCOPT-between-the-lines case and the bare logo fallback have none. | n = 9 | Aug 22, 2026 |