EPG 时间为什么会错:XMLTV 时间戳与时区偏移
节目单整点差上几个小时,几乎从来都不是什么谜。那是文件里明明写了、却没被采纳的一个后缀。
<programme start="20260719150000 +0200" stop="20260719160000 +0200" channel="one.example.com">
十四位数字,一个空格,然后是决定这一行到底从 15:00 还是 13:00 开始的五个字符。那十四位数字并不是一个时刻。它们只是一个没有附带地点的挂钟读数。节目单要正确,就得靠最后这五个字符被读到;如果它们不在,就得靠你按写出这个文件的抓取器本来的意思去理解这种缺失。
动手改任何东西之前,先把偏差量出来。如果每个频道的每个节目都差同样的整数个小时,那就只有一处出了问题,而且几乎总是文件里写了、却没被采纳的一个偏移。如果有的频道对、有的频道错,或者某一行偏了、它前后两行都好好的,那就是另一种故障,下面所有关于偏移的功夫都碰不到它。
我们的播放器是在你的设备上解析节目单的,针对的是你自己指向的那个地址。我们会精选一批节目单地址,同一个解析器也会在我们这边跑一遍这些精选地址,用于目录方面的工作。你自己提供的节目单地址,是在你的机器上抓取和读取的,我们从不保存你的节目单数据。
整份节目单偏移,和单个节目错了,是两种 bug
整体偏移有三个说得通的成因,一分钟就能分辨出来。
第一,文件里带了偏移,被读取器扔掉了。第二,文件里压根没带偏移,抓取器写的是本地挂钟时间、不加任何后缀,读取器无从知晓。第三,同一个校正被加了两次,来源那边一次、你这边一次。这是大家第一反应就会去动的那个,也是唯一一个会让偏差正好翻倍的。如果你设了两小时校正之后,节目单从差一小时变成了差三小时,那就是第三种,答案是先把校正调回零,再去诊断别的。
量它只需要两次比对和一块表。挑一个正在播的节目,记下它标称的开始时间跟实际差多少,然后在同一个来源的第二个频道上再来一次,再到另一个来源的频道上来一次。同一个来源的两个频道错得一样、另一个来源没错,就能把范围缩到那个来源的文件上。哪里都错得一样,问题就指向设备时钟,那完全是另一种修法。
不整齐的偏移根本不是时区问题。常见的原因是节目单的行挂到了错误的频道上,于是别家台正确的时间表,看上去就成了你这个台错误的时间表。这是身份识别的故障,它长什么样,另有一篇文章专门讲。播放列表里有 id,跟这个 id 能对上你的节目单,是两回事。我们量过的两份播放列表里,较大的那份 1,895 行中有 527 行最后根本没有可用的 id。其余造成不整齐偏移的原因是时长而不是开始时间,后面会讲到。
以符号开头的后缀,已经告诉你它是个数字
下面是我们的时间戳读取器所执行的规则,以及它上方那段注释的原文:
var suffix = value[14..].Trim();
if (suffix.Length > 0 && (suffix[0] == '+' || suffix[0] == '-'))
{
// A suffix that looks like a numeric offset must read as one. Falling
// through to the assumed timezone here is what used to shift a whole
// feed by hours with nothing in the logs: the offset was present, said
// +02:00, and was read as UTC.
if (!TryParseOffset(suffix, out var offset))
{
return false;
}
那条注释说的就是我们自己的 bug。这个读取器的早期版本里我们就搞错了。读不懂的偏移,被它当成完全没有偏移来处理:落到下面,假定 UTC,接着往下走。症状是整份节目单按文件里写明的偏移量原封不动地挪了位,每个频道都挪,源里的每一天都挪,而导入摘要干干净净,日志里什么都没有。原因就是少了一个提前 return。代价是这个故障在应用内部根本发现不了。没有计数器动过,没有写下一行日志,节目单就那么一直错着,直到有人拿它跟表对了一下。
拒掉这一行,对这一个节目来说更糟,对整份节目单来说好得多。被丢弃的节目会留下一个你看得见的空洞。被悄悄重新解释过的源,是一份整整齐齐、理直气壮地错着的时间表,你盯着它看多久都看不出来。
这条规则有个漏洞,值得挑明了说。检查只看后缀的第一个字符。写成 GMT+0200 的后缀,标签在符号前面而不是后面,开头就不是符号,于是它走的是那条静悄悄的路。拿已发布的解析器跑一遍,本地时间 15:00 返回的是 UTC 15:00,也就是说偏移明明就在字符串里摆着,却被无视了。没有测试钉住这种写法,我们自己也没有 XMLTV 语料,所以没见过并不等于它罕见。
三种偏移写法读得出来,七种被拒绝
符号之后,读取器取到第一个空格为止的全部内容,量一下这串数字有多长。只有三种长度是有意义的:两个字符是只写小时,四个是小时和分钟连在一起写,五个则要求第三个字符是冒号。+02、+0200 和 +02:00 都解析成同样的两小时。总长度不到三个字符的,还没走到 switch 就被拒了,其余长度一律从 default 分支掉出去。
这三种写法都是 ISO 8601,这也正是三种都要读的原因。+0200 是基本格式,+02:00 是扩展格式,+02 是只写小时的格式,而 XMLTV DTD 自己的示例用的就是基本格式,写作 +0300。只认其中一种、把另外两种默默当成 UTC,会让每一份选了别种写法的源都差上一个小时甚至更多。
围绕这一点有两处容错。符号前面的空格可有可无,因为后缀被定义成第十五个字符起的全部内容,所以 20260719150000+0200 效果完全一样。真实偏移后面的时区标签会被丢掉,而不是当成损坏来处理。+0100 BST 在空格处截断,读成 +0100,本地时间 15:00 得出 UTC 14:00。半小时时区是白捡的,因为分钟是按自己那两位单独解析的,所以 +05:30 能正确解析。
第十五个字符这条边界带来了一个没人设计过的后果。日期主体正好取前十四个字符,所以一个带第十五位数字的时间戳 202607191500000,前十四位解析得干干净净,多出来的那个 0 被交给后缀读取器,后者看到一个不是符号的字符,就走了无偏移那条路。拿解析器跑一遍,这个值返回 UTC 15:00,多出来的那位数字被一声不吭地丢掉。对结尾的 Z 来说这个结果无害,同一条落空路径处理它,而它本来也就是 UTC 的意思;对其他任何东西来说,这就是一次无声的截断。Z 这种情况之所以无害,只是因为已发布的应用里没有任何地方设过假定时区,这一点后面有专门一节。
下面这七种被测试钉死为失败,每一种都对应着一类真实的损坏:
+2,一位数的小时,短到走不到 switch+02:0,冒号写法少了一位数字+0X30,数字串里混进了一个字母+2400,二十四小时+99:00,九十九小时-0270,七十分钟+,只有一个符号,别的什么都没有
这些拒绝里大部分出自两道护栏。分钟数大于 59 直接拒掉;总量上限是 14 小时,因为底层的日期类型本来就不接受超出这个范围的值,而且用代码注释里的话说,“a guide claiming one is corrupt rather than exotic”(一份声称如此的节目单是坏了,不是稀奇)。这个界限是闭的,所以 +1400 解析成 UTC 零点过一小时,+1401 失败。数字检查还专门要求是 ASCII 数字,并且解析时不启用任何数字样式,这样带前导符号的、带千位分隔符的、全角数字的,都别想从一次宽松的、跟区域设置有关的解析里混进来。
DTD 给的三个示例时间戳,有两个被我们的读取器拒掉
DTD 允许的不只是十四位数字那种写法。它说日期可以是 YYYYMMDDhhmmss,也可以是它的某个前缀子串,并印出了三个示例:200007281733 BST、200209 和 19880523083000 +0300。在我们的解析器下只有第三个活了下来,解析成 UTC 05:30。第二个栽在长度下限上,任何短于十四个字符的值在开始解析之前就被拒。第一个过了这道下限,然后照样失败,因为读取器把前十四个字符切出来当日期主体,而 200007281733 B 不是一个日期。
这种收窄是我们自己做的选择,它触发的时候不能怪文件。精确到分钟的时间戳没有秒可以切,只有年月的时间戳在任何有用的意义上都不算一个节目的开始时间,而接受这两种的读取器只能自己把缺的字段编出来。拒绝它们说得过去。把它们呈现成损坏就说不过去了,下面走查里的最后一行就是合规的 XMLTV,而我们把它丢了。
DTD 的第一个示例,用一个括号就带出了本文另一半的不对称。200007281733 BST 在那里被标注为 (BST == +0100.),也就是说,定义这个格式的文档自己解析了那个我们的读取器拒绝解析的缩写。
为什么 +0100 BST 可信,光一个 BST 就不可信
这里有一处不对称,看着像疏忽,其实是决定。后缀 +0100 BST 解析成加一小时,因为数字在,标签被扔掉。只有 BST 的后缀解析成 UTC,凡是它出现的英国夏令时日期,都会错一小时。两者都有测试钉住,也就是说两者都是有意为之。
带名字的时区一律不解析,注释给了理由:“the abbreviations are ambiguous across regions”(这些缩写跨地区是有歧义的)。BST 是英国夏令时,也曾被写来指孟加拉标准时间,两者差五个小时。CST 则是在 tz 数据库里活到今天的那个撞名,芝加哥、哈瓦那和上海同时都用它。解析一个名字就意味着从中挑一个,也就是猜;而一个在某个市场猜对、在另一个市场猜错的规则,比一个到哪儿都错得一样的规则更糟。
我们选了“按固定规则、错得可知”那个答案,而不是猜。代价是,凡是用标签而不是用数字来标时间的源,在夏令时日期上都要差一小时。DTD 给自己的示例加了那条标注,让这个立场比写这篇文章之前看上去更难辩护。格式自己的文档解析了一个缩写,而上面那一段就是我们至今仍不解析的全部理由。
这是实打实的局限,而且这种写法到底有多常见,我们不知道。因为光一个时区名,恰恰就是那种不产生错误、不动计数器、也不引来任何抱怨的输入,直到有人把节目单跟表对上为止。
诊断行只数丢弃,不说原因
解析失败的时间戳不会抛异常。属性从来没被赋过值,start 停在零,这一行在构建时被拒,然后一个计数器加一。这个计数器出现在该来源的节目单设置对话框里,标题是“上次指南获取”,格式是:
{0} channels · {1} programmes · {2} dropped · {3} ms
这是你能拿到的唯一读数,它对总数是诚实的,对原因是沉默的。三种互不相干的情况会把同一个数字加一:没有 channel 属性的节目、start 时间戳失败的节目,以及落在保留窗口之外的节目。
数字很大通常就是这个窗口造成的,而它并不是一个窗口。它的起点永远是解析时刻往前 24 小时。终点分几种情况。按来源的节目单是往后八天,解压后的下载量超过 200 MB 时是四天,全家共用的节目单库是两天,它留得少是因为它一次装下了所有来源。所以一份发布三周节目表的节目单,在一次完全健康的导入里也会报出很大的丢弃数;同一个文件,在全家共用的节目单库打开之后,报出来的数还会大得多。数字高是去看一眼的理由,不是时间出问题的证据。
让人不舒服的地方在于,我们最响亮的失败提示,只是一个对话框里的一个整数,而那个对话框,除非已经看出哪里不对,否则没人会去打开。
坏掉的 stop 让你付出三小时,而不是一个节目
start 时间戳上读不懂的偏移会让这一行被丢弃。stop 时间戳上读不懂的偏移不会。
当 start 解析成功、stop 失败时,stop 停在零,通不过“stop 要晚于 start”这道检查,于是这一行被赋予三小时的默认时长。另有一道钳制把任何节目限制在六小时以内;它在这里形同虚设,因为三小时小于六小时,只有当 stop 确实解析成功、却落到很远的地方时,它才会真的卡住一行。默认时长和钳制,两者都不会让丢弃计数器加一。所以,一个 stop 属性坏了、start 属性没坏的文件,产出的节目单开始时间是对的,满屏三小时的方块,行与行互相重叠,而导入摘要干干净净。用户报告“时间看着对,可就是对不上”的节目单,背后常常就是这个形状。
六个节目进去,四个出来
下面这段片段是为了一次跑通所有路径而造的。它是拿已发布的解析器实际跑出来的,不是手工推演的。每个频道 id 用的都是保留的示例域名,每个标题都是编的;另外请假定保留窗口覆盖了这些日期,否则还没轮到偏移出场,保留窗口就先把论据清掉了。
<?xml version="1.0" encoding="UTF-8"?>
<tv>
<channel id="one.example.com"><display-name>Example One</display-name></channel>
<programme start="20260719150000 +0200" stop="20260719160000 +0200" channel="one.example.com">
<title>Clean offset</title>
</programme>
<programme start="20260719160000 +02:0" stop="20260719170000 +02:0" channel="one.example.com">
<title>Broken on both ends</title>
</programme>
<programme start="20260719170000 +02:00" stop="20260719180000 +02:0" channel="one.example.com">
<title>Broken on the stop only</title>
</programme>
<programme start="20260719180000 BST" stop="20260719190000 BST" channel="one.example.com">
<title>Zone name, no number</title>
</programme>
<programme start="20260719190000 +0100 BST" stop="20260719200000 +0100 BST" channel="one.example.com">
<title>Zone name after a number</title>
</programme>
<programme start="20260719" stop="20260720" channel="one.example.com">
<title>Date only</title>
</programme>
</tv>
第一行是顺利路径。15:00 加两小时的偏移就是 UTC 13:00,一直播到 UTC 14:00。
第二行两个属性都是 +02:0。后缀以符号开头,所以它必须能读成偏移;数字串是四个字符,而最后两个并非都是数字,于是失败。start 从来没被赋过值,这一行被拒,丢弃计数器变成一。
第三行 start 是干净的,stop 跟上面一样是坏的。start 解析成 UTC 15:00。stop 解析不出来,于是三小时的默认时长生效,这一行占的是 UTC 15:00 到 18:00,而不是一小时。什么都不会被计入。这一行就是丢弃计数器不能当健康指标的原因。
第四行是差一小时那种情况最纯粹的样子。后缀是 BST,不以符号开头,于是读取器走无偏移那条路,套用假定时区,也就是 UTC。抓取器的本意是 UTC 17:00。节目单显示的是 UTC 18:00。没有任何计数器动过。
第五行把同一个标签放在一个真实数字后面,正确地解析成 UTC 18:00。于是第四行和第五行在同一个频道上都从 UTC 18:00 开始,而其中只有一行是真想这样的。一个背后有两个抓取器的文件,从内部看就是这副样子。
第六行是八个字符,低于十四个字符的下限,所以两个属性都失败,丢弃计数器变成二。它同时也是 DTD 会认为合法的那一行,一个完整时间戳的前缀子串,所以这段片段里被记下的损坏,有一半是我们的收窄造成的,不是文件的问题。
有四行进了库,其中两行错得没有任何东西会报出来。一行晚了一小时开始,另一行跑了实际长度的三倍。计数器报的是另外两行,而且哪一行的原因都不给。
偏移是按每个时刻解析的,不是整个文件解析一次
日期主体解析时完全不设任何假定标志,返回的值 kind 是 unspecified,这是有意的。随后读取器显式地给它配上一个偏移。另一种做法,是把一个 unspecified 的值交给一个会替你把空白填上的构造函数,那样 .NET 会套上宿主机器自己的偏移,于是同一个文件在巴黎的设备和突尼斯的设备上会产出不同的时间表,节目单库在同一个家庭的两台设备之间也就不再搬得动了。
在无偏移那条路上,假定时区的偏移是针对那个具体时刻去查的,而不是整个文件查一次。在西欧某个时区跨过春季切换时,本地时钟上相差两个半小时的两个时间戳,换算成 UTC 后相差九十分钟。这是对的,而一个文件级的单一偏移会把切换之后的每一行都算错。切换造成的那两个不可用的本地时间,也就是出现两次的那一小时和从不出现的那一小时,都按标准偏移解析,而且重新解析一次得到的还是同一个值,所以同一个文件不会在连续两次导入里渲染成两份不同的节目单。
这套机制里有一个我得老实承认的缺口。假定时区这个参数是存在的,设备上负责导入节目单的两条路径都会传它,而两条传的都是 null。已发布的应用里没有任何地方设过它,并且有测试钉住这一点。一份不带后缀、只写本地挂钟时间的节目单会被按 UTC 读,这是 DTD 里写明的默认行为,而且没有任何按来源的开关能改变它的解析方式。上面那段讲切换的机制,目前没有任何已发布的代码路径走得到。
小时校正只支持整小时,有两个地方会把它关掉
确实有一个校正,它工作在另一层。每个来源都带一个以整小时为单位的时间偏移,从负 48 到正 48,就在那个节目单设置对话框里的“时间偏移(整小时)”下面改,输入框标着“要添加的小时数”。它是在节目表从库里读出来的时候生效的,不是在写进去的时候,所以改它不需要重新下载任何东西,也不会改写已经存下来的内容。
有两件事它是故意不做的。它只支持整小时,所以修不了半小时时区,一个位于 +05:30 区域、又被当成 UTC 读了的来源,靠它是拉不回来的。另外,它在两种情况下会被强制归零。第一种是全家共用的节目单库,它在入库时就已经归一化成 UTC,没剩下什么可供偏移去校正的了。第二种是属于聚合来源的任何频道,这种频道同时由好几个成员来源撑着,正确的偏移取决于当下是哪个成员在供流。一个数字对其中除了一个之外的所有成员都是错的,所以这个控件干脆不提供,而不是提供了却在撒谎。
如果证据变了,我第一个要重新考虑的就是最后这个决定。它说得过去,但它同时也意味着,一个家庭来源最多、其中某一个最有机会出错的地方,恰恰就是这个应急口子缺席的地方。
我们的 Windows 和 Xbox 应用把这些解析全部放在本地做,针对的是你手上已经有的节目单地址。诊断行和偏移控件放在同一个对话框里是有原因的。第一个数字告诉你这个文件到底有没有被读进来,而第二个,是你确认它被读进来之后才去动的。
如果你在自己写读取器,能救你的那条规则,正是我们花了最长时间才学会的。以符号开头的后缀,已经告诉你它是个数字。要么读它,要么让这一行失败,但不要落到某个默认值上,因为默认值会被整齐划一、理直气壮地套到文件里的每一个时间戳上,而整齐划一、理直气壮的错,恰恰是没人会报告的那一种故障。
本文测量了什么28 主张,每项主张都有其背后的证据
| 索赔 | 证据 | 已计算 |
|---|---|---|
| XMLTV 的文档类型定义规定,不带显式时区的时间戳一律按 UTC 读。xmltv.dtd, date field documentation: you can also append a timezone to the end; if no explicit timezone is given, UTC is assumed. | 规格 | 不适用 |
| XMLTV DTD 自己那个示例偏移用的是四位数的基本格式,写作 +0300。xmltv.dtd, date field documentation, example 19880523083000 +0300 | 规格 | 不适用 |
| XMLTV DTD 允许日期写成 YYYYMMDDhhmmss,或者它的某个前缀子串,并印出了三个示例:200007281733 BST、200209 和 19880523083000 +0300。xmltv.dtd, date field documentation, including the annotation (BST == +0100.) on the first example | 规格 | 不适用 |
| ISO 8601 有三种 UTC 偏移写法:基本格式(+0200)、扩展格式(+02:00),以及只写小时(+02)。ISO 8601-1, time offset from UTC | 规格 | 不适用 |
| .NET 的 DateTimeOffset 会拒绝绝对值超过 14 小时的偏移,正好 14 小时则接受。.NET DateTimeOffset constructor, offset argument range -14:00 to +14:00 inclusive | 规格 | 不适用 |
| 用 Kind 为 Unspecified 的 DateTime 构造 DateTimeOffset,会套上宿主机器的本地偏移。.NET DateTimeOffset(DateTime) constructor remarks, which treat an Unspecified Kind as local time | 规格 | 不适用 |
| 时区缩写并不唯一。America/Chicago、America/Havana 和 Asia/Shanghai 同时都叫 CST;BST 是英国夏令时,也曾被写来指孟加拉标准时间,两者差五个小时。IANA time zone database, theory.html on the ambiguity of zone abbreviations, plus the CST abbreviation on America/Chicago, America/Havana and Asia/Shanghai | 规格 | 不适用 |
| 以 + 或 - 开头的时间戳后缀必须能解析成数值偏移,否则整个时间戳判定失败。落回到假定时区,正是这条规则所取代的那个已发布缺陷。 | n = 2 | 2026年8月22日 |
| 只接受长度为 2、长度为 4,以及长度为 5 且索引 2 处是冒号的数字串,所以 +02、+0200 和 +02:00 都能解析出来;其他长度一律从 default 分支掉出去。 | n = 3 | 2026年8月22日 |
| 有七种偏移写法被测试钉死为解析失败:+2、+02:0、+0X30、+2400、+99:00、-0270,以及一个光秃秃的 +。 | n = 7 | 2026年8月22日 |
| 分钟数大于 59 会被拒绝,总量上限是 14 小时且含 14 小时,所以 +1400 能解析、+1401 不能;两位数读取器只认 ASCII 数字,并且用 NumberStyles.None 解析。 | n = 2 | 2026年8月22日 |
| 真实偏移后面的时区标签会在第一个空格处被截掉,所以 +0100 BST 解析成 +01:00,本地时间 15:00 得出 UTC 14:00。 | n = 1 | 2026年8月22日 |
| 光有字母的时区名根本到不了偏移读取器,会走假定时区那条路,所以 20260719150000 BST 和 20260719150000 UTC 都解析成 UTC 15:00。 | n = 2 | 2026年8月22日 |
| XMLTV DTD 印出来的三个示例时间戳,有两个被我们的读取器拒掉。200007281733 BST 和 200209 都失败,只有 19880523083000 +0300 能解析,结果是 UTC 05:30。 | n = 3 | 2026年8月22日 |
| 短于 14 个字符的时间戳,在任何解析动作之前就被拒绝,所以只写日期的值会失败。 | n = 1 | 2026年8月22日 |
| 日期主体正好取前 14 个字符,所以多出来的第 15 位数字会落进后缀里,通不过符号检查,然后被悄无声息地丢掉。 | n = 1 | 2026年8月22日 |
| 硬报错这条规则只看后缀的第一个字符,所以像 GMT+0200 这种标签在前的写法会走假定时区那条路,被当成 UTC 读,偏移直接被忽略。 | n = 1 | 2026年8月22日 |
| 同一个丢弃计数器会因为三件事加一:缺少 channel 属性、start 解析不了,以及节目落在保留窗口之外。 | n = 3 | 2026年8月22日 |
| 保留窗口的起点始终是解析时刻往前 24 小时。终点在按来源这条路上是往后八天,解压后的下载量超过 200 MB 时是四天,全家共用的节目单库是两天。 | n = 3 | 2026年8月22日 |
| start 有效、stop 解析不了的节目会被保留下来,套用三小时的默认时长,并且不计入丢弃;另外那条六小时上限对它不起作用。 | n = 1 | 2026年8月22日 |
| 上次获取的读数按频道数、节目数、丢弃数和毫秒数排布,标题是“上次指南获取”。 | n = 1 | 2026年8月22日 |
| 按来源的校正以整小时为单位,范围从 -48 到 +48,界面上叫“时间偏移(整小时)”,输入框是“要添加的小时数”,在读取时生效而不是在入库时。 | n = 1 | 2026年8月22日 |
| 全家共用的节目单库启用时,以及频道属于聚合来源时,这个校正会被强制归零。 | n = 2 | 2026年8月22日 |
| 设备上的两条入库路径都用 assumeTimezone 为 null 来构造解析流水线,所以裸时间戳按 UTC 读,宿主机时区根本不参与。 | n = 2 | 2026年8月22日 |
| 在假定时区那条路上,偏移是按每个时刻分别解析的,所以跨过春季切换、本地时间相差两个半小时的两个时间点,换算成 UTC 后相差九十分钟。 | n = 2 | 2026年8月22日 |
| 重复出现的那一小时和从不出现的那一小时,本地时间都按标准偏移解析,重新解析一次得到的还是同一个值。 | n = 3 | 2026年8月22日 |
| 本文里手写的那段片段跑出来是 1 个频道、4 条入库记录和 2 次丢弃;入库的记录里有一条晚了一小时开始,还有一条本该一小时却跑了三小时。 | n = 6 | 2026年8月22日 |
| 在我们 M3U 语料里较大的那份播放列表中,1,895 行里有 527 行最后没有可用的频道 id,节目单的一行也就没有可靠的东西可挂靠。 | n = 1895 | 2026年8月22日 |