m3u plus 与 m3u:扩展播放列表多出来的那些属性,以及怎么解析它们

并不存在 M3U+ 规范。有的是一套约定、一批语料,以及一个必须在约定和语料两边都活下来的解析器。

#EXTINF:-1 tvg-id="" tvg-logo="" group-title="News;General" tvg-chno="1",Example One
https://provider.example.com/stream/one.m3u8

两行。这一行是拼出来的,不是抄来的:我们留着做解析器工作的那两份播放列表里写的是真实的广播机构名,所以这里没有任何东西是从它们那儿一字不差搬过来的。它里面的每一种形状,那两份文件确实都有;而每一个取值都不具辨识度。在那份 1,895 条记录的文件里,107 行宣称自己是 1 号频道,20 行带着空的 tvg-id,42 行带着空的 tvg-logo。在那份 308 条记录的文件里,25 行带着空白的 group-title,19 行带着空的 tvg-logo,还有 10 行用分号把两个分组名塞进了同一个属性。

第一行上那些看着像结构化数据的东西,几乎全都是缺失的、空白的,或者跟上百个互不相干的频道共用的。唯一既可靠地存在、又接近于唯一的字段,是第二行那个 URL:较小那份文件里 308 条记录有 308 个不同的 URL,较大那份里 1,895 条有 1,884 个。

播放器本身不含任何频道,也没法提供任何频道。里面出现的东西来自你指向的那个文件,在你自己的设备上解析;也就是说,这次解析面对不完美输入时的表现,基本上就决定了你的频道列表最后长什么样。

没有一份规范,只有一套约定

RFC 8216 为 HLS 把 #EXTINF 定义为一个时长、一个逗号,以及一个一直延伸到行尾的、给人看的可选标题。它对 tvg-id、tvg-chno、group-title、tvg-logo、catchup-source,以及真实文件塞在时长和逗号之间的其他任何记号,都只字未提。

那些记号来自后来一套面向直播电视播放列表的约定,这套约定没有公认的名字,而大家顺手抓来的两个名字都是错的。Extended M3U 是 Winamp 时代那套约定,#EXTM3U 和 #EXTINF 本身就是它引入的;RFC 8216 在 4.3.1.1 节里指名引用了它,而它里面没有任何 tvg- 开头的东西。M3U+ 则压根就不是一种格式:m3u_plus 是通过服务商登录接入的提供商服务器的一种输出模式。正是这个查询取值,把导出结果从光秃秃的 URL 切换成带着 tvg-id、tvg-logo 和 group-title 的行。前者是上世纪九十年代的一套播放列表约定,后者是一个查询参数,两者都不是规范。这套约定的定义,就是各家生成方实际生成出来的东西。

这个区分贯穿下面所有内容。约定允许什么,和某一个解析器接受什么,是两份不同的清单;把它们混为一谈,正是一个解析器最后把某一个来源的习惯当成规则写死的过程。所以:我们的解析器实现的语法就是下面这些,仅此而已。

line     := "#EXTINF:" WS* duration WS* attributes "," displayName
duration := "-"? DIGIT+

前缀匹配是按序数进行的,所以 #EXTINF: 连冒号在内都区分大小写;解析器把这个前缀的长度写死成八个字符,然后把剩下的交给一个时长扫描器。那个扫描器接受一个可选的单个减号,然后要求至少有一位数字,接着把整串数字吃掉。它记下数字在哪里结束,然后把取值丢掉。整条流水线里从来没有任何地方读过这个时长。我们那两份样本播放列表里全部 2,203 行 #EXTINF 带的都是 -1,这是这套约定用来说“直播,长度未知”的方式;就算它们写的是 7200,也没有任何代码路径会在意。

从这个扫描器直接掉出三个后果。+1 会被拒,因为加号既不是减号也不是数字。.5 会被拒,因为点号前面必须先有一位数字。而像 #EXTINF:120.5,Example 这样带小数的时长,是碰巧解析对的:扫描器在点号处停下,剩下的 .5 落进了属性区域,而没有任何一次属性查找会匹配上它。RFC 8216 允许这种小数写法,我们的解析器也确实处理得了,但没有人实现过它。这一点值得直说,而不是归档到“我们支持小数”名下。

这套语法内部有一处不对称值得认下来,不过方向跟乍看之下的不一样。标签前缀是区分大小写匹配的,这正是 RFC 8216 第 4.1 节对每一个以 #EXT 开头的标签的要求。属性名是不区分大小写匹配的,而这一点没有任何规范要求。所以小写的 #extinf: 会被当成无法识别的指令跳过,而同一行上的 TVG-CHNO = "18" 却能正常解析;偏离 RFC 的那一半,恰恰是宽松的那一半。这处不一致是从一个更早的服务端解析器移植过来时带进来的,源文件顶部至今还这么写着。两份样本播放列表里都没有小写标签、没有大写属性名、也没有等号两侧的空格,所以这两半在我们的语料里从来没有被走到过。

那个不是分隔符的逗号

把一行 #EXTINF 按它的第一个逗号切开,你就在我们那两份文件的其中一份里切坏了 308 条记录中的 30 条。

原因是属性取值是带引号的字符串,而带引号的字符串里有逗号。那 30 行每一行都带着一个 user-agent 属性,里面装着浏览器的 UA 字符串,而这 30 个字符串里都含有 KHTML, like Gecko 这一段。其中二十八个把它包在括号里,另外两个的括号在上游某处被剥掉了,而这对逗号没有任何影响。下面是真正的对比,拿真实文件跑出来的,来源的主机名已经去掉,频道自己的名字也换过了:

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)

这份文件里有百分之九点七的行,显示名变成了某个人的浏览器版本字符串。目录照样能建起来。什么错都不报。频道列表错了,而且错得看起来像渲染 bug,其实不是。

修法是一个手写的扫描器,不是正则:逐个字符走过属性区域,每碰到一个双引号就翻转一次 inQuotes 标志,返回该标志为 false 时看到的第一个逗号。左边全部是属性区域,右边全部是显示名,两边都去掉首尾空白。这是九行代码,也是整个解析器里价值最高的九行。

这个扫描器同时也决定了畸形行的下场,而这个决定是严格的。如果属性区域里双引号的个数是奇数,比如 group-title="Movies,Example Four 这种收尾引号被丢掉的情况,那个标志就再也回不到 false,也就永远找不到引号外的逗号,整条记录会被拒掉并留下一条诊断。既不会退回到第一个逗号,也不会去猜那个引号本该在哪儿。我们选择拒绝而不是修补,因为被修补过的行是悄无声息地错着,而被拒掉的行是响亮地缺席着;响亮地缺席的那一行,才是读者能报上来的那一行。

我们读五个属性,而有一份文件里有 1,895 行带着我们不读的第六个

我们的解析器会去找的属性,完整清单就是五个拼写:

  • group-title,保留到第一个分号为止,空白折叠成没有
  • tvg-logo,去掉首尾空白
  • logo,只有在 tvg-logo 完全不存在时才去看它
  • tvg-id,解析时原样取走,信任检查放到后面做
  • tvg-chno,交给一个允许什么都不返回的数字解析器

整份清单就这些。属性名按不区分大小写匹配,等号两侧的空白可有可无,所以 TVG-CHNO = "18" 和 tvg-chno="18" 对我们来说是一回事。取值必须用双引号;不存在不带引号的取值路径,单引号在任何地方都不被识别。

现在说观测到的那一面,那一面更有意思。在那份 1,895 条记录的文件里,tvg-name 出现在全部 1,895 行上,我们一行都不读。tvg-country 出现在 1,642 行上,这些我们也不读。显示名永远取自逗号之后的那段文本,从来不取自 tvg-name,哪怕某个来源在它发布的每一行上都同时给了这两样。这是设计决定,不是疏漏:逗号之后的标题是语法本身保证会有的字段,tvg-name 是可选的,而一个说了算的名字来源,胜过一套在不同文件上表现不同的优先级顺序。

较小那份文件展示了夹在一条记录两行之间的那一层指令。它带着 30 个 user-agent 属性、2 个 referrer 属性(那个生成方把它拼成了两个 r,而这不是 HTTP 头部的拼法),以及 32 行独立的 #EXTVLCOPT 指令,其中 30 行重复的正是 #EXTINF 行上已经写过的 user agent。这些我们一个都不读。

别的播放器会读一些我们不读的属性,而它们的清单没有任何两份是一样的。Kodi 的 IPTV Simple 插件在文档里写了 catchup、catchup-source、catchup-days、tvg-shift、radio,还有一个 #EXTGRP 指令。SS-IPTV 是给智能电视用的播放器,它写了 aspect-ratio,而 Kodi 没有。tvg-language 来自 iptv-org,后来 iptv-org 把它连同 tvg-country 一起去掉了。这些记号在我们的样本里一个都没出现过,我们也一个都不读。由此产生了一条值得说清楚的产品局限:我们的回看功能是有的,但它建立在服务商登录之上,所以一个 M3U 来源不管写什么属性,都没法把回看暴露给我们。

七个头部行,三个频道

下面这段片段是为了一次跑通所有路径而造的。里面每一个主机名都是保留的示例域名,每一个名字都是编的。它演练到的大多数形状,我们的语料里确实有,BS4K101 也在其中,那是从较大那份文件里原样取来的真实取值。有五种形状语料里一处都没有:畸形的时长、不成对的引号、光秃秃的 logo、等号两侧带空格的大写属性名,以及以 ## 开头的名字。手写片段是走到这五条路径的唯一办法,在你把下面这段走查当成关于真实文件的证据之前,这一点值得先知道。

#EXTM3U x-tvg-url="https://provider.example.com/guide.xml"
#EXTINF:-1 tvg-id="one.example" TVG-CHNO = "22.1" group-title="News;General" tvg-logo="",Example One
https://provider.example.com/stream/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://provider.example.com/stream/two.m3u8
#EXTINF:-1 tvg-chno="BS4K101" group-title="Local",## ===== LOCAL =====
https://provider.example.com/stream/divider
#EXTINF:x tvg-chno="7" group-title="Movies",Example Three
https://provider.example.com/stream/three.m3u8
#EXTINF:-1 group-title="Movies,Example Four
https://provider.example.com/stream/four.m3u8
#EXTINF:-1 tvg-chno="9",Example Five
rtmp://provider.example.com/stream/five
#EXTINF:-1 logo="https://provider.example.com/logos/six.png",Example Six
https://provider.example.com/stream/six.m3u8

七个头部行进去。四条记录从解析器里出来,其中三条到达目录,另外还产生五条诊断字符串。

第 1 行被跳过。我们的解析器压根就不去找 #EXTM3U;它跟其他所有无法识别的东西一起,从那条通用的“以井号开头”的跳过路径掉下去,它上面的 x-tvg-url 属性也不会被读。RFC 8216 要求这个头部行作为播放列表的第一行。我们并不要求它,一份没有头部、直接以 #EXTINF 开场的文件也能正常解析,因为解析之前在设备上跑的那道形状检查,接受这两个标签中的任何一个作为文件里的第一样东西,前面可以有一个可选的字节序标记和前导空白。除此之外不允许有别的东西在前面:以注释行或 #EXTVLCOPT 开场的文件,在解析器看到它之前就被拒了。

Example One 在属性名大小写不对、等号两侧有空格的情况下解析成功。它的分组变成 News,因为只保留第一个分号之前那一段。它的编号变成小数 22.1,而不是整数 22,这一点下面有关系。它的 logo 变成一个空字符串,而你本来预期这里什么都没有,这一点下面也有关系。

Example Two 是引号内逗号那种情况,而它后面那行 #EXTVLCOPT,正是通用井号跳过路径不能清掉待处理头部的原因。指令合法地待在一行 #EXTINF 和它的 URL 之间。一个每遇到一行无法识别的 # 就重置状态的解析器,会丢掉这个频道,以及每一个像它这样的频道;在较小那份样本文件里,这是 308 条记录中的 30 条。

那行分隔行解析得干干净净,就语法而言它就是一个频道。它的 tvg-chno 取值 BS4K101 被数字解析器拒掉,变成没有,代价是丢了编号而不是丢了这一行。真正把它拿掉的,是它以 ## 开头的名字,而这发生在后面的名字归一化阶段,不是在解析时。把这两个阶段分开,意味着解析器对一个频道名意味着什么不持任何看法,而这正是那条边界该在的地方。

Example Three 栽在时长上:x 既不是减号也不是数字,所以这个头部行被拒并留下一条诊断,五个待处理字段全部被清掉。接着它的 URL 到达时没有待处理的头部,于是产生第二条诊断。一个畸形的头部行代价是两行,产生两条日志记录,这是对的,在你读一次真实导入的诊断时值得知道。

Example Four 是引号不成对那种情况,表现完全一样:两条诊断,什么都不产出。

Example Five 有一个完全合法的头部行,和一个 rtmp 的 URL。那是一个绝对 URI,所以它确实按绝对 URI 解析出来了,但方案既不是 http 也不是 https,我们把它拒掉。它是这段片段里最危险的一行,因为如果拒绝路径没能清掉待处理状态,Example Six 就会悄无声息地继承 9 号频道编号。这条路径不是假想出来的。较大那份样本文件里,1,895 条记录中有 6 条以 rtmp 或 rtsp 的 URL 结尾,分别是五条和一条,而这 6 行是整批 2,203 条记录的语料里唯一被我们的解析器拒掉的行。

Example Six 带着一个光秃秃的 logo 属性,完全没有 tvg-logo,也完全没有 group-title,所以 logo 回退生效,这个频道落进一个以来源命名的默认分组。

每一条跳过路径都必须清掉那个头部

一条 M3U 记录是共享可变状态的两行,而这就是这个格式的全部难处。在读到一个头部行和读到一个 URL 之间,解析器持有五个待处理字段:名字、分组、logo、id、编号。循环里跑着七条互不相同的路径,而解析器的正确性,完完全全就是每一条路径对这五个字段做了什么的问题。

空行跳过而不清空,这是对的,因为一个头部行和它的 URL 之间的空行,不该毁掉这条记录。无法识别的 # 指令跳过而不清空,理由上面那段片段已经演示过了。畸形的头部行清空全部五个。被拒的 URL 清空全部五个。成功产出清空全部五个。

第七条路径是大多数成对行解析器会搞错的那一条,而有意思的地方在于,它根本不是靠重置来处理的。当第二行 #EXTINF 在任何 URL 之前到来时,解析器无条件地给全部五个字段赋值,没有 if,也没有空合并赋值。一次什么都没找到的属性查找返回的是没有,一个什么都没拿到的数字解析器返回的也是没有,所以一个不带 tvg-chno 的头部行,会把空写到上一个头部行的编号上,而不是让它原地留着。不存在任何一条路径,能让第 K 条记录的第 N 个字段活到第 K+1 条记录里。这是一个比“我们记得重置”更强的性质,也值得有意识地把一个解析器组织成这样,因为重置那个版本只有在往后每一次改动都记得去扩充它的前提下才一直是对的。

有一条路径值得记一笔,因为它看着像 bug,其实不是。“URL 到达时没有待处理头部”这条路径产出它的诊断,什么都不清空。这一点的安全来自构造,不是来自运气,而这个不变式是可以靠读赋值语句来核对的:另外四个字段唯一被赋值的地方就是头部行那条路径,而那条路径总是在同一趟里把名字设成一个非空值,标题为空时会退回到字面量字符串 Unnamed channel。所以名字为 null 就意味着另外四个已经是 null,那个缺失的重置是走不到的无用功。

这些路径每一条都需要自己的回归测试,而我们手上那九个确实覆盖了泄漏路径:一个断言带编号的记录不会把编号泄漏到下一条上,另一个断言畸形头部行也不会。完全没有测试的,是那个引号内逗号扫描器,也就是本文称为解析器里价值最高的九行的那九行,还有引号不成对的拒绝、#EXTVLCOPT 夹在两行之间的情况,以及光秃秃的 logo 回退。这个缺口不会抛出任何东西。目录照样能建起来。一行坏数据下游的上百个频道,带着错误的分组。

那两个看着像标识符的字段

tvg-chno 是展示数据,而定下这一点的测量只要一行 shell 的活儿。在那份 1,895 条记录的文件里,有 1,355 条记录带这个属性。这 1,355 个编号覆盖 263 个不同取值。有 107 条记录宣称自己是 1 号。这种重复不是把分组合并到一起造成的假象,因为它在分组之内依然成立:那份文件里有一个分组,里面 7 个频道全都编号为 1,而在 85 个带有任何编号的分组里,有 33 个内部至少有一处重复。那份文件总共有 92 个分组,所以其中 7 个完全没有编号。另一份文件,308 条记录,任何地方都没有 tvg-chno。任何东西都不能以这个取值为键,任何东西都不能假设它唯一,而每一个按它排序的消费方,都需要一条确定的、用来打破并列的次级规则。

解析它是一次拒绝耍聪明的小练习。这个取值是在固定区域性下按浮点数读的,不是按整数读,而且从来不按设备的区域设置读。1,355 个里有 56 个被直接拒掉。这 56 个里没有一个是数字:

  • 49 个是广播系统代码:7 个是从 D011 到 D081 这一串,19 个属于 BS101 到 BS4K101 这一族,22 个在 CS055 到 CS363 这一段里,还有单独一个带有某家有线电视运营商前缀的代码
  • 4 个是字面量 #,看起来是生成方从来没填上的占位符
  • 3 个是里面埋着数字的自由文本,其中两个写的是 CH 106 和 CH 108,还有一个是以 527 结尾的日语服务名

解析器还带着一道 99999 的上限,超过它的取值被当成垃圾。在这批语料上它从来没有触发过。文件里最大的编号是 4999,这是一个有用的提醒:为一个假设写下的护栏,并不能证明那个假设真的存在过。

按小数解析不是风格上的选择。那份文件里有 25 条记录用的是 ATSC 式的子频道编号,24 个不同取值,从 22.1 一直到 36.2;而按整数解析会把 22.1、22.2 和 22.3 熔成三个都编号为 22 的频道。固定区域性同样不是风格上的选择:照顾设备的区域设置,会让同一份文件在一台配置成用逗号作小数分隔符的机器上产出不同的编号,而这类 bug 永远只在别人的电脑上复现。

tvg-id 更糟,因为它的失败不是空白。在那份 1,895 条记录的文件里,这个属性在 483 行里根本不存在,另有 20 行是空的,还有 24 行虽然写了却通不过我们的形状规则,所以有 527 行,占该文件的 27.8%,最后没有可用的 id。让人不舒服的是那 24 行。其中十七个看上去就是真实的 id,比如很短的名字加上国家后缀,被拒的理由只有一个,就是短于六个字符。另外 7 个不含点号。最小长度是一条粗糙的规则,它让我们损失了那 17 个,如果这个数字哪天涨上去,我们会重新考虑这个阈值。

这条规则之所以存在,起因是另一批语料,那批我们留着做解析器工作,由提供商导出组成,而不是上面那两份精选文件。在它三个来源中的两个里,最常见的单个 tvg-id 取值就是字面量字符串 TS:一个里面 446 行,另一个里面 274 行。TS 是传输流容器格式,从一个输出格式字段泄漏进了 id 那一列。那 720 个频道互不相干,一个英国的娱乐频道和一个阿拉伯语的广播频道,并排挂在同一个标识符下面。因为频道键是从 id 推导出来的,这 720 个全部塌缩成了一个逻辑频道。后果就是:一个收藏出现在没人标记过的频道上,一次家长锁一下子锁住 720 样东西,还有一个多画面槽位代表着它们全体。

修法是一条只有一个落脚点的信任规则,施加在入库边界上,这样下游就没有任何东西需要记得再检查一遍。一个 id 只有在长度为 6 到 128 个字符、含有一个点号、并且不是 TS 的情况下才可用;活下来的会在固定区域性下转成小写,因为不同来源对同一个频道 id 的大小写确实各执一词。其他一切归一化成没有,而“没有 id”是每一个消费方本来就会处理的状态,因为大多数频道确实一个都没有。这条规则是必要的,但不充分,我们知道这一点:有一个通过了形状检查的 id,结果挂着 74 个互不相干的频道,所以凡是有来源被聚合的地方,都会在它之上再跑一道构建期的基数检查。我们样本里那两份精选文件,一个 TS id 都没有,这是对上面这一切的诚实告诫。一批干净的语料永远不会让你看到这种失败。我们是从一份缺陷报告里知道它的,不是从测试里。

大多数文件里没有可用的 id,那就只能推导一个出来,而推导正是这个取舍变得让人不舒服的地方。流 URL 是唯一一个总是存在、并且在同一个来源内部接近唯一的字段,所以合成 id 就是这个 URL 的 SHA-1,取前四个字节按小端读,折成一个 32 位正整数。这样剩下大约 2^31 的键空间,而按生日界估算,一份 50,000 条记录的播放列表出现至少一次内部碰撞的概率大约是 44%。我们是把算式摆在面前接受这个数字的,理由是一次碰撞只会把同一个来源内部的两个频道合并起来,而当一个来源把同一个频道列了两遍时,这本来就已经在发生了。我们自己的语料里,较大那份文件有 9 个 URL 出现了这种情况,覆盖 1,895 行中的 20 行,较小那份 308 条记录的文件里一次都没有;所以这确实会发生,但不是常事,而这个测量对这个论点的削弱,大过它对这个论点的支撑。真正把问题定下来的,是把依据扩宽到把 tvg-id 也算进去、或者换成更宽的哈希,都是发不出去的:推导出来的 id 会被写进保存下来的数据里,别的设备会把它读回去,所以改变推导方式会一次性作废掉全部这些数据。碰撞率就是一个冻结的传输格式要付的代价。

空值不等于没有,只有一个字段例外

三个属性,对空字符串有三种不同的策略,而我们直到动手测量才注意到。

group-title="" 折叠成没有,因为分组归一化器把空白当成不存在,较小那份文件里有 25 行走这条路。tvg-id="" 折叠成没有,因为信任规则把它挡了回去。tvg-logo="" 不折叠:它以空字符串的身份到达,一路走到目录行的图标字段。一份样本文件里有 19 行、另一份里有 42 行带着空的 logo 属性,所以在一批 2,203 行的语料里,有 61 行在别的字段都会是没有的位置上,握着一个空字符串。logo 成为例外,除了这三处折叠被写下来的先后顺序之外,没有别的原因。

这要付出什么代价,取决于一条我们还没读过的路径。如果图片加载器把空字符串和 null 一视同仁,那这就只是一个疣子,仅此而已。如果一个空字符串会发起一次注定失败的请求,那就是 61 行在每次渲染时都在做无用功,这个疣子也就成了一个小 bug。在这个页面认定是其中哪一种之前,应该有人去把那条路径读一遍。

我们没有量过的东西

这个解析器里有些限制在我们的样本里没有见证,把这一点说出来,比假装它们受过测试有用。不存在不带引号的取值路径:不加引号的 tvg-chno=18 对我们来说是看不见的。我们手上 2,203 行 #EXTINF 里,用不带引号取值的有零行,所以它从来没咬过我们,而这种形状在外面到底有多常见,我们不知道。光秃秃的 logo 回退是同一个问题的反面,因为我们的行里用到它的也是零行,所以一个我们已经发出去的回退,没有任何针对真实输入的测试。

写这个页面找出了一个我们已经发出去的 bug,这是它做的最有用的一件事。把属性匹配器摆出来、摆到能拿去发表的清楚程度,就明显看出这个模式没有左边界,于是 logo 的模式会在任何以 logo 结尾的属性名内部匹配上。这里面就包括 provider-logo,那是 Kodi 的 IPTV Simple 插件写进文档的一个真实属性。一份带着 provider-logo 而没有 tvg-logo 的播放列表会取到错的图片,而那个短路判断救不了我们,因为它只有在 tvg-logo 存在时才生效。同一个窟窿也让 tvg-id 在 x-tvg-id 内部匹配上,让 group-title 和 tvg-chno 在任何以它们结尾的东西内部匹配上。

让我们意外的是,那个显而易见的修法是错的。单词边界在这里不管用:这套约定里每一个属性名都是用连字符拼起来的,而 - 不是单词字符,所以连字符和名字之间本来就已经有一条边界,\btvg-logo 照样会在 x-tvg-logo 内部匹配上。你真正想要的条件是:这个名字要么开启属性区域,要么紧跟在空白之后。在 .NET 里那就是一个 (?<=^|\s) 的后向查找,而它可移植到的引擎比你以为的要少:Python 拒绝编译它,因为大多数引擎要求后向查找是定宽的,而这一个是带选择分支的。现在这四个属性都带上了那条边界,上面那六种情况也都由测试钉住了。

我们的播放器把这一切都放在你的设备上做,针对的是你自己提供的播放列表;每一次拒绝产生的是一条诊断字符串而不是一个异常,因为一份 3,000 行里有 40 行坏数据的文件,必须把另外 2,960 行导进来,并告诉你少了什么。Windows 和 Xbox 应用把这些活儿放在本地做,而你只要把它指向一个你手上已经有的来源。

如果你在写自己的解析器,上面这些测量对你的用处会大过格式文档,主要是因为格式文档本来就不多。数一数你的记录条数。数一数每个属性出现的频次,数一数它有多少个不同取值,在你拿那个字段做键之前,先把这两个数字比一比。这是拿一批你手上已经有的语料花一个下午的事,而它就是“在你测试用的那份文件上能跑的解析器”和“在别人发给你的那份文件上能跑的解析器”之间的差别。

本文测量了什么26 主张,每项主张都有其背后的证据
索赔证据已计算
RFC 8216 把 #EXTINF 定义为一个时长、一个逗号,以及一个一直延伸到行尾的可选标题。RFC 8216 section 4.3.2.1规格不适用
RFC 8216 允许 #EXTINF 的时长带小数。RFC 8216 section 4.3.2.1规格不适用
RFC 8216 要求 #EXTM3U 作为播放列表的第一行。RFC 8216 section 4.3.1.1规格不适用
RFC 8216 规定标签以 #EXT 开头且区分大小写,所以按序数做前缀匹配才是符合规范的做法。RFC 8216 section 4.1规格不适用
Extended M3U 是 Winamp 时代的那套约定,#EXTM3U 和 #EXTINF 就是它引入的,RFC 8216 还指名引用了它。它不包含任何 tvg- 开头的记号。RFC 8216 section 4.3.1.1, which reads: the EXTM3U tag indicates that the file is an Extended M3U [M3U] Playlist file规格不适用
M3U+ 并不是一个格式名。m3u_plus 是通过服务商登录接入的提供商服务器的一种输出模式:正是这个查询取值,把导出结果从光秃秃的 URL 切换成带着 tvg-id、tvg-logo 和 group-title 的行。Provider login API, playlist export parameter type=m3u_plus规格不适用
Kodi 的 IPTV Simple 插件在文档里写了 catchup、catchup-source、catchup-days、tvg-shift、radio、provider-logo,还有一个 #EXTGRP 指令。kodi-pvr/pvr.iptvsimple README, playlist attribute reference规格不适用
aspect-ratio 是 SS-IPTV 写进文档的,不是 Kodi。SS-IPTV playlist attribute documentation规格不适用
tvg-language 来自 iptv-org,后来 iptv-org 把它连同 tvg-country 一起去掉了。iptv-org/iptv repository, playlist field history规格不适用
一份 50,000 条记录的播放列表,落在大约 2^31 的键空间上,出现至少一次内部碰撞的概率约为 44%。Birthday bound, 1 - exp(-n(n-1)/2N) with N = 2^31 and n = 50,000, which evaluates to 0.4412规格不适用
语料里每一行 #EXTINF 的时长都是 -1。n = 22032026年8月22日
按第一个逗号切分,会把较小那份文件里 308 条记录中的 30 条切坏;这 30 条的 user-agent 取值里都含有 KHTML, like Gecko 这一段。n = 3082026年8月22日
1,895 条记录里有 1,355 条带 tvg-chno,分布在 263 个不同取值上,其中 107 行宣称自己是 1 号,还有一个分组里的 7 个频道全都编号为 1。n = 18952026年8月22日
较大那份文件有 92 个分组;其中 85 个至少带一个编号,而这 85 个里有 33 个含有重复的编号。n = 18952026年8月22日
1,355 个 tvg-chno 取值里有 56 个被拒掉,而且没有一个是数字:49 个广播系统代码,4 个字面量 # 占位符,3 个自由文本取值。解析器 99999 这道上限从来没有触发过;文件里最大的编号是 4999。n = 13552026年8月22日
有 25 行用的是 ATSC 式的子频道编号,分布在 24 个不同取值上,从 22.1 一直到 36.2。n = 18952026年8月22日
在较大那份文件里,tvg-id 在 483 行里根本不存在,另有 20 行是空的,还有 24 行虽然写了却通不过形状规则,所以有 527 行(27.8%)最后没有可用的 id。n = 18952026年8月22日
这 24 个形状失败里有 17 个只是因为短于六个字符才被拒,而它们看起来就是真实的 id,比如一个很短的名字加上国家后缀。另外 7 个不含点号。n = 242026年8月22日
在另一批由提供商导出组成的语料里,三个来源中有两个的最常见 tvg-id 都是 TS,分别是 446 行和 274 行,而这 720 个互不相干的频道塌缩成了同一个频道键。有一个通过了形状检查的 id,挂着 74 个互不相干的频道。n = 720早期语料库
重复的流 URL 确实存在,但很少见:较大那份文件里有 9 个重复的 URL 取值,覆盖 1,895 行中的 20 行,较小那份里是 308 行中的 0 行。n = 22032026年8月22日
较大那份文件里有 6 行指向 rtmp 或 rtsp 的 URL(分别是 5 行和 1 行),它们是整批语料里唯一被解析器拒掉的行。n = 22032026年8月22日
有 61 行带着空的 tvg-logo(较小那份文件 19 行,较大那份 42 行),这个空字符串会一路活到目录行;而空的 group-title(25 行)和空的 tvg-id 都会折叠成没有。n = 22032026年8月22日
tvg-name 出现在较大那份文件全部 1,895 行上,tvg-country 出现在 1,642 行上;较小那份文件带着 30 个 user-agent 属性、2 个 referrer 属性和 32 行 #EXTVLCOPT 指令。n = 22032026年8月22日
整批语料里没有任何不带引号的属性取值,没有任何小写的 #extinf 标签,没有任何大写的属性名,没有任何等号两侧带空格的写法,没有任何引号不成对的行,也没有任何光秃秃的 logo 属性。n = 22032026年8月22日
本文里手写的那段片段跑出来是 4 条解析行、3 条目录行和 5 条诊断。n = 72026年8月22日
解析器有 9 个单元测试,其中两个断言字段不会在记录之间泄漏。引号内逗号扫描器、引号不成对的拒绝、#EXTVLCOPT 夹在两行之间的情况,以及光秃秃的 logo 回退,一个测试都没有。n = 92026年8月22日