m3u plus vs m3u: gli attributi che una playlist estesa aggiunge, e come analizzarli

Non esiste una specifica M3U+. Esistono una convenzione, un corpus, e un parser che deve sopravvivere sia alla convenzione sia al corpus.

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

Due righe. Quella riga è assemblata, non copiata: le due playlist che teniamo per il lavoro sul parser nominano emittenti reali, quindi qui non c'è niente preso da loro alla lettera. Ogni forma che contiene è una forma che quei file contengono davvero, e nessuno dei valori è distintivo. Nel file da 1.895 voci, 107 righe rivendicano il numero di canale 1, 20 portano un tvg-id vuoto e 42 portano un tvg-logo vuoto. Nel file da 308 voci, 25 righe portano un group-title in bianco, 19 portano un tvg-logo vuoto e 10 impacchettano due nomi di gruppo in un solo attributo con un punto e virgola.

Quasi tutto quello che sulla prima riga sembra un dato strutturato è assente, in bianco, oppure condiviso con cento canali senza alcun rapporto tra loro. L'unico campo che è insieme presente in modo affidabile e quasi distinto è l'URL sulla seconda riga: 308 URL distinti su 308 voci nel file più piccolo, 1.884 su 1.895 nel più grande.

Il lettore non contiene canali e non può fornirne. Quello che ci compare dentro viene dal file a cui lo fai puntare, analizzato sul tuo dispositivo, il che vuol dire che il comportamento di quell'analisi davanti a un input imperfetto è gran parte di quello che la tua lista canali finisce per essere.

Non c'è una specifica, c'è una convenzione

RFC 8216 definisce #EXTINF per HLS come una durata, una virgola e un titolo facoltativo leggibile da una persona che arriva fino a fine riga. Non dice niente su tvg-id, tvg-chno, group-title, tvg-logo, catchup-source, né su nessuno degli altri token che i file reali impacchettano fra la durata e la virgola.

Quei token vengono da una convenzione successiva per le playlist di TV in diretta che non ha un nome condiviso, e tutti e due i nomi a cui la gente ricorre sono sbagliati. Extended M3U è la convenzione dell'epoca di Winamp che ha introdotto #EXTM3U e #EXTINF stessi; RFC 8216 la cita per nome nella sezione 4.3.1.1, e non contiene niente che cominci per tvg-. M3U+ non è affatto un formato: m3u_plus è una modalità di uscita dei server dei provider a cui si accede con le credenziali provider, il valore di query che fa passare un'esportazione da URL nudi a righe che portano tvg-id, tvg-logo e group-title. Una delle due è una convenzione per playlist degli anni Novanta, l'altro è un parametro di query, e nessuno dei due è una specifica. La convenzione è definita da qualunque cosa emettano gli emettitori.

Quella distinzione attraversa tutto quello che segue. Quello che la convenzione permette e quello che un parser accetta sono due elenchi diversi, e mescolarli è il modo in cui un parser finisce per codificare le abitudini di una sorgente come se fossero una regola. Quindi: la grammatica che il nostro parser implementa è questa, e nient'altro.

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

Il confronto del prefisso è ordinale, quindi #EXTINF: distingue maiuscole e minuscole fino ai due punti, e il parser fissa nel codice la lunghezza del prefisso a otto caratteri prima di passare il resto a uno scanner della durata. Quello scanner accetta un solo meno facoltativo, poi pretende almeno una cifra, poi consuma la sequenza di cifre. Registra dove finiscono le cifre e butta via il valore. La durata non viene mai letta da nessuna parte nella pipeline. Tutte e 2.203 le righe #EXTINF delle nostre due playlist di esempio portano -1, che è il modo della convenzione di dire “in diretta, durata sconosciuta”, e a nessun percorso di codice importerebbe niente se dicessero 7200.

Da quello scanner discendono direttamente tre conseguenze. +1 viene rifiutato, perché un più non è né un meno né una cifra. .5 viene rifiutato, perché prima del punto deve esserci una cifra. E una durata frazionaria come #EXTINF:120.5,Example si analizza correttamente per caso: lo scanner si ferma al punto, il .5 avanzato finisce dentro la regione degli attributi, e nessuna ricerca di attributo lo trova mai. RFC 8216 ammette quella forma frazionaria e il nostro parser la gestisce, ma nessuno l'ha implementata, e vale la pena dirlo apertamente invece di archiviarlo sotto “supportiamo i decimali”.

C'è un'asimmetria dentro quella grammatica di cui vale la pena assumersi la responsabilità, anche se non nella direzione che sembra a prima vista. Il prefisso del tag viene confrontato distinguendo maiuscole e minuscole, che è quello che RFC 8216 nella sezione 4.1 pretende da ogni tag che inizia per #EXT. I nomi degli attributi vengono confrontati senza distinguere maiuscole e minuscole, cosa che non pretende nessuno. Così #extinf: in minuscolo viene saltato come direttiva non riconosciuta mentre TVG-CHNO = "18" sulla stessissima riga si analizza senza problemi, e la metà che si discosta dalla RFC è quella indulgente. L'incoerenza è arrivata con un port da un vecchio parser lato server e il file sorgente lo dice ancora in cima. Nessuna delle due playlist di esempio contiene un tag in minuscolo, un nome di attributo in maiuscolo o uno spazio attorno a un segno di uguale, quindi niente nel nostro corpus ha mai esercitato nessuna delle due metà.

La virgola che non è un delimitatore

Dividi una riga #EXTINF sulla sua prima virgola e hai rotto 30 delle 308 voci in uno dei nostri due file.

Il motivo è che i valori degli attributi sono stringhe tra virgolette e le stringhe tra virgolette contengono virgole. Ognuna di quelle 30 righe porta un attributo user-agent che contiene una stringa UA di browser, e tutte e 30 quelle stringhe contengono il frammento KHTML, like Gecko. Ventotto di esse lo racchiudono tra parentesi e due hanno perso le parentesi da qualche parte a monte, il che sulla virgola non cambia niente. Ecco il confronto vero, eseguito sul file reale, con i nomi host della sorgente rimossi e il nome del canale sostituito:

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)

Il nove virgola sette per cento di quel file si ritrova un nome visualizzato fatto della stringa di versione del browser di qualcuno. Il catalogo si costruisce lo stesso. Non va in errore niente. La lista canali è sbagliata in un modo che sembra un bug di rendering e non lo è.

La soluzione è uno scanner scritto a mano, non una regex: percorrere la regione degli attributi carattere per carattere, ribaltare un flag inQuotes a ogni doppia virgoletta e restituire la prima virgola vista mentre quel flag è falso. Tutto quello che sta a sinistra è la regione degli attributi, tutto quello che sta a destra è il nome visualizzato, entrambi ripuliti dagli spazi. Sono nove righe di codice e sono le nove righe di maggior valore di tutto il parser.

Quello scanner decide anche che cosa succede a una riga malformata, e la decisione è severa. Se la regione degli attributi contiene un numero dispari di doppie virgolette, per esempio group-title="Movies,Example Four dove la virgoletta di chiusura è caduta, il flag non torna mai falso, non viene mai trovata nessuna virgola fuori dalle virgolette e l'intera voce viene rifiutata con un messaggio diagnostico. Non c'è nessuna ricaduta sulla prima virgola e nessun tentativo di indovinare dove sarebbe dovuta stare la virgoletta. Abbiamo scelto il rifiuto invece della riparazione, perché una riga riparata è sbagliata in silenzio mentre una riga rifiutata manca rumorosamente, e una riga che manca rumorosamente è quella che un utente può segnalare.

Gli attributi letti sono cinque, e un file porta 1.895 righe di un sesto che ignoriamo

L'insieme completo degli attributi che il nostro parser cerca è di cinque grafie:

  • group-title, tenuto fino al primo punto e virgola, in bianco ridotto a niente
  • tvg-logo, ripulito dagli spazi
  • logo, consultato solo quando tvg-logo manca del tutto
  • tvg-id, preso grezzo in fase di analisi e verificato più tardi contro la regola di fiducia
  • tvg-chno, passato a un parser di numeri a cui è permesso non restituire niente

Questo è tutto l'elenco. I nomi degli attributi vengono confrontati senza distinguere maiuscole e minuscole, con spazi facoltativi da entrambi i lati del segno di uguale, quindi TVG-CHNO = "18" e tvg-chno="18" per noi sono la stessa cosa. I valori devono stare tra doppie virgolette; non esiste un percorso per i valori senza virgolette, e le virgolette singole non vengono riconosciute da nessuna parte.

Ora il lato osservato, che è più interessante. Nel file da 1.895 voci, tvg-name compare su tutte e 1.895 le righe e le ignoriamo tutte quante. tvg-country compare su 1.642 righe e ignoriamo anche quelle. Il nome visualizzato viene sempre dal testo dopo la virgola, mai da tvg-name, anche quando una sorgente li fornisce entrambi su ogni riga che pubblica. È una decisione di progetto e non una svista: il titolo dopo la virgola è il campo che la grammatica stessa garantisce, tvg-name è facoltativo, e un'unica origine autorevole del nome batte un ordine di preferenza che si comporta in modo diverso da un file all'altro.

Il file più piccolo mostra lo strato di direttive che sta fra le due righe di una voce. Porta 30 attributi user-agent, 2 attributi referrer (scritto con due r da quell'emettitore, che non è la grafia dell'intestazione HTTP) e 32 righe di direttiva #EXTVLCOPT a sé stanti, 30 delle quali ripetono lo user agent che la riga #EXTINF aveva già dichiarato. Di questi non ne leggiamo nessuno.

Altri lettori leggono attributi che noi non leggiamo, e non ce ne sono due che abbiano lo stesso elenco. L'add-on IPTV Simple per Kodi documenta catchup, catchup-source, catchup-days, tvg-shift, radio e una direttiva #EXTGRP. SS-IPTV, un lettore per televisori smart, documenta aspect-ratio, che Kodi non documenta. tvg-language veniva da iptv-org, che nel frattempo l'ha abbandonato insieme a tvg-country. Nemmeno uno di quei token compare da qualche parte nel nostro campione e non ne leggiamo nessuno, il che produce un limite di prodotto onesto che vale la pena dichiarare: il supporto alla TV differita ce l'abbiamo, ma è costruito sulle credenziali provider, quindi una sorgente M3U non può esporci la TV differita, quali che siano gli attributi che scrive.

Sette intestazioni, tre canali

Ecco un frammento costruito per mettere alla prova tutti i percorsi insieme. Ogni host che contiene è un dominio di esempio riservato e ogni nome è inventato. La maggior parte delle forme che mette alla prova nel nostro corpus c'è davvero, BS4K101 compreso, che è un valore reale preso dal file più grande. Cinque non ci sono da nessuna parte: la durata malformata, la virgoletta sbilanciata, il logo nudo, il nome di attributo in maiuscolo con gli spazi attorno al segno di uguale, e il nome che inizia per ##. Un frammento costruito a mano è l'unico modo per raggiungere quei cinque percorsi, e vale la pena saperlo prima di leggere la spiegazione passo passo qui sotto come se fosse una prova su file reali.

#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

Entrano sette intestazioni. Dal parser escono quattro righe, tre di esse arrivano al catalogo, e vengono prodotte cinque stringhe diagnostiche.

La riga 1 viene saltata. Il nostro parser non cerca affatto #EXTM3U; cade nel salto generico “inizia con un cancelletto” insieme a tutto il resto che non riconosce, e l'attributo x-tvg-url che quella riga porta non viene letto. RFC 8216 richiede quell'intestazione come prima riga di una playlist. Noi non la richiediamo, e un file senza intestazione che si apre direttamente con #EXTINF si analizza senza problemi, perché il controllo di forma che gira sul dispositivo prima dell'analisi accetta uno qualunque dei due tag come prima cosa del file, dopo un byte-order mark facoltativo e gli spazi iniziali. Nient'altro può precederlo: un file che si apre con una riga di commento o con un #EXTVLCOPT viene rifiutato prima ancora che il parser lo veda.

Example One si analizza con il nome dell'attributo nel caso sbagliato e con gli spazi attorno al segno di uguale. Il suo gruppo diventa News, perché viene tenuto solo il segmento prima del primo punto e virgola. Il suo numero diventa 22.1 come decimale, non 22 come intero, e questo conta più avanti. Il suo logo diventa una stringa vuota dove ti aspetteresti niente del tutto, e anche questo conta più avanti.

Example Two è il caso della virgola tra virgolette, e la riga #EXTVLCOPT che la segue è il motivo per cui il salto generico del cancelletto non deve azzerare l'intestazione in sospeso. Le direttive stanno legittimamente fra un #EXTINF e il suo URL. Un parser che azzerasse lo stato a ogni riga # non riconosciuta perderebbe questo canale e ogni canale come questo, che nel file di esempio più piccolo fa 30 voci su 308.

La riga divisoria si analizza pulita ed è un canale per quanto ne sa la grammatica. Il suo valore tvg-chno, BS4K101, viene rifiutato dal parser di numeri e diventa niente, il che costa il numero ma non la riga. A toglierla di mezzo è il suo nome che inizia per ##, e questo succede più tardi, durante la normalizzazione dei nomi, non al momento dell'analisi. Tenere separati i due stadi vuol dire che il parser non ha nessuna opinione su che cosa significhi il nome di un canale, ed è il posto giusto per quel confine.

Example Three fallisce sulla durata: x non è né un meno né una cifra, quindi l'intestazione viene rifiutata con un messaggio diagnostico e tutti e cinque i campi in sospeso vengono azzerati. Il suo URL arriva poi senza nessuna intestazione in sospeso e produce un secondo messaggio diagnostico. Una sola intestazione malformata costa due righe e produce due voci di log, il che è corretto e vale la pena saperlo quando leggi i messaggi diagnostici di un'importazione reale.

Example Four è la virgoletta sbilanciata, e si comporta esattamente allo stesso modo: due messaggi diagnostici, niente emesso.

Example Five ha un'intestazione perfettamente valida e un URL rtmp. È un URI assoluto, quindi si analizza come tale, ma lo schema non è né http né https e lo rifiutiamo. È la riga più pericolosa del frammento, perché se il percorso di rifiuto non azzerasse lo stato in sospeso, Example Six erediterebbe in silenzio il numero di canale 9. Quel percorso non è ipotetico. Il file di esempio più grande chiude 6 delle sue 1.895 voci su un URL rtmp o rtsp, cinque e uno, e quelle 6 righe sono le uniche righe di tutto il corpus da 2.203 voci che il nostro parser rifiuta.

Example Six porta un attributo logo nudo senza nessun tvg-logo e senza nessun group-title, quindi scatta la ricaduta sul logo e il canale finisce in un gruppo predefinito che prende il nome dalla sorgente.

Ogni percorso di salto deve azzerare l'intestazione

Una voce M3U è fatta di due righe che condividono uno stato mutabile, ed è tutta lì la difficoltà del formato. Il parser tiene cinque campi in sospeso fra la lettura di un'intestazione e la lettura di un URL: nome, gruppo, logo, id, numero. Nel ciclo passano sette percorsi distinti, e la correttezza del parser è interamente una questione di che cosa ognuno di loro fa a quei cinque campi.

Le righe vuote saltano senza azzerare, e fanno bene, perché una riga vuota fra un'intestazione e il suo URL non deve distruggere la voce. Le direttive # non riconosciute saltano senza azzerare, per il motivo che il frammento qui sopra dimostra. Un'intestazione malformata li azzera tutti e cinque. Un URL rifiutato li azzera tutti e cinque. Un'emissione riuscita li azzera tutti e cinque.

Il settimo percorso è quello che la maggior parte dei parser a coppie di righe sbaglia, e la parte interessante è che non viene gestito con un azzeramento. Quando arriva un secondo #EXTINF prima di qualsiasi URL, il parser assegna tutti e cinque i campi senza condizioni, senza nessun if e senza nessuna assegnazione con coalescenza dei null. Una ricerca di attributo che non trova niente restituisce niente, e il parser di numeri a cui non si dà niente restituisce niente, quindi un'intestazione che non porta tvg-chno scrive il vuoto sopra il numero dell'intestazione precedente invece di lasciarlo dov'è. Non esiste nessun percorso per cui il campo N della voce K sopravviva nella voce K+1. È una proprietà più forte di “ci ricordiamo di azzerare”, e vale la pena strutturare apposta un parser così, perché la versione con l'azzeramento resta corretta solo finché ogni modifica futura si ricorda di estenderla.

Un percorso merita una nota perché sembra un bug e non lo è. Il percorso “è arrivato un URL senza nessuna intestazione in sospeso” emette il suo messaggio diagnostico e non azzera niente. È sicuro per costruzione e non per fortuna, e l'invariante si verifica leggendo le assegnazioni: l'unico posto dove gli altri quattro campi vengono impostati è il percorso dell'intestazione, e quel percorso imposta sempre il nome a un valore non vuoto nello stesso passaggio, ricadendo sulla stringa letterale Unnamed channel quando il titolo è in bianco. Quindi un nome nullo implica che gli altri quattro sono già nulli, e l'azzeramento mancante è lavoro irraggiungibile.

Ognuno di questi percorsi ha bisogno del suo test di regressione, e i nove che abbiamo coprono davvero i percorsi di travaso: uno verifica che una voce numerata non travasi il suo numero su quella dopo, un altro verifica che nemmeno un'intestazione malformata lo faccia. Quello che non ha nessun test è lo scanner della virgola tra virgolette, le nove righe che questo articolo ha chiamato le nove righe di maggior valore del parser, insieme al rifiuto delle virgolette sbilanciate, al caso dell'#EXTVLCOPT in mezzo alle due righe e alla ricaduta sul logo nudo. Quella lacuna non solleva niente. Il catalogo si costruisce. Cento canali a valle di una riga sbagliata portano il gruppo sbagliato.

I due campi che sembrano identificatori

tvg-chno è un dato di visualizzazione, e la misura che chiude la questione si fa con una riga di shell. Nel file da 1.895 voci, 1.355 voci portano l'attributo. Quei 1.355 numeri coprono 263 valori distinti. 107 voci rivendicano il numero 1. La duplicazione non è un artefatto della fusione dei gruppi, perché sopravvive al raggruppamento: un gruppo in quel file contiene 7 canali tutti numerati 1, e 33 degli 85 gruppi che portano un numero qualsiasi hanno almeno un duplicato al loro interno. Quel file ha 92 gruppi in totale, quindi 7 di loro non portano nessun numero. L'altro file, 308 voci, non ha tvg-chno da nessuna parte. Su questo valore non si deve indicizzare niente, niente deve dare per scontato che sia unico, e ogni consumatore che ci ordina sopra ha bisogno di un criterio deterministico per spareggiare.

Analizzarlo è un piccolo esercizio nel rifiutarsi di fare i furbi. Il valore viene letto come numero in virgola mobile sotto la cultura invariante, non come intero, e mai sotto la cultura del dispositivo. 56 dei 1.355 vengono rifiutati senza appello. Nessuno dei 56 è numerico:

  • 49 sono codici di sistema di trasmissione: 7 che vanno da D011 a D081, 19 in una famiglia da BS101 a BS4K101, 22 in una serie da CS055 a CS363, e un solo codice con il prefisso di un operatore via cavo
  • 4 sono un # letterale, a quanto pare un segnaposto che l'emettitore non ha mai riempito
  • 3 sono testo libero con dentro un numero sepolto, due che dicono CH 106 e CH 108, uno un nome di servizio in giapponese che finisce in 527

Il parser porta anche un tetto di 99999, sopra il quale un valore viene trattato come spazzatura. Su questo corpus non è mai scattato. Il numero più grande nel file è 4999, il che è un promemoria utile: una guardia scritta per un'ipotesi non è la prova che l'ipotesi fosse reale.

L'analisi decimale non è una scelta di stile. 25 voci di quel file usano la numerazione dei sottocanali in stile ATSC, 24 valori distinti che vanno da 22.1 a 36.2, e un'analisi come intero fonde 22.1, 22.2 e 22.3 in tre canali tutti numerati 22. Nemmeno la cultura invariante è una scelta di stile: onorare la cultura del dispositivo farebbe produrre allo stesso file numeri diversi su una macchina configurata con la virgola come separatore decimale, che è una classe di bug che si riproduce soltanto sul computer di qualcun altro.

tvg-id è peggio, perché i suoi fallimenti non sono vuoti. Nel file da 1.895 voci l'attributo manca in 483 righe, è vuoto in altre 20 ed è presente ma non supera la nostra regola di forma in altre 24, quindi 527 righe, il 27.8 per cento del file, finiscono senza un id utilizzabile. Sono quelle 24 le scomode. Diciassette di esse sembrano esattamente id veri e vengono rifiutate per un motivo solo, essere più corte di sei caratteri, come un nome breve seguito da un suffisso di paese. Le altre 7 non portano il punto. Una lunghezza minima è una regola grossolana, ci costa quelle 17, e rivedremmo la soglia se quel numero crescesse.

Il motivo per cui quella regola esiste è un corpus diverso, che teniamo per il lavoro sul parser, fatto di esportazioni di provider e non dei due file curati di cui sopra. In due delle sue tre sorgenti il valore tvg-id più frequente in assoluto era la stringa letterale TS: 446 righe in una, 274 nell'altra. TS è il formato contenitore transport stream, che esce da un campo di formato di uscita e finisce nella colonna dell'id. Quei 720 canali non avevano niente a che vedere l'uno con l'altro: un canale di intrattenimento britannico e una radio in lingua araba, uno accanto all'altro sotto un solo identificatore. Siccome la chiave di canale si ricava dall'id, tutti e 720 sono collassati in un unico canale logico. La conseguenza è un preferito che compare su canali che nessuno ha segnato, un blocco parentale che blocca 720 cose in una volta sola, e un solo riquadro di Multiview che sta per ognuna di loro.

La riparazione è stata una regola di fiducia con una sola casa, applicata al confine dell'importazione così che niente a valle debba ricordarsi di ricontrollare. Un id è utilizzabile solo se è lungo da 6 a 128 caratteri, contiene un punto e non è TS, e quello che sopravvive viene portato in minuscolo sotto la cultura invariante, perché le sorgenti sono davvero in disaccordo sul caso dell'id dello stesso canale. Tutto il resto si normalizza a niente, e “nessun id” è uno stato che ogni consumatore già gestisce, perché la maggior parte dei canali davvero non ne ha. La regola è necessaria e non sufficiente, e lo sappiamo: un id che supera il controllo di forma si è poi rivelato attaccato a 74 canali senza alcun rapporto tra loro, quindi dovunque le sorgenti vengano aggregate ci gira sopra un controllo di cardinalità in fase di build. Nessuno dei due file curati del nostro campione contiene un solo id TS, ed è questa la riserva onesta su tutto quanto. Un corpus pulito questo guasto non te lo mostrerà mai. L'abbiamo scoperto da una segnalazione di bug, non da un test.

Senza un id utilizzabile nella maggior parte dei file, uno bisogna ricavarselo, ed è nella derivazione che il compromesso diventa scomodo. L'URL dello stream è l'unico campo sempre presente e quasi unico dentro una sola sorgente, quindi l'id sintetico è uno SHA-1 di quell'URL, con i primi quattro byte letti in little-endian e ripiegati in un intero positivo a 32 bit. Restano all'incirca 2^31 di spazio delle chiavi, e per il paradosso del compleanno una playlist da 50.000 voci porta con sé una probabilità di circa il 44 per cento di almeno una collisione interna. Quel numero l'abbiamo accettato con i conti davanti, sul presupposto che una collisione fonde due canali dentro una sola sorgente, che è quello che già succede quando una sorgente elenca due volte lo stesso canale. Il nostro corpus lo fa su 9 URL che coprono 20 delle 1.895 righe del file più grande, e mai una volta in quello da 308 voci, quindi è un evento reale ma non di routine, e quella misura indebolisce l'argomento più di quanto lo sostenga. Quello che chiude davvero la questione è che allargare la base per includere tvg-id, o passare a un hash più largo, non si può spedire: l'id derivato viene scritto nei dati salvati che altri dispositivi rileggono, quindi cambiare la derivazione li invalida tutti in una volta. Il tasso di collisione è il prezzo di un formato di scambio congelato.

Vuoto non vuol dire assente, tranne in un campo

Tre attributi, tre politiche diverse per la stringa vuota, e non ce ne siamo accorti finché non abbiamo misurato.

group-title="" si riduce a niente, perché il normalizzatore dei gruppi tratta il vuoto come assente, e 25 righe del file più piccolo prendono quella strada. tvg-id="" si riduce a niente, perché la regola di fiducia lo respinge. tvg-logo="" non si riduce: arriva come stringa vuota e viaggia fino al campo icona della riga di catalogo. 19 righe in un file di esempio e 42 nell'altro portano un attributo logo vuoto, quindi 61 righe in un corpus da 2.203 righe contengono una stringa vuota dove ogni altro campo conterrebbe niente. Il logo è l'eccezione per nessun motivo che non sia l'ordine in cui sono state scritte le tre riduzioni.

Quanto costi dipende da un percorso che non abbiamo letto. Se il caricatore di immagini tratta una stringa vuota e un null allo stesso modo, questo è un neo e niente di più. Se una stringa vuota fa partire una richiesta che fallisce, 61 righe stanno facendo lavoro inutile a ogni render, e il neo è un piccolo bug. Qualcuno dovrebbe leggere quel percorso prima che questa pagina affermi l'una o l'altra cosa.

Quello che non abbiamo misurato

Alcuni limiti di questo parser nel nostro campione non hanno nessun testimone, e dirlo è più utile che far finta che siano collaudati. Non esiste un percorso per i valori senza virgolette: tvg-chno=18 senza virgolette per noi è invisibile. Zero delle 2.203 righe #EXTINF che abbiamo usano un valore senza virgolette, quindi non ci ha mai morso, e non sappiamo quanto sia diffusa questa forma là fuori. La ricaduta sul logo nudo ha lo stesso problema al contrario, dato che zero delle nostre righe la usano, quindi una ricaduta che spediamo non ha nessun test contro un input reale.

Scrivere questa pagina ha trovato un bug che avevamo già spedito, ed è la cosa più utile che abbia fatto. Esporre il matcher degli attributi in modo abbastanza chiaro da poterlo pubblicare ha reso evidente che il pattern non aveva nessun confine a sinistra, quindi il pattern per logo corrispondeva dentro qualsiasi nome di attributo che finisse per logo. Questo include provider-logo, un attributo reale che l'add-on IPTV Simple per Kodi documenta. Una playlist che portava provider-logo e nessun tvg-logo prendeva l'immagine sbagliata, e il corto circuito non ci salvava, perché scatta solo quando tvg-logo è presente. Lo stesso buco lasciava che tvg-id corrispondesse dentro x-tvg-id, e group-title e tvg-chno dentro qualsiasi cosa finisse così.

Ci ha sorpreso che la soluzione ovvia sia sbagliata. Un confine di parola qui non funziona: in questa convenzione ogni nome di attributo è unito da trattini, e - non è un carattere di parola, quindi fra il trattino e il nome un confine c'è già e \btvg-logo corrisponde ancora dentro x-tvg-logo. La condizione che serve davvero è che il nome apra la regione degli attributi oppure segua uno spazio. In .NET quello è un lookbehind (?<=^|\s), che è portabile su meno motori di quanti ne immagineresti: Python si rifiuta di compilarlo, perché la maggior parte dei motori pretende un lookbehind a larghezza fissa e questo alterna. Adesso tutti e quattro gli attributi portano quel confine, e i sei casi qui sopra sono fissati dai test.

Il nostro lettore esegue tutto questo sul tuo dispositivo, contro la playlist che gli fornisci tu, e ogni rifiuto produce una stringa diagnostica invece di un'eccezione, perché un file con 40 righe sbagliate su 3.000 deve importare le altre 2.960 e dirti che cosa è andato perso. Le nostre app per Windows e Xbox fanno quel lavoro in locale, e tu ne punti una a una sorgente che hai già.

Se ti stai scrivendo un parser tuo, le misure qui sopra ti serviranno più della documentazione del formato, soprattutto perché di documentazione del formato ce n'è poca. Conta le tue voci. Conta quante volte compare ogni attributo, conta quanti valori distinti ha, e confronta quei due numeri prima di indicizzare qualcosa su quel campo. È un pomeriggio con un corpus che hai già, ed è la differenza fra un parser che funziona sul file con cui hai fatto le prove e uno che funziona sul file che ti è arrivato.

Cosa ha misurato questo articolo26 affermazioni, ciascuna con le prove a sostegno
RichiediProveContato
RFC 8216 definisce #EXTINF come una durata, una virgola e un titolo facoltativo che arriva fino a fine riga.RFC 8216 section 4.3.2.1SpecificaNon applicabile
RFC 8216 ammette una durata #EXTINF frazionaria.RFC 8216 section 4.3.2.1SpecificaNon applicabile
RFC 8216 richiede #EXTM3U come prima riga di una playlist.RFC 8216 section 4.3.1.1SpecificaNon applicabile
RFC 8216 dice che i tag iniziano con #EXT e distinguono maiuscole e minuscole, quindi il confronto ordinale del prefisso è il comportamento conforme.RFC 8216 section 4.1SpecificaNon applicabile
Extended M3U è la convenzione dell'epoca di Winamp che ha introdotto #EXTM3U e #EXTINF, e RFC 8216 la cita per nome. Non contiene nessuno dei token tvg-.RFC 8216 section 4.3.1.1, which reads: the EXTM3U tag indicates that the file is an Extended M3U [M3U] Playlist fileSpecificaNon applicabile
M3U+ non è il nome di un formato. m3u_plus è una modalità di uscita dei server dei provider a cui si accede con le credenziali provider, il valore di query che fa passare un'esportazione da URL nudi a righe che portano tvg-id, tvg-logo e group-title.Provider login API, playlist export parameter type=m3u_plusSpecificaNon applicabile
L'add-on IPTV Simple per Kodi documenta catchup, catchup-source, catchup-days, tvg-shift, radio, provider-logo e una direttiva #EXTGRP.kodi-pvr/pvr.iptvsimple README, playlist attribute referenceSpecificaNon applicabile
aspect-ratio è documentato da SS-IPTV, non da Kodi.SS-IPTV playlist attribute documentationSpecificaNon applicabile
tvg-language veniva da iptv-org, che nel frattempo l'ha abbandonato insieme a tvg-country.iptv-org/iptv repository, playlist field historySpecificaNon applicabile
Una playlist da 50.000 voci su uno spazio di chiavi di circa 2^31 porta con sé una probabilità di circa il 44 per cento di almeno una collisione interna.Birthday bound, 1 - exp(-n(n-1)/2N) with N = 2^31 and n = 50,000, which evaluates to 0.4412SpecificaNon applicabile
Ogni riga #EXTINF del corpus porta una durata di -1.n = 220322 ago 2026
Dividere sulla prima virgola rompe 30 delle 308 voci del file più piccolo; tutte e 30 portano un valore user-agent che contiene il frammento KHTML, like Gecko.n = 30822 ago 2026
1.355 voci su 1.895 portano tvg-chno, distribuite su 263 valori distinti, con 107 righe che rivendicano il numero 1 e un gruppo che contiene 7 canali tutti numerati 1.n = 189522 ago 2026
Il file più grande ha 92 gruppi; 85 di essi portano almeno un numero, e 33 di quegli 85 contengono un numero duplicato.n = 189522 ago 2026
56 dei 1.355 valori tvg-chno vengono rifiutati, e nessuno di essi è numerico: 49 codici di sistema di trasmissione, 4 segnaposto # letterali, 3 valori di testo libero. Il tetto di 99999 del parser non è mai scattato; il numero più grande nel file è 4999.n = 135522 ago 2026
25 righe usano la numerazione dei sottocanali in stile ATSC, su 24 valori distinti che vanno da 22.1 a 36.2.n = 189522 ago 2026
Nel file più grande tvg-id manca in 483 righe, è vuoto in altre 20 ed è presente ma non supera la regola di forma in altre 24, quindi 527 righe (il 27.8 per cento) finiscono senza un id utilizzabile.n = 189522 ago 2026
17 di quei 24 fallimenti di forma vengono rifiutati soltanto perché più corti di sei caratteri, e hanno l'aspetto di id veri, per esempio un nome breve seguito da un suffisso di paese. Gli altri 7 non contengono nessun punto.n = 2422 ago 2026
Su un corpus separato di esportazioni di provider, TS era il tvg-id più frequente in due sorgenti su tre, 446 righe e 274 righe, e quei 720 canali senza alcun rapporto tra loro sono collassati in un'unica chiave di canale. Un id che superava il controllo di forma risultava attaccato a 74 canali senza alcun rapporto tra loro.n = 720Corpus precedente
Gli URL di stream duplicati esistono e sono rari: 9 valori di URL duplicati che coprono 20 delle 1.895 righe del file più grande, e 0 su 308 nel più piccolo.n = 220322 ago 2026
6 righe del file più grande puntano a un URL rtmp o rtsp (5 e 1), e sono le uniche righe dell'intero corpus che il parser rifiuta.n = 220322 ago 2026
61 righe portano un tvg-logo vuoto (19 nel file più piccolo, 42 nel più grande) e la stringa vuota sopravvive fino alla riga di catalogo, mentre un group-title vuoto (25 righe) e un tvg-id vuoto si riducono entrambi a niente.n = 220322 ago 2026
tvg-name compare su tutte le 1.895 righe del file più grande e tvg-country su 1.642; il file più piccolo porta 30 attributi user-agent, 2 attributi referrer e 32 righe di direttiva #EXTVLCOPT.n = 220322 ago 2026
Il corpus contiene zero valori di attributo senza virgolette, zero tag #extinf in minuscolo, zero nomi di attributo in maiuscolo, zero spazi attorno al segno di uguale, zero righe con virgolette sbilanciate e zero attributi logo nudi.n = 220322 ago 2026
Il frammento costruito a mano in questo articolo produce 4 righe analizzate, 3 righe di catalogo e 5 messaggi diagnostici.n = 722 ago 2026
Il parser ha 9 test unitari, tra cui due che verificano che i campi non travasino da una voce all'altra. Lo scanner della virgola tra virgolette, il rifiuto delle virgolette sbilanciate, il caso dell'#EXTVLCOPT in mezzo alle due righe e la ricaduta sul logo nudo non ne hanno nessuno.n = 922 ago 2026