m3u plus vs m3u : les attributs qu’une playlist étendue ajoute, et comment les analyser

Il n’existe pas de spécification M3U+. Il existe une convention, un corpus, et un analyseur qui doit survivre à la convention comme au corpus.

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

Deux lignes. Cette entrée est assemblée, pas recopiée : les deux playlists que nous gardons pour le travail d’analyse nomment de vrais diffuseurs, donc rien ici n’en est tiré mot pour mot. Chaque forme qu’elle contient est une forme que ces fichiers portent bel et bien, et aucune des valeurs n’est distinctive. Dans le fichier de 1 895 entrées, 107 lignes revendiquent le numéro de chaîne 1, 20 portent un tvg-id vide et 42 portent un tvg-logo vide. Dans le fichier de 308 entrées, 25 lignes portent un group-title vierge, 19 portent un tvg-logo vide et 10 tassent deux noms de groupe dans un seul attribut avec un point-virgule.

Presque tout ce qui, sur la première ligne, ressemble à de la donnée structurée est absent, vide, ou partagé avec une centaine de chaînes sans rapport. Le seul champ à la fois présent de façon fiable et presque distinctif, c’est l’URL de la deuxième ligne : 308 URL distinctes pour 308 entrées dans le petit fichier, 1 884 pour 1 895 dans le grand.

Le lecteur ne contient aucune chaîne et ne peut en fournir aucune. Ce qui y apparaît vient du fichier que vous lui désignez, analysé sur votre propre appareil, ce qui veut dire que le comportement de cette analyse face à une entrée imparfaite fait l’essentiel de ce que votre liste de chaînes finit par être.

Il n’y a pas de spécification, il y a une convention

La RFC 8216 définit #EXTINF pour HLS comme une durée, une virgule et un titre lisible facultatif qui court jusqu’à la fin de la ligne. Elle ne dit rien de tvg-id, tvg-chno, group-title, tvg-logo, catchup-source, ni d’aucun des autres jetons que de vrais fichiers tassent entre la durée et la virgule.

Ces jetons viennent d’une convention plus tardive des playlists de TV en direct, qui n’a pas de nom convenu, et les deux noms vers lesquels on se tourne sont faux tous les deux. Extended M3U est la convention de l’ère Winamp qui a introduit #EXTM3U et #EXTINF eux-mêmes ; la RFC 8216 la cite par son nom en section 4.3.1.1, et elle ne contient aucun tvg- de quelque sorte que ce soit. M3U+ n’est pas un format du tout : m3u_plus est un mode de sortie des serveurs auxquels on se connecte avec des identifiants de fournisseur, la valeur de requête qui fait passer un export d’URL nues à des lignes portant tvg-id, tvg-logo et group-title. L’une est une convention de playlist du siècle dernier, l’autre est un paramètre de requête, et ni l’une ni l’autre n’est une spécification. La convention se définit par ce que les émetteurs émettent.

Cette distinction traverse tout ce qui suit. Ce que la convention autorise et ce qu’un analyseur accepte sont deux listes différentes, et les confondre, c’est ainsi qu’un analyseur finit par graver les habitudes d’une seule source comme si elles étaient une règle. Donc : la grammaire que notre analyseur implémente est celle-ci, et rien de plus.

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

La correspondance de préfixe est ordinale, donc #EXTINF: est sensible à la casse jusqu’au deux-points inclus, et l’analyseur code en dur une longueur de préfixe de huit caractères avant de passer le reste à un scanneur de durée. Ce scanneur prend un signe moins unique et facultatif, puis exige au moins un chiffre, puis consomme la suite de chiffres. Il note où les chiffres se terminent et jette la valeur. La durée n’est jamais lue nulle part dans le pipeline. Les 2 203 lignes #EXTINF de nos deux playlists d’exemple portent toutes -1, ce qui est la façon qu’a la convention de dire “en direct, longueur inconnue”, et aucun chemin de code ne s’en soucierait si elles disaient 7200.

Trois conséquences découlent directement de ce scanneur. +1 est rejeté, parce qu’un plus n’est ni un moins ni un chiffre. .5 est rejeté, parce qu’un chiffre doit précéder le point. Et une durée fractionnaire comme #EXTINF:120.5,Example s’analyse correctement par accident : le scanneur s’arrête au point, le .5 restant atterrit dans la région des attributs, et aucune recherche d’attribut ne le trouve jamais. La RFC 8216 permet cette forme fractionnaire et notre analyseur la gère, mais personne ne l’a implémentée, ce qui vaut la peine d’être dit franchement plutôt que rangé sous “nous prenons en charge les décimales”.

Une asymétrie dans cette grammaire mérite d’être assumée, mais pas dans le sens où elle apparaît d’abord. Le préfixe de balise est comparé en respectant la casse, ce que la RFC 8216 section 4.1 exige de toute balise commençant par #EXT. Les noms d’attributs sont comparés sans tenir compte de la casse, ce que rien n’exige. Donc #extinf: en minuscules est ignoré comme directive non reconnue tandis que TVG-CHNO = "18" sur cette même ligne s’analyse sans problème, et la moitié qui s’écarte de la RFC est la moitié permissive. L’incohérence est arrivée avec un portage depuis un ancien analyseur côté serveur, et le fichier source le dit toujours en tête. Aucune des deux playlists d’exemple ne contient de balise en minuscules, de nom d’attribut en majuscules ni d’espace autour d’un signe égal, donc rien dans notre corpus n’a jamais exercé l’une ou l’autre moitié.

La virgule qui n’est pas un séparateur

Découpez une ligne #EXTINF à sa première virgule et vous avez cassé 30 des 308 entrées de l’un de nos deux fichiers.

La raison, c’est que les valeurs d’attributs sont des chaînes entre guillemets et que les chaînes entre guillemets contiennent des virgules. Chacune de ces 30 lignes porte un attribut user-agent qui contient une chaîne d’agent utilisateur de navigateur, et ces 30 chaînes de caractères contiennent toutes le fragment KHTML, like Gecko. Vingt-huit d’entre elles l’entourent de parenthèses et deux ont vu les parenthèses retirées quelque part en amont, ce qui ne change rien à la virgule. Voici la comparaison réelle, passée sur le vrai fichier, les noms d’hôtes de la source retirés et le nom propre de la chaîne remplacé :

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)

Neuf virgule sept pour cent de ce fichier se retrouve avec un nom d’affichage fait de la chaîne de version du navigateur de quelqu’un. Le catalogue se construit quand même. Rien ne signale d’erreur. La liste de chaînes est fausse d’une façon qui ressemble à un bug d’affichage et qui n’en est pas un.

Le correctif est un scanneur écrit à la main, pas une expression régulière : parcourir la région des attributs caractère par caractère, basculer un drapeau inQuotes à chaque guillemet double, et renvoyer la première virgule vue pendant que ce drapeau est faux. Tout ce qui est à gauche est la région des attributs, tout ce qui est à droite est le nom d’affichage, l’un et l’autre débarrassés de leurs espaces. Cela fait neuf lignes de code, et ce sont les neuf lignes qui rapportent le plus dans tout l’analyseur.

Ce scanneur décide aussi de ce qui arrive à une ligne malformée, et la décision est stricte. Si la région des attributs contient un nombre impair de guillemets doubles, par exemple group-title="Movies,Example Four où le guillemet fermant a sauté, le drapeau ne revient jamais à faux, aucune virgule hors guillemets n’est jamais trouvée, et l’entrée entière est rejetée avec un diagnostic. Il n’y a pas de repli sur la première virgule et aucune tentative de deviner où le guillemet aurait dû être. Nous avons choisi le rejet plutôt que la réparation, parce qu’une ligne réparée est fausse en silence là où une ligne rejetée manque bruyamment, et une ligne qui manque bruyamment est celle qu’un lecteur peut signaler.

Cinq attributs sont lus, et un fichier porte 1 895 lignes d’un sixième que nous ignorons

L’ensemble complet des attributs que notre analyseur cherche tient en cinq graphies :

  • group-title, gardé jusqu’au premier point-virgule, le vide replié sur rien
  • tvg-logo, débarrassé de ses espaces
  • logo, consulté seulement quand tvg-logo est entièrement absent
  • tvg-id, pris brut à l’analyse et soumis plus tard à un contrôle de confiance
  • tvg-chno, confié à un analyseur de nombre autorisé à ne rien renvoyer

Voilà toute la liste. Les noms d’attributs sont comparés sans tenir compte de la casse, avec des espaces facultatifs des deux côtés du signe égal, donc TVG-CHNO = "18" et tvg-chno="18" sont pour nous la même chose. Les valeurs doivent être entre guillemets doubles ; il n’existe pas de chemin pour une valeur sans guillemets, et les guillemets simples ne sont reconnus nulle part.

Passons au côté observé, qui est plus intéressant. Dans le fichier de 1 895 entrées, tvg-name apparaît sur les 1 895 lignes et nous les ignorons toutes. tvg-country apparaît sur 1 642 lignes et nous les ignorons aussi. Le nom d’affichage vient toujours du texte après la virgule, jamais de tvg-name, même quand une source fournit les deux sur chaque ligne qu’elle publie. C’est une décision de conception et non un oubli : le titre après la virgule est le champ que la grammaire elle-même garantit, tvg-name est facultatif, et une seule origine de nom qui fait autorité vaut mieux qu’un ordre de préférence qui se comporte différemment d’un fichier à l’autre.

Le petit fichier montre la couche de directives qui s’intercale entre les deux lignes d’une entrée. Il porte 30 attributs user-agent, 2 attributs referrer (écrits avec deux r par cet émetteur, ce qui n’est pas l’orthographe de l’en-tête HTTP), et 32 lignes de directive #EXTVLCOPT distinctes, dont 30 répètent l’agent utilisateur que la ligne #EXTINF avait déjà indiqué. Nous n’en lisons aucun.

D’autres lecteurs lisent des attributs que nous ne lisons pas, et il n’y en a pas deux dont la liste soit la même. L’extension IPTV Simple pour Kodi documente catchup, catchup-source, catchup-days, tvg-shift, radio et une directive #EXTGRP. SS-IPTV, un lecteur pour téléviseurs connectés, documente aspect-ratio, ce que Kodi ne fait pas. tvg-language venait d’iptv-org, qui l’a depuis abandonné en même temps que tvg-country. Pas un seul de ces jetons n’apparaît où que ce soit dans notre échantillon et nous n’en lisons aucun, ce qui produit une limite honnête du produit qui mérite d’être dite : notre prise en charge du Replay existe, mais elle est bâtie sur les identifiants de fournisseur, donc une source M3U ne peut pas nous exposer de Replay, quels que soient les attributs qu’elle écrit.

Sept en-têtes, trois chaînes

Voici un fragment construit pour exercer tous les chemins d’un coup. Chaque hôte qu’il contient est un domaine d’exemple réservé et chaque nom est inventé. La plupart des formes qu’il exerce se rencontrent bel et bien dans notre corpus, BS4K101 compris, qui est une vraie valeur tirée du grand fichier. Cinq ne s’y rencontrent nulle part : la durée malformée, le guillemet non apparié, le logo nu, le nom d’attribut en majuscules avec des espaces autour de son signe égal, et le nom qui commence par ##. Un fragment construit à la main est le seul moyen d’atteindre ces cinq chemins, ce qu’il vaut mieux savoir avant de lire le déroulé ci-dessous comme une preuve portant sur de vrais fichiers.

#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

Sept en-têtes entrent. Quatre lignes sortent de l’analyseur, trois d’entre elles atteignent le catalogue, et cinq chaînes de diagnostic sont produites.

La ligne 1 est ignorée. Notre analyseur ne cherche jamais #EXTM3U du tout : cette ligne tombe dans le saut générique “ça commence par un dièse” avec tout le reste de ce qu’il ne reconnaît pas, et l’attribut x-tvg-url qu’elle porte n’est pas lu. La RFC 8216 exige cet en-tête comme première ligne d’une playlist. Nous ne l’exigeons pas, et un fichier sans en-tête qui s’ouvre directement sur #EXTINF s’analyse très bien, parce que le contrôle de forme qui tourne sur l’appareil avant l’analyse accepte l’une ou l’autre balise comme première chose du fichier, après une marque d’ordre des octets facultative et des espaces de tête. Rien d’autre ne peut la précéder : un fichier qui s’ouvre sur une ligne de commentaire ou sur un #EXTVLCOPT est rejeté avant même que l’analyseur le voie.

Example One s’analyse avec le nom d’attribut dans la mauvaise casse et des espaces autour du signe égal. Son groupe devient News, parce que seul le segment avant le premier point-virgule est gardé. Son numéro devient 22.1 en décimal, pas 22 en entier, ce qui compte plus bas. Son logo devient une chaîne vide là où vous n’attendriez rien du tout, ce qui compte aussi plus bas.

Example Two est le cas de la virgule entre guillemets, et la ligne #EXTVLCOPT qui la suit est la raison pour laquelle le saut générique sur le dièse ne doit pas effacer l’en-tête en attente. Des directives se placent légitimement entre un #EXTINF et son URL. Un analyseur qui réinitialiserait son état à chaque ligne # non reconnue perdrait cette chaîne et toutes les chaînes du même genre, ce qui, dans le petit fichier d’exemple, fait 30 entrées sur 308.

La ligne de séparation s’analyse proprement et, du point de vue de la grammaire, c’est une chaîne. Sa valeur tvg-chno BS4K101 est rejetée par l’analyseur de nombre et devient rien, ce qui coûte le numéro mais pas la ligne. C’est son nom commençant par ## qui l’élimine, et cela se produit plus tard, pendant la normalisation des noms, pas à l’analyse. Séparer les deux étapes veut dire que l’analyseur n’a aucun avis sur ce que signifie un nom de chaîne, et c’est le bon endroit pour cette frontière.

Example Three échoue sur la durée : x n’est ni un moins ni un chiffre, donc l’en-tête est rejeté avec un diagnostic et les cinq champs en attente sont effacés. Son URL arrive ensuite sans en-tête en attente et produit un deuxième diagnostic. Un en-tête malformé coûte deux lignes et produit deux entrées de journal, ce qui est correct et bon à savoir quand vous lisez les diagnostics d’un vrai import.

Example Four, c’est le guillemet non apparié, et le comportement est identique : deux diagnostics, rien d’émis.

Example Five a un en-tête parfaitement valide et une URL rtmp. C’est un URI absolu, donc il s’analyse comme tel, mais le schéma n’est ni http ni https et nous le rejetons. C’est la ligne la plus dangereuse du fragment, parce que si le chemin de rejet n’effaçait pas l’état en attente, Example Six hériterait en silence du numéro de chaîne 9. Ce chemin n’est pas hypothétique. Le grand fichier d’exemple termine 6 de ses 1 895 entrées sur une URL rtmp ou rtsp, cinq et une, et ces 6 lignes sont les seules de tout le corpus de 2 203 entrées que notre analyseur rejette.

Example Six porte un attribut logo nu, sans tvg-logo et sans group-title du tout, donc le repli sur le logo se déclenche et la chaîne atterrit dans un groupe par défaut nommé d’après la source.

Chaque chemin de saut doit effacer l’en-tête

Une entrée M3U, ce sont deux lignes qui partagent un état mutable, et c’est là toute la difficulté du format. L’analyseur garde cinq champs en attente entre la lecture d’un en-tête et la lecture d’une URL : nom, groupe, logo, identifiant, numéro. Sept chemins distincts traversent la boucle, et la justesse de l’analyseur tient entièrement à ce que chacun d’eux fait à ces cinq champs.

Les lignes vides sautent sans effacer, et c’est correct, parce qu’une ligne vide entre un en-tête et son URL ne doit pas détruire l’entrée. Les directives # non reconnues sautent sans effacer, pour la raison que le fragment ci-dessus démontre. Un en-tête malformé efface les cinq. Une URL rejetée efface les cinq. Une émission réussie efface les cinq.

Le septième chemin est celui que la plupart des analyseurs à paires de lignes ratent, et le plus intéressant, c’est qu’il n’est pas traité par une réinitialisation du tout. Quand un deuxième #EXTINF arrive avant toute URL, l’analyseur affecte les cinq champs sans condition, sans if et sans affectation de coalescence des nuls. Une recherche d’attribut qui ne trouve rien renvoie rien, et l’analyseur de nombre à qui on ne donne rien renvoie rien, donc un en-tête qui ne porte pas de tvg-chno écrit du vide par-dessus le numéro de l’en-tête précédent au lieu de le laisser en place. Il n’existe aucun chemin par lequel le champ N de l’entrée K survit dans l’entrée K+1. C’est une propriété plus forte que “on pense à réinitialiser”, et cela vaut la peine de structurer délibérément un analyseur ainsi, parce que la version à réinitialisation ne reste correcte que tant que chaque modification future pense à l’étendre.

Un chemin mérite une note parce qu’il ressemble à un bug et n’en est pas un. Le chemin “une URL est arrivée sans en-tête en attente” émet son diagnostic et n’efface rien. C’est sûr par construction et non par chance, et l’invariant se vérifie en lisant les affectations : le seul endroit où les quatre autres champs sont posés, c’est le chemin de l’en-tête, et ce chemin pose toujours le nom à une valeur non vide dans la même passe, en retombant sur la chaîne littérale Unnamed channel quand le titre est vide. Donc un nom nul implique que les quatre autres sont déjà nuls, et la réinitialisation manquante est un travail inatteignable.

Chacun de ces chemins a besoin de son propre test de non-régression, et les neuf que nous avons couvrent bien les chemins de fuite : l’un affirme qu’une entrée numérotée ne laisse pas fuir son numéro sur la suivante, un autre qu’un en-tête malformé ne le fait pas non plus. Ce qui n’a aucun test, c’est le scanneur de virgule entre guillemets, les neuf lignes que cet article a appelées les neuf lignes qui rapportent le plus dans l’analyseur, ainsi que le rejet du guillemet non apparié, le cas du #EXTVLCOPT entre les deux lignes, et le repli sur le logo nu. Rien dans cette lacune ne lève d’exception. Le catalogue se construit. Une centaine de chaînes en aval d’une seule mauvaise ligne portent le mauvais groupe.

Les deux champs qui ressemblent à des identifiants

tvg-chno est une donnée d’affichage, et la mesure qui tranche la question tient en une ligne de shell. Dans le fichier de 1 895 entrées, 1 355 entrées portent l’attribut. Ces 1 355 numéros couvrent 263 valeurs distinctes. Le numéro 1 est revendiqué par 107 entrées. La duplication n’est pas un artefact de la fusion des groupes, parce qu’elle survit au regroupement : un groupe de ce fichier contient 7 chaînes toutes numérotées 1, et 33 des 85 groupes qui portent le moindre numéro ont au moins un doublon à l’intérieur. Ce fichier compte 92 groupes en tout, donc 7 d’entre eux ne portent aucun numéro. L’autre fichier, 308 entrées, n’a de tvg-chno nulle part. Rien ne doit être indexé sur cette valeur, rien ne doit supposer qu’elle est unique, et tout consommateur qui trie dessus a besoin d’un départage déterministe.

L’analyser est un petit exercice de refus d’être malin. La valeur est lue comme un nombre à virgule flottante sous la culture invariante, pas comme un entier, et jamais sous la locale de l’appareil. Sur les 1 355, 56 sont rejetées d’emblée. Pas une seule des 56 n’est numérique :

  • 49 sont des codes de système de diffusion : 7 qui vont de D011 à D081, 19 d’une famille BS101 à BS4K101, 22 d’une série CS055 à CS363, et un seul code d’opérateur du câble
  • 4 sont un # littéral, apparemment un espace réservé que l’émetteur n’a jamais rempli
  • 3 sont du texte libre avec un nombre enfoui dedans, deux qui disent CH 106 et CH 108, un qui est un nom de service en japonais se terminant par 527

L’analyseur porte aussi un plafond de 99999, au-dessus duquel une valeur est traitée comme du rebut. Il ne s’est jamais déclenché sur ce corpus. Le plus grand nombre du fichier est 4999, ce qui rappelle utilement qu’un garde-fou écrit pour une hypothèse n’est pas la preuve que l’hypothèse était réelle.

L’analyse décimale n’est pas un choix esthétique. Dans ce fichier, 25 entrées utilisent une numérotation de sous-canal à la manière ATSC, 24 valeurs distinctes allant de 22.1 à 36.2, et une analyse entière fusionne 22.1, 22.2 et 22.3 en trois chaînes toutes numérotées 22. La culture invariante n’est pas non plus un choix esthétique : honorer la locale de l’appareil ferait produire au même fichier des numéros différents sur une machine réglée sur la virgule décimale, ce qui est une catégorie de bug qui ne se reproduit jamais que sur l’ordinateur de quelqu’un d’autre.

tvg-id est pire, parce que ses échecs ne sont pas vides. Dans le fichier de 1 895 entrées, l’attribut est absent de 483 lignes, vide sur 20 de plus, et présent mais en échec sur notre règle de forme sur 24 autres, si bien que 527 lignes, soit 27.8 pour cent du fichier, finissent sans identifiant exploitable. Ce sont ces 24 qui sont gênantes. Dix-sept d’entre elles ont exactement la forme de vrais identifiants courts et ne sont rejetées que pour une seule raison, faire moins de six caractères. Les 7 autres ne portent pas de point. Une longueur minimale est une règle grossière, elle nous coûte ces 17, et nous reverrions le seuil si le compte venait à grossir.

Si cette règle existe, c’est à cause d’un autre corpus, que nous gardons pour le travail d’analyse, fait d’exports de fournisseurs plutôt que des deux fichiers sélectionnés ci-dessus. Dans deux de ses trois sources, la valeur de tvg-id la plus fréquente était la chaîne littérale TS : 446 lignes dans l’une, 274 dans l’autre. TS est le format de conteneur transport stream, qui déborde d’un champ de format de sortie vers la colonne d’identifiant. Ces 720 chaînes n’avaient aucun rapport entre elles, une chaîne de divertissement britannique et une chaîne de radio arabophone côte à côte sous un même identifiant. Comme la clé de chaîne est dérivée de l’identifiant, les 720 se sont effondrées en une seule chaîne logique. La conséquence, c’est un favori qui apparaît sur des chaînes que personne n’a marquées, un verrou parental qui verrouille 720 choses d’un coup, et un seul emplacement de Multivue qui les représente toutes.

La réparation a été une règle de confiance avec un seul domicile, appliquée à la frontière d’ingestion pour que rien en aval n’ait à penser à revérifier. Un identifiant n’est exploitable que s’il fait de 6 à 128 caractères, contient un point et n’est pas TS, et ce qui survit est passé en minuscules sous la culture invariante, parce que les sources sont réellement en désaccord sur la casse de l’identifiant d’une même chaîne. Tout le reste se normalise à rien, et “pas d’identifiant” est un état que tout consommateur gère déjà, parce que la plupart des chaînes n’en ont réellement aucun. La règle est nécessaire et pas suffisante, et nous le savons : un identifiant qui passe le contrôle de forme s’est révélé attaché à 74 chaînes sans rapport, donc partout où des sources sont agrégées, un contrôle de cardinalité à la compilation vient par-dessus. Aucun des deux fichiers sélectionnés de notre échantillon ne contient le moindre identifiant TS, et c’est la réserve honnête sur tout ce qui précède. Un corpus propre ne vous montrera jamais cette panne. Nous l’avons apprise par un signalement de bug, pas par un test.

Faute d’identifiant exploitable dans la plupart des fichiers, il faut en dériver un, et c’est dans la dérivation que le compromis devient inconfortable. L’URL du flux est le seul champ toujours présent et presque unique à l’intérieur d’une source, donc l’identifiant synthétique est un SHA-1 de cette URL, les quatre premiers octets lus en little-endian, repliés en un entier 32 bits positif. Cela laisse à peu près 2^31 d’espace de clés, et par la borne des anniversaires, une playlist de 50 000 entrées porte environ 44 pour cent de chances d’au moins une collision interne. Nous avons accepté ce nombre avec le calcul sous les yeux, au motif qu’une collision fusionne deux chaînes à l’intérieur d’une même source, ce qui arrive déjà quand une source liste deux fois la même chaîne. Notre propre corpus le fait sur 9 URL couvrant 20 des 1 895 lignes du grand fichier, et pas une seule fois dans celui de 308 entrées, donc c’est un événement réel mais pas courant, et cette mesure affaiblit l’argument plus qu’elle ne le soutient. Ce qui tranche vraiment la question, c’est qu’élargir la base pour y inclure tvg-id, ou passer à un hachage plus large, est inlivrable : l’identifiant dérivé est écrit dans des données enregistrées que d’autres appareils relisent, donc changer la dérivation les invalide toutes d’un coup. Le taux de collision est le prix d’un format de transmission figé.

Vide n’est pas absent, sauf dans un champ

Trois attributs, trois politiques différentes pour la chaîne vide, et nous ne l’avons pas remarqué avant de mesurer.

group-title="" se replie sur rien, parce que le normaliseur de groupe traite un vide comme une absence, et 25 lignes du petit fichier prennent ce chemin. tvg-id="" se replie sur rien, parce que la règle de confiance l’écarte. tvg-logo="" ne se replie pas : il arrive comme chaîne vide et voyage jusqu’au champ d’icône de la ligne de catalogue. Un fichier d’exemple porte 19 lignes avec un attribut de logo vide et l’autre 42, donc 61 lignes d’un corpus de 2 203 lignes contiennent une chaîne vide là où tout autre champ ne contiendrait rien. Le logo est l’intrus sans autre raison que l’ordre dans lequel les trois replis ont été écrits.

Ce que cela coûte dépend d’un chemin que nous n’avons pas lu. Si le chargeur d’images traite une chaîne vide et un nul de la même façon, c’est une verrue et rien de plus. Si une chaîne vide déclenche une requête qui échoue, 61 lignes font un travail inutile à chaque rendu, et la verrue est un petit bug. Quelqu’un devrait lire ce chemin avant que cette page n’affirme l’une ou l’autre chose.

Ce que nous n’avons pas mesuré

Certaines limites de cet analyseur n’ont aucun témoin dans notre échantillon, et le dire est plus utile que de faire comme si elles étaient testées. Il n’existe pas de chemin pour une valeur sans guillemets : tvg-chno=18 sans guillemets nous est invisible. Sur les 2 203 lignes #EXTINF que nous détenons, aucune n’utilise de valeur sans guillemets, donc cela ne nous a jamais mordus, et nous ne savons pas à quel point la forme est répandue dans la nature. Le repli sur le logo nu a le même problème à l’envers, puisqu’aucune de nos lignes ne l’utilise, donc un repli que nous livrons n’a aucun test sur une entrée réelle.

L’écriture de cette page a mis au jour un bug que nous avions livré, et c’est la chose la plus utile qu’elle ait faite. Exposer le comparateur d’attributs assez clairement pour le publier a rendu évident que le motif n’avait pas de frontière à gauche, si bien que le motif de logo correspondait à l’intérieur de tout nom d’attribut se terminant par logo. Cela inclut provider-logo, un vrai attribut que l’extension IPTV Simple pour Kodi documente. Une playlist portant provider-logo et pas de tvg-logo prenait la mauvaise image, et le court-circuit ne nous a pas sauvés, puisqu’il ne se déclenche que quand tvg-logo est présent. Le même trou laissait tvg-id correspondre à l’intérieur de x-tvg-id, et group-title et tvg-chno à l’intérieur de tout ce qui se termine par ces noms.

Ce qui nous a surpris, c’est que le correctif évident est faux. Une frontière de mot ne marche pas ici : dans cette convention, tout nom d’attribut est joint par des traits d’union, et - n’est pas un caractère de mot, donc il y a déjà une frontière entre le trait d’union et le nom, et \btvg-logo correspond toujours à l’intérieur de x-tvg-logo. La condition qu’on veut vraiment, c’est que le nom commence la région des attributs ou suive un espace. En .NET, cela donne un lookbehind (?<=^|\s), portable dans moins de moteurs qu’on ne le croirait : Python refuse de le compiler, parce que la plupart des moteurs exigent un lookbehind de largeur fixe et que celui-ci alterne. Les quatre attributs portent maintenant cette frontière, et les six cas ci-dessus sont figés par des tests.

Notre lecteur fait tout cela sur votre appareil, sur la playlist que vous fournissez, et chaque rejet produit une chaîne de diagnostic plutôt qu’une exception, parce qu’un fichier de 3 000 lignes dont 40 sont mauvaises doit importer les 2 960 autres et vous dire ce qui a disparu. Les applications Windows et Xbox font ce travail localement, et vous en pointez une vers une source que vous avez déjà.

Si vous construisez votre propre analyseur, les mesures ci-dessus vous serviront mieux que la documentation du format, surtout parce qu’il n’y a pas beaucoup de documentation du format. Comptez vos entrées. Comptez la fréquence de chaque attribut, comptez son nombre de valeurs distinctes, et comparez ces deux nombres avant d’indexer quoi que ce soit sur ce champ. C’est un après-midi avec un corpus que vous avez déjà, et c’est la différence entre un analyseur qui marche sur le fichier avec lequel vous avez testé et un analyseur qui marche sur le fichier qu’on vous a envoyé.

Ce que cet article a mesuré26 affirmations, chacune avec les preuves derrière elles
RéclamationPreuveCompté
La RFC 8216 définit #EXTINF comme une durée, une virgule et un titre facultatif qui court jusqu’à la fin de la ligne.RFC 8216 section 4.3.2.1SpécificationSans objet
La RFC 8216 permet une durée #EXTINF fractionnaire.RFC 8216 section 4.3.2.1SpécificationSans objet
La RFC 8216 exige #EXTM3U comme première ligne d’une playlist.RFC 8216 section 4.3.1.1SpécificationSans objet
La RFC 8216 dit que les balises commencent par #EXT et sont sensibles à la casse, donc une comparaison de préfixe ordinale est le comportement conforme.RFC 8216 section 4.1SpécificationSans objet
Extended M3U est la convention de l’ère Winamp qui a introduit #EXTM3U et #EXTINF, et la RFC 8216 la cite par son nom. Elle ne contient aucun des jetons tvg-.RFC 8216 section 4.3.1.1, which reads: the EXTM3U tag indicates that the file is an Extended M3U [M3U] Playlist fileSpécificationSans objet
M3U+ n’est pas un nom de format. m3u_plus est un mode de sortie des serveurs auxquels on se connecte avec des identifiants de fournisseur, la valeur de requête qui fait passer un export d’URL nues à des lignes portant tvg-id, tvg-logo et group-title.Provider login API, playlist export parameter type=m3u_plusSpécificationSans objet
L’extension IPTV Simple pour Kodi documente catchup, catchup-source, catchup-days, tvg-shift, radio, provider-logo et une directive #EXTGRP.kodi-pvr/pvr.iptvsimple README, playlist attribute referenceSpécificationSans objet
aspect-ratio est documenté par SS-IPTV, pas par Kodi.SS-IPTV playlist attribute documentationSpécificationSans objet
tvg-language venait d’iptv-org, qui l’a depuis abandonné en même temps que tvg-country.iptv-org/iptv repository, playlist field historySpécificationSans objet
Une playlist de 50 000 entrées sur un espace de clés d’environ 2^31 porte environ 44 pour cent de chances d’au moins une collision interne.Birthday bound, 1 - exp(-n(n-1)/2N) with N = 2^31 and n = 50,000, which evaluates to 0.4412SpécificationSans objet
Chaque ligne #EXTINF du corpus porte une durée de -1.n = 220322 août 2026
Découper à la première virgule casse 30 des 308 entrées du petit fichier ; les 30 portent une valeur user-agent contenant le fragment KHTML, like Gecko.n = 30822 août 2026
1 355 entrées sur 1 895 portent tvg-chno, réparties sur 263 valeurs distinctes, avec 107 lignes qui revendiquent le numéro 1 et un groupe qui contient 7 chaînes toutes numérotées 1.n = 189522 août 2026
Le grand fichier compte 92 groupes ; 85 d’entre eux portent au moins un numéro, et 33 de ces 85 contiennent un numéro dupliqué.n = 189522 août 2026
56 des 1 355 valeurs tvg-chno sont rejetées, et pas une seule n’est numérique : 49 codes de système de diffusion, 4 espaces réservés # littéraux, 3 valeurs en texte libre. Le plafond de 99999 de l’analyseur ne s’est jamais déclenché ; le plus grand nombre du fichier est 4999.n = 135522 août 2026
25 lignes utilisent une numérotation de sous-canal à la manière ATSC, réparties sur 24 valeurs distinctes allant de 22.1 à 36.2.n = 189522 août 2026
Dans le grand fichier, tvg-id est absent de 483 lignes, vide sur 20 de plus, et présent mais en échec sur la règle de forme sur 24 autres, si bien que 527 lignes (27.8 pour cent) finissent sans identifiant exploitable.n = 189522 août 2026
17 de ces 24 échecs de forme ne sont rejetés que parce qu’ils font moins de six caractères, et ils ont la forme de vrais identifiants courts. Les 7 autres ne contiennent pas de point.n = 2422 août 2026
Sur un corpus distinct d’exports de fournisseurs, TS était le tvg-id le plus fréquent dans deux sources sur trois, 446 lignes et 274 lignes, et ces 720 chaînes sans rapport se sont effondrées en une seule clé de chaîne. Un identifiant qui passait le contrôle de forme était attaché à 74 chaînes sans rapport.n = 720Corpus antérieur
Les URL de flux en double sont réelles et rares : 9 valeurs d’URL dupliquées couvrant 20 des 1 895 lignes du grand fichier, et 0 sur 308 dans le petit.n = 220322 août 2026
6 lignes du grand fichier pointent vers une URL rtmp ou rtsp (5 et 1), et ce sont les seules lignes de tout le corpus que l’analyseur rejette.n = 220322 août 2026
61 lignes portent un tvg-logo vide (19 dans le petit fichier, 42 dans le grand) et la chaîne vide survit jusqu’à la ligne de catalogue, tandis qu’un group-title vide (25 lignes) et un tvg-id vide se replient tous deux sur rien.n = 220322 août 2026
tvg-name apparaît sur les 1 895 lignes du grand fichier et tvg-country sur 1 642 ; le petit fichier porte 30 attributs user-agent, 2 attributs referrer et 32 lignes de directive #EXTVLCOPT.n = 220322 août 2026
Le corpus contient zéro valeur d’attribut sans guillemets, zéro balise #extinf en minuscules, zéro nom d’attribut en majuscules, zéro espace autour d’un signe égal, zéro ligne à guillemet non apparié et zéro attribut logo nu.n = 220322 août 2026
Le fragment construit à la main dans cet article donne 4 lignes analysées, 3 lignes de catalogue et 5 diagnostics.n = 722 août 2026
L’analyseur a 9 tests unitaires, dont deux qui affirment que les champs ne fuient pas d’une entrée à l’autre. Le scanneur de virgule entre guillemets, le rejet du guillemet non apparié, le cas du #EXTVLCOPT entre les deux lignes et le repli sur le logo nu n’en ont aucun.n = 922 août 2026