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
Две строки. Эта запись собрана, а не скопирована: два плейлиста, которые мы держим для работы над парсером, называют настоящих вещателей, поэтому дословно отсюда не взято ничего. Каждая форма в ней это форма, которая в тех файлах действительно есть, и ни одно из значений не является приметным. В файле на 1895 записей 107 строк заявляют номер канала 1, 20 несут пустой tvg-id, а 42 несут пустой tvg-logo. В файле на 308 записей 25 строк несут пустой group-title, 19 несут пустой tvg-logo, а 10 упаковывают два имени группы в один атрибут через точку с запятой.
Почти всё в первой строке, что выглядит как структурированные данные, либо отсутствует, либо пусто, либо разделено с сотней никак не связанных каналов. Единственное поле, которое и надёжно присутствует, и близко к уникальному, это адрес во второй строке: 308 различных адресов на 308 записей в меньшем файле и 1884 на 1895 в большем.
Плеер не содержит каналов и поставлять их не может. То, что в нём появляется, приходит из файла, на который вы его направили, и разбирается у вас на устройстве, а значит поведение этого разбора на несовершенном входе и есть большая часть того, чем окажется ваш список каналов.
Спецификации нет, есть конвенция
RFC 8216 определяет #EXTINF для HLS как длительность, запятую и необязательный человекочитаемый заголовок, идущий до конца строки. Про 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 это режим вывода серверов провайдеров, к которым подключаются по логину провайдера, значение параметра запроса, которое переключает выгрузку с голых адресов на строки, несущие tvg-id, tvg-logo и group-title. Одно из двух это конвенция плейлистов девяностых годов, другое это параметр запроса, и спецификацией не является ни то ни другое. Конвенцию определяет то, что выдают эмиттеры.
Это различие проходит через всё, что ниже. То, что разрешает конвенция, и то, что принимает один конкретный парсер, это два разных списка, и именно из их смешения выходит парсер, закодировавший привычки одного источника так, будто это правило. Итак: грамматика, которую реализует наш парсер, вот такая, и не больше.
line := "#EXTINF:" WS* duration WS* attributes "," displayName
duration := "-"? DIGIT+
Сравнение префикса порядковое, поэтому #EXTINF: чувствителен к регистру вплоть до двоеточия, а длину префикса, восемь символов, парсер зашивает в код, прежде чем передать остаток считывателю длительности. Считыватель берёт необязательный одиночный минус, затем требует хотя бы одну цифру, затем съедает всю цепочку цифр. Он запоминает, где цифры кончились, и выбрасывает значение. Длительность нигде в конвейере не читается. Все 2203 строки #EXTINF в двух наших образцах плейлистов несут -1, что на языке конвенции означает «идёт прямо сейчас, длина неизвестна», и ни одна ветка кода не заметила бы, если бы там стояло 7200.
Из этого считывателя прямо следуют три вещи. +1 отвергается, потому что плюс это ни минус, ни цифра. .5 отвергается, потому что перед точкой обязана стоять цифра. А дробная длительность вроде #EXTINF:120.5,Example разбирается правильно по случайности: считыватель останавливается на точке, оставшееся .5 попадает в область атрибутов, и ни один поиск атрибута с ним не совпадает. RFC 8216 такую дробную форму разрешает, и наш парсер её переживает, но никто её не реализовывал, и сказать это прямо честнее, чем подшить в графу «мы поддерживаем дробные значения».
Одну асимметрию внутри этой грамматики стоит признать, хотя и не в ту сторону, в какую кажется сначала. Префикс тега сравнивается с учётом регистра, чего RFC 8216 в разделе 4.1 и требует от всякого тега, начинающегося с #EXT. Имена атрибутов сравниваются без учёта регистра, чего не требует ничто. Поэтому #extinf: в нижнем регистре пропускается как нераспознанная директива, а TVG-CHNO = "18" в той же самой строке разбирается прекрасно, и от RFC отклоняется как раз снисходительная половина. Несогласованность приехала вместе с портированием старого серверного парсера, и файл исходника до сих пор сообщает об этом в самом верху. Ни в одном из двух образцов плейлиста нет ни тега в нижнем регистре, ни имени атрибута в верхнем, ни пробела вокруг знака равенства, так что ни одна из половин на нашем корпусе ни разу не отрабатывала.
Запятая, которая не разделитель
Разбейте строку #EXTINF по первой запятой, и вы сломали 30 из 308 записей в одном из наших двух файлов.
Причина в том, что значения атрибутов это строки в кавычках, а строки в кавычках содержат запятые. Каждая из этих 30 строк несёт атрибут user-agent со строкой User-Agent браузера, и все 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)
Девять и семь десятых процента этого файла получают отображаемое имя, сделанное из чьей-то строки версии браузера. Каталог при этом собирается. Ничто не падает с ошибкой. Список каналов неправильный так, что это выглядит багом отрисовки, а багом отрисовки не является.
Лечится это написанным вручную сканером, а не регуляркой: идти по области атрибутов символ за символом, переключать флаг inQuotes на каждой двойной кавычке и вернуть первую запятую, встреченную при опущенном флаге. Всё слева это область атрибутов, всё справа это отображаемое имя, и то и другое с обрезанными пробелами. Это девять строк кода, и это самые ценные девять строк во всём парсере.
Тот же сканер решает и судьбу битой строки, и решение строгое. Если в области атрибутов нечётное число двойных кавычек, например group-title="Movies,Example Four, где закрывающую потеряли, флаг больше никогда не опускается, запятая вне кавычек не находится, и вся запись отвергается с диагностикой. Никакого отката на первую запятую нет, и угадывать, где кавычка должна была стоять, никто не пытается. Мы выбрали отказ вместо починки, потому что починенная строка молча неправильна, а отвергнутая громко отсутствует, и именно о громко отсутствующей строке читатель может нам сообщить.
Читаются пять атрибутов, и один файл несёт 1895 строк шестого, который мы игнорируем
Полный набор атрибутов, которые ищет наш парсер, это пять написаний:
group-title, берётся до первой точки с запятой, пустое сворачивается в ничтоtvg-logo, с обрезанными пробеламиlogo, смотрится только тогда, когдаtvg-logoотсутствует полностьюtvg-id, берётся при разборе как есть, а на доверие проверяется позжеtvg-chno, отдаётся разборщику чисел, которому позволено не вернуть ничего
Это весь список. Имена атрибутов сравниваются без учёта регистра, с необязательными пробелами по обе стороны от знака равенства, поэтому TVG-CHNO = "18" и tvg-chno="18" для нас одно и то же. Значения обязаны быть в двойных кавычках; пути для значения без кавычек нет, а одинарные кавычки не распознаются нигде.
Теперь наблюдаемая сторона, она интереснее. В файле на 1895 записей tvg-name присутствует во всех 1895 строках, и мы игнорируем каждую из них. tvg-country присутствует в 1642 строках, и их мы игнорируем тоже. Отображаемое имя всегда берётся из текста после запятой и никогда из tvg-name, даже когда источник даёт оба на каждой публикуемой строке. Это решение по устройству, а не недосмотр: заголовок после запятой это поле, которое гарантирует сама грамматика, tvg-name необязателен, а один авторитетный источник имени лучше порядка предпочтений, который в каждом файле ведёт себя по-своему.
Меньший файл показывает слой директив, который сидит между двумя строками записи. Он несёт 30 атрибутов user-agent, 2 атрибута referrer (с двумя буквами r, как их пишет тот эмиттер, и это не то написание, что у HTTP-заголовка) и 32 отдельные строки директивы #EXTVLCOPT, 30 из которых повторяют тот же user agent, что уже указан в строке #EXTINF. Ничего из этого мы не читаем.
Другие плееры читают атрибуты, которых не читаем мы, и ни у каких двух из них списки не совпадают. Дополнение IPTV Simple для Kodi документирует catchup, catchup-source, catchup-days, tvg-shift, radio и директиву #EXTGRP. SS-IPTV, плеер для умных телевизоров, документирует aspect-ratio, которого нет у Kodi. tvg-language пришёл из 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 как целое, и ниже это важно. Его логотипом становится пустая строка там, где вы ожидали бы вообще ничего, и это ниже тоже важно.
Example Two это случай запятой в кавычках, а строка #EXTVLCOPT после него и есть причина, по которой общий пропуск по решётке не имеет права сбрасывать отложенный заголовок. Директивы законно стоят между #EXTINF и его адресом. Парсер, сбрасывающий состояние на каждой нераспознанной строке с #, потерял бы этот канал и все каналы вроде него, а в меньшем образце файла это 30 записей из 308.
Строка-разделитель разбирается чисто и, с точки зрения грамматики, является каналом. Её значение tvg-chno, BS4K101, разборщик чисел отвергает, и оно становится ничем, что стоит номера, но не строки. Убирает её имя, начинающееся с ##, и происходит это позже, при нормализации имён, а не при разборе. Раздельность этих двух стадий означает, что у парсера нет мнения о том, что значит имя канала, и граница проходит именно там, где надо.
Example Three падает на длительности: x это ни минус, ни цифра, поэтому заголовок отвергается с диагностикой, и все пять отложенных полей очищаются. Его адрес затем приходит без отложенного заголовка и даёт вторую диагностику. Один битый заголовок стоит двух строк и даёт две записи в логе, и это правильно, и об этом стоит помнить, когда вы читаете диагностику настоящего импорта.
Example Four это непарная кавычка, и ведёт он себя точно так же: две диагностики, на выходе ничего.
У Example Five совершенно корректный заголовок и адрес rtmp. Это абсолютный URI, поэтому как URI он и разбирается, но схема тут ни http, ни https, и мы его отвергаем. Это самая опасная строка во фрагменте, потому что, не очисти ветка отказа отложенное состояние, Example Six молча унаследовал бы номер канала 9. Ветка не гипотетическая. Больший образец файла заканчивает 6 из своих 1895 записей адресом rtmp или rtsp, пять и одна, и эти 6 строк единственные во всём корпусе на 2203 записи, которые наш парсер отвергает.
Example Six несёт голый атрибут logo, без tvg-logo и вообще без group-title, поэтому срабатывает запасной путь через logo, и канал попадает в группу по умолчанию, названную по источнику.
Каждый путь пропуска обязан очистить заголовок
Запись M3U это две строки, разделяющие изменяемое состояние, и в этом вся трудность формата. Между чтением заголовка и чтением адреса парсер держит пять отложенных полей: имя, группа, логотип, идентификатор, номер. Через цикл проходят семь различных путей, и правильность парсера целиком сводится к вопросу, что каждый из них делает с этими пятью.
Пустые строки пропускаются без очистки, и это правильно: пустая строка между заголовком и его адресом не должна уничтожать запись. Нераспознанные директивы # пропускаются без очистки по той причине, которую показывает фрагмент выше. Битый заголовок очищает все пять. Отвергнутый адрес очищает все пять. Успешная выдача очищает все пять.
Седьмой путь это тот самый, на котором ошибается большинство парсеров парных строк, и самое интересное, что обрабатывается он вовсе не сбросом. Когда второй #EXTINF приходит раньше любого адреса, парсер безусловно присваивает все пять полей, без всякого if и без присваивания с объединением по null. Поиск атрибута, ничего не нашедший, возвращает ничто, и разборщик чисел, которому дали ничто, возвращает ничто, поэтому заголовок без tvg-chno записывает пустоту поверх номера предыдущего заголовка, а не оставляет его на месте. Пути, которым поле N записи K дожило бы до записи K+1, не существует. Это более сильное свойство, чем «мы не забываем сбрасывать», и парсер стоит устраивать так намеренно, потому что версия со сбросом остаётся правильной ровно до тех пор, пока каждая будущая правка не забывает его дополнить.
Один путь заслуживает отдельного слова, потому что выглядит багом и им не является. Путь «адрес пришёл без отложенного заголовка» выдаёт свою диагностику и не очищает ничего. Это безопасно по построению, а не по везению, и инвариант проверяется чтением присваиваний: единственное место, где выставляются остальные четыре поля, это путь заголовка, а он на том же проходе всегда выставляет имя в непустое значение, откатываясь на буквальную строку Unnamed channel, когда название пустое. Значит, пустое имя влечёт, что остальные четыре уже пусты, и недостающий сброс это недостижимая работа.
Каждому из этих путей нужен свой регрессионный тест, и те девять, что у нас есть, пути протечки закрывают: один проверяет, что запись с номером не протекает своим номером в следующую, другой проверяет, что этого не делает и битый заголовок. Совсем без теста остаются сканер запятой в кавычках, те самые девять строк, которые эта статья назвала самыми ценными девятью строками в парсере, а с ними отказ по непарной кавычке, случай с #EXTVLCOPT между строками и запасной путь через голый logo. Ничто в этом пробеле не бросает исключений. Каталог собирается. Сотня каналов ниже по течению от одной плохой строки несёт неправильную группу.
Два поля, которые выглядят идентификаторами
tvg-chno это данные для показа, и измерение, которое закрывает вопрос, это одна строчка работы в шелле. В файле на 1895 записей атрибут несут 1355 записей. Эти 1355 номеров покрывают 263 различных значения. Номер 1 заявляют 107 записей. Дублирование не артефакт слияния групп, потому что оно переживает группировку: одна группа в этом файле держит 7 каналов, и все под номером 1, а 33 из 85 групп, несущих хоть какой-то номер, содержат внутри себя хотя бы один повтор. Всего в этом файле 92 группы, значит 7 из них не несут номеров вовсе. В другом файле, на 308 записей, tvg-chno нет нигде. На это значение нельзя ничего ключевать, нельзя считать его уникальным, и всякому потребителю, который по нему сортирует, нужен детерминированный способ разрешать совпадения.
Разбор его это небольшое упражнение в отказе от изобретательности. Значение читается как число с плавающей точкой в инвариантной культуре, не как целое и никогда не в локали устройства. 56 из 1355 отвергаются сразу. Ни одно из этих 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. Инвариантная культура тоже не вопрос стиля: если уважить локаль устройства, тот же самый файл даст другие номера на машине, настроенной на запятую в качестве десятичного разделителя, а это класс багов, который воспроизводится исключительно на чужом компьютере.
С tvg-id хуже, потому что его провалы не пусты. В файле на 1895 записей атрибут отсутствует в 483 строках, пуст ещё в 20 и присутствует, но не проходит наше правило формы ещё в 24, так что 527 строк, 27.8 процента файла, остаются без пригодного идентификатора. Неудобны как раз эти 24. Семнадцать из них выглядят в точности как настоящие идентификаторы и отвергнуты по одной-единственной причине, за длину меньше шести символов, вроде короткого имени с суффиксом страны. В остальных 7 нет точки. Минимальная длина это грубое правило, оно стоит нам этих 17, и мы вернулись бы к порогу, если бы их число выросло.
Само это правило существует из-за другого корпуса, который мы держим для работы над парсером и который состоит из выгрузок от провайдеров, а не из двух курируемых файлов выше. В двух его источниках из трёх самым частым значением tvg-id была буквальная строка TS: 446 строк в одном, 274 в другом. TS это формат контейнера transport stream, вытекший из поля формата вывода в колонку идентификатора. Эти 720 каналов не были связаны между собой никак: британский развлекательный канал и радиоканал на арабском стояли бок о бок под одним идентификатором. Поскольку ключ канала выводится из идентификатора, все 720 схлопнулись в один логический канал. Последствия это Избранное, появляющееся на каналах, которых никто не отмечал, родительский замок, запирающий 720 вещей разом, и один слот Мультиэкрана, стоящий за каждой из них.
Починкой стало правило доверия, живущее в одном месте и применяемое на границе приёма, чтобы ниже по течению никому не приходилось помнить о повторной проверке. Идентификатор пригоден, только если он длиной от 6 до 128 символов, содержит точку и не равен TS, а то, что уцелело, приводится к нижнему регистру в инвариантной культуре, потому что источники и правда расходятся в регистре идентификатора одного и того же канала. Всё остальное нормализуется в ничто, и «идентификатора нет» это состояние, которое всякий потребитель и так умеет обрабатывать, потому что у большинства каналов его действительно нет. Правило необходимое, но не достаточное, и мы это знаем: один идентификатор, прошедший проверку формы, оказался привязан к 74 не связанным между собой каналам, поэтому там, где источники объединяются, поверх него работает проверка мощности на сборке. Ни один из двух курируемых файлов в нашем образце не содержит ни одного идентификатора TS, и это честная оговорка ко всему сказанному. Чистый корпус вам этого сбоя не покажет никогда. Мы узнали о нём из сообщения об ошибке, а не из теста.
Раз в большинстве файлов пригодного идентификатора нет, его приходится выводить, и вот на выводе размен становится неудобным. Адрес потока это единственное поле, которое всегда есть и внутри одного источника близко к уникальному, поэтому синтетический идентификатор это SHA-1 от этого адреса, первые четыре байта, прочитанные как little-endian, свёрнутые в положительное 32-битное целое. Остаётся примерно 2^31 пространства ключей, и по парадоксу дней рождения плейлист на 50000 записей несёт примерно 44 процента вероятности хотя бы одной внутренней коллизии. Мы приняли это число с арифметикой перед глазами, исходя из того, что коллизия сливает два канала внутри одного источника, а это и так происходит, когда источник перечисляет один и тот же канал дважды. Наш собственный корпус делает это на 9 адресах, покрывающих 20 из 1895 строк большего файла, и ни разу в файле на 308 записей, так что событие настоящее, но не рутинное, и это измерение аргумент скорее ослабляет, чем поддерживает. Вопрос закрывает на самом деле другое: расширить основу, включив в неё tvg-id, или перейти на более широкий хеш нельзя, потому что выведенный идентификатор пишется в сохранённые данные, которые читают обратно другие устройства, и смена вывода разом обесценивает их все. Частота коллизий это цена замороженного формата на проводе.
Пустое не значит отсутствующее, кроме одного поля
Три атрибута, три разные политики для пустой строки, и мы этого не замечали, пока не измерили.
group-title="" сворачивается в ничто, потому что нормализатор группы считает пустое отсутствующим, и 25 строк в меньшем файле идут этим путём. tvg-id="" сворачивается в ничто, потому что правило доверия его отклоняет. tvg-logo="" не сворачивается: он приходит пустой строкой и доезжает до самого поля значка в строке каталога. 19 строк в одном образце файла и 42 в другом несут пустой атрибут логотипа, так что 61 строка в корпусе на 2203 строки держит пустую строку там, где всякое другое поле держало бы ничто. Логотип выбивается из ряда без всякой причины, кроме порядка, в котором эти три свёртки были написаны.
Чего это стоит, зависит от пути, который мы не читали. Если загрузчик изображений обрабатывает пустую строку и null одинаково, это просто бородавка и ничего больше. Если пустая строка начинает запрос, который падает, то 61 строка делает лишнюю работу на каждой отрисовке, и бородавка становится небольшим багом. Кому-то стоит прочитать этот путь, прежде чем эта страница заявит хоть одно из двух.
Чего мы не измеряли
У некоторых ограничений этого парсера в нашем образце нет свидетеля, и сказать об этом полезнее, чем делать вид, что они покрыты тестами. Пути для значения без кавычек нет: tvg-chno=18 без кавычек для нас невидим. Ни одна из 2203 строк #EXTINF, что у нас есть, значения без кавычек не использует, так что нас это ни разу не кусало, и насколько такая форма распространена в дикой природе, мы не знаем. У запасного пути через голый logo та же беда наоборот: его не использует ни одна наша строка, то есть у запасного пути, который мы выпустили, нет теста на настоящем входе.
Написание этой страницы нашло баг, который мы уже выпустили, и это самое полезное, что она сделала. Стоило изложить сопоставитель атрибутов достаточно внятно, чтобы это можно было публиковать, как стало очевидно, что у шаблона нет левой границы, и поэтому шаблон для logo совпадал внутри любого имени атрибута, оканчивающегося на logo. В том числе внутри provider-logo, настоящего атрибута, который документирует дополнение IPTV Simple для Kodi. Плейлист, несущий provider-logo и не несущий tvg-logo, брал не ту картинку, и короткое замыкание нас не спасало, потому что срабатывает оно только тогда, когда tvg-logo присутствует. Та же дыра позволяла tvg-id совпадать внутри x-tvg-id, а group-title и tvg-chno внутри чего угодно, что оканчивается на них.
Нас удивило, что очевидное исправление неверно. Граница слова тут не работает: всякое имя атрибута в этой конвенции склеено через дефис, а - символом слова не является, поэтому между дефисом и именем граница уже есть, и \btvg-logo по-прежнему совпадает внутри x-tvg-logo. Условие, которое нужно на самом деле, это что имя начинает область атрибутов либо идёт следом за пробельным символом. В .NET это ретроспективная проверка (?<=^|\s), переносимая между движками хуже, чем можно было бы ожидать: Python компилировать её отказывается, потому что большинство движков требуют ретроспективную проверку фиксированной ширины, а эта содержит альтернацию. Эту границу теперь несут все четыре атрибута, а шесть случаев выше зафиксированы тестами.
Наш плеер делает всё это у вас на устройстве, над тем плейлистом, который вы дали, и каждый отказ порождает строку диагностики, а не исключение, потому что файл с 40 плохими строками из 3000 обязан импортировать остальные 2960 и сказать вам, что пропало. Приложения для 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 это режим вывода серверов провайдеров, к которым подключаются по логину провайдера, значение параметра запроса, которое переключает выгрузку с голых адресов на строки, несущие tvg-id, tvg-logo и group-title.Provider login API, playlist export parameter type=m3u_plus | Спецификация | Не применимо |
| Дополнение IPTV Simple для Kodi документирует 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, который с тех пор отказался и от него, и от tvg-country.iptv-org/iptv repository, playlist field history | Спецификация | Не применимо |
| Плейлист на 50000 записей при пространстве ключей около 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 = 2203 | 22 авг. 2026 г. |
| Разбиение по первой запятой ломает 30 из 308 записей меньшего файла; все 30 несут значение user-agent, содержащее фрагмент KHTML, like Gecko. | n = 308 | 22 авг. 2026 г. |
| tvg-chno несут 1355 записей из 1895, по 263 различным значениям, причём 107 строк заявляют номер 1, а одна группа держит 7 каналов, и все они под номером 1. | n = 1895 | 22 авг. 2026 г. |
| В большем файле 92 группы; 85 из них несут хотя бы один номер, и 33 из этих 85 содержат повторяющийся номер. | n = 1895 | 22 авг. 2026 г. |
| 56 из 1355 значений tvg-chno отвергаются, и ни одно из них не является числом: 49 кодов вещательных систем, 4 буквальных заглушки #, 3 значения свободным текстом. Потолок парсера в 99999 не срабатывал ни разу; самое большое число в файле это 4999. | n = 1355 | 22 авг. 2026 г. |
| 25 строк используют нумерацию субканалов в стиле ATSC, по 24 различным значениям в диапазоне от 22.1 до 36.2. | n = 1895 | 22 авг. 2026 г. |
| В большем файле tvg-id отсутствует в 483 строках, пуст ещё в 20 и присутствует, но не проходит правило формы ещё в 24, так что 527 строк (27.8 процента) остаются без пригодного идентификатора. | n = 1895 | 22 авг. 2026 г. |
| 17 из этих 24 провалов по форме отвергнуты единственно за длину меньше шести символов и выглядят как настоящие идентификаторы, например короткое имя с суффиксом страны. В остальных 7 нет точки. | n = 24 | 22 авг. 2026 г. |
| На отдельном корпусе выгрузок от провайдеров TS оказался самым частым tvg-id в двух источниках из трёх, 446 строк и 274 строки, и эти 720 никак не связанных каналов схлопнулись в один ключ канала. Один идентификатор, прошедший проверку формы, был привязан к 74 не связанным между собой каналам. | n = 720 | Более ранний корпус |
| Повторяющиеся адреса потоков встречаются, но редко: 9 повторяющихся значений адреса покрывают 20 из 1895 строк большего файла и 0 из 308 в меньшем. | n = 2203 | 22 авг. 2026 г. |
| 6 строк большего файла указывают на адрес rtmp или rtsp (5 и 1), и это единственные строки во всём корпусе, которые парсер отвергает. | n = 2203 | 22 авг. 2026 г. |
| 61 строка несёт пустой tvg-logo (19 в меньшем файле, 42 в большем), и пустая строка доживает до строки каталога, тогда как пустой group-title (25 строк) и пустой tvg-id оба сворачиваются в ничто. | n = 2203 | 22 авг. 2026 г. |
| tvg-name присутствует во всех 1895 строках большего файла, а tvg-country в 1642; меньший файл несёт 30 атрибутов user-agent, 2 атрибута referrer и 32 строки директивы #EXTVLCOPT. | n = 2203 | 22 авг. 2026 г. |
| В корпусе ноль значений атрибутов без кавычек, ноль тегов #extinf в нижнем регистре, ноль имён атрибутов в верхнем регистре, ноль пробелов вокруг знака равенства, ноль строк с непарной кавычкой и ноль голых атрибутов logo. | n = 2203 | 22 авг. 2026 г. |
| Собранный вручную фрагмент из этой статьи даёт 4 разобранные строки, 3 строки каталога и 5 диагностик. | n = 7 | 22 авг. 2026 г. |
| У парсера 9 модульных тестов, включая два, которые проверяют, что поля не протекают между записями. У сканера запятой в кавычках, у отказа по непарной кавычке, у случая с #EXTVLCOPT между строками и у отката на голый logo нет ни одного. | n = 9 | 22 авг. 2026 г. |