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>
This commit is contained in:
authored and GitHub committed 2026-09-13 21:04:41 +02:00
1 parent d9c8ba4d0b
commit d438f5c655
65 files changed
+1621 -149

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
type: feature
area: epg
highlight: Local XMLTV files as EPG sources
---
EPG sources no longer have to be URLs: point Settings → EPG or a playlist's EPG list at an XMLTV file on your computer, by absolute path, `file://` link, or the new folder button. Plain `.xml` and gzip `.xml.gz` both work. A typed path is confirmed once in a native dialog before it is read; the settings page spells out the accepted formats with examples.
+1 -1
View File
@@ -1583,7 +1583,7 @@ stream_id`); it drops `series_id`/`movie_id`, so the builder pins the
**EPG (Electronic Program Guide)**:
- XMLTV format support
- XMLTV format support, from `http(s)` links or local files (Electron only): a `file:` URL, an absolute POSIX path, or a Windows drive/UNC path, plain `.xml` or gzip (detected by signature). A folder button beside each row opens the native picker (`EPG_OPEN_FILE_DIALOG`, `RuntimeCapabilitiesService.supportsEpgFilePicker`). Shape rules: `classifyEpgSourceReference` in `libs/shared/interfaces`; the worker opens both kinds through `openEpgSourceStream` (`workers/epg-source-stream.ts`). Only hand-chosen sources may be local: `extractM3uEpgUrls` harvests only remote links from M3U headers (legacy stored non-remote entries are dropped unless manual), and `EpgWorkerService.startFetch` asks the main-process `EpgLocalSourceAuthorizer` before a local path reaches the worker — picker results are trusted, a typed path is confirmed once in a native message box, allowed paths persist under `TRUSTED_LOCAL_EPG_SOURCES`, and the worker's local branch requires the main-set `allowLocalFile` flag (deny-all until wired). Contract: `docs/architecture/m3u-playlist-module.md` ("Local XMLTV files")
- Background parsing in worker thread; HTTP/file gzip compatibility follows `docs/architecture/m3u-playlist-module.md` ("XMLTV response compression").
- Stored in database for quick lookup
- Global display-time offset (`Settings.epgOffsetMinutes`, Settings → EPG, ±720 min, Electron only): display-only, provider data is never rewritten. Two equivalent forms in `libs/shared/interfaces/src/lib/epg-display-offset.util.ts` — `epgDisplayTimeMs` (shift the programme; `ui/epg` rendering via the `offsetMinutes` input, channel rows, dashboard/recording labels; the programme dialog and the programme guide read the store themselves) and `epgProviderClockMs` (shift "now"; every "currently airing" decision: the `GET_CURRENT_PROGRAMS_BATCH` lookup takes an explicit `nowMs` and `EpgService` tags its cache with the offset, Xtream/Stalker/M3U current-programme selection and previews, the unified collection resolver, dashboard progress, recording overlap). A consumer applies exactly one form per comparison. Contract: `docs/architecture/m3u-playlist-module.md` ("EPG display offset")
+110 -32
View File
@@ -1,3 +1,4 @@
import { writeFileSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import { join } from 'node:path';
@@ -233,10 +234,7 @@ test.describe('Electron EPG', () => {
removedUrls: [],
}));
});
await app.mainWindow
.locator('.epg-source-row button')
.nth(1)
.click();
await app.mainWindow.getByTestId('epg-source-remove').click();
await saveSettings(app.mainWindow);
await expect(
app.mainWindow.getByTestId('settings-unsaved-bar')
@@ -296,6 +294,106 @@ test.describe('Electron EPG', () => {
}
});
test('@epg @electron imports a local gzipped XMLTV file given as an absolute path', async ({
dataDir,
}) => {
const localGuide = join(dataDir, 'local guide.xml.gz');
writeFileSync(localGuide, gzipSync(epgFixtureXml));
const app = await launchElectronApp(dataDir);
try {
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await expect(
app.mainWindow.getByTestId('epg-source-formats')
).toContainText('file://');
await app.mainWindow
.getByRole('button', { name: 'Add EPG source' })
.click();
const field = app.mainWindow
.locator('.epg-source-row input')
.first();
await field.fill('guide.xml');
// Material reveals errors once the control is touched.
await field.blur();
await expect(
app.mainWindow.locator('.epg-source-row mat-error')
).toBeVisible();
await field.fill(localGuide);
await expect(
app.mainWindow.locator('.epg-source-row mat-error')
).toHaveCount(0);
// Native dialogs cannot be driven from Playwright: stub them in
// the main process. A hand-typed path is confirmed once through
// the message box; a refused path is reported, never read.
await app.electronApp.evaluate(({ dialog }) => {
dialog.showMessageBox = (async () => ({
response: 1,
checkboxChecked: false,
})) as typeof dialog.showMessageBox;
});
await app.mainWindow.getByTestId('epg-source-refresh').click();
await expect(
app.mainWindow.locator(
'.epg-progress-panel .import-item.status-error'
)
).toContainText('was not allowed');
expect(await getEpgChannelCount(app.mainWindow)).toBe(0);
await app.electronApp.evaluate(({ dialog }) => {
dialog.showMessageBox = (async () => ({
response: 0,
checkboxChecked: false,
})) as typeof dialog.showMessageBox;
});
await app.mainWindow
.locator('.epg-progress-panel .retry-btn')
.click();
await expect
.poll(() => getEpgChannelCount(app.mainWindow), {
timeout: 30000,
})
.toBeGreaterThan(0);
await expect(
app.mainWindow.locator(
'.epg-progress-panel .import-item.status-complete'
)
).toHaveCount(1);
// A file chosen in the native picker is trusted without a prompt.
const browsedGuide = join(dataDir, 'browsed guide.xml.gz');
writeFileSync(browsedGuide, gzipSync(epgFixtureXml));
await app.electronApp.evaluate(({ dialog }, filePath) => {
dialog.showOpenDialog = (async () => ({
canceled: false,
filePaths: [filePath],
})) as typeof dialog.showOpenDialog;
dialog.showMessageBox = (async () => {
throw new Error('picked files must not prompt');
}) as typeof dialog.showMessageBox;
}, browsedGuide);
await app.mainWindow.getByTestId('epg-source-browse').click();
await expect(field).toHaveValue(browsedGuide);
await app.mainWindow.getByTestId('epg-source-refresh').click();
await expect(
app.mainWindow.locator(
'.epg-progress-panel .import-item.status-complete'
)
).toHaveCount(2);
// Leave with a pristine form: a dirty settings form arms the
// main-process close guard, and the app would then wait for a
// confirmation dialog instead of closing.
await app.mainWindow.getByTestId('discard-settings').click();
await expect(
app.mainWindow.getByTestId('settings-unsaved-bar')
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('@epg @electron adds an EPG source, fetches guide data, removes its stored EPG data on save', async ({
dataDir,
}) => {
@@ -316,10 +414,7 @@ test.describe('Electron EPG', () => {
.first()
.fill(epgServer.resourceUrl);
await app.mainWindow
.locator('.epg-source-row button')
.first()
.click();
await app.mainWindow.getByTestId('epg-source-refresh').click();
await expect(
app.mainWindow.locator('.epg-progress-panel')
).toBeVisible();
@@ -355,10 +450,7 @@ test.describe('Electron EPG', () => {
await saveSettings(app.mainWindow);
await app.mainWindow
.locator('.epg-source-row button')
.nth(1)
.click();
await app.mainWindow.getByTestId('epg-source-remove').click();
await expect(app.mainWindow.locator('.epg-source-row')).toHaveCount(
0
);
@@ -436,10 +528,7 @@ test.describe('Electron EPG', () => {
'.epg-progress-panel .import-item.status-error'
)
).toHaveCount(1);
await app.mainWindow
.locator('.epg-source-row button')
.nth(1)
.click();
await app.mainWindow.getByTestId('epg-source-remove').click();
await saveSettings(app.mainWindow);
await expect(
app.mainWindow.locator('.epg-progress-panel .import-item')
@@ -502,8 +591,7 @@ test.describe('Electron EPG', () => {
await app.mainWindow
.locator('.epg-source-row')
.first()
.locator('button')
.nth(1)
.getByTestId('epg-source-remove')
.click();
// A staged removal must not delete data before Save.
expect(await programs()).toContain('Removed Bulletin');
@@ -523,10 +611,7 @@ test.describe('Electron EPG', () => {
await expect.poll(programs).toEqual(['Retained Bulletin']);
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow
.locator('.epg-source-row button')
.nth(1)
.click();
await app.mainWindow.getByTestId('epg-source-remove').click();
await saveSettings(app.mainWindow);
await expect.poll(programs).toEqual([]);
await expect.poll(() => getEpgChannelCount(app.mainWindow)).toBe(0);
@@ -592,8 +677,7 @@ test.describe('Electron EPG', () => {
await app.mainWindow
.locator('.epg-source-row')
.nth(1)
.locator('button')
.nth(1)
.getByTestId('epg-source-remove')
.click();
await saveSettings(app.mainWindow);
const firstMetadata = {
@@ -701,10 +785,7 @@ test.describe('Electron EPG', () => {
.locator('.epg-source-row input')
.fill(epgServer.resourceUrl);
await saveSettings(app.mainWindow);
await app.mainWindow
.locator('.epg-source-row button')
.nth(1)
.click();
await app.mainWindow.getByTestId('epg-source-remove').click();
await saveSettings(app.mainWindow);
// The same URL still belongs to the saved M3U playlist.
const retained = await app.mainWindow.evaluate(async () =>
@@ -771,10 +852,7 @@ test.describe('Electron EPG', () => {
.locator('.epg-source-row input')
.first()
.fill(epgServer.resourceUrl);
await app.mainWindow
.locator('.epg-source-row button')
.first()
.click();
await app.mainWindow.getByTestId('epg-source-refresh').click();
await expect
.poll(() => getEpgChannelCount(app.mainWindow), {
@@ -96,7 +96,7 @@ test('@epg @xtream @electron removes uploaded guide data and restores provider E
.toContain('Temporary XMLTV Bulletin');
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow.locator('.epg-source-row button').nth(1).click();
await app.mainWindow.getByTestId('epg-source-remove').click();
await saveSettings(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
@@ -190,7 +190,7 @@ test('@epg @stalker @electron invalidates a loaded manual XMLTV mapping after so
.toContain('Retired Stalker Bulletin');
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow.locator('.epg-source-row button').nth(1).click();
await app.mainWindow.getByTestId('epg-source-remove').click();
await saveSettings(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
@@ -550,6 +550,12 @@ export const epgPreloadCases: PreloadInvokeCase[] = [
},
],
},
{
method: 'openEpgFileDialog',
args: [],
channel: 'EPG_OPEN_FILE_DIALOG',
forwardedArgs: [],
},
{
method: 'clearEpgData',
args: [],
@@ -680,6 +680,7 @@ const electronApi: ElectronBridgeApi = {
ipcRenderer.invoke('EPG_GET_PROGRAM_COVERAGE', window),
forceFetchEpg: (url: string, options?: ElectronBridgeTrustOptions) =>
ipcRenderer.invoke('EPG_FORCE_FETCH', { url, options }),
openEpgFileDialog: () => ipcRenderer.invoke('EPG_OPEN_FILE_DIALOG'),
clearEpgData: () => ipcRenderer.invoke('EPG_CLEAR_ALL'),
reconcileEpgSources: (urls: string[]) =>
ipcRenderer.invoke('EPG_RECONCILE_SOURCES', { urls }),
@@ -9,6 +9,11 @@ const ipcHandlers = new Map<
(event: unknown, args: unknown) => Promise<unknown>
>();
jest.mock('../services/store.service', () => ({
TRUSTED_LOCAL_EPG_SOURCES: 'TRUSTED_LOCAL_EPG_SOURCES',
store: { get: jest.fn(), set: jest.fn() },
}));
jest.mock('electron', () => ({
app: {
isPackaged: false,
@@ -0,0 +1,106 @@
jest.mock('electron', () => ({
BrowserWindow: { getFocusedWindow: () => null, getAllWindows: () => [] },
dialog: { showMessageBox: jest.fn() },
}));
import { dialog } from 'electron';
import {
PersistedEpgLocalSourceAuthorizer,
promptForLocalEpgSource,
} from './epg-local-source-authorizer';
describe('PersistedEpgLocalSourceAuthorizer', () => {
let stored: string[];
let prompt: jest.Mock;
let authorizer: PersistedEpgLocalSourceAuthorizer;
beforeEach(() => {
stored = [];
prompt = jest.fn();
authorizer = new PersistedEpgLocalSourceAuthorizer(
{
load: () => [...stored],
save: (paths) => {
stored = [...paths];
},
},
prompt
);
});
it('trusts a picked file without prompting and persists it once', async () => {
authorizer.authorize('/epg/guide.xml');
authorizer.authorize('/epg/guide.xml');
await expect(authorizer.ensureAllowed('/epg/guide.xml')).resolves.toBe(
true
);
expect(prompt).not.toHaveBeenCalled();
expect(stored).toEqual(['/epg/guide.xml']);
});
it('persists a hand-typed path only after the user allowed it', async () => {
prompt.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
await expect(authorizer.ensureAllowed('/epg/typed.xml')).resolves.toBe(
false
);
expect(stored).toEqual([]);
await expect(authorizer.ensureAllowed('/epg/typed.xml')).resolves.toBe(
true
);
expect(stored).toEqual(['/epg/typed.xml']);
expect(prompt).toHaveBeenCalledTimes(2);
await expect(authorizer.ensureAllowed('/epg/typed.xml')).resolves.toBe(
true
);
expect(prompt).toHaveBeenCalledTimes(2);
});
it('shares one prompt between concurrent requests for the same file', async () => {
let resolvePrompt: (allowed: boolean) => void = () => undefined;
prompt.mockReturnValue(
new Promise<boolean>((resolve) => {
resolvePrompt = resolve;
})
);
const first = authorizer.ensureAllowed('/epg/shared.xml');
const second = authorizer.ensureAllowed('/epg/shared.xml');
expect(prompt).toHaveBeenCalledTimes(1);
resolvePrompt(true);
await expect(Promise.all([first, second])).resolves.toEqual([
true,
true,
]);
expect(stored).toEqual(['/epg/shared.xml']);
});
});
describe('promptForLocalEpgSource', () => {
it('maps the Allow button to true and anything else to false', async () => {
(dialog.showMessageBox as jest.Mock).mockResolvedValueOnce({
response: 0,
});
await expect(promptForLocalEpgSource('/epg/guide.xml')).resolves.toBe(
true
);
expect(dialog.showMessageBox).toHaveBeenCalledWith(
expect.objectContaining({
buttons: ['Allow', 'Cancel'],
cancelId: 1,
detail: expect.stringContaining('/epg/guide.xml'),
})
);
(dialog.showMessageBox as jest.Mock).mockResolvedValueOnce({
response: 1,
});
await expect(promptForLocalEpgSource('/epg/guide.xml')).resolves.toBe(
false
);
});
});
@@ -0,0 +1,96 @@
import { BrowserWindow, dialog } from 'electron';
/**
* Main-process gate for local XMLTV files.
*
* The renderer hands EPG source strings to `FETCH_EPG`/`EPG_FORCE_FETCH`
* unchanged, so a compromised renderer could name any file on disk. Like
* `save-file-dialog` → `write-file` for playlist exports, the decision
* therefore lives here: a path is readable only after the native picker
* returned it or the user allowed it in a native confirmation the renderer
* cannot fake. Allowed paths persist in the main-process config so startup
* refreshes need no prompt.
*/
export interface EpgLocalSourceAuthorizer {
/** A file the user chose in the native picker is trusted right away. */
authorize(filePath: string): void;
/** Whether `filePath` may be read; prompts once for a hand-typed path. */
ensureAllowed(filePath: string): Promise<boolean>;
}
export interface EpgLocalSourcePersistence {
load(): string[];
save(filePaths: string[]): void;
}
export const EPG_LOCAL_SOURCE_REFUSED_MESSAGE =
'Reading this local file was not allowed. Retry to be asked again.';
/** Refuses every local file; the real authorizer is wired at registration. */
export const DENY_ALL_EPG_LOCAL_SOURCES: EpgLocalSourceAuthorizer = {
authorize: () => undefined,
ensureAllowed: () => Promise.resolve(false),
};
export class PersistedEpgLocalSourceAuthorizer implements EpgLocalSourceAuthorizer {
private readonly pendingPrompts = new Map<string, Promise<boolean>>();
constructor(
private readonly persistence: EpgLocalSourcePersistence,
private readonly prompt: (filePath: string) => Promise<boolean>
) {}
authorize(filePath: string): void {
const allowed = this.persistence.load();
if (allowed.includes(filePath)) {
return;
}
this.persistence.save([...allowed, filePath]);
}
ensureAllowed(filePath: string): Promise<boolean> {
if (this.persistence.load().includes(filePath)) {
return Promise.resolve(true);
}
// Concurrent fetches of the same file (settings save plus a startup
// refresh) share one prompt instead of stacking dialogs.
const pending = this.pendingPrompts.get(filePath);
if (pending) {
return pending;
}
const decision = this.prompt(filePath)
.then((allowed) => {
if (allowed) {
this.authorize(filePath);
}
return allowed;
})
.finally(() => {
this.pendingPrompts.delete(filePath);
});
this.pendingPrompts.set(filePath, decision);
return decision;
}
}
/** Native confirmation for a hand-typed local EPG path. */
export async function promptForLocalEpgSource(
filePath: string
): Promise<boolean> {
const options: Electron.MessageBoxOptions = {
type: 'question',
title: 'Allow local EPG file?',
message: 'Read this file as an EPG source?',
detail: `${filePath}\n\nIPTVnator will read the XMLTV guide data in this file. Only allow files you added yourself.`,
buttons: ['Allow', 'Cancel'],
defaultId: 0,
cancelId: 1,
noLink: true,
};
const window =
BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0];
const { response } = window
? await dialog.showMessageBox(window, options)
: await dialog.showMessageBox(options);
return response === 0;
}
@@ -11,6 +11,11 @@ const ipcHandlers = new Map<
(event: unknown, args: unknown) => Promise<unknown>
>();
jest.mock('../services/store.service', () => ({
TRUSTED_LOCAL_EPG_SOURCES: 'TRUSTED_LOCAL_EPG_SOURCES',
store: { get: jest.fn(), set: jest.fn() },
}));
jest.mock('electron', () => ({
app: {
isPackaged: false,
@@ -38,6 +38,7 @@ jest.mock('../workers/worker-runtime-paths', () => ({
mockResolveWorkerRuntimeBootstrap(...args),
}));
import { retireEpgSource } from './epg-source-generation';
import { EpgWorkerService } from './epg-worker.service';
describe('EpgWorkerService worker lifecycle', () => {
@@ -166,6 +167,116 @@ describe('EpgWorkerService worker lifecycle', () => {
expect(service.hasFetchedUrl(url)).toBe(true);
});
it('refuses a local file the authorizer denies without spawning a worker', async () => {
const localPath = '/home/user/epg/guide.xml.gz';
const progressSpy = jest.spyOn(service, 'sendProgressToRenderer');
service.localSourceAuthorizer = {
authorize: jest.fn(),
ensureAllowed: jest.fn().mockResolvedValue(false),
};
await expect(
service.fetchEpgFromUrl(localPath, {
allowLocalFile: true,
} as any)
).rejects.toThrow('was not allowed');
expect(
service.localSourceAuthorizer.ensureAllowed
).toHaveBeenCalledWith(localPath);
expect(mockWorkerInstances).toHaveLength(0);
expect(progressSpy).toHaveBeenCalledWith(
localPath,
'error',
undefined,
expect.stringContaining('was not allowed')
);
expect(service.hasFetchedUrl(localPath)).toBe(false);
});
it('cancels a local source retired while its authorization prompt is open', async () => {
const localPath = '/home/user/epg/retired.xml.gz';
const progressSpy = jest.spyOn(service, 'sendProgressToRenderer');
let answer: (allowed: boolean) => void = () => undefined;
service.localSourceAuthorizer = {
authorize: jest.fn(),
ensureAllowed: jest.fn(
() =>
new Promise<boolean>((resolve) => {
answer = resolve;
})
),
};
const fetchPromise = service.fetchEpgFromUrl(localPath);
await Promise.resolve();
retireEpgSource(localPath);
answer(true);
await expect(fetchPromise).resolves.toBeUndefined();
expect(mockWorkerInstances).toHaveLength(0);
expect(progressSpy).toHaveBeenCalledWith(
localPath,
'cancelled',
undefined,
undefined,
undefined,
undefined,
undefined,
0
);
});
it('unlocks the worker local branch only for an allowed file', async () => {
const localPath = '/home/user/epg/guide.xml.gz';
service.localSourceAuthorizer = {
authorize: jest.fn(),
ensureAllowed: jest.fn().mockResolvedValue(true),
};
const fetchPromise = service.fetchEpgFromUrl(localPath, {
trustedInsecureTlsHosts: ['example.com'],
});
await Promise.resolve();
const worker = mockWorkerInstances[0];
worker.emit('message', { type: 'READY' });
expect(worker.postMessage).toHaveBeenCalledWith({
type: 'FETCH_EPG',
url: localPath,
options: {
trustedInsecureTlsHosts: ['example.com'],
allowLocalFile: true,
},
});
worker.emit('message', {
type: 'EPG_COMPLETE',
stats: { totalChannels: 1, totalPrograms: 1 },
});
await expect(fetchPromise).resolves.toBeUndefined();
});
it('never forwards a renderer-supplied allowLocalFile flag for remote sources', async () => {
const fetchPromise = service.fetchEpgFromUrl(url, {
allowLocalFile: true,
} as any);
await Promise.resolve();
const worker = mockWorkerInstances[0];
worker.emit('message', { type: 'READY' });
expect(worker.postMessage).toHaveBeenCalledWith({
type: 'FETCH_EPG',
url,
options: {},
});
worker.emit('message', {
type: 'EPG_COMPLETE',
stats: { totalChannels: 1, totalPrograms: 1 },
});
await expect(fetchPromise).resolves.toBeUndefined();
});
it('rejects when the worker emits an error event', async () => {
const fetchPromise = service.fetchEpgFromUrl(url);
const worker = mockWorkerInstances[0];
@@ -7,6 +7,12 @@ import {
import { BrowserWindow } from 'electron';
import { EpgWorkerRuntime } from './epg-worker-runtime';
import { runEpgFetch } from './epg-fetch-operation';
import {
DENY_ALL_EPG_LOCAL_SOURCES,
EPG_LOCAL_SOURCE_REFUSED_MESSAGE,
EpgLocalSourceAuthorizer,
} from './epg-local-source-authorizer';
import { resolveLocalEpgSourcePath } from '../util/epg-local-source-path';
import { Worker } from 'worker_threads';
import {
ElectronBridgeSecurityErrorCode,
@@ -118,18 +124,76 @@ export class EpgWorkerService {
return;
}
const fetchPromise = this.startFetch(url, options).finally(() => {
this.inFlightFetches.delete(url);
});
const fetchPromise = this.startFetch(url, options, generation).finally(
() => {
this.inFlightFetches.delete(url);
}
);
this.inFlightFetches.set(url, fetchPromise);
return fetchPromise;
}
private startFetch(
/**
* Gate for local XMLTV files; `epg.events.ts` installs the persisted,
* prompting authorizer at registration. Until then every local path is
* refused, so a missing wiring fails closed rather than open.
*/
localSourceAuthorizer: EpgLocalSourceAuthorizer =
DENY_ALL_EPG_LOCAL_SOURCES;
private async startFetch(
url: string,
options: ElectronBridgeTrustOptions
options: ElectronBridgeTrustOptions,
generation: number
): Promise<void> {
return runEpgFetch(url, options, {
// The renderer's options must not be able to unlock the worker's
// local branch: the flag is set here, only for an allowed path.
const { allowLocalFile: _rendererFlag, ...trustOptions } =
options as ElectronBridgeTrustOptions & {
allowLocalFile?: unknown;
};
let workerOptions: ElectronBridgeTrustOptions & {
allowLocalFile?: boolean;
} = trustOptions;
const localPath = resolveLocalEpgSourcePath(url);
if (localPath) {
const allowed =
await this.localSourceAuthorizer.ensureAllowed(localPath);
// The prompt can stay open for a while; a source retired
// meanwhile (settings save, reconciliation) must end as
// cancelled rather than start an import the clear then awaits.
if (generation !== epgSourceGeneration(url)) {
this.sendProgressToRenderer(
url,
'cancelled',
undefined,
undefined,
undefined,
undefined,
undefined,
generation
);
return;
}
if (!allowed) {
epgLogger.log(
this.loggerLabel,
'Local EPG file was not allowed by the user'
);
this.sendProgressToRenderer(
url,
'error',
undefined,
EPG_LOCAL_SOURCE_REFUSED_MESSAGE
);
// A refusal is a failed fetch, not a completed one: the
// renderer's fetch result and EPG availability must not
// claim the file was read.
throw new Error(EPG_LOCAL_SOURCE_REFUSED_MESSAGE);
}
workerOptions = { ...trustOptions, allowLocalFile: true };
}
return runEpgFetch(url, workerOptions, {
runtime: this.runtime,
workers: this.workers,
fetchedUrls: this.fetchedUrls,
@@ -39,6 +39,11 @@ jest.mock('../workers/worker-runtime-paths', () => ({
resolveWorkerRuntimeBootstrap(...args),
}));
jest.mock('../services/store.service', () => ({
TRUSTED_LOCAL_EPG_SOURCES: 'TRUSTED_LOCAL_EPG_SOURCES',
store: { get: jest.fn(), set: jest.fn() },
}));
jest.mock('../database/connection', () => ({
getDatabase: (...args: unknown[]) => getDatabase(...args),
}));
@@ -1,5 +1,10 @@
import { reconcileEpgSources } from './epg-source-settings.service';
import { ipcMain } from 'electron';
import { dialog, ipcMain } from 'electron';
import { store, TRUSTED_LOCAL_EPG_SOURCES } from '../services/store.service';
import {
PersistedEpgLocalSourceAuthorizer,
promptForLocalEpgSource,
} from './epg-local-source-authorizer';
import {
ElectronBridgeCurrentProgramsOptions,
ElectronBridgeEpgGuideWindow,
@@ -131,6 +136,24 @@ export default class EpgEvents {
}
);
epgWorkerService.localSourceAuthorizer =
new PersistedEpgLocalSourceAuthorizer(
{
load: () => store.get(TRUSTED_LOCAL_EPG_SOURCES) ?? [],
save: (filePaths) =>
store.set(TRUSTED_LOCAL_EPG_SOURCES, filePaths),
},
promptForLocalEpgSource
);
ipcMain.handle('EPG_OPEN_FILE_DIALOG', async () => {
const filePath = await this.pickEpgSourceFile();
if (filePath) {
epgWorkerService.localSourceAuthorizer.authorize(filePath);
}
return filePath;
});
ipcMain.handle('EPG_CLEAR_ALL', async () => {
await this.clearEpgData();
return { success: true };
@@ -207,6 +230,22 @@ export default class EpgEvents {
return ipcMain;
}
/**
* Native picker for a local XMLTV file. Resolves the absolute path the
* user chose, or null on cancel; the renderer stores it as the source
* value and the worker reads it through `openEpgSourceStream`.
*/
private static async pickEpgSourceFile(): Promise<string | null> {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{ name: 'XMLTV', extensions: ['xml', 'gz', 'xmltv'] },
{ name: 'All Files', extensions: ['*'] },
],
});
return canceled || filePaths.length === 0 ? null : filePaths[0];
}
private static async checkEpgFreshness(
urls: string[],
maxAgeHours: number
@@ -28,6 +28,14 @@ export const STARTUP_WINDOW_MODE = 'STARTUP_WINDOW_MODE';
/** Desktop portal request cooldown; absent means enabled. */
export const PORTAL_CONNECTIVITY_GUARD = 'PORTAL_CONNECTIVITY_GUARD';
/**
* Local XMLTV files the EPG importer may read: every path the native picker
* returned plus every hand-typed path the user allowed in the main-process
* confirmation (`epg-local-source-authorizer.ts`). Owned by main, never by
* the renderer, because the renderer is what this list defends against.
*/
export const TRUSTED_LOCAL_EPG_SOURCES = 'TRUSTED_LOCAL_EPG_SOURCES';
/**
* Extra libmpv options for embedded sessions, one "key=value" per line, as
* typed in Settings > Playback. Mirrored here by the SETTINGS_UPDATE handler
@@ -56,6 +64,7 @@ export type StoreType = {
[EMBEDDED_MPV_AUTO_RECONNECT]: boolean;
[STARTUP_WINDOW_MODE]: StartupWindowMode;
[PORTAL_CONNECTIVITY_GUARD]: boolean;
[TRUSTED_LOCAL_EPG_SOURCES]: string[];
};
// Export singleton store instance
@@ -0,0 +1,24 @@
import { join } from 'path';
import { pathToFileURL } from 'url';
import { resolveLocalEpgSourcePath } from './epg-local-source-path';
describe('resolveLocalEpgSourcePath', () => {
const file = join(process.cwd(), 'epg', 'guide v2.xml.gz');
it('maps a file URL and the plain path to the same normalized path', () => {
expect(resolveLocalEpgSourcePath(pathToFileURL(file).href)).toBe(file);
expect(resolveLocalEpgSourcePath(` ${file} `)).toBe(file);
expect(
resolveLocalEpgSourcePath(join(file, '..', 'guide v2.xml.gz'))
).toBe(file);
});
it('returns null for remote links, relative paths and empty values', () => {
expect(
resolveLocalEpgSourcePath('https://epg.example.org/guide.xml')
).toBeNull();
expect(resolveLocalEpgSourcePath('epg/guide.xml')).toBeNull();
expect(resolveLocalEpgSourcePath('')).toBeNull();
expect(resolveLocalEpgSourcePath('file://')).toBeNull();
});
});
@@ -0,0 +1,25 @@
import { isAbsolute, resolve } from 'path';
import { fileURLToPath } from 'url';
import { classifyEpgSourceReference } from '@iptvnator/shared/interfaces';
/**
* The filesystem path behind an EPG source reference, or null when the
* reference is not a local file (a remote link, an empty value or a relative
* path). Shared by the main-process authorizer and the worker so both agree
* on which string names which file: a `file:` URL and the plain path it
* encodes normalize to the same value.
*/
export function resolveLocalEpgSourcePath(reference: string): string | null {
const trimmed = reference.trim();
if (classifyEpgSourceReference(trimmed) !== 'local') {
return null;
}
if (/^file:/i.test(trimmed)) {
try {
return resolve(fileURLToPath(trimmed));
} catch {
return null;
}
}
return isAbsolute(trimmed) ? resolve(trimmed) : null;
}
@@ -5,30 +5,20 @@ import {
ElectronBridgeSecurityErrorCode,
ElectronBridgeTrustOptions,
} from '@iptvnator/shared/interfaces';
import { Readable } from 'stream';
import { parentPort, workerData } from 'worker_threads';
import {
EpgDatabase,
EpgDatabaseClearOperation,
EpgDatabaseSourceClearOperation,
} from './epg-database';
import { createDecodedEpgStream } from './epg-stream-decoder';
import { openEpgSourceStream } from './epg-source-stream';
import { StreamingEpgParser } from './epg-streaming-parser';
import {
getEpgResponseContentEncoding,
shouldGunzipEpgResponse,
} from './epg-response-utils';
import {
isPrivateNetworkUrlAccessAllowed,
UnsafeUrlError,
} from '../events/url-safety';
import { createPlaylistAgentFactory } from '../util/secure-https';
import { UnsafeUrlError } from '../events/url-safety';
import {
getHostnameFromErrorUrl,
getHostnameFromUrl,
isInvalidTlsCertificateError,
} from '../util/security-errors';
import { requestWithValidatedRedirects } from '../util/validated-axios';
import {
getNativeModuleSearchPaths,
getWorkerDataNativeModuleSearchPaths,
@@ -115,54 +105,7 @@ async function fetchAndParseEpgStreaming(
let hasClearedSource = false;
try {
// EPG URLs can originate from an untrusted M3U `url-tvg` attribute.
// Validate every redirect and require an explicit operator opt-in for
// private/LAN sources.
const response = await requestWithValidatedRedirects<Readable>(
url.trim(),
{
agentFactory: createPlaylistAgentFactory({
trustedInsecureTlsHosts: options.trustedInsecureTlsHosts,
}),
decompress: false,
method: 'GET',
responseType: 'stream',
},
{
allowPrivateNetworks:
isPrivateNetworkUrlAccessAllowed() ||
isTrustedPrivateNetworkEpgSource(url, options),
}
);
const responseUrl = response.config.url;
const isGzipped = shouldGunzipEpgResponse(url, {
headers: response.headers,
url: responseUrl,
});
const contentEncoding = getEpgResponseContentEncoding(response.headers);
if (responseUrl && responseUrl !== url) {
epgLogger.log(loggerLabel, 'Resolved EPG redirect');
}
epgLogger.log(
loggerLabel,
`EPG response detected as gzipped: ${isGzipped}`
);
if (contentEncoding) {
epgLogger.log(
loggerLabel,
`EPG response content-encoding: ${contentEncoding}`
);
}
if (response.status < 200 || response.status >= 300) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (!response.data) {
throw new Error('Response body is null');
}
const decodedStream = await openEpgSourceStream(url, options);
const parser = new StreamingEpgParser(
(channels) => {
@@ -188,11 +131,7 @@ async function fetchAndParseEpgStreaming(
);
return new Promise((resolve, reject) => {
const dataStream = createDecodedEpgStream(
response.data,
response.headers,
isGzipped
);
const dataStream = decodedStream;
dataStream.on('data', (chunk: Buffer) => {
try {
@@ -260,18 +199,6 @@ async function fetchAndParseEpgStreaming(
}
}
function isTrustedPrivateNetworkEpgSource(
url: string,
options: ElectronBridgeTrustOptions
): boolean {
const normalizedUrl = url.trim();
return (
options.trustedPrivateNetworkEpgUrls?.some(
(trustedUrl) => trustedUrl.trim() === normalizedUrl
) ?? false
);
}
function toEpgFetchError(
error: unknown,
url: string
@@ -0,0 +1,164 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { Readable } from 'stream';
import { pathToFileURL } from 'url';
import { gzipSync } from 'zlib';
const requestWithValidatedRedirects = jest.fn();
jest.mock('../util/validated-axios', () => ({
requestWithValidatedRedirects: (...args: unknown[]) =>
requestWithValidatedRedirects(...args),
}));
jest.mock('../util/secure-https', () => ({
createPlaylistAgentFactory: () => ({}),
}));
jest.mock('../events/url-safety', () => ({
isPrivateNetworkUrlAccessAllowed: () => false,
}));
jest.mock('../util/epg-logger', () => ({
epgLogger: { log: jest.fn(), error: jest.fn(), warn: jest.fn() },
}));
import { openEpgSourceStream } from './epg-source-stream';
const LOCAL = { allowLocalFile: true };
const xmltv =
'<?xml version="1.0" encoding="utf-8"?><tv><channel id="c1">' +
'<display-name>One</display-name></channel></tv>';
async function readAll(stream: Readable): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk));
}
return Buffer.concat(chunks).toString('utf-8');
}
describe('openEpgSourceStream', () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'iptvnator-epg-source-'));
requestWithValidatedRedirects.mockReset();
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('reads a plain XML file from an absolute path', async () => {
const file = join(dir, 'guide.xml');
writeFileSync(file, xmltv);
await expect(
readAll(await openEpgSourceStream(file, LOCAL))
).resolves.toBe(xmltv);
expect(requestWithValidatedRedirects).not.toHaveBeenCalled();
});
it('gunzips a .xml.gz file', async () => {
const file = join(dir, 'guide.xml.gz');
writeFileSync(file, gzipSync(xmltv));
await expect(
readAll(await openEpgSourceStream(file, LOCAL))
).resolves.toBe(xmltv);
});
it('detects gzip by signature when the extension is missing', async () => {
const file = join(dir, 'guide');
writeFileSync(file, gzipSync(xmltv));
await expect(
readAll(await openEpgSourceStream(file, LOCAL))
).resolves.toBe(xmltv);
});
it('accepts a file: URL with percent-encoded characters', async () => {
const file = join(dir, 'guide v2.xml');
writeFileSync(file, xmltv);
await expect(
readAll(await openEpgSourceStream(pathToFileURL(file).href, LOCAL))
).resolves.toBe(xmltv);
});
it('tolerates surrounding whitespace in the stored value', async () => {
const file = join(dir, 'guide.xml');
writeFileSync(file, xmltv);
await expect(
readAll(await openEpgSourceStream(` ${file} `, LOCAL))
).resolves.toBe(xmltv);
});
it('reports a missing file with its path instead of an ENOENT code', async () => {
const file = join(dir, 'missing.xml');
await expect(openEpgSourceStream(file, LOCAL)).rejects.toThrow(
`EPG file not found: ${file}`
);
});
it('refuses a directory', async () => {
const folder = join(dir, 'epg');
mkdirSync(folder);
await expect(openEpgSourceStream(folder, LOCAL)).rejects.toThrow(
`EPG source is not a file: ${folder}`
);
});
it('fails a truncated gzip file instead of parsing it as XML', async () => {
const file = join(dir, 'guide.xml.gz');
writeFileSync(file, gzipSync(xmltv).subarray(0, 20));
await expect(
readAll(await openEpgSourceStream(file, LOCAL))
).rejects.toThrow();
});
it('routes remote links through the validated HTTP client', async () => {
requestWithValidatedRedirects.mockResolvedValue({
config: { url: 'https://epg.example.org/guide.xml' },
data: Readable.from([Buffer.from(xmltv)]),
headers: {},
status: 200,
});
const stream = await openEpgSourceStream(
'https://epg.example.org/guide.xml',
{ trustedPrivateNetworkEpgUrls: [] }
);
await expect(readAll(stream)).resolves.toBe(xmltv);
expect(requestWithValidatedRedirects).toHaveBeenCalledWith(
'https://epg.example.org/guide.xml',
expect.objectContaining({ responseType: 'stream' }),
{ allowPrivateNetworks: false }
);
});
it('refuses a local file the main process did not authorize', async () => {
const file = join(dir, 'guide.xml');
writeFileSync(file, xmltv);
await expect(openEpgSourceStream(file)).rejects.toThrow(
'Local EPG file was not authorized'
);
await expect(
openEpgSourceStream(file, {
allowLocalFile: 'yes' as unknown as boolean,
})
).rejects.toThrow('Local EPG file was not authorized');
expect(requestWithValidatedRedirects).not.toHaveBeenCalled();
});
it('never reads a relative path from disk', async () => {
await expect(openEpgSourceStream('guide.xml')).rejects.toThrow();
expect(requestWithValidatedRedirects).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,151 @@
import { createReadStream } from 'fs';
import { stat } from 'fs/promises';
import { PassThrough, Readable, pipeline } from 'stream';
import { ElectronBridgeTrustOptions } from '@iptvnator/shared/interfaces';
import { isPrivateNetworkUrlAccessAllowed } from '../events/url-safety';
import { epgLogger } from '../util/epg-logger';
import { resolveLocalEpgSourcePath } from '../util/epg-local-source-path';
import { createPlaylistAgentFactory } from '../util/secure-https';
import { requestWithValidatedRedirects } from '../util/validated-axios';
import { createOptionalEpgGunzip } from './epg-optional-gunzip';
import {
getEpgResponseContentEncoding,
shouldGunzipEpgResponse,
} from './epg-response-utils';
import { createDecodedEpgStream } from './epg-stream-decoder';
const loggerLabel = '[EPG Worker]';
/** Trust options plus the main-process verdict on a local file. */
export type EpgWorkerFetchOptions = ElectronBridgeTrustOptions & {
/**
* Set by `EpgWorkerService.startFetch` only after the main-process
* authorizer allowed the path (native picker or native confirmation).
* Never taken from the renderer.
*/
allowLocalFile?: boolean;
};
/**
* Opens the decoded XMLTV byte stream for an EPG source.
*
* Remote sources go through the validated-redirect HTTP client with the
* private-network and TLS trust policy. Local sources — a `file:` URL or an
* absolute path — are read from disk; gzip is detected from the file's own
* signature, so `guide.xml`, `guide.xml.gz` and a gzip file without the
* extension all work.
*
* The local branch bypasses `validateRemoteUrl`, which only knows http(s).
* Two things make that safe: header-declared M3U sources are filtered to
* remote links before they are stored (`extractM3uEpgUrls`), and the main
* process opens the branch only for a path its authorizer allowed
* (`allowLocalFile`), so neither a downloaded playlist nor a compromised
* renderer can name an arbitrary file.
*/
export async function openEpgSourceStream(
url: string,
options: EpgWorkerFetchOptions = {}
): Promise<Readable> {
const localPath = resolveLocalEpgSourcePath(url);
if (localPath) {
if (options.allowLocalFile !== true) {
throw new Error('Local EPG file was not authorized');
}
return openLocalEpgSource(localPath);
}
return openRemoteEpgSource(url, options);
}
async function openLocalEpgSource(filePath: string): Promise<Readable> {
let info: Awaited<ReturnType<typeof stat>>;
try {
info = await stat(filePath);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT' || code === 'ENOTDIR') {
throw new Error(`EPG file not found: ${filePath}`);
}
throw error;
}
if (!info.isFile()) {
throw new Error(`EPG source is not a file: ${filePath}`);
}
epgLogger.log(loggerLabel, 'Reading local EPG file');
const output = new PassThrough();
pipeline(
createReadStream(filePath),
createOptionalEpgGunzip(),
output,
(error) => {
if (error) output.destroy(error);
}
);
return output;
}
async function openRemoteEpgSource(
url: string,
options: ElectronBridgeTrustOptions
): Promise<Readable> {
// EPG URLs can originate from an untrusted M3U `url-tvg` attribute.
// Validate every redirect and require an explicit operator opt-in for
// private/LAN sources.
const response = await requestWithValidatedRedirects<Readable>(
url.trim(),
{
agentFactory: createPlaylistAgentFactory({
trustedInsecureTlsHosts: options.trustedInsecureTlsHosts,
}),
decompress: false,
method: 'GET',
responseType: 'stream',
},
{
allowPrivateNetworks:
isPrivateNetworkUrlAccessAllowed() ||
isTrustedPrivateNetworkEpgSource(url, options),
}
);
const responseUrl = response.config.url;
const isGzipped = shouldGunzipEpgResponse(url, {
headers: response.headers,
url: responseUrl,
});
const contentEncoding = getEpgResponseContentEncoding(response.headers);
if (responseUrl && responseUrl !== url) {
epgLogger.log(loggerLabel, 'Resolved EPG redirect');
}
epgLogger.log(
loggerLabel,
`EPG response detected as gzipped: ${isGzipped}`
);
if (contentEncoding) {
epgLogger.log(
loggerLabel,
`EPG response content-encoding: ${contentEncoding}`
);
}
if (response.status < 200 || response.status >= 300) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (!response.data) {
throw new Error('Response body is null');
}
return createDecodedEpgStream(response.data, response.headers, isGzipped);
}
function isTrustedPrivateNetworkEpgSource(
url: string,
options: ElectronBridgeTrustOptions
): boolean {
const normalizedUrl = url.trim();
return (
options.trustedPrivateNetworkEpgUrls?.some(
(trustedUrl) => trustedUrl.trim() === normalizedUrl
) ?? false
);
}
@@ -70,6 +70,19 @@
</div>
<div class="setting-item setting-item--full-control">
<div class="setting-item__meta" data-test-id="epg-source-formats">
<h4>{{ 'SETTINGS.EPG_SOURCE_LIST_TITLE' | translate }}</h4>
<p>{{ 'SETTINGS.EPG_SOURCE_FORMATS_HINT' | translate }}</p>
<p class="epg-sources__formats-examples">
<span>{{
'SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES' | translate
}}</span>
<code>https://example.org/guide.xml.gz</code>
<code>/home/you/epg/guide.xml</code>
<code>C:\epg\guide.xml.gz</code>
<code>file:///…/guide.xml</code>
</p>
</div>
<div class="setting-item__control">
<ng-container formArrayName="epgUrl">
@if (epgUrl().length === 0) {
@@ -102,7 +115,8 @@
}}</mat-label>
<input
matInput
type="url"
type="text"
spellcheck="false"
[formControlName]="i"
#epgField
/>
@@ -115,12 +129,31 @@
[url]="epgField.value"
/>
}
@if (canBrowseFiles()) {
<button
mat-icon-button
[matTooltip]="
'SETTINGS.BROWSE_EPG_FILE'
| translate
"
[attr.aria-label]="
'SETTINGS.BROWSE_EPG_FILE'
| translate
"
type="button"
data-test-id="epg-source-browse"
(click)="browseEpgSource.emit(i)"
>
<mat-icon>folder_open</mat-icon>
</button>
}
<button
mat-icon-button
[matTooltip]="
'SETTINGS.REFRESH_EPG' | translate
"
type="button"
data-test-id="epg-source-refresh"
[disabled]="epgField.value === ''"
(click)="refreshEpg.emit(epgUrl().value[i])"
>
@@ -132,6 +165,7 @@
'SETTINGS.REMOVE_EPG_SOURCE' | translate
"
type="button"
data-test-id="epg-source-remove"
(click)="removeEpgSource.emit(i)"
>
<mat-icon>remove</mat-icon>
@@ -34,9 +34,11 @@ export class SettingsEpgSectionComponent {
readonly form = input.required<FormGroup>();
readonly epgUrl = input.required<FormArray>();
readonly isClearingEpgData = input(false);
readonly canBrowseFiles = input(false);
readonly epgViewModeOptions = input.required<EpgViewModeOption[]>();
readonly refreshEpg = output<string>();
readonly browseEpgSource = output<number>();
readonly removeEpgSource = output<number>();
readonly addEpgSource = output<void>();
readonly refreshAllEpg = output<void>();
@@ -174,4 +174,50 @@ describe('SettingsEpgFacade', () => {
expect(epgService.fetchEpg).not.toHaveBeenCalled();
});
describe('browse', () => {
it('writes the picked file into the row and dirties the form', async () => {
formFacade.setEpgUrls([
'https://epg.example.org/guide.xml',
'https://epg.example.org/old.xml',
]);
(epgBridge.pickEpgFile as jest.Mock).mockResolvedValue(
'/home/user/epg/guide.xml.gz'
);
await facade.browse(1);
expect(formFacade.epgUrl.at(1).value).toBe(
'/home/user/epg/guide.xml.gz'
);
expect(formFacade.epgUrl.at(0).value).toBe(
'https://epg.example.org/guide.xml'
);
expect(formFacade.epgUrl.at(1).dirty).toBe(true);
expect(formFacade.form.dirty).toBe(true);
});
it('leaves the row untouched when the picker is cancelled', async () => {
formFacade.setEpgUrls(['https://epg.example.org/guide.xml']);
(epgBridge.pickEpgFile as jest.Mock).mockResolvedValue(null);
await facade.browse(0);
expect(formFacade.epgUrl.at(0).value).toBe(
'https://epg.example.org/guide.xml'
);
expect(formFacade.form.dirty).toBe(false);
});
it('never opens the picker without the capability or for a missing row', async () => {
formFacade.setEpgUrls(['https://epg.example.org/guide.xml']);
await facade.browse(5);
expect(epgBridge.pickEpgFile).not.toHaveBeenCalled();
(epgBridge as { supportsFilePicker: boolean }).supportsFilePicker =
false;
await facade.browse(0);
expect(epgBridge.pickEpgFile).not.toHaveBeenCalled();
});
});
});
@@ -25,6 +25,29 @@ export class SettingsEpgFacade {
readonly isClearing = signal(false);
get supportsFilePicker(): boolean {
return this.epgBridge.supportsFilePicker;
}
/**
* Native picker for a local XMLTV file. The chosen absolute path becomes
* the value of row `index`; a cancelled dialog leaves the row untouched.
*/
async browse(index: number): Promise<void> {
const control = this.formFacade.epgUrl.at(index);
if (!control || !this.epgBridge.supportsFilePicker) {
return;
}
const filePath = await this.epgBridge.pickEpgFile();
if (!filePath) {
return;
}
control.setValue(filePath);
control.markAsDirty();
control.markAsTouched();
this.formFacade.form.markAsDirty();
}
/**
* Force-fetch EPG for a single URL, bypassing the 12-hour freshness check.
* The plain fetchEpg would short-circuit on fresh data and click the
@@ -1,6 +1,7 @@
import { FormBuilder } from '@angular/forms';
import { Settings, VideoPlayer } from '@iptvnator/shared/interfaces';
import {
createEpgUrlControl,
createSettingsForm,
createSettingsFromFormValue,
} from './settings-form.utils';
@@ -202,3 +203,24 @@ describe('settings form utils — embedded MPV session options', () => {
expect(settings.embeddedMpvAutoReconnect).toBe(true);
});
});
describe('settings form utils — EPG source control', () => {
it.each([
'https://epg.example.org/guide.xml.gz',
'/home/user/epg/guide.xml',
'C:\\epg\\guide.xml.gz',
'file:///home/user/epg/guide.xml',
'',
])('accepts %j', (value) => {
expect(createEpgUrlControl(value).valid).toBe(true);
});
it.each(['guide.xml', 'ftp://epg.example.org/guide.xml', 'epg'])(
'rejects %j',
(value) => {
const control = createEpgUrlControl(value);
expect(control.valid).toBe(false);
expect(control.errors).toEqual({ epgSourceReference: true });
}
);
});
@@ -23,11 +23,10 @@ import {
StreamFormat,
Theme,
validateEmbeddedMpvExtraOptions,
validateEpgSourceReferenceControl,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
export const EPG_URL_PATTERN = /^(http|https|file):\/\/[^ "]+$/;
/**
* Rejects malformed lines and the option keys the embed depends on, so a
* value that the main process would have to drop never gets saved.
@@ -44,8 +43,13 @@ export function embeddedMpvExtraOptionsValidator(
return validateEmbeddedMpvExtraOptions(control.value);
}
/**
* An EPG source is a remote XMLTV link or a local file (absolute path or
* `file:` URL); the accepted shapes live in `@iptvnator/shared/interfaces`
* so the playlist dialog validates identically.
*/
export function createEpgUrlControl(value = ''): FormControl<string | null> {
return new FormControl(value, [Validators.pattern(EPG_URL_PATTERN)]);
return new FormControl(value, [validateEpgSourceReferenceControl]);
}
export function createSettingsForm(
@@ -40,8 +40,10 @@
[form]="settingsForm"
[epgUrl]="form.epgUrl"
[isClearingEpgData]="epg.isClearing()"
[canBrowseFiles]="epg.supportsFilePicker"
[epgViewModeOptions]="epgViewModeOptions"
(refreshEpg)="epg.refresh($event)"
(browseEpgSource)="epg.browse($event)"
(removeEpgSource)="form.removeEpgSource($event)"
(addEpgSource)="form.addEpgSource()"
(refreshAllEpg)="epg.refreshAll()"
@@ -303,6 +303,22 @@
gap: 8px;
}
.epg-sources__formats-examples {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 8px;
code {
padding: 1px 6px;
border-radius: 6px;
background: var(--app-hover-overlay);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.75rem;
white-space: nowrap;
}
}
.epg-sources-empty {
width: 100%;
display: flex;
@@ -216,7 +216,9 @@ export function createEpgBridgeStub(): Partial<EpgRuntimeBridgeService> {
clearEpgData: jest.fn().mockResolvedValue({ success: true }),
clearEpgDataForSource: jest.fn().mockResolvedValue({ success: true }),
forceFetchEpg: jest.fn().mockResolvedValue({ success: true }),
pickEpgFile: jest.fn().mockResolvedValue(null),
supportsDataManagement: true,
supportsFilePicker: true,
supportsImport: true,
};
}
+4
View File
@@ -319,6 +319,7 @@
"REFRESH_EPG": "تحديث EPG",
"REFRESH_ALL_EPG": "تحديث الكل",
"REMOVE_EPG_SOURCE": "إزالة مصدر EPG",
"BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"EPG_STATUS_FRESH": "محدث",
"EPG_STATUS_STALE": "يحتاج تحديث",
"EPG_STATUS_ERROR": "فشل",
@@ -364,6 +365,9 @@
"EPG_DATA_CLEAR_FAILED": "فشل مسح بيانات EPG. يرجى المحاولة مرة أخرى.",
"CLEARING_EPG_DATA": "جارٍ المسح…",
"EPG_URL_ERROR": "القيمة ليست رابطًا صالحًا",
"EPG_SOURCE_LIST_TITLE": "Source list",
"EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"VIDEO_PLAYER_DESCRIPTION": "اختر مشغل الفيديو الافتراضي",
"WEB_PLAYER_SHARED_CONTROLS": "عناصر تحكم موحّدة لمشغلات الويب",
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "استخدم عناصر التحكم المشتركة في IPTVnator مع HTML5 وVideo.js وArtPlayer.",
+4
View File
@@ -319,6 +319,7 @@
"REFRESH_EPG": "حدث EPG",
"REFRESH_ALL_EPG": "حدث الكل",
"REMOVE_EPG_SOURCE": "زول مصدر EPG",
"BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"EPG_STATUS_FRESH": "محدث",
"EPG_STATUS_STALE": "خاصو يتحدث",
"EPG_STATUS_ERROR": "فشل",
@@ -364,6 +365,9 @@
"EPG_DATA_CLEAR_FAILED": "فشل مسح بيانات EPG. عفاك حاول مرة أخرى.",
"CLEARING_EPG_DATA": "جاري المسح…",
"EPG_URL_ERROR": "القيمة ماشي رابط صحيح",
"EPG_SOURCE_LIST_TITLE": "Source list",
"EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"VIDEO_PLAYER_DESCRIPTION": "اختار مشغل الفيديو الافتراضي",
"WEB_PLAYER_SHARED_CONTROLS": "عناصر تحكم موحدة لمشغلات الويب",
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "استعمل عناصر التحكم المشتركة ديال IPTVnator مع HTML5 وVideo.js وArtPlayer.",
+4
View File
@@ -319,6 +319,7 @@
"REFRESH_EPG": "Абнавіць праграму каналаў (EPG)",
"REFRESH_ALL_EPG": "Абнавіць усё",
"REMOVE_EPG_SOURCE": "Выдаліць крыніцу EPG",
"BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"EPG_STATUS_FRESH": "Актуальна",
"EPG_STATUS_STALE": "Патрабуе абнаўлення",
"EPG_STATUS_ERROR": "Памылка",
@@ -364,6 +365,9 @@
"EPG_DATA_CLEAR_FAILED": "Не ўдалося ачысціць дадзеныя EPG. Калі ласка, паспрабуйце яшчэ раз.",
"CLEARING_EPG_DATA": "Ачышчаецца...",
"EPG_URL_ERROR": "Уведзенае значэнне не з'яўляецца URL-адрасам.",
"EPG_SOURCE_LIST_TITLE": "Source list",
"EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"VIDEO_PLAYER_DESCRIPTION": "Выбраць стандартны відэапрайгравальнік",
"WEB_PLAYER_SHARED_CONTROLS": "Адзіныя элементы кіравання для вэб-прайгравальнікаў",
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Выкарыстоўваць агульныя элементы кіравання IPTVnator у HTML5, Video.js і ArtPlayer.",
+9 -5
View File
@@ -268,10 +268,10 @@
"GENERAL_SUBTITLE": "Allgemeine App-Einstellungen ändern",
"PLAYBACK": "Wiedergabe",
"EPG_SOURCES": "EPG-Quellen",
"EPG_SOURCES_DESCRIPTION": "Eine oder mehrere URLs als EPG-Quellen hinzufügen",
"EPG_SOURCES_DESCRIPTION": "Eine oder mehrere XMLTV-Quellen hinzufügen: Links oder lokale Dateien",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} von {{detected}} erkannten EPG-Quellen der Wiedergabeliste wurden automatisch für den Import ausgewählt.",
"EPG_EMPTY_TITLE": "Noch keine EPG-Quellen",
"EPG_EMPTY_HINT": "Füge eine URL hinzu, um Programmdaten für deine Kanäle abzurufen.",
"EPG_EMPTY_HINT": "Füge eine URL oder eine lokale XMLTV-Datei hinzu, um Programmdaten für deine Kanäle zu laden.",
"PREFER_UPLOADED_EPG_TITLE": "Eigene EPG-Quellen bei Xtream-Playlists bevorzugen",
"PREFER_UPLOADED_EPG_HINT": "Standardmäßig nutzen Xtream-Playlists die Programmdaten deines Anbieters, und dein hochgeladenes EPG füllt nur fehlende Kanäle. Aktiviere die Option, um stattdessen zuerst dein hochgeladenes EPG zu nutzen.",
"DATA": "Daten",
@@ -314,11 +314,12 @@
"HERO_DESCRIPTION": "Passen Sie Wiedergabe, Programmführer, Fernzugriff und Datenverwaltung zentral an, ohne den Stil des Arbeitsbereichs zu verlieren.",
"SUBTITLE": "Die App-Einstellungen ändern",
"DESCRIPTION": "Bitte vergessen Sie nicht die Änderungen mit dem Speicher-Button zu bestätigen",
"EPG_URL_LABEL": "TV-Programm (EPG) URL",
"EPG_URL_PLACEHOLDER": "URL eingeben",
"EPG_URL_LABEL": "EPG-Quelle (URL oder lokale Datei, *.xml oder *.xml.gz)",
"EPG_URL_PLACEHOLDER": "URL oder Dateipfad eingeben",
"REFRESH_EPG": "TV-Programm aktualisieren",
"REFRESH_ALL_EPG": "Alle aktualisieren",
"REMOVE_EPG_SOURCE": "EPG-Quelle entfernen",
"BROWSE_EPG_FILE": "Lokale XMLTV-Datei auswählen…",
"EPG_STATUS_FRESH": "Aktuell",
"EPG_STATUS_STALE": "Aktualisierung nötig",
"EPG_STATUS_ERROR": "Fehlgeschlagen",
@@ -363,7 +364,10 @@
"EPG_DATA_CLEARED": "EPG-Daten wurden gelöscht",
"EPG_DATA_CLEAR_FAILED": "EPG-Daten konnten nicht gelöscht werden. Bitte erneut versuchen.",
"CLEARING_EPG_DATA": "Wird gelöscht…",
"EPG_URL_ERROR": "Der Wert ist keine gültige URL",
"EPG_URL_ERROR": "Gib einen http(s)-Link, einen absoluten Dateipfad oder eine file://-URL ein",
"EPG_SOURCE_LIST_TITLE": "Quellenliste",
"EPG_SOURCE_FORMATS_HINT": "Unterstützt werden XMLTV-Links (http/https) und Dateien auf diesem Computer, als einfaches .xml oder gzip-komprimiertes .xml.gz. Lokale Dateien brauchen einen absoluten Pfad oder eine file://-URL.",
"EPG_SOURCE_FORMATS_EXAMPLES": "Beispiele:",
"VIDEO_PLAYER_DESCRIPTION": "Wählen Sie den Standard-Videoplayer aus",
"WEB_PLAYER_SHARED_CONTROLS": "Einheitliche Steuerung für Web-Player",
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Verwendet die gemeinsamen IPTVnator-Steuerelemente in HTML5, Video.js und ArtPlayer.",
+4
View File
@@ -319,6 +319,7 @@
"REFRESH_EPG": "Ανανέωση EPG",
"REFRESH_ALL_EPG": "Ανανέωση όλων",
"REMOVE_EPG_SOURCE": "Αφαίρεση πηγής EPG",
"BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"EPG_STATUS_FRESH": "Ενημερωμένο",
"EPG_STATUS_STALE": "Χρειάζεται ανανέωση",
"EPG_STATUS_ERROR": "Αποτυχία",
@@ -364,6 +365,9 @@
"EPG_DATA_CLEAR_FAILED": "Αποτυχία διαγραφής δεδομένων EPG. Παρακαλώ δοκιμάστε ξανά.",
"CLEARING_EPG_DATA": "Διαγραφή…",
"EPG_URL_ERROR": "Η τιμή δεν είναι έγκυρη διεύθυνση URL",
"EPG_SOURCE_LIST_TITLE": "Source list",
"EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"VIDEO_PLAYER_DESCRIPTION": "Επιλογή προεπιλεγμένου προγράμματος αναπαραγωγής βίντεο",
"WEB_PLAYER_SHARED_CONTROLS": "Ενοποιημένα στοιχεία ελέγχου για web players",
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Χρησιμοποιήστε τα κοινά στοιχεία ελέγχου του IPTVnator σε HTML5, Video.js και ArtPlayer.",
+9 -5
View File
@@ -268,10 +268,10 @@
"GENERAL_SUBTITLE": "Change the configuration of the application",
"PLAYBACK": "Playback",
"EPG_SOURCES": "EPG sources",
"EPG_SOURCES_DESCRIPTION": "Add one or more URLs as EPG sources",
"EPG_SOURCES_DESCRIPTION": "Add one or more XMLTV sources: remote links or local files",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
"EPG_EMPTY_TITLE": "No EPG sources yet",
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
"EPG_EMPTY_HINT": "Add a URL or a local XMLTV file to fetch program data for your channels.",
"PREFER_UPLOADED_EPG_TITLE": "Prefer your EPG sources for Xtream playlists",
"PREFER_UPLOADED_EPG_HINT": "By default, Xtream playlists use the program guide from your provider, and your uploaded EPG only fills missing channels. Turn this on to use your uploaded EPG first instead.",
"DATA": "Data",
@@ -314,11 +314,12 @@
"HERO_DESCRIPTION": "Tune playback, guides, remote access and data handling from one place without losing the current workspace style.",
"SUBTITLE": "Change the configuration of the application",
"DESCRIPTION": "Please adapt the options from the list below and confirm with the save changes button",
"EPG_URL_LABEL": "EPG Url (*.xml or *.xml.gz)",
"EPG_URL_PLACEHOLDER": "Enter an URL",
"EPG_URL_LABEL": "EPG source (URL or local file, *.xml or *.xml.gz)",
"EPG_URL_PLACEHOLDER": "Enter a URL or a local file path",
"REFRESH_EPG": "Refresh EPG",
"REFRESH_ALL_EPG": "Refresh all",
"REMOVE_EPG_SOURCE": "Remove EPG source",
"BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"EPG_STATUS_FRESH": "Up to date",
"EPG_STATUS_STALE": "Needs refresh",
"EPG_STATUS_ERROR": "Failed",
@@ -363,7 +364,10 @@
"EPG_DATA_CLEARED": "EPG data has been cleared",
"EPG_DATA_CLEAR_FAILED": "Failed to clear EPG data. Please try again.",
"CLEARING_EPG_DATA": "Clearing…",
"EPG_URL_ERROR": "The value is not a valid URL",
"EPG_URL_ERROR": "Enter an http(s) link, an absolute file path or a file:// URL",
"EPG_SOURCE_LIST_TITLE": "Source list",
"EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"VIDEO_PLAYER_DESCRIPTION": "Select default video player",
"WEB_PLAYER_SHARED_CONTROLS": "Unified controls for web players",
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Use IPTVnator's shared controls in HTML5, Video.js, and ArtPlayer.",
+4
View File
@@ -319,6 +319,7 @@
"REFRESH_EPG": "Actualizar EPG",
"REFRESH_ALL_EPG": "Actualizar todo",
"REMOVE_EPG_SOURCE": "Eliminar fuente EPG",
"BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"EPG_STATUS_FRESH": "Actualizado",
"EPG_STATUS_STALE": "Necesita actualización",
"EPG_STATUS_ERROR": "Error",
@@ -364,6 +365,9 @@
"EPG_DATA_CLEAR_FAILED": "No se pudieron borrar los datos EPG. Inténtalo de nuevo.",
"CLEARING_EPG_DATA": "Borrando…",
"EPG_URL_ERROR": "El valor no es una URL válida.",
"EPG_SOURCE_LIST_TITLE": "Source list",
"EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"VIDEO_PLAYER_DESCRIPTION": "Seleccionar reproductor de vídeo predeterminado",
"WEB_PLAYER_SHARED_CONTROLS": "Controles unificados para reproductores web",
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Usa los controles compartidos de IPTVnator en HTML5, Video.js y ArtPlayer.",
+4
View File
@@ -319,6 +319,7 @@
"REFRESH_EPG": "Mise à jour de l'EPG",
"REFRESH_ALL_EPG": "Tout actualiser",
"REMOVE_EPG_SOURCE": "Supprimer la source EPG",
"BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"EPG_STATUS_FRESH": "À jour",
"EPG_STATUS_STALE": "À actualiser",
"EPG_STATUS_ERROR": "Échec",
@@ -364,6 +365,9 @@
"EPG_DATA_CLEAR_FAILED": "Échec de l'effacement des données EPG. Veuillez réessayer.",
"CLEARING_EPG_DATA": "Effacement…",
"EPG_URL_ERROR": "La valeur n'est pas une URL valide",
"EPG_SOURCE_LIST_TITLE": "Source list",
"EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"VIDEO_PLAYER_DESCRIPTION": "Sélectionnez le lecteur vidéo par défaut",
"WEB_PLAYER_SHARED_CONTROLS": "Commandes unifiées pour les lecteurs web",
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Utilise les commandes partagées d’IPTVnator dans HTML5, Video.js et ArtPlayer.",
+4
View File
@@ -319,6 +319,7 @@
"REFRESH_EPG": "EPG frissítése",
"REFRESH_ALL_EPG": "Összes frissítése",
"REMOVE_EPG_SOURCE": "EPG-forrás eltávolítása",
"BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"EPG_STATUS_FRESH": "Naprakész",
"EPG_STATUS_STALE": "Frissítendő",
"EPG_STATUS_ERROR": "Sikertelen",
@@ -364,6 +365,9 @@
"EPG_DATA_CLEAR_FAILED": "Nem sikerült törölni az EPG-adatokat. Próbálja újra.",
"CLEARING_EPG_DATA": "Törlés…",
"EPG_URL_ERROR": "A megadott érték nem érvényes URL-cím",
"EPG_SOURCE_LIST_TITLE": "Source list",
"EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"VIDEO_PLAYER_DESCRIPTION": "Az alapértelmezett videólejátszó kiválasztása",
"WEB_PLAYER_SHARED_CONTROLS": "Egységes vezérlők a webes lejátszókhoz",
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Az IPTVnator közös vezérlőinek használata a HTML5-, Video.js- és ArtPlayer-lejátszóban.",
+4
View File
@@ -319,6 +319,7 @@
"REFRESH_EPG": "Aggiorna EPG",
"REFRESH_ALL_EPG": "Aggiorna tutto",
"REMOVE_EPG_SOURCE": "Rimuovi sorgente EPG",
"BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"EPG_STATUS_FRESH": "Aggiornato",
"EPG_STATUS_STALE": "Da aggiornare",
"EPG_STATUS_ERROR": "Errore",
@@ -364,6 +365,9 @@
"EPG_DATA_CLEAR_FAILED": "Impossibile cancellare i dati EPG. Riprova.",
"CLEARING_EPG_DATA": "Cancellazione…",
"EPG_URL_ERROR": "Il valore non è una URL valida",
"EPG_SOURCE_LIST_TITLE": "Source list",
"EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"VIDEO_PLAYER_DESCRIPTION": "Seleziona lettore video predefinito",
"WEB_PLAYER_SHARED_CONTROLS": "Controlli unificati per i player web",
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Usa i controlli condivisi di IPTVnator in HTML5, Video.js e ArtPlayer.",
+4
View File
@@ -319,6 +319,7 @@
"REFRESH_EPG": "EPGを更新",
"REFRESH_ALL_EPG": "すべて更新",
"REMOVE_EPG_SOURCE": "EPGソースを削除",
"BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"EPG_STATUS_FRESH": "最新",
"EPG_STATUS_STALE": "更新が必要",
"EPG_STATUS_ERROR": "失敗",
@@ -364,6 +365,9 @@
"EPG_DATA_CLEAR_FAILED": "EPGデータのクリアに失敗しました。もう一度お試しください。",
"CLEARING_EPG_DATA": "クリア中…",
"EPG_URL_ERROR": "有効なURLではありません",
"EPG_SOURCE_LIST_TITLE": "Source list",
"EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"VIDEO_PLAYER_DESCRIPTION": "デフォルトのビデオプレーヤーを選択",
"WEB_PLAYER_SHARED_CONTROLS": "Webプレーヤーの統一コントロール",
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "HTML5、Video.js、ArtPlayerでIPTVnator共通のコントロールを使用します。",
+4
View File
@@ -319,6 +319,7 @@
"REFRESH_EPG": "EPG 새로 고침",
"REFRESH_ALL_EPG": "모두 새로 고침",
"REMOVE_EPG_SOURCE": "EPG 소스 제거",
"BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"EPG_STATUS_FRESH": "최신",
"EPG_STATUS_STALE": "새로 고침 필요",
"EPG_STATUS_ERROR": "실패",
@@ -364,6 +365,9 @@
"EPG_DATA_CLEAR_FAILED": "EPG 데이터를 지우지 못했습니다. 다시 시도해 주세요.",
"CLEARING_EPG_DATA": "지우는 중…",
"EPG_URL_ERROR": "값이 유효한 URL이 아닙니다.",
"EPG_SOURCE_LIST_TITLE": "Source list",
"EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"VIDEO_PLAYER_DESCRIPTION": "기본 비디오 플레이어 선택",
"WEB_PLAYER_SHARED_CONTROLS": "웹 플레이어 통합 컨트롤",
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "HTML5, Video.js 및 ArtPlayer에서 IPTVnator 공통 컨트롤을 사용합니다.",
+4
View File
@@ -319,6 +319,7 @@
"REFRESH_EPG": "EPG vernieuwen",
"REFRESH_ALL_EPG": "Alles vernieuwen",
"REMOVE_EPG_SOURCE": "EPG-bron verwijderen",
"BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"EPG_STATUS_FRESH": "Up-to-date",
"EPG_STATUS_STALE": "Verouderd",
"EPG_STATUS_ERROR": "Mislukt",
@@ -364,6 +365,9 @@
"EPG_DATA_CLEAR_FAILED": "Wissen van EPG-gegevens mislukt. Probeer het opnieuw.",
"CLEARING_EPG_DATA": "Wissen…",
"EPG_URL_ERROR": "De opgegeven waarde is geen geldige URL.",
"EPG_SOURCE_LIST_TITLE": "Source list",
"EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"VIDEO_PLAYER_DESCRIPTION": "Selecteer de standaard videospeler",
"WEB_PLAYER_SHARED_CONTROLS": "Uniforme bediening voor webspelers",
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Gebruik de gedeelde IPTVnator-bediening in HTML5, Video.js en ArtPlayer.",
+4
View File
@@ -319,6 +319,7 @@
"REFRESH_EPG": "Odśwież EPG",
"REFRESH_ALL_EPG": "Odśwież wszystko",
"REMOVE_EPG_SOURCE": "Usuń źródło EPG",
"BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"EPG_STATUS_FRESH": "Aktualne",
"EPG_STATUS_STALE": "Wymaga odświeżenia",
"EPG_STATUS_ERROR": "Błąd",
@@ -364,6 +365,9 @@
"EPG_DATA_CLEAR_FAILED": "Nie udało się wyczyścić danych EPG. Spróbuj ponownie.",
"CLEARING_EPG_DATA": "Czyszczenie…",
"EPG_URL_ERROR": "Wartość nie jest prawidłowym URL",
"EPG_SOURCE_LIST_TITLE": "Source list",
"EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"VIDEO_PLAYER_DESCRIPTION": "Wybierz domyślny odtwarzacz wideo",
"WEB_PLAYER_SHARED_CONTROLS": "Ujednolicone sterowanie odtwarzaczami internetowymi",
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Używaj wspólnych elementów sterowania IPTVnator w HTML5, Video.js i ArtPlayer.",
+4
View File
@@ -319,6 +319,7 @@
"REFRESH_EPG": "Atualizar EPG",
"REFRESH_ALL_EPG": "Atualizar tudo",
"REMOVE_EPG_SOURCE": "Remover fonte EPG",
"BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"EPG_STATUS_FRESH": "Atualizado",
"EPG_STATUS_STALE": "Necessita atualização",
"EPG_STATUS_ERROR": "Falha",
@@ -364,6 +365,9 @@
"EPG_DATA_CLEAR_FAILED": "Falha ao limpar os dados de EPG. Tente novamente.",
"CLEARING_EPG_DATA": "Limpando…",
"EPG_URL_ERROR": "O valor não é uma URL válida",
"EPG_SOURCE_LIST_TITLE": "Source list",
"EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"VIDEO_PLAYER_DESCRIPTION": "Selecionar reprodutor de vídeo padrão",
"WEB_PLAYER_SHARED_CONTROLS": "Controlos unificados para leitores web",
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Usa os controlos partilhados do IPTVnator em HTML5, Video.js e ArtPlayer.",
+9 -5
View File
@@ -268,10 +268,10 @@
"GENERAL_SUBTITLE": "Изменение основных параметров приложения",
"PLAYBACK": "Воспроизведение",
"EPG_SOURCES": "Источники EPG",
"EPG_SOURCES_DESCRIPTION": "Добавьте один или несколько URL-адресов источников EPG",
"EPG_SOURCES_DESCRIPTION": "Добавьте один или несколько источников XMLTV: ссылки или локальные файлы",
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} из {{detected}} обнаруженных источников EPG плейлиста были автоматически выбраны для импорта.",
"EPG_EMPTY_TITLE": "Источников EPG пока нет",
"EPG_EMPTY_HINT": "Добавьте URL, чтобы получить программу передач для каналов.",
"EPG_EMPTY_HINT": "Добавьте ссылку или локальный XMLTV-файл, чтобы получить программу передач для каналов.",
"PREFER_UPLOADED_EPG_TITLE": "Предпочитать ваши источники EPG для плейлистов Xtream",
"PREFER_UPLOADED_EPG_HINT": "По умолчанию плейлисты Xtream используют программу передач от вашего провайдера, а ваши источники EPG заполняют только недостающие каналы. Включите, чтобы сначала использовать ваш загруженный EPG.",
"DATA": "Данные",
@@ -314,11 +314,12 @@
"HERO_DESCRIPTION": "Настраивайте воспроизведение, телегид, удалённый доступ и работу с данными в одном месте, не теряя стиль текущего рабочего пространства.",
"SUBTITLE": "Здесь можно изменить общие настройки приложения",
"DESCRIPTION": "Закончив настройку, не забудьте подтвердить новые настройки кнопкой 'Сохранить'",
"EPG_URL_LABEL": "Ссылка на программу каналов (EPG) (*.xml или *.xml.gz)",
"EPG_URL_PLACEHOLDER": "Введите ссылку",
"EPG_URL_LABEL": "Источник EPG (ссылка или локальный файл, *.xml или *.xml.gz)",
"EPG_URL_PLACEHOLDER": "Введите ссылку или путь к файлу",
"REFRESH_EPG": "Обновить программу каналов (EPG)",
"REFRESH_ALL_EPG": "Обновить все",
"REMOVE_EPG_SOURCE": "Удалить источник EPG",
"BROWSE_EPG_FILE": "Выбрать локальный XMLTV-файл…",
"EPG_STATUS_FRESH": "Актуально",
"EPG_STATUS_STALE": "Требует обновления",
"EPG_STATUS_ERROR": "Ошибка",
@@ -363,7 +364,10 @@
"EPG_DATA_CLEARED": "Данные EPG очищены",
"EPG_DATA_CLEAR_FAILED": "Не удалось очистить данные EPG. Попробуйте ещё раз.",
"CLEARING_EPG_DATA": "Очистка…",
"EPG_URL_ERROR": "Введенное значение не является URL-адресом.",
"EPG_URL_ERROR": "Введите ссылку http(s), абсолютный путь к файлу или URL вида file://",
"EPG_SOURCE_LIST_TITLE": "Список источников",
"EPG_SOURCE_FORMATS_HINT": "Поддерживаются как ссылки на XMLTV (http/https), так и файлы на этом компьютере, в виде обычного .xml или сжатого .xml.gz. Для локального файла укажите абсолютный путь или URL вида file://.",
"EPG_SOURCE_FORMATS_EXAMPLES": "Примеры:",
"VIDEO_PLAYER_DESCRIPTION": "Выбрать стандартный видео плеер",
"WEB_PLAYER_SHARED_CONTROLS": "Единые элементы управления для веб-плееров",
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Использовать общие элементы управления IPTVnator в HTML5, Video.js и ArtPlayer.",
+4
View File
@@ -319,6 +319,7 @@
"REFRESH_EPG": "EPG'yi yenile",
"REFRESH_ALL_EPG": "Tümünü yenile",
"REMOVE_EPG_SOURCE": "EPG kaynağını kaldır",
"BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"EPG_STATUS_FRESH": "Güncel",
"EPG_STATUS_STALE": "Yenilenmeli",
"EPG_STATUS_ERROR": "Başarısız",
@@ -364,6 +365,9 @@
"EPG_DATA_CLEAR_FAILED": "EPG verileri temizlenemedi. Lütfen tekrar deneyin.",
"CLEARING_EPG_DATA": "Temizleniyor…",
"EPG_URL_ERROR": "Girilen değer geçerli bir URL değildir",
"EPG_SOURCE_LIST_TITLE": "Source list",
"EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"VIDEO_PLAYER_DESCRIPTION": "Varsayılan video oynatıcıyı seçin",
"WEB_PLAYER_SHARED_CONTROLS": "Web oynatıcıları için birleşik kontroller",
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "HTML5, Video.js ve ArtPlayer'da IPTVnator'ın ortak kontrollerini kullan.",
+4
View File
@@ -319,6 +319,7 @@
"REFRESH_EPG": "刷新电子节目单EPG",
"REFRESH_ALL_EPG": "全部刷新",
"REMOVE_EPG_SOURCE": "移除 EPG 源",
"BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"EPG_STATUS_FRESH": "已更新",
"EPG_STATUS_STALE": "需要刷新",
"EPG_STATUS_ERROR": "失败",
@@ -364,6 +365,9 @@
"EPG_DATA_CLEAR_FAILED": "清除 EPG 数据失败。请重试。",
"CLEARING_EPG_DATA": "清除中…",
"EPG_URL_ERROR": "该值不是有效的 URL",
"EPG_SOURCE_LIST_TITLE": "Source list",
"EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"VIDEO_PLAYER_DESCRIPTION": "选择默认视频播放器",
"WEB_PLAYER_SHARED_CONTROLS": "Web 播放器统一控件",
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "在 HTML5、Video.js 和 ArtPlayer 中使用 IPTVnator 的共享控件。",
+4
View File
@@ -319,6 +319,7 @@
"REFRESH_EPG": "重新整理 EPG",
"REFRESH_ALL_EPG": "全部重新整理",
"REMOVE_EPG_SOURCE": "移除 EPG 來源",
"BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"EPG_STATUS_FRESH": "已更新",
"EPG_STATUS_STALE": "需要重新整理",
"EPG_STATUS_ERROR": "失敗",
@@ -364,6 +365,9 @@
"EPG_DATA_CLEAR_FAILED": "清除 EPG 資料失敗。請重試。",
"CLEARING_EPG_DATA": "正在清除…",
"EPG_URL_ERROR": "該值不是有效的網址",
"EPG_SOURCE_LIST_TITLE": "Source list",
"EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"VIDEO_PLAYER_DESCRIPTION": "選擇預設影片播放器",
"WEB_PLAYER_SHARED_CONTROLS": "Web 播放器統一控制項",
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "在 HTML5、Video.js 和 ArtPlayer 中使用 IPTVnator 的共用控制項。",
+57
View File
@@ -1081,6 +1081,63 @@ retried as plain XML. Source errors and consumer cancellation terminate the
whole decoding chain. The decoder does not recursively unpack file layers or
change XML parsing, source reconciliation, or database persistence contracts.
### Local XMLTV files
An EPG source is not necessarily a URL. Settings → EPG and the playlist
dialog accept, next to `http(s)` links, a file on the user's computer in any
of these shapes (Electron only — the PWA has no EPG import at all):
- a `file:` URL (`file:///home/you/epg/guide.xml.gz`, `file:///C:/epg/guide.xml`)
- an absolute POSIX path (`/home/you/epg/guide.xml`)
- a Windows drive or UNC path (`C:\epg\guide.xml.gz`, `\\nas\share\guide.xml`)
Both surfaces also offer a folder button that opens the native file picker
(`EPG_OPEN_FILE_DIALOG` → `ElectronBridgeApi.openEpgFileDialog`, gated on
`RuntimeCapabilitiesService.supportsEpgFilePicker`) and writes the chosen
absolute path into the row. Relative paths are refused: the main process
has no meaningful working directory to resolve them against. The value is stored exactly as typed
(trimmed) and is the source key everywhere — freshness, reconciliation, the
progress panel and `epg_channel_sources` all treat it like a URL string.
The shape rules live in one place, `classifyEpgSourceReference()` in
`libs/shared/interfaces/src/lib/epg-source-reference.util.ts`, and both
forms validate through its structural `validateEpgSourceReferenceControl`.
In the worker, `openEpgSourceStream()` (`epg-source-stream.ts`) is the single
entry that yields decoded XMLTV bytes: remote sources still go through
`requestWithValidatedRedirects` with the private-network/TLS trust policy,
while local sources are read with `fs.createReadStream` behind the same
signature-sniffing optional gunzip stage the HTTP path uses, so `.xml`,
`.xml.gz` and an extension-less gzip file all parse. A missing file, a
directory, or a truncated gzip fail the import with a plain message; nothing
is retried as XML.
**Provenance rule.** Only values the user chose or typed by hand may be
local, and the decision is enforced in the main process, not in the form.
The local branch bypasses `validateRemoteUrl` (which only knows http/https),
so two layers guard it:
- Header-declared M3U sources (`x-tvg-url`/`url-tvg`/`tvg-url`) are filtered
to remote links — `extractM3uEpgUrls` no longer matches `file:`, and
`resolvePlaylistEpgSourceState` / `filterPlaylistEpgUrlsForFetch` drop a
legacy non-remote entry that older versions stored from a header unless it
also appears in `manualEpgUrls`. A downloaded playlist cannot point the
importer at a file on disk; a user whose own local M3U references a local
XMLTV adds that file in the playlist dialog instead.
- `FETCH_EPG`/`EPG_FORCE_FETCH` carry renderer-supplied strings, so
`EpgWorkerService.startFetch` asks the main-process
`EpgLocalSourceAuthorizer` (`epg-local-source-authorizer.ts`) 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 fails that fetch — the progress panel
shows it with Retry ("Retry to be asked again") and the IPC result does
not report success. Allowed paths persist under
`TRUSTED_LOCAL_EPG_SOURCES` in the main-process config (`store.service.ts`)
so startup refreshes need no prompt. The worker's local branch opens only
when main set `allowLocalFile` on the fetch options — the flag is never
taken from the renderer — and the service defaults to a deny-all authorizer
until `epg.events.ts` installs the persisted one, so a missing wiring fails
closed. This mirrors `save-file-dialog` → `write-file` for playlist exports.
### EpgService (`@iptvnator/epg/data-access`)
```typescript
@@ -10,6 +10,7 @@ describe('EpgRuntimeBridgeService', () => {
beforeEach(() => {
runtimeCapabilities = {
supportsEpgImport: false,
supportsEpgFilePicker: false,
supportsEpgProgress: false,
supportsEpgProgramLookup: false,
supportsEpgCurrentProgramBatch: false,
@@ -39,6 +40,24 @@ describe('EpgRuntimeBridgeService', () => {
jest.restoreAllMocks();
});
it('opens the native XMLTV picker only when the capability is available', async () => {
const openEpgFileDialog = jest
.fn()
.mockResolvedValue('/home/user/epg/guide.xml.gz');
window.electron = {
...window.electron,
openEpgFileDialog,
} as unknown as typeof window.electron;
await expect(service.pickEpgFile()).resolves.toBeNull();
expect(openEpgFileDialog).not.toHaveBeenCalled();
runtimeCapabilities.supportsEpgFilePicker = true;
await expect(service.pickEpgFile()).resolves.toBe(
'/home/user/epg/guide.xml.gz'
);
});
it('does not call Electron EPG methods when the matching capability is unavailable', async () => {
const fetchEpg = jest.fn().mockResolvedValue({ success: true });
const checkEpgFreshness = jest.fn().mockResolvedValue({
@@ -37,6 +37,7 @@ type EpgElectronBridge = Pick<
| 'clearEpgDataForSource'
| 'fetchEpg'
| 'forceFetchEpg'
| 'openEpgFileDialog'
| 'getChannelPrograms'
| 'getCurrentProgramsBatch'
| 'getEpgChannelMetadata'
@@ -63,6 +64,10 @@ export class EpgRuntimeBridgeService {
return this.runtime.supportsEpgProgress;
}
get supportsFilePicker(): boolean {
return this.runtime.supportsEpgFilePicker;
}
get supportsProgramLookup(): boolean {
return this.runtime.supportsEpgProgramLookup;
}
@@ -119,6 +124,15 @@ export class EpgRuntimeBridgeService {
);
}
/** Native picker for a local XMLTV file; null when unsupported or cancelled. */
pickEpgFile(): Promise<string | null> {
if (!this.supportsFilePicker) {
return Promise.resolve(null);
}
return this.bridge?.openEpgFileDialog?.() ?? Promise.resolve(null);
}
clearEpgData(): Promise<EpgClearResult | null> {
if (!this.supportsDataManagement) {
return Promise.resolve(null);
+1 -1
View File
@@ -539,7 +539,7 @@ export class PlaylistEffects {
private fetchPlaylistScopedEpg(
playlist: Pick<
Playlist,
'_id' | 'epgUrls' | 'macAddress' | 'serverUrl'
'_id' | 'epgUrls' | 'macAddress' | 'manualEpgUrls' | 'serverUrl'
>,
options: { force?: boolean } = {}
): void {
@@ -138,4 +138,23 @@ describe('resolvePlaylistScopedEpgFetchPlan', () => {
],
});
});
it('fetches a manual local file but never a legacy header-declared one', () => {
expect(
resolvePlaylistScopedEpgFetchPlan(
{
epgUrls: [
'file:///etc/passwd',
'/home/user/epg/guide.xml.gz',
'https://playlist.example.com/guide.xml',
],
manualEpgUrls: ['/home/user/epg/guide.xml.gz'],
},
[]
).urls
).toEqual([
'/home/user/epg/guide.xml.gz',
'https://playlist.example.com/guide.xml',
]);
});
});
@@ -12,7 +12,10 @@ export interface PlaylistScopedEpgFetchOptions {
}
export function resolvePlaylistScopedEpgFetchPlan(
playlist: Pick<PlaylistMeta, 'epgUrls' | 'macAddress' | 'serverUrl'>,
playlist: Pick<
PlaylistMeta,
'epgUrls' | 'macAddress' | 'manualEpgUrls' | 'serverUrl'
>,
globalEpgUrls: readonly string[],
previousKey = '',
options: PlaylistScopedEpgFetchOptions = {}
@@ -28,7 +31,11 @@ export function resolvePlaylistScopedEpgFetchPlan(
return { key: previousKey, shouldFetch: false, urls: [] };
}
const urls = filterPlaylistEpgUrlsForFetch(playlist.epgUrls, globalEpgUrls);
const urls = filterPlaylistEpgUrlsForFetch(
playlist.epgUrls,
globalEpgUrls,
playlist.manualEpgUrls
);
const key = urls.join('\n');
const previousUrls = new Set(
previousKey
@@ -313,6 +313,26 @@
}}</mat-error>
}
</mat-form-field>
@if (canBrowseEpgFiles) {
<button
mat-icon-button
type="button"
data-test-id="playlist-epg-source-browse"
[matTooltip]="
'SETTINGS.BROWSE_EPG_FILE'
| translate
"
[attr.aria-label]="
'SETTINGS.BROWSE_EPG_FILE'
| translate
"
(click)="
browsePlaylistEpgSourceInput($index)
"
>
<mat-icon>folder_open</mat-icon>
</button>
}
<button
mat-icon-button
type="button"
@@ -31,8 +31,10 @@ describe('PlaylistInfoComponent', () => {
};
let epgBridge: {
supportsDataManagement: boolean;
supportsFilePicker: boolean;
forceFetchEpg: jest.Mock;
clearEpgDataForSource: jest.Mock;
pickEpgFile: jest.Mock;
};
let runtime: {
isElectron: boolean;
@@ -82,6 +84,8 @@ describe('PlaylistInfoComponent', () => {
};
epgBridge = {
supportsDataManagement: true,
supportsFilePicker: true,
pickEpgFile: jest.fn().mockResolvedValue(null),
forceFetchEpg: jest.fn().mockResolvedValue({ success: true }),
clearEpgDataForSource: jest
.fn()
@@ -583,6 +587,23 @@ describe('PlaylistInfoComponent', () => {
expect(component.playlistEpgSourceInputs.at(0).value).toBe('');
});
it('fills a playlist-local EPG source input from the native file picker', async () => {
createComponent();
const input = component.playlistEpgSourceInputs.at(0);
await component.browsePlaylistEpgSourceInput(0);
expect(input.value).toBe('');
epgBridge.pickEpgFile.mockResolvedValue('/home/user/epg/guide.xml.gz');
await component.browsePlaylistEpgSourceInput(0);
expect(input.value).toBe('/home/user/epg/guide.xml.gz');
expect(input.valid).toBe(true);
expect(input.dirty).toBe(true);
epgBridge.supportsFilePicker = false;
await component.browsePlaylistEpgSourceInput(0);
expect(epgBridge.pickEpgFile).toHaveBeenCalledTimes(2);
});
it('shows a validation error for invalid playlist-local EPG source URLs', () => {
createComponent();
fixture.detectChanges();
@@ -40,6 +40,7 @@ import {
Playlist,
PlaylistMeta,
PlaylistMetaUpdate,
validateEpgSourceReferenceControl,
} from '@iptvnator/shared/interfaces';
import {
normalizeEpgUrls,
@@ -60,8 +61,6 @@ type DesktopFileSaveBridge = Pick<
'saveFileDialog' | 'writeFile'
>;
const EPG_URL_PATTERN = /^\s*(http|https|file):\/\/[^ "]+\s*$/;
@Component({
selector: 'app-playlist-info',
templateUrl: './playlist-info.component.html',
@@ -727,6 +726,25 @@ export class PlaylistInfoComponent {
);
}
get canBrowseEpgFiles(): boolean {
return this.epgBridge.supportsFilePicker;
}
/** Native picker for a local XMLTV file; fills EPG input `index`. */
async browsePlaylistEpgSourceInput(index: number): Promise<void> {
const control = this.playlistEpgSourceInputs.at(index);
if (!control || !this.epgBridge.supportsFilePicker) {
return;
}
const filePath = await this.epgBridge.pickEpgFile();
if (!filePath) {
return;
}
control.setValue(filePath);
control.markAsDirty();
control.markAsTouched();
}
removePlaylistEpgSourceInput(index: number): void {
if (this.playlistEpgSourceInputs.length <= 1) {
this.playlistEpgSourceInputs.at(0).reset('');
@@ -861,7 +879,7 @@ export class PlaylistInfoComponent {
private createPlaylistEpgSourceControl(value = ''): FormControl<string> {
return new FormControl(value, {
nonNullable: true,
validators: [Validators.pattern(EPG_URL_PATTERN)],
validators: [validateEpgSourceReferenceControl],
});
}
@@ -47,6 +47,7 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.supportsRemoteControl).toBe(false);
expect(service.supportsXtreamSectionNavigation).toBe(true);
expect(service.supportsEpgImport).toBe(false);
expect(service.supportsEpgFilePicker).toBe(false);
expect(service.supportsEpgProgress).toBe(false);
expect(service.supportsEpgProgramLookup).toBe(false);
expect(service.supportsEpgCurrentProgramBatch).toBe(false);
@@ -143,6 +144,7 @@ describe('RuntimeCapabilitiesService', () => {
checkEpgFreshness: jest.fn(),
onEpgProgress: jest.fn(),
forceFetchEpg: jest.fn(),
openEpgFileDialog: jest.fn(),
clearEpgData: jest.fn(),
clearEpgDataForSource: jest.fn(),
getEpgProgramsForChannels: jest.fn(),
@@ -174,6 +176,7 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.supportsRemoteControl).toBe(true);
expect(service.supportsXtreamSectionNavigation).toBe(true);
expect(service.supportsEpgImport).toBe(true);
expect(service.supportsEpgFilePicker).toBe(true);
expect(service.supportsEpgProgress).toBe(true);
expect(service.supportsEpgProgramLookup).toBe(true);
expect(service.supportsEpgCurrentProgramBatch).toBe(true);
@@ -113,6 +113,11 @@ export class RuntimeCapabilitiesService {
return this.hasElectronMethod('fetchEpg');
}
/** Native file picker for local XMLTV sources (Settings → EPG, playlist dialog). */
get supportsEpgFilePicker(): boolean {
return this.hasElectronMethod('openEpgFileDialog');
}
get supportsEpgProgress(): boolean {
return this.hasElectronMethod('onEpgProgress');
}
+1
View File
@@ -14,6 +14,7 @@ export * from './lib/epg-display-offset.util';
export * from './lib/epg-item.interface';
export * from './lib/epg-mapping-key.util';
export * from './lib/epg-program.model';
export * from './lib/epg-source-reference.util';
export * from './lib/external-player-arguments.utils';
export * from './lib/embedded-mpv-extra-options.util';
export * from './lib/external-player-session.interface';
@@ -838,6 +838,8 @@ export interface ElectronBridgeApi {
url: string,
options?: ElectronBridgeTrustOptions
) => Promise<ElectronBridgeEpgFetchResult>;
/** Native picker for a local XMLTV file; resolves null when cancelled. */
openEpgFileDialog: () => Promise<string | null>;
clearEpgData: () => Promise<ElectronBridgeResult>;
reconcileEpgSources: (urls: string[]) => Promise<ElectronBridgeResult>;
clearEpgDataForSource: (sourceUrl: string) => Promise<ElectronBridgeResult>;
@@ -0,0 +1,80 @@
import {
classifyEpgSourceReference,
isLocalEpgSourceReference,
isRemoteEpgSourceUrl,
validateEpgSourceReferenceControl,
} from './epg-source-reference.util';
describe('classifyEpgSourceReference', () => {
it.each([
'http://epg.example.org/guide.xml',
'https://epg.example.org/guide.xml.gz?token=abc',
' HTTPS://epg.example.org/guide.xml ',
])('treats %s as remote', (value) => {
expect(classifyEpgSourceReference(value)).toBe('remote');
expect(isRemoteEpgSourceUrl(value)).toBe(true);
expect(isLocalEpgSourceReference(value)).toBe(false);
});
it.each([
'file:///home/user/epg/guide.xml.gz',
'file:///C:/epg/guide.xml',
'/home/user/epg/guide.xml',
'/Users/me/Downloads/guide.xml.gz',
'C:\\epg\\guide.xml.gz',
'd:/epg/guide.xml',
'\\\\nas\\share\\epg\\guide.xml.gz',
])('treats %s as a local file', (value) => {
expect(classifyEpgSourceReference(value)).toBe('local');
expect(isLocalEpgSourceReference(value)).toBe(true);
expect(isRemoteEpgSourceUrl(value)).toBe(false);
});
it.each([
'guide.xml',
'./guide.xml',
'~/epg/guide.xml',
'ftp://epg.example.org/guide.xml',
'file://',
'epg.example.org/guide.xml',
'C:',
'',
' ',
])('rejects %j', (value) => {
expect(classifyEpgSourceReference(value)).toBeNull();
});
it('rejects null and undefined', () => {
expect(classifyEpgSourceReference(null)).toBeNull();
expect(classifyEpgSourceReference(undefined)).toBeNull();
});
});
describe('validateEpgSourceReferenceControl', () => {
it('accepts an empty control like Validators.pattern does', () => {
expect(validateEpgSourceReferenceControl({ value: '' })).toBeNull();
expect(validateEpgSourceReferenceControl({ value: null })).toBeNull();
});
it('accepts remote links and local files', () => {
expect(
validateEpgSourceReferenceControl({
value: 'https://epg.example.org/guide.xml',
})
).toBeNull();
expect(
validateEpgSourceReferenceControl({ value: '/tmp/guide.xml.gz' })
).toBeNull();
});
it('flags relative paths and unsupported schemes', () => {
expect(
validateEpgSourceReferenceControl({ value: 'guide.xml' })
).toEqual({ epgSourceReference: true });
expect(
validateEpgSourceReferenceControl({
value: 'ftp://epg.example.org/guide.xml',
})
).toEqual({ epgSourceReference: true });
});
});
@@ -0,0 +1,72 @@
/**
* Classification of the value a user types as an EPG source.
*
* A source is either a remote XMLTV link (`http:`/`https:`) or a local file:
* a `file:` URL, an absolute POSIX path, a Windows drive path or a UNC path.
* Relative paths are refused on purpose — the main process has no meaningful
* working directory to resolve them against.
*
* Only values the user entered by hand (Settings → EPG, the playlist dialog)
* may be local. URLs harvested from an M3U header are filtered to remote ones
* before they are stored, so a downloaded playlist can never point the EPG
* importer at a file on the user's disk.
*/
export type EpgSourceReferenceKind = 'remote' | 'local';
export const EPG_SOURCE_REFERENCE_ERROR = 'epgSourceReference';
const REMOTE_EPG_SOURCE_PATTERN = /^https?:\/\/[^\s"]+$/i;
const FILE_URL_EPG_SOURCE_PATTERN = /^file:\/\/[^\s"]+$/i;
const POSIX_ABSOLUTE_PATH_PATTERN = /^\/[^\0]+$/;
const WINDOWS_DRIVE_PATH_PATTERN = /^[a-z]:[\\/][^\0]*$/i;
const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\/\0]+\\[^\\/\0]+/;
export function classifyEpgSourceReference(
value: string | null | undefined
): EpgSourceReferenceKind | null {
const trimmed = value?.trim() ?? '';
if (trimmed === '') {
return null;
}
if (REMOTE_EPG_SOURCE_PATTERN.test(trimmed)) {
return 'remote';
}
if (
FILE_URL_EPG_SOURCE_PATTERN.test(trimmed) ||
POSIX_ABSOLUTE_PATH_PATTERN.test(trimmed) ||
WINDOWS_DRIVE_PATH_PATTERN.test(trimmed) ||
WINDOWS_UNC_PATH_PATTERN.test(trimmed)
) {
return 'local';
}
return null;
}
export function isRemoteEpgSourceUrl(
value: string | null | undefined
): boolean {
return classifyEpgSourceReference(value) === 'remote';
}
export function isLocalEpgSourceReference(
value: string | null | undefined
): boolean {
return classifyEpgSourceReference(value) === 'local';
}
/**
* Form validator shared by the Settings EPG list and the playlist dialog.
* Typed structurally so this contracts library stays Angular-free; an empty
* control is valid, mirroring `Validators.pattern`.
*/
export function validateEpgSourceReferenceControl(control: {
value: unknown;
}): Record<typeof EPG_SOURCE_REFERENCE_ERROR, true> | null {
const value = control.value;
if (typeof value !== 'string' || value.trim() === '') {
return null;
}
return classifyEpgSourceReference(value)
? null
: { [EPG_SOURCE_REFERENCE_ERROR]: true };
}
@@ -130,6 +130,21 @@ describe('playlist utils', () => {
]);
});
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({
@@ -319,6 +334,50 @@ describe('playlist utils', () => {
});
});
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(
@@ -1,6 +1,7 @@
import {
Channel,
createRandomId,
isRemoteEpgSourceUrl,
ParsedPlaylist,
ParsedPlaylistItem,
Playlist,
@@ -67,7 +68,11 @@ export const getFilenameFromUrl = (value: string): string => {
};
const M3U_EPG_HEADER_ATTRS = ['x-tvg-url', 'url-tvg', 'tvg-url'] as const;
const M3U_EPG_URL_PATTERN = /\b(?:https?|file):\/\/[^\s,"']+/gi;
// Header-declared sources are provider-controlled input. Only remote links
// are harvested: a `file:` URL here would let a downloaded playlist point the
// EPG importer at a file on the user's disk. Local XMLTV files are supported,
// but only when the user types the path in Settings or the playlist dialog.
const M3U_EPG_URL_PATTERN = /\bhttps?:\/\/[^\s,"']+/gi;
export const M3U_AUTO_IMPORT_EPG_URL_LIMIT = 5;
export const M3U_RECOMMENDED_EPG_URL_LIMIT = 12;
@@ -105,14 +110,25 @@ export function normalizeEpgUrls(
export function resolvePlaylistEpgSourceState(
input: PlaylistEpgSourceStateInput
): PlaylistEpgSourceState {
const detectedEpgUrls = normalizeEpgUrls(input.detectedEpgUrls);
const manualEpgUrls = normalizeEpgUrls(input.manualEpgUrls);
const manualSet = new Set(manualEpgUrls);
// Versions before local-file support stored whatever the M3U header
// declared, `file://` included. Header-declared entries are provider
// input, so a stored non-remote one is dropped unless the user also added
// it by hand — only hand-added sources may point at a local file.
const detectedEpgUrls = normalizeEpgUrls(input.detectedEpgUrls).filter(
isRemoteEpgSourceUrl
);
const disabledEpgUrls = normalizeEpgUrls(input.disabledEpgUrls);
const disabledSet = new Set(disabledEpgUrls);
const epgUrls = normalizeEpgUrls([
...normalizeEpgUrls(input.enabledEpgUrls),
...manualEpgUrls,
]).filter((url) => !disabledSet.has(url));
]).filter(
(url) =>
!disabledSet.has(url) &&
(isRemoteEpgSourceUrl(url) || manualSet.has(url))
);
return {
detectedEpgUrls,
@@ -122,13 +138,22 @@ export function resolvePlaylistEpgSourceState(
};
}
/**
* Playlist-scoped URLs worth fetching: not already configured globally, and
* never a stored non-remote entry unless it is in `manualEpgUrls` (see
* `resolvePlaylistEpgSourceState` for why legacy header values are dropped).
*/
export function filterPlaylistEpgUrlsForFetch(
playlistEpgUrls: readonly string[] | undefined,
globalEpgUrls: readonly string[] | undefined
globalEpgUrls: readonly string[] | undefined,
manualEpgUrls: readonly string[] | undefined = []
): string[] {
const globalUrlSet = new Set(normalizeEpgUrls(globalEpgUrls));
const manualSet = new Set(normalizeEpgUrls(manualEpgUrls));
return normalizeEpgUrls(playlistEpgUrls).filter(
(url) => !globalUrlSet.has(url)
(url) =>
!globalUrlSet.has(url) &&
(isRemoteEpgSourceUrl(url) || manualSet.has(url))
);
}