From 45b6d8a0414d4c26a8a5624175b306f304cd4c3c Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:56:26 +0200 Subject: [PATCH] fix(m3u): parse playlists with URLs longer than 2084 characters (#1204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * chore(m3u): bump parser to optimized fork build Pulls the fork's optimized parse() rewrite (2.5-3x faster: 100k channels ~780ms -> ~285ms, 10k ~79ms -> ~25ms) and the README documenting fork deltas. Output is differential-verified byte-identical to the previous build; all parser-contract, unit, and import E2E suites rerun green against the new pin. Co-Authored-By: Claude Fable 5 * chore(m3u): bump parser for input robustness and library hygiene Pulls the fork's real-world input tolerance: UTF-8 BOM, blank lines and whitespace before the header, and case-insensitive #EXTM3U no longer reject the playlist (all VLC-accepted forms); Node Buffers are decoded as UTF-8 and other non-string input throws a clear TypeError. Also brings truthful types (url?: string), fork metadata, an enforced 100% coverage gate, and the fork CHANGELOG. Extends the contract spec with a BOM regression test. Co-Authored-By: Claude Fable 5 * chore(m3u): pin parser to the tagged fork release v0.15.2-iptvnator.1 Same commit as before (33f5e9c) — the readable tag replaces the raw SHA in package.json while pnpm-lock still records the immutable codeload tarball by commit. Fork release: https://github.com/4gray/iptv-playlist-parser/releases/tag/v0.15.2-iptvnator.1 Co-Authored-By: Claude Fable 5 * fix(types): make ParsedPlaylistItem.url optional to match runtime The parser fork's d.ts now truthfully declares url?: string (a trailing #EXTINF without a stream URL yields url === undefined at runtime, and always has). The local ParsedPlaylistItem mirrored the old type lie and made the production typecheck reject the parser's Playlist type. createChannel and createPlaylistObject already tolerate the absent url. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../app/iptv-playlist-parser.contract.spec.ts | 109 ++++++++++++++++++ docs/architecture/m3u-playlist-module.md | 13 +++ libs/services/src/lib/playlists.service.ts | 5 +- .../src/lib/parsed-playlist.interface.ts | 3 +- package.json | 2 +- pnpm-lock.yaml | 52 ++------- 6 files changed, 134 insertions(+), 50 deletions(-) create mode 100644 apps/web/src/app/iptv-playlist-parser.contract.spec.ts diff --git a/apps/web/src/app/iptv-playlist-parser.contract.spec.ts b/apps/web/src/app/iptv-playlist-parser.contract.spec.ts new file mode 100644 index 000000000..85f2338cc --- /dev/null +++ b/apps/web/src/app/iptv-playlist-parser.contract.spec.ts @@ -0,0 +1,109 @@ +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('accepts playlists with a UTF-8 BOM before the header', () => { + const result = parse( + '#EXTM3U\n#EXTINF:-1,One\nhttp://example.com/one.m3u8\n' + ); + + expect(result.items.length).toBe(1); + expect(result.items[0].url).toBe('http://example.com/one.m3u8'); + }); + + 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' + ); + }); +}); diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 9ba3d33e6..85847412d 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -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 diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index f9fefc171..cc6938fc7 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -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); diff --git a/libs/shared/interfaces/src/lib/parsed-playlist.interface.ts b/libs/shared/interfaces/src/lib/parsed-playlist.interface.ts index 32f7a3473..350be337f 100644 --- a/libs/shared/interfaces/src/lib/parsed-playlist.interface.ts +++ b/libs/shared/interfaces/src/lib/parsed-playlist.interface.ts @@ -22,7 +22,8 @@ export interface ParsedPlaylistItem { referrer: string; 'user-agent': string; }; - url: string; + /** absent when an #EXTINF entry has no stream URL (e.g. truncated file) */ + url?: string; raw: string; catchup?: { type?: string; diff --git a/package.json b/package.json index 76f64db8a..3b726e78b 100644 --- a/package.json +++ b/package.json @@ -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#v0.15.2-iptvnator.1", "marked": "18.0.5", "mpegts.js": "1.8.0", "ms": "2.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b151b699a..f01426f3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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#v0.15.2-iptvnator.1 + version: https://codeload.github.com/4gray/iptv-playlist-parser/tar.gz/33f5e9c09539524d758901c02a6f29302833c0a4 marked: specifier: 18.0.5 version: 18.0.5 @@ -7594,9 +7594,10 @@ 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/33f5e9c09539524d758901c02a6f29302833c0a4: + resolution: {tarball: https://codeload.github.com/4gray/iptv-playlist-parser/tar.gz/33f5e9c09539524d758901c02a6f29302833c0a4} + version: 0.15.2-iptvnator.1 + engines: {node: '>=18'} iron-webcrypto@1.2.1: resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} @@ -7663,10 +7664,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 +7691,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 +7711,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 +7808,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 +11529,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 +20591,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/33f5e9c09539524d758901c02a6f29302833c0a4: {} iron-webcrypto@1.2.1: {} @@ -20680,8 +20658,6 @@ snapshots: is-docker@3.0.0: {} - is-extglob@1.0.0: {} - is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: @@ -20706,10 +20682,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 +20696,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 +20767,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 +25342,6 @@ snapshots: validate-npm-package-name@7.0.2: {} - validator@13.15.26: {} - varint@6.0.0: {} vary@1.1.2: {}