fix(m3u): parse playlists with URLs longer than 2084 characters

Pluto TV style playlists (issue #1189) embed a session JWT in every
stream URL (~2200 chars). validator.isURL inside iptv-playlist-parser
rejected anything over its IE-era 2084-char default, and the parser's
stalled item index then collapsed the whole playlist into a single
channel.

Sync the 4gray/iptv-playlist-parser fork with upstream v0.15.2, which
removes URL validation entirely and adds an explicit branch so '#'
comments and unknown directives are never treated as URLs. Two fork
deltas are preserved on top: the radio attribute (radio player
detection) and pipe stripping (item.url is cut at the first '|' while
|User-Agent=/|Referer= params still land in item.http). The now-dead
validator/is-valid-path dependencies are dropped from the fork.

- pin iptv-playlist-parser to the fork commit SHA
- add a parser contract spec guarding long URLs, comment handling,
  radio, pipe stripping, and header EPG attrs
- document the parser fork contract in the M3U architecture doc

Fixes #1189

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-18 19:50:31 +02:00
1 parent 8fdac824fd
commit e22f3ed5fa
5 files changed
+122 -49

No files matched your search

@@ -0,0 +1,100 @@
import { parse } from 'iptv-playlist-parser';
/**
* Contract tests for the 4gray/iptv-playlist-parser fork (jest maps the
* module to the real parser source via test-stubs/iptv-playlist-parser.mjs).
* Guards the fork-specific behaviors iptvnator depends on:
* - no URL length/format validation (issue #1189: Pluto JWT URLs > 2084 chars)
* - '#' comments never become URLs and never shift the item index
* - the fork-only `radio` attribute survives upstream syncs
* - `url` is stripped at the first '|' while pipe params land in `http.*`
*/
describe('iptv-playlist-parser contract (4gray fork)', () => {
const plutoUrl = (id: string) =>
`https://cfd-v4-service-channel-stitcher-use1-1.prd.pluto.tv/v2/stitch/hls/channel/${id}/master.m3u8?jwt=${'e'.repeat(2100)}&masterJWTPassthrough=true`;
it('parses playlists whose URLs are longer than 2084 characters (#1189)', () => {
const playlist = [
'#EXTM3U',
'#EXTINF:-1 group-title="Anime & Geek" tvg-id="one" tvg-name="Beyblade",Beyblade',
plutoUrl('one'),
'#EXTINF:-1 group-title="TV Brasileira" tvg-id="two" tvg-name="TV Cultura",TV Cultura',
plutoUrl('two'),
'',
].join('\n');
const result = parse(playlist);
expect(result.items.length).toBe(2);
expect(result.items[0].name).toBe('Beyblade');
expect(result.items[0].url).toBe(plutoUrl('one'));
expect(result.items[1].name).toBe('TV Cultura');
expect(result.items[1].url).toBe(plutoUrl('two'));
});
it('ignores comments and unknown directives between #EXTINF and the URL', () => {
const playlist = [
'#EXTM3U',
'#EXTINF:-1 tvg-id="one",Channel One',
'# stray comment',
'#EXT-X-SESSION-DATA:DATA-ID="com.example",VALUE="x"',
'http://example.com/one.m3u8',
'#EXTINF:-1 tvg-id="two",Channel Two',
'http://example.com/two.m3u8',
'',
].join('\n');
const result = parse(playlist);
expect(result.items.length).toBe(2);
expect(result.items[0].url).toBe('http://example.com/one.m3u8');
expect(result.items[0].raw).toContain('# stray comment');
expect(result.items[1].url).toBe('http://example.com/two.m3u8');
});
it('parses the radio attribute used by the radio player', () => {
const playlist = [
'#EXTM3U',
'#EXTINF:-1 radio="true" tvg-id="r1",Radio One',
'http://example.com/radio1',
'#EXTINF:-1 tvg-id="tv1",TV One',
'http://example.com/tv1',
'',
].join('\n');
const result = parse(playlist);
expect(result.items[0].radio).toBe('true');
expect(result.items[1].radio).toBe('');
});
it('strips pipe options from url while keeping them in http.*', () => {
const playlist = [
'#EXTM3U',
'#EXTINF:-1,Piped',
'http://example.com/stream.ts|User-Agent=CustomUA&Referer=http://ref.example',
'',
].join('\n');
const result = parse(playlist);
expect(result.items[0].url).toBe('http://example.com/stream.ts');
expect(result.items[0].http['user-agent']).toBe('CustomUA');
expect(result.items[0].http.referrer).toBe('http://ref.example');
});
it('exposes EPG urls from the playlist header', () => {
const result = parse(
[
'#EXTM3U x-tvg-url="https://epg.example/guide.xml"',
'#EXTINF:-1,One',
'http://example.com/one.m3u8',
'',
].join('\n')
);
expect(result.header.attrs['x-tvg-url']).toBe(
'https://epg.example/guide.xml'
);
});
});
+13
View File
@@ -42,6 +42,19 @@ The M3U playlist module provides:
└─────────────────────────────────────────────────────────────────────┘
```
## M3U Parsing (`iptv-playlist-parser` fork)
All four parse call sites (Electron `playlist-source.ts` import, `playlist-refresh.worker.ts`, `web-backend` `/parse`, PWA `playlists.service.ts`) use the
[4gray/iptv-playlist-parser](https://github.com/4gray/iptv-playlist-parser) fork, pinned by commit SHA in `package.json`. The fork tracks upstream
`freearhey/iptv-playlist-parser` (currently synced to v0.15.2) plus two deliberate deltas iptvnator depends on:
- **`radio` attribute** — `item.radio` (string, `'true'` triggers the radio player, EPG suppression, and external-player gating app-wide). Upstream does not have this field; it must survive every upstream sync.
- **Pipe stripping** — `item.url` is cut at the first `|`; `|User-Agent=` / `|Referer=` params still land in `item.http`. Upstream 0.15.0 stopped stripping, but iptvnator consumes `item.url` verbatim in hls.js/mpv/vlc, catch-up URL building, and url-keyed favorites.
There is intentionally **no URL validation** (upstream removed it in 0.15.0): any non-empty non-`#` line after `#EXTINF` becomes the item URL. This is what fixes issue #1189 (Pluto TV JWT URLs longer than validator's 2084-char IE-era limit used to be rejected, and the stalled item index collapsed the whole playlist into one channel). `#` comment lines and unknown directives are appended to `item.raw` and never treated as URLs.
The behavioral contract is guarded by `apps/web/src/app/iptv-playlist-parser.contract.spec.ts` (jest maps the module to the real parser source) and by the fork's own test suite.
## State Management (libs/m3u-state/)
### State Structure
+2 -3
View File
@@ -898,9 +898,8 @@ export class PlaylistsService {
path?: string
) {
try {
// Dynamic import keeps the ~130KB validator dep (transitively pulled
// by iptv-playlist-parser) out of the eager bundle. parse() only runs
// on user-triggered imports.
// Dynamic import keeps the parser out of the eager bundle;
// parse() only runs on user-triggered imports.
const parserModule = await import('iptv-playlist-parser');
const parse = resolvePlaylistParser(parserModule);
const parsedPlaylist = parse(playlist);
+1 -1
View File
@@ -97,7 +97,7 @@
"epg-parser": "^0.1.6",
"fix-path": "5.0.0",
"hls.js": "1.6.13",
"iptv-playlist-parser": "github:4gray/iptv-playlist-parser",
"iptv-playlist-parser": "github:4gray/iptv-playlist-parser#1b5cf3bd6131a07f57c339c4b74bfc4985b8adb3",
"marked": "18.0.5",
"mpegts.js": "1.8.0",
"ms": "2.1.3",
+6 -45
View File
@@ -126,8 +126,8 @@ importers:
specifier: 1.6.13
version: 1.6.13
iptv-playlist-parser:
specifier: github:4gray/iptv-playlist-parser
version: https://codeload.github.com/4gray/iptv-playlist-parser/tar.gz/38a62d0b7c98f442bfa102ee8e1fe14169ae9386
specifier: github:4gray/iptv-playlist-parser#1b5cf3bd6131a07f57c339c4b74bfc4985b8adb3
version: https://codeload.github.com/4gray/iptv-playlist-parser/tar.gz/1b5cf3bd6131a07f57c339c4b74bfc4985b8adb3
marked:
specifier: 18.0.5
version: 18.0.5
@@ -7594,9 +7594,9 @@ packages:
resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==}
engines: {node: '>= 10'}
iptv-playlist-parser@https://codeload.github.com/4gray/iptv-playlist-parser/tar.gz/38a62d0b7c98f442bfa102ee8e1fe14169ae9386:
resolution: {tarball: https://codeload.github.com/4gray/iptv-playlist-parser/tar.gz/38a62d0b7c98f442bfa102ee8e1fe14169ae9386}
version: 0.12.2
iptv-playlist-parser@https://codeload.github.com/4gray/iptv-playlist-parser/tar.gz/1b5cf3bd6131a07f57c339c4b74bfc4985b8adb3:
resolution: {tarball: https://codeload.github.com/4gray/iptv-playlist-parser/tar.gz/1b5cf3bd6131a07f57c339c4b74bfc4985b8adb3}
version: 0.15.2
iron-webcrypto@1.2.1:
resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==}
@@ -7663,10 +7663,6 @@ packages:
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
hasBin: true
is-extglob@1.0.0:
resolution: {integrity: sha512-7Q+VbVafe6x2T+Tu6NcOf6sRklazEPmBoB3IWk3WdGZM2iGUwU/Oe3Wtq5lSEkDTTlpp8yx+5t4pzO/i9Ty1ww==}
engines: {node: '>=0.10.0'}
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -7694,10 +7690,6 @@ packages:
resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
engines: {node: '>= 0.4'}
is-glob@2.0.1:
resolution: {integrity: sha512-a1dBeB19NXsf/E0+FHqkagizel/LQw2DjSQpvQrj3zT+jYPpaUCryPnrQajXKFLCMuf4I6FhRpaGtw4lPrG6Eg==}
engines: {node: '>=0.10.0'}
is-glob@4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
@@ -7718,10 +7710,6 @@ packages:
resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==}
engines: {node: '>=12'}
is-invalid-path@0.1.0:
resolution: {integrity: sha512-aZMG0T3F34mTg4eTdszcGXx54oiZ4NtHSft3hWNJMGJXUUqdIj3cOZuHcU0nCWWcY3jd7yRe/3AEm3vSNTpBGQ==}
engines: {node: '>=0.10.0'}
is-lambda@1.0.1:
resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==}
@@ -7819,10 +7807,6 @@ packages:
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
engines: {node: '>=18'}
is-valid-path@0.1.1:
resolution: {integrity: sha512-+kwPrVDu9Ms03L90Qaml+79+6DZHqHyRoANI6IsZJ/g8frhnfchDOBCa0RbQ6/kdHt5CS5OeIEyrYznNuVN+8A==}
engines: {node: '>=0.10.0'}
is-weakmap@2.0.2:
resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
engines: {node: '>= 0.4'}
@@ -11544,10 +11528,6 @@ packages:
resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==}
engines: {node: ^20.17.0 || >=22.9.0}
validator@13.15.26:
resolution: {integrity: sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==}
engines: {node: '>= 0.10'}
varint@6.0.0:
resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==}
@@ -20610,10 +20590,7 @@ snapshots:
ipaddr.js@2.3.0: {}
iptv-playlist-parser@https://codeload.github.com/4gray/iptv-playlist-parser/tar.gz/38a62d0b7c98f442bfa102ee8e1fe14169ae9386:
dependencies:
is-valid-path: 0.1.1
validator: 13.15.26
iptv-playlist-parser@https://codeload.github.com/4gray/iptv-playlist-parser/tar.gz/1b5cf3bd6131a07f57c339c4b74bfc4985b8adb3: {}
iron-webcrypto@1.2.1: {}
@@ -20680,8 +20657,6 @@ snapshots:
is-docker@3.0.0: {}
is-extglob@1.0.0: {}
is-extglob@2.1.1: {}
is-finalizationregistry@1.1.1:
@@ -20706,10 +20681,6 @@ snapshots:
has-tostringtag: 1.0.2
safe-regex-test: 1.1.0
is-glob@2.0.1:
dependencies:
is-extglob: 1.0.0
is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
@@ -20724,10 +20695,6 @@ snapshots:
is-interactive@2.0.0: {}
is-invalid-path@0.1.0:
dependencies:
is-glob: 2.0.1
is-lambda@1.0.1: {}
is-map@2.0.3: {}
@@ -20799,10 +20766,6 @@ snapshots:
is-unicode-supported@2.1.0: {}
is-valid-path@0.1.1:
dependencies:
is-invalid-path: 0.1.0
is-weakmap@2.0.2: {}
is-weakref@1.1.1:
@@ -25378,8 +25341,6 @@ snapshots:
validate-npm-package-name@7.0.2: {}
validator@13.15.26: {}
varint@6.0.0: {}
vary@1.1.2: {}