mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
* 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>
387 lines
13 KiB
TypeScript
387 lines
13 KiB
TypeScript
import { reconcileEpgSources } from './epg-source-settings.service';
|
|
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,
|
|
ElectronBridgeTrustOptions,
|
|
EpgChannelMetadata,
|
|
EpgProgram,
|
|
} from '@iptvnator/shared/interfaces';
|
|
import { epgQueryService } from './epg-query.service';
|
|
import { epgGuideQueryService } from './epg-guide-query.service';
|
|
import { epgWorkerService } from './epg-worker.service';
|
|
import { checkEpgFreshness, handleFetchEpg } from './epg-fetch.service';
|
|
import type { EpgFetchResult, EpgFreshnessResult } from './epg-fetch.service';
|
|
import {
|
|
handleDeleteEpgMapping,
|
|
handleGetEpgMapping,
|
|
handleGetEpgMappingsBatch,
|
|
handleSearchEpgChannels,
|
|
handleSetEpgMapping,
|
|
queryByResolvedChannelIds,
|
|
resolveChannelIds,
|
|
resolveChannelIdsStrict,
|
|
} from './epg-mapping.service';
|
|
|
|
/**
|
|
* EPG Events Handler
|
|
* Manages EPG IPC registration and delegates worker/query behavior.
|
|
* Freshness and fetch orchestration live in `epg-fetch.service.ts`; manual
|
|
* channel-mapping resolution and CRUD live in `epg-mapping.service.ts`.
|
|
*/
|
|
export default class EpgEvents {
|
|
/**
|
|
* Bootstrap EPG events
|
|
*/
|
|
static bootstrapEpgEvents(): Electron.IpcMain {
|
|
ipcMain.handle(
|
|
'EPG_RECONCILE_SOURCES',
|
|
async (_event, args: { urls: string[] }) => {
|
|
await reconcileEpgSources(args.urls);
|
|
return { success: true };
|
|
}
|
|
);
|
|
|
|
ipcMain.handle(
|
|
'FETCH_EPG',
|
|
async (
|
|
_event,
|
|
args: { url: string[]; options?: ElectronBridgeTrustOptions }
|
|
) => {
|
|
return await this.handleFetchEpg(args.url, args.options);
|
|
}
|
|
);
|
|
|
|
ipcMain.handle(
|
|
'GET_CHANNEL_PROGRAMS',
|
|
async (
|
|
_event,
|
|
args: { channelId: string; options?: { sourceUrls?: string[] } }
|
|
) => {
|
|
return this.handleGetChannelPrograms(
|
|
args.channelId,
|
|
args.options
|
|
);
|
|
}
|
|
);
|
|
|
|
ipcMain.handle(
|
|
'GET_CURRENT_PROGRAMS_BATCH',
|
|
async (
|
|
_event,
|
|
args: {
|
|
channelIds: string[];
|
|
options?: ElectronBridgeCurrentProgramsOptions;
|
|
}
|
|
) => {
|
|
return this.handleGetCurrentProgramsBatch(
|
|
args.channelIds,
|
|
args.options
|
|
);
|
|
}
|
|
);
|
|
|
|
ipcMain.handle('EPG_GET_CHANNELS', async () => {
|
|
return this.handleGetAllChannels();
|
|
});
|
|
|
|
ipcMain.handle(
|
|
'EPG_GET_CHANNEL_METADATA',
|
|
async (
|
|
_event,
|
|
args: {
|
|
channelIds: string[];
|
|
options?: { sourceUrls?: string[] };
|
|
}
|
|
) => {
|
|
return this.handleGetChannelMetadata(
|
|
args.channelIds,
|
|
args.options
|
|
);
|
|
}
|
|
);
|
|
|
|
ipcMain.handle(
|
|
'EPG_GET_PROGRAMS_FOR_CHANNELS',
|
|
async (_event, args: ElectronBridgeEpgGuideWindow) => {
|
|
return this.handleGetGuidePrograms(args);
|
|
}
|
|
);
|
|
|
|
ipcMain.handle(
|
|
'EPG_GET_PROGRAM_COVERAGE',
|
|
async (_event, args: ElectronBridgeEpgGuideWindow) => {
|
|
return this.handleGetGuideCoverage(args);
|
|
}
|
|
);
|
|
|
|
ipcMain.handle(
|
|
'EPG_FORCE_FETCH',
|
|
async (
|
|
_event,
|
|
args:
|
|
| string
|
|
| { url: string; options?: ElectronBridgeTrustOptions }
|
|
) => {
|
|
const url = typeof args === 'string' ? args : args.url;
|
|
const options =
|
|
typeof args === 'string' ? undefined : args.options;
|
|
epgWorkerService.deleteFetchedUrl(url);
|
|
return await this.handleFetchEpg([url], options);
|
|
}
|
|
);
|
|
|
|
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 };
|
|
});
|
|
|
|
ipcMain.handle(
|
|
'EPG_CLEAR_SOURCE',
|
|
async (_event, args: { sourceUrl: string }) => {
|
|
await this.clearEpgDataForSource(args.sourceUrl);
|
|
return { success: true };
|
|
}
|
|
);
|
|
|
|
ipcMain.handle(
|
|
'EPG_CHECK_FRESHNESS',
|
|
async (
|
|
_event,
|
|
args: { urls: string[]; maxAgeHours?: number }
|
|
): Promise<{ staleUrls: string[]; freshUrls: string[] }> => {
|
|
return this.checkEpgFreshness(
|
|
args.urls,
|
|
args.maxAgeHours ?? 12
|
|
);
|
|
}
|
|
);
|
|
|
|
// EPG channel mapping CRUD — handled entirely by epg-mapping.service.
|
|
ipcMain.handle(
|
|
'EPG_MAPPING_GET',
|
|
async (_event, args: { channelKey: string }) => {
|
|
return handleGetEpgMapping(args.channelKey);
|
|
}
|
|
);
|
|
|
|
ipcMain.handle(
|
|
'EPG_MAPPING_SET',
|
|
async (
|
|
_event,
|
|
args: {
|
|
channelKey: string;
|
|
epgChannelId: string;
|
|
playlistId?: string;
|
|
}
|
|
) => {
|
|
return handleSetEpgMapping(
|
|
args.channelKey,
|
|
args.epgChannelId,
|
|
args.playlistId
|
|
);
|
|
}
|
|
);
|
|
|
|
ipcMain.handle(
|
|
'EPG_MAPPING_GET_BATCH',
|
|
async (_event, args: { channelKeys: string[] }) => {
|
|
return handleGetEpgMappingsBatch(args.channelKeys);
|
|
}
|
|
);
|
|
|
|
ipcMain.handle(
|
|
'EPG_MAPPING_DELETE',
|
|
async (_event, args: { channelKey: string }) => {
|
|
return handleDeleteEpgMapping(args.channelKey);
|
|
}
|
|
);
|
|
|
|
ipcMain.handle(
|
|
'EPG_CHANNEL_SEARCH',
|
|
async (_event, args: { searchTerm: string; limit?: number }) => {
|
|
return handleSearchEpgChannels(args.searchTerm, args.limit);
|
|
}
|
|
);
|
|
|
|
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
|
|
): Promise<EpgFreshnessResult> {
|
|
return checkEpgFreshness(urls, maxAgeHours);
|
|
}
|
|
|
|
private static async handleFetchEpg(
|
|
urls: string[],
|
|
options: ElectronBridgeTrustOptions = {}
|
|
): Promise<EpgFetchResult> {
|
|
return handleFetchEpg(urls, options);
|
|
}
|
|
|
|
private static async fetchEpgFromUrl(
|
|
url: string,
|
|
options: ElectronBridgeTrustOptions = {}
|
|
): Promise<void> {
|
|
return epgWorkerService.fetchEpgFromUrl(url, options);
|
|
}
|
|
|
|
private static async handleGetChannelPrograms(
|
|
channelId: string,
|
|
options?: { sourceUrls?: string[] }
|
|
): Promise<EpgProgram[]> {
|
|
return epgQueryService.getChannelPrograms(channelId, options);
|
|
}
|
|
|
|
private static async handleGetCurrentProgramsBatch(
|
|
channelIds: string[],
|
|
options?: ElectronBridgeCurrentProgramsOptions
|
|
): Promise<Record<string, EpgProgram | null>> {
|
|
return queryByResolvedChannelIds(channelIds, (resolvedIds) =>
|
|
epgQueryService.getCurrentProgramsBatch(resolvedIds, options)
|
|
);
|
|
}
|
|
|
|
private static async handleGetAllChannels(): Promise<{
|
|
channels: Array<{ id: string; displayName: string }>;
|
|
programs: never[];
|
|
}> {
|
|
return epgQueryService.getAllChannels();
|
|
}
|
|
|
|
private static async handleGetChannelMetadata(
|
|
channelIds: string[],
|
|
options?: { sourceUrls?: string[] }
|
|
): Promise<Record<string, EpgChannelMetadata | null>> {
|
|
return queryByResolvedChannelIds(channelIds, (resolvedIds) =>
|
|
epgQueryService.getChannelMetadata(resolvedIds, options)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The guide query service keys its answer by the TRIMMED, deduplicated,
|
|
* cap-respecting form of the channel ids it was given
|
|
* (`normalizeGuideWindow`). Handlers must resolve the same trimmed key
|
|
* to look up that answer, or a padded request id (`" CNN "`) would never
|
|
* find its entry even though the service queried it successfully.
|
|
*/
|
|
private static resolvedGuideKey(
|
|
mapping: Map<string, string>,
|
|
id: unknown
|
|
): string {
|
|
return typeof id === 'string' ? (mapping.get(id) ?? id).trim() : '';
|
|
}
|
|
|
|
/**
|
|
* Guide reads take playlist channel keys. Manual mappings are applied
|
|
* here, before the query, and the answer is keyed back by the requested
|
|
* key so the renderer never sees a mapped id. A key the service did not
|
|
* answer for (cut by its per-request cap) stays absent from the result
|
|
* rather than being filled with `[]`, so callers can tell "queried,
|
|
* nothing found" apart from "not queried at all".
|
|
*/
|
|
private static async handleGetGuidePrograms(
|
|
args: ElectronBridgeEpgGuideWindow
|
|
): Promise<Record<string, EpgProgram[]>> {
|
|
const requested = Array.isArray(args?.channelIds)
|
|
? args.channelIds
|
|
: [];
|
|
const mapping = await resolveChannelIds(requested);
|
|
const resolvedIds = requested.map((id) => mapping.get(id) ?? id);
|
|
const programs = await epgGuideQueryService.getProgramsForChannels({
|
|
...args,
|
|
channelIds: resolvedIds,
|
|
});
|
|
const answer: Record<string, EpgProgram[]> = Object.create(null);
|
|
for (const id of requested) {
|
|
const key = this.resolvedGuideKey(mapping, id);
|
|
if (Object.prototype.hasOwnProperty.call(programs, key)) {
|
|
// Copy so two requested ids resolving to one target never
|
|
// share an array reference.
|
|
answer[String(id)] = [...programs[key]];
|
|
}
|
|
}
|
|
return answer;
|
|
}
|
|
|
|
/** A thrown error rejects the renderer's `invoke`, which is the intended fail-open path for coverage. */
|
|
private static async handleGetGuideCoverage(
|
|
args: ElectronBridgeEpgGuideWindow
|
|
): Promise<string[]> {
|
|
const requested = Array.isArray(args?.channelIds)
|
|
? args.channelIds
|
|
: [];
|
|
// Strict on purpose: a mapping lookup failure must reject (coverage
|
|
// unknown), not report mapped channels as unmapped and uncovered.
|
|
const mapping = await resolveChannelIdsStrict(requested);
|
|
const resolvedIds = requested.map((id) => mapping.get(id) ?? id);
|
|
const covered = new Set(
|
|
await epgGuideQueryService.getProgramCoverage({
|
|
...args,
|
|
channelIds: resolvedIds,
|
|
})
|
|
);
|
|
const seen = new Set<string>();
|
|
const answer: string[] = [];
|
|
for (const id of requested) {
|
|
if (seen.has(id)) {
|
|
continue;
|
|
}
|
|
seen.add(id);
|
|
if (covered.has(this.resolvedGuideKey(mapping, id))) {
|
|
answer.push(id);
|
|
}
|
|
}
|
|
return answer;
|
|
}
|
|
|
|
static async clearEpgData(): Promise<void> {
|
|
return epgWorkerService.clearEpgData();
|
|
}
|
|
|
|
static async clearEpgDataForSource(sourceUrl: string): Promise<void> {
|
|
return epgWorkerService.clearEpgDataForSource(sourceUrl);
|
|
}
|
|
}
|