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: {}