fix(dashboard): live rails find XMLTV programmes outside the global EPG sources

The dashboard's "Now on air on favorite channels" and "Recently watched
Live TV" rails looked up XMLTV programmes scoped to the Settings-managed
global EPG URLs only, with no fallback. A favourite whose guide lives in
another playlist's XMLTV (or whose programmes are tagged with a source
other than the channel row's) showed only the LIVE chip while its "See
all" row — resolved by StreamResolverService, which never scopes by
source — showed the programme.

EpgLookupOptions gains a renderer-only `anySourceFallback`: after the
scope (playlist sources, then global) has answered, the keys still
without a programme are retried once through the source-less batch path
and its cache. The dashboard rails pass it; the channel list keeps the
strict scope.

Regression E2E: playlist-declared XMLTV + a global XMLTV without the
channel → the favourite's dashboard card shows the programme (fails on
the old code with the subtitle in its place).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-19 17:23:06 +02:00
1 parent 4cce4acaad
commit 54071e2a4d
8 files changed
+367 -11

No files matched your search

@@ -0,0 +1,9 @@
---
type: fix
area: dashboard
---
The dashboard's "Now on air on favorite channels" and "Recently watched Live
TV" rails now show the current programme for channels whose guide lives in an
XMLTV another playlist imported, not only in the global EPG sources from
Settings — the same lookup the "See all" pages already used.
+1
View File
@@ -1606,6 +1606,7 @@ stream_id`); it drops `series_id`/`movie_id`, so the builder pins the
- 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
- Source scope: batch "now" lookups search the playlist's own XMLTV first, then the Settings-managed global sources, and stop there; only `EpgLookupOptions.anySourceFallback` (renderer-only) retries the still-unresolved keys against every imported source. The dashboard live rails pass it — they mix every playlist and have no scope — so a favourite whose guide lives in another playlist's XMLTV gets the same programme its "See all" row already showed; the channel list keeps the strict scope. Contract: `docs/architecture/m3u-playlist-module.md` (the "Scoped lookups" bullet under playlist-scoped URLs)
- 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")
- Programme guide (Electron, M3U): `app-epg-guide` in `libs/ui/epg` fed by the host-provided `EPG_GUIDE_SOURCE`; the M3U host switches into guide mode (docked player strip, no sidebar/timeline, no remount) from the header action, the palette, the EPG panel's Guide button (timeline or list view) or `G`. Data: `EPG_GET_PROGRAMS_FOR_CHANNELS` / `EPG_GET_PROGRAM_COVERAGE` (keys resolved in main; manual mappings honoured). Contract: `docs/architecture/m3u-playlist-module.md` ("Programme guide").
- Manual EPG mapping (Electron only): right-click a channel in any list (M3U views, Xtream portal list, Stalker ITV sidebar, global favorites) → "Map EPG channel" attaches it to an uploaded-XMLTV channel; stored in `epg_channel_mappings` keyed by the M3U lookup key or a playlist-scoped portal key (`xtream:{playlistId}:{id}` / `stalker:{playlistId}:{id}`, helpers in `libs/shared/interfaces/src/lib/epg-mapping-key.util.ts`); resolved on every EPG path (single + batch IPC lookups, portal detail views, preview queues); dialog: `libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/`
+103
View File
@@ -801,6 +801,109 @@ test.describe('Electron EPG', () => {
}
});
test('@epg @electron dashboard live rails show the programme of a favourite whose guide is playlist-scoped while a global source exists', async ({
dataDir,
}) => {
test.setTimeout(120000);
// The playlist declares its own XMLTV; Settings carry a global XMLTV
// that does not know the channel. The dashboard rails have no
// playlist scope, so they used to search the global source alone and
// showed no programme while the "See all" row resolved it unscoped.
const playlistEpgServer = await createMutableTextServer(
createCurrentXmltvFixture(
'playlist-guide-news',
'Playlist Guide News',
'Playlist Scoped Bulletin'
),
{
contentType: 'application/xml; charset=utf-8',
resourcePath: '/guides/playlist-guide.xml',
}
);
const globalEpgServer = await createMutableTextServer(
createCurrentXmltvFixture(
'global-other',
'Global Other',
'Global Other Bulletin'
),
{
contentType: 'application/xml; charset=utf-8',
resourcePath: '/guides/global-guide.xml',
}
);
const playlistContent = buildM3uContent([
{
name: 'Playlist Guide News',
tvgId: 'playlist-guide-news',
url: 'https://example.com/live/playlist-guide-news.m3u8',
},
]).replace(
'#EXTM3U',
`#EXTM3U x-tvg-url="${playlistEpgServer.resourceUrl}"`
);
const playlistServer = await createMutableTextServer(playlistContent, {
contentType: 'application/x-mpegurl; charset=utf-8',
resourcePath: '/playlist-with-scoped-epg.m3u',
});
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromUrl(
app.mainWindow,
playlistServer.resourceUrl
);
await expect(
app.mainWindow.locator(
'.epg-progress-panel .import-item.status-complete'
)
).toHaveCount(1, { timeout: 30000 });
await openWorkspaceSection(app.mainWindow, 'All channels');
const channelItem = channelItemByTitle(
app.mainWindow,
'Playlist Guide News'
);
await expect(channelItem).toBeVisible();
await channelItem.hover();
await channelItem.locator('.favorite-button').first().click();
await expect(
channelItem.locator('.favorite-button mat-icon').first()
).toHaveText(/star/);
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow
.getByRole('button', { name: 'Add EPG source' })
.click();
await app.mainWindow
.locator('.epg-source-row input')
.first()
.fill(globalEpgServer.resourceUrl);
await saveSettings(app.mainWindow);
await expect
.poll(() => getEpgChannelCount(app.mainWindow), {
timeout: 30000,
})
.toBe(2);
await goToDashboard(app.mainWindow);
const card = app.mainWindow
.locator('[data-test-id="dashboard-live-favorites-rail-card"]')
.filter({ hasText: 'Playlist Guide News' })
.first();
await expect(card).toBeVisible({ timeout: 20000 });
await expect(card.locator('.rail__channel-now')).toContainText(
'Playlist Scoped Bulletin',
{ timeout: 30000 }
);
} finally {
await closeElectronApp(app);
await playlistServer.close();
await playlistEpgServer.close();
await globalEpgServer.close();
}
});
test('@epg @electron uses the XMLTV channel icon as a fallback when the playlist has no tvg-logo', async ({
dataDir,
}) => {
+11 -1
View File
@@ -978,7 +978,17 @@ These URLs are playlist-scoped by default:
TTL expires.
- Scoped lookups fall back only to Settings-managed EPG URLs for channels
missing from the playlist-declared source. Playlist-local sources from other
playlists are not treated as global fallback sources. Single-channel current
playlists are not treated as global fallback sources. The one opt-out is
`EpgLookupOptions.anySourceFallback` (renderer-only, never forwarded to the
bridge): after the scope — playlist sources, then the global ones — has
answered, the keys still without a programme are retried once against every
imported source through the source-less batch path and its cache. The
dashboard live rails pass it because they mix channels of every playlist
and have no playlist scope to offer; without it a favourite whose guide
only exists in another playlist's XMLTV showed no programme on the
dashboard while its "See all" row — resolved by `StreamResolverService`,
which never scopes by source — had one. The channel list keeps the strict
scope. Single-channel current
program lookups include the source URL set in their cache and in-flight keys,
so playlist-local and global lookups deduplicate without reusing the wrong
source scope. Batch current-program lookups use the same source-scoped
@@ -27,7 +27,18 @@ export type EpgImportProgress = ElectronBridgeEpgProgress;
export type EpgFetchResult = ElectronBridgeEpgFetchResult;
export type EpgFreshnessResult = ElectronBridgeEpgFreshnessResult;
export type EpgClearResult = ElectronBridgeResult;
export type EpgLookupOptions = ElectronBridgeEpgLookupOptions;
export interface EpgLookupOptions extends ElectronBridgeEpgLookupOptions {
/**
* Retry the keys the scoped lookup (playlist scope, then Settings-managed
* global sources) left without a programme against every imported XMLTV
* source. Off by default: the scopes exist so a playlist's own guide wins
* over a same-named channel in another playlist's guide. Surfaces with no
* playlist context — the dashboard live rails — opt in, matching the
* collection resolver and the timeline, which never scope by source.
* Renderer-only: it is not forwarded to the desktop bridge.
*/
anySourceFallback?: boolean;
}
export type EpgCurrentProgramsOptions = ElectronBridgeCurrentProgramsOptions;
type EpgElectronBridge = Pick<
@@ -594,6 +594,162 @@ describe('EpgService', () => {
);
});
it('retries keys the global scope left unresolved against every source when anySourceFallback is set', async () => {
// The dashboard rails: no playlist scope, one global XMLTV in
// Settings, and a channel whose guide only exists in an XMLTV another
// playlist imported. The "See all" pages resolve it unscoped, so the
// rails must too — but only after the configured scope had its say.
settingsStore.getSettings.mockReturnValue({
epgUrl: ['https://global.example.com/guide.xml'],
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
});
epgBridge.supportsProgramLookup = true;
epgBridge.supportsCurrentProgramBatch = true;
epgBridge.getCurrentProgramsBatch = jest
.fn()
.mockResolvedValueOnce({
'guide-news': {
channel: 'guide-news',
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Global Bulletin',
},
'guide-sports': null,
})
.mockResolvedValueOnce({
'guide-sports': {
channel: 'guide-sports',
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Other Playlist Sports',
},
});
const result = await firstValueFrom(
service.getCurrentProgramsForChannels(
['guide-news', 'guide-sports'],
{ anySourceFallback: true }
)
);
expect(result.get('guide-news')?.title).toBe('Global Bulletin');
expect(result.get('guide-sports')?.title).toBe('Other Playlist Sports');
expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(2);
expect(epgBridge.getCurrentProgramsBatch).toHaveBeenNthCalledWith(
1,
['guide-news', 'guide-sports'],
expect.objectContaining({
sourceUrls: ['https://global.example.com/guide.xml'],
})
);
// The retry carries only the unresolved key and no source scope.
const [retryIds, retryOptions] = (
epgBridge.getCurrentProgramsBatch as jest.Mock
).mock.calls[1];
expect(retryIds).toEqual(['guide-sports']);
expect(retryOptions).not.toHaveProperty('sourceUrls');
});
it('keeps the scoped verdict when the any-source retry finds nothing either', async () => {
settingsStore.getSettings.mockReturnValue({
epgUrl: ['https://global.example.com/guide.xml'],
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
});
epgBridge.supportsProgramLookup = true;
epgBridge.supportsCurrentProgramBatch = true;
epgBridge.getCurrentProgramsBatch = jest
.fn()
.mockResolvedValueOnce({ 'guide-sports': null })
.mockResolvedValueOnce({ 'guide-sports': null });
const result = await firstValueFrom(
service.getCurrentProgramsForChannels(['guide-sports'], {
anySourceFallback: true,
})
);
expect(result.size).toBe(1);
expect(result.get('guide-sports')).toBeNull();
expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(2);
});
it('skips the any-source retry when every key resolved in scope, and never retries without the option', async () => {
settingsStore.getSettings.mockReturnValue({
epgUrl: ['https://global.example.com/guide.xml'],
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
});
epgBridge.supportsProgramLookup = true;
epgBridge.supportsCurrentProgramBatch = true;
epgBridge.getCurrentProgramsBatch = jest
.fn()
.mockResolvedValueOnce({
'guide-news': {
channel: 'guide-news',
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Global Bulletin',
},
})
.mockResolvedValueOnce({ 'guide-sports': null });
const resolved = await firstValueFrom(
service.getCurrentProgramsForChannels(['guide-news'], {
anySourceFallback: true,
})
);
expect(resolved.get('guide-news')?.title).toBe('Global Bulletin');
expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(1);
// Scoped callers (the channel list) keep today's contract: a miss in
// the configured scope stays a miss.
const unresolved = await firstValueFrom(
service.getCurrentProgramsForChannels(['guide-sports'])
);
expect(unresolved.get('guide-sports')).toBeNull();
expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(2);
});
it('runs the any-source retry after the playlist scope and its global fallback', async () => {
settingsStore.getSettings.mockReturnValue({
epgUrl: ['https://global.example.com/guide.xml'],
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
});
epgBridge.supportsProgramLookup = true;
epgBridge.supportsCurrentProgramBatch = true;
epgBridge.getCurrentProgramsBatch = jest
.fn()
.mockResolvedValueOnce({ 'guide-sports': null })
.mockResolvedValueOnce({ 'guide-sports': null })
.mockResolvedValueOnce({
'guide-sports': {
channel: 'guide-sports',
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Other Playlist Sports',
},
});
const result = await firstValueFrom(
service.getCurrentProgramsForChannels(['guide-sports'], {
sourceUrls: ['https://playlist.example.com/guide.xml'],
anySourceFallback: true,
})
);
expect(result.get('guide-sports')?.title).toBe('Other Playlist Sports');
const calls = (epgBridge.getCurrentProgramsBatch as jest.Mock).mock
.calls;
expect(calls.map(([, options]) => options.sourceUrls)).toEqual([
['https://playlist.example.com/guide.xml'],
['https://global.example.com/guide.xml'],
undefined,
]);
});
it('caches scoped batch current programs by EPG source URL scope', async () => {
epgBridge.supportsProgramLookup = true;
epgBridge.supportsCurrentProgramBatch = true;
+67 -8
View File
@@ -249,11 +249,37 @@ export class EpgService {
return of(new Map());
}
const scoped$ = this.getSourceScopedCurrentProgramsForChannels(
channelIds,
options
);
if (!scoped$) {
return this.getUnscopedCurrentProgramsForChannels(channelIds);
}
if (!options?.anySourceFallback) {
return scoped$;
}
return scoped$.pipe(
switchMap((scopedMap) => this.fillFromAnySource(scopedMap))
);
}
/**
* The scoped lookup: the caller's playlist sources first (with the global
* sources as fallback), else the Settings-managed global sources alone.
* `null` when no scope applies and the unscoped pool is the only answer.
*/
private getSourceScopedCurrentProgramsForChannels(
channelIds: string[],
options?: EpgLookupOptions
): Observable<Map<string, EpgProgram | null>> | null {
if (!this.epgBridge.supportsCurrentProgramBatch) {
return null;
}
const sourceUrls = this.normalizeSourceUrls(options);
if (
sourceUrls.length > 0 &&
this.epgBridge.supportsCurrentProgramBatch
) {
if (sourceUrls.length > 0) {
return this.getScopedCurrentProgramsForChannels(
channelIds,
sourceUrls
@@ -261,10 +287,7 @@ export class EpgService {
}
const globalSourceUrls = this.getGlobalEpgSourceUrls();
if (
globalSourceUrls.length > 0 &&
this.epgBridge.supportsCurrentProgramBatch
) {
if (globalSourceUrls.length > 0) {
return this.getScopedCurrentProgramsForChannels(
channelIds,
globalSourceUrls,
@@ -272,6 +295,42 @@ export class EpgService {
);
}
return null;
}
/**
* `anySourceFallback`: keys the scope answered with `null` are retried
* against every imported source. A scoped miss is kept as the answer
* when the pool has nothing either, so the merged map still names every
* requested key.
*/
private fillFromAnySource(
scopedMap: Map<string, EpgProgram | null>
): Observable<Map<string, EpgProgram | null>> {
const unresolvedIds = Array.from(scopedMap.entries())
.filter(([, program]) => !program)
.map(([channelId]) => channelId);
if (unresolvedIds.length === 0) {
return of(scopedMap);
}
return this.getUnscopedCurrentProgramsForChannels(unresolvedIds).pipe(
map((anySourceMap) => {
const mergedMap = new Map(scopedMap);
anySourceMap.forEach((program, channelId) => {
if (program) {
mergedMap.set(channelId, program);
}
});
return mergedMap;
})
);
}
/** Lookup across every imported source, cached under the source-less key. */
private getUnscopedCurrentProgramsForChannels(
channelIds: string[]
): Observable<Map<string, EpgProgram | null>> {
const resultMap = new Map<string, EpgProgram | null>();
const channelsToFetch: string[] = [];
const now = Date.now();
@@ -271,6 +271,12 @@ export class WorkspaceDashboardRailsComponent {
// (tvg-id -> tvg-name -> name), with the card title as a final fallback.
// Xtream/Stalker live items often have no XMLTV side-channel and will
// simply return null — the card renders without the program row.
// The rails mix channels from every playlist and carry no playlist
// scope, so the lookup opts into the any-source retry: the Settings
// global sources first, then every imported XMLTV, which is what the
// "See all" collection pages resolve against. Without it a channel
// whose guide only exists in another playlist's XMLTV showed no
// programme here while its "See all" row had one.
private readonly liveChannelLookupKeys = computed(() => {
const heroLiveCard = this.heroLiveCard();
return buildLiveEpgLookupKeys(
@@ -298,7 +304,8 @@ export class WorkspaceDashboardRailsComponent {
startWith(0),
switchMap(() =>
this.epgService.getCurrentProgramsForChannels(
keys
keys,
{ anySourceFallback: true }
)
)
)