mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
* feat(m3u): support #KODIPROP lines placed before #EXTINF Bumps the iptv-playlist-parser fork pin to v0.15.2-iptvnator.2: Kodi property lines apply to the next list entry, so #KODIPROP lines placed above the #EXTINF are now preserved in item.raw (previously the parser dropped them and ClearKey config in that layout was lost). The DASH + ClearKey feature (#1225) extracts license config from item.raw, so both KODIPROP layouts now work on every import path. Covered by a parser contract case and an extended web-backend /parse regression (before-EXTINF + between-EXTINF-and-URL + plain channel). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: reflect before-#EXTINF KODIPROP support in the M3U architecture doc Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: list the KODIPROP delta in the parser-fork inventory Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
136 lines
5.0 KiB
TypeScript
136 lines
5.0 KiB
TypeScript
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'
|
||
);
|
||
});
|
||
|
||
it('preserves #KODIPROP lines placed before #EXTINF in item raw', () => {
|
||
// Kodi property lines apply to the NEXT entry; the DASH/ClearKey
|
||
// support extracts license config from item.raw (#1225).
|
||
const result = parse(
|
||
[
|
||
'#EXTM3U',
|
||
'#KODIPROP:inputstream.adaptive.license_type=clearkey',
|
||
'#KODIPROP:inputstream.adaptive.license_key=kid123:key456',
|
||
'#EXTINF:-1,Encrypted',
|
||
'http://example.com/enc.mpd',
|
||
'#EXTINF:-1,Plain',
|
||
'http://example.com/plain.m3u8',
|
||
'',
|
||
].join('\n')
|
||
);
|
||
|
||
expect(result.items.length).toBe(2);
|
||
expect(result.items[0].raw).toContain(
|
||
'#KODIPROP:inputstream.adaptive.license_type=clearkey'
|
||
);
|
||
expect(result.items[0].raw).toContain(
|
||
'#KODIPROP:inputstream.adaptive.license_key=kid123:key456'
|
||
);
|
||
expect(result.items[1].raw).not.toContain('#KODIPROP');
|
||
});
|
||
});
|