Files
iptvnator/libs/shared/m3u-utils/src/lib/playlist.utils.spec.ts
T
d438f5c655 feat(epg): accept local XMLTV files as EPG sources (#1600)
* feat(epg): accept local XMLTV files as EPG sources

Settings → EPG and the playlist dialog accepted `file://` in their form
pattern, but the main process rejected everything except http(s), so a local
XMLTV entry saved fine and then failed on import. Both surfaces now take a
remote link, a `file:` URL, an absolute POSIX path or a Windows drive/UNC
path (`classifyEpgSourceReference` in shared/interfaces), and the settings
section spells out the accepted formats with examples.

The EPG worker opens every source through `openEpgSourceStream`: remote
links keep the validated-redirect client and trust policy, local files are
read from disk behind the signature-sniffing optional gunzip stage, so
.xml, .xml.gz and extension-less gzip all parse. Only hand-typed sources
may be local: `extractM3uEpgUrls` harvests http(s) links only from M3U
headers, since the local branch bypasses `validateRemoteUrl`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): pick local XMLTV files with a native file dialog

A folder button beside each EPG source row (Settings → EPG and the playlist
dialog) opens the native open-file dialog and writes the chosen absolute
path into the row. New `EPG_OPEN_FILE_DIALOG` IPC behind
`ElectronBridgeApi.openEpgFileDialog`, gated in the renderer by
`RuntimeCapabilitiesService.supportsEpgFilePicker`.

The row's refresh/remove buttons carry `data-test-id`s now, and the EPG
e2e suites address them by id instead of index, since the folder button
became the first button in a row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): authorize local XMLTV files in the main process

Review follow-up (Greptile P1, Codex P1). The renderer hands source strings
to FETCH_EPG/EPG_FORCE_FETCH unchanged, so the form validator alone could
not enforce the provenance rule: a compromised renderer, or a legacy
`file://` entry an older version stored from an M3U header, could name any
file on disk.

`EpgWorkerService.startFetch` now asks a main-process
`EpgLocalSourceAuthorizer` before a local path reaches the worker: a path
the native picker returned is trusted at once, a hand-typed path is
confirmed once in a native message box the renderer cannot fake, and a
refusal is reported in the progress panel. Allowed paths persist under
TRUSTED_LOCAL_EPG_SOURCES in the main-process config. The worker opens its
local branch only when main set `allowLocalFile`; the service defaults to
deny-all until epg.events installs the persisted authorizer.
`resolvePlaylistEpgSourceState` and `filterPlaylistEpgUrlsForFetch` drop a
stored non-remote entry unless it is also in `manualEpgUrls`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): fail a refused local EPG fetch instead of resolving it

Review follow-up (Codex P2). A denied native confirmation now rejects the
fetch after reporting the error row, so handleFetchEpg and the renderer's
fetch result cannot claim the file was read. Also restores the unrelated
CLAUDE.md paragraph an earlier formatter pass had reflowed into a list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): cancel a local source retired during its authorization prompt

Review follow-up (Codex P2). startFetch keeps the request generation
captured before awaiting the native confirmation and rechecks it
afterwards: a source retired meanwhile ends as cancelled instead of
starting an import that a pending clear would then have to await.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(epg): leave the local XMLTV e2e with a pristine settings form

The local-file test ended with the EPG source field still dirty, which
arms the main-process close guard: the app then waited for the unsaved
changes dialog instead of closing, the close timeout killed it, and on
Windows the killed process kept iptvnator.db busy (EBUSY on the data-dir
cleanup) and hung the Playwright worker teardown. Discarding the form
before the app closes takes the test from 15 s to 4 s locally.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 21:04:41 +02:00

397 lines
14 KiB
TypeScript

import { Channel, Playlist } from '@iptvnator/shared/interfaces';
import {
aggregateFavoriteChannels,
createPlaylistObject,
extractM3uEpgUrls,
filterPlaylistEpgUrlsForFetch,
getExtensionFromUrl,
getStreamExtensionFromUrl,
resolvePlaylistEpgSourceState,
} from './playlist.utils';
function createChannel(id: string, url: string, name = id): Channel {
return {
group: { title: 'Group' },
http: {
origin: '',
referrer: '',
'user-agent': '',
},
id,
name,
radio: 'false',
tvg: {
id,
logo: '',
name,
rec: '',
url: '',
},
url,
};
}
function createPlaylist(
id: string,
channels: Channel[],
favorites: Playlist['favorites']
): Playlist {
return {
_id: id,
autoRefresh: false,
count: channels.length,
favorites,
importDate: '2026-01-01T00:00:00.000Z',
lastUsage: '2026-01-01T00:00:00.000Z',
playlist: {
items: channels,
},
title: id,
};
}
describe('playlist utils', () => {
it('aggregates M3U favorite channels with constant-time id and URL lookups', () => {
const first = createChannel(
'channel-1',
'https://example.com/stream-1.m3u8',
'Channel One'
);
const second = createChannel(
'channel-2',
'https://example.com/stream-2.m3u8',
'Channel Two'
);
const third = createChannel(
'channel-3',
'https://example.com/stream-3.m3u8',
'Channel Three'
);
const result = aggregateFavoriteChannels([
createPlaylist(
'playlist-1',
[first, second],
['channel-1', 'missing-channel']
),
createPlaylist(
'playlist-2',
[third],
['https://example.com/stream-3.m3u8']
),
]);
expect(result).toEqual([first, third]);
});
describe('getExtensionFromUrl', () => {
it.each([
['https://host/path/file.ts?token=x', 'ts'],
['https://host/ace/getstream?infohash=x', undefined],
['https://host/path.with.dots/stream?x=y', undefined],
['https://host/path/file.m3u8', 'm3u8'],
['https://host/path/.ts', undefined],
])('extracts the path extension from %s', (url, expected) => {
expect(getExtensionFromUrl(url)).toBe(expected);
});
});
describe('getStreamExtensionFromUrl', () => {
it.each([
['https://host/play?extension=m3u8&token=x', 'm3u8'],
['https://host/live.php?stream=123&extension=ts', 'ts'],
['https://host/path/file.ts?token=x', 'ts'],
['https://host/ace/getstream?infohash=x', undefined],
])(
'prefers declared stream extension metadata from %s',
(url, expected) => {
expect(getStreamExtensionFromUrl(url)).toBe(expected);
}
);
});
describe('extractM3uEpgUrls', () => {
it('extracts and deduplicates playlist-scoped EPG URLs from supported M3U header attributes', () => {
expect(
extractM3uEpgUrls({
header: {
attrs: {
'x-tvg-url':
' https://example.com/guide.xml, https://example.com/guide.xml https://example.com/extra.xml.gz ',
'url-tvg': 'https://example.com/url-tvg.xml',
},
raw: '#EXTM3U x-tvg-url="https://example.com/guide.xml" url-tvg="https://example.com/url-tvg.xml"',
},
})
).toEqual([
'https://example.com/guide.xml',
'https://example.com/extra.xml.gz',
'https://example.com/url-tvg.xml',
]);
});
it('ignores local file references declared by the playlist header', () => {
expect(
extractM3uEpgUrls({
header: {
attrs: {
'x-tvg-url':
'file:///etc/passwd, https://example.com/guide.xml',
'url-tvg': '/home/user/epg/guide.xml.gz',
},
raw: '#EXTM3U x-tvg-url="file:///etc/passwd, https://example.com/guide.xml" url-tvg="/home/user/epg/guide.xml.gz"',
},
})
).toEqual(['https://example.com/guide.xml']);
});
it('falls back to the raw header for tvg-url variants the parser does not expose as attrs', () => {
expect(
extractM3uEpgUrls({
header: {
attrs: {},
raw: '#EXTM3U tvg-url="https://example.com/raw-guide.xml"',
},
})
).toEqual(['https://example.com/raw-guide.xml']);
});
});
it('stores detected M3U EPG URLs on the created playlist', () => {
const playlist = createPlaylistObject('Playlist with EPG', {
header: {
attrs: {
'x-tvg-url': 'https://example.com/guide.xml',
},
raw: '#EXTM3U x-tvg-url="https://example.com/guide.xml"',
},
items: [],
});
expect(playlist.epgUrls).toEqual(['https://example.com/guide.xml']);
expect(playlist.detectedEpgUrls).toEqual([
'https://example.com/guide.xml',
]);
});
it('keeps large detected EPG lists separate from the auto-imported playlist EPG subset', () => {
const playlist = createPlaylistObject('Playlist with global EPG list', {
header: {
attrs: {
'x-tvg-url': [
'https://iptv-org.github.io/epg/guides/us/tvguide.com.epg.xml',
'https://iptv-org.github.io/epg/guides/de/hd-plus.de.epg.xml',
'https://iptv-org.github.io/epg/guides/ua/example.ua.epg.xml',
'https://iptv-org.github.io/epg/guides/ru/tv.yandex.ru.epg.xml',
'https://iptv-org.github.io/epg/guides/fr/programme-tv.net.epg.xml',
'https://iptv-org.github.io/epg/guides/uk/sky.com.epg.xml',
].join(','),
},
raw: '#EXTM3U',
},
items: [
{
name: '1+1',
tvg: {
id: '1Plus1.ua',
name: '1+1',
url: '',
logo: '',
rec: '',
},
group: { title: 'Undefined' },
http: { referrer: '', 'user-agent': '' },
raw: '#EXTINF:-1 tvg-id="1Plus1.ua" tvg-country="RU;UA" tvg-language="Ukrainian",1+1',
url: 'https://example.com/stream.m3u8',
},
],
});
expect(playlist.detectedEpgUrls).toHaveLength(6);
expect(playlist.epgUrls).toEqual([
'https://iptv-org.github.io/epg/guides/ua/example.ua.epg.xml',
'https://iptv-org.github.io/epg/guides/ru/tv.yandex.ru.epg.xml',
]);
});
it('falls back to the first detected EPG URLs when a large list has no region recommendation match', () => {
const epgUrls = [
'https://provider.example.com/epg/source-1.xml',
'https://provider.example.com/epg/source-2.xml',
'https://provider.example.com/epg/source-3.xml',
'https://provider.example.com/epg/source-4.xml',
'https://provider.example.com/epg/source-5.xml',
'https://provider.example.com/epg/source-6.xml',
];
const playlist = createPlaylistObject(
'Playlist with generic EPG list',
{
header: {
attrs: {
'x-tvg-url': epgUrls.join(','),
},
raw: '#EXTM3U',
},
items: [
{
name: 'Generic Channel',
tvg: {
id: 'generic-channel',
name: 'Generic Channel',
url: '',
logo: '',
rec: '',
},
group: { title: 'Undefined' },
http: { referrer: '', 'user-agent': '' },
raw: '#EXTINF:-1 tvg-id="generic-channel",Generic Channel',
url: 'https://example.com/stream.m3u8',
},
],
}
);
expect(playlist.detectedEpgUrls).toEqual(epgUrls);
expect(playlist.epgUrls).toEqual(epgUrls.slice(0, 5));
});
it('attaches ClearKey DRM extracted from raw KODIPROP lines and leaves other channels untouched', () => {
const kid = '9eb4050de44b4802932e27d75083e266';
const key = '166634c675823c235a4a9446fad52e4d';
const baseItem = {
tvg: { id: '', name: '', url: '', logo: '', rec: '' },
group: { title: 'DASH' },
http: { referrer: '', 'user-agent': '' },
};
const playlist = createPlaylistObject('DRM playlist', {
header: { attrs: {}, raw: '#EXTM3U' },
items: [
{
...baseItem,
name: 'Encrypted DASH',
url: 'https://example.com/enc.mpd',
raw: [
'#EXTINF:-1,Encrypted DASH',
'#KODIPROP:inputstream.adaptive.license_type=clearkey',
`#KODIPROP:inputstream.adaptive.license_key=${kid}:${key}`,
'https://example.com/enc.mpd',
].join('\r\n'),
},
{
...baseItem,
name: 'Plain HLS',
url: 'https://example.com/live.m3u8',
raw: '#EXTINF:-1,Plain HLS\r\nhttps://example.com/live.m3u8',
},
],
});
const [encrypted, plain] = playlist.playlist.items;
expect(encrypted.drm).toEqual({
licenseType: 'clearkey',
supported: true,
clearKeys: { [kid]: key },
});
expect(plain.drm).toBeUndefined();
expect('drm' in plain).toBe(false);
});
it('keeps disabled playlist EPG sources out while preserving manually enabled URLs', () => {
expect(
resolvePlaylistEpgSourceState({
detectedEpgUrls: [
'https://playlist.example.com/auto.xml',
'https://playlist.example.com/disabled.xml',
],
enabledEpgUrls: [
'https://playlist.example.com/auto.xml',
'https://playlist.example.com/disabled.xml',
],
manualEpgUrls: [
' https://playlist.example.com/manual.xml ',
'https://playlist.example.com/auto.xml',
],
disabledEpgUrls: [
'https://playlist.example.com/disabled.xml',
'',
'https://playlist.example.com/disabled.xml',
],
})
).toEqual({
detectedEpgUrls: [
'https://playlist.example.com/auto.xml',
'https://playlist.example.com/disabled.xml',
],
epgUrls: [
'https://playlist.example.com/auto.xml',
'https://playlist.example.com/manual.xml',
],
manualEpgUrls: [
'https://playlist.example.com/manual.xml',
'https://playlist.example.com/auto.xml',
],
disabledEpgUrls: ['https://playlist.example.com/disabled.xml'],
});
});
it('drops legacy header-declared local references but keeps manual local files', () => {
expect(
resolvePlaylistEpgSourceState({
detectedEpgUrls: [
'file:///etc/passwd',
'https://playlist.example.com/auto.xml',
],
enabledEpgUrls: [
'file:///etc/passwd',
'https://playlist.example.com/auto.xml',
],
manualEpgUrls: ['/home/user/epg/guide.xml.gz'],
})
).toEqual({
detectedEpgUrls: ['https://playlist.example.com/auto.xml'],
epgUrls: [
'https://playlist.example.com/auto.xml',
'/home/user/epg/guide.xml.gz',
],
manualEpgUrls: ['/home/user/epg/guide.xml.gz'],
disabledEpgUrls: [],
});
});
it('never fetches a stored local reference the user did not add by hand', () => {
expect(
filterPlaylistEpgUrlsForFetch(
[
'file:///etc/passwd',
'/home/user/epg/guide.xml.gz',
'https://playlist.example.com/auto.xml',
],
[],
['/home/user/epg/guide.xml.gz']
)
).toEqual([
'/home/user/epg/guide.xml.gz',
'https://playlist.example.com/auto.xml',
]);
expect(
filterPlaylistEpgUrlsForFetch(['/home/user/epg/guide.xml.gz'], [])
).toEqual([]);
});
it('filters playlist EPG fetch URLs that are already configured globally', () => {
expect(
filterPlaylistEpgUrlsForFetch(
[
'https://global.example.com/guide.xml',
'https://playlist.example.com/local.xml',
' https://playlist.example.com/local.xml ',
],
[
' https://global.example.com/guide.xml ',
'https://global.example.com/other.xml',
]
)
).toEqual(['https://playlist.example.com/local.xml']);
});
});