From 54071e2a4d6c1817e33d4c6f16798a629e18bd97 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 19 Sep 2026 17:23:06 +0200 Subject: [PATCH] fix(dashboard): live rails find XMLTV programmes outside the global EPG sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../dashboard-live-rails-epg-any-source.md | 9 + CLAUDE.md | 1 + apps/electron-backend-e2e/src/epg.e2e.ts | 103 ++++++++++++ docs/architecture/m3u-playlist-module.md | 12 +- .../src/lib/epg-runtime-bridge.service.ts | 13 +- .../data-access/src/lib/epg.service.spec.ts | 156 ++++++++++++++++++ libs/epg/data-access/src/lib/epg.service.ts | 75 ++++++++- .../workspace-dashboard-rails.component.ts | 9 +- 8 files changed, 367 insertions(+), 11 deletions(-) create mode 100644 .changes/dashboard-live-rails-epg-any-source.md diff --git a/.changes/dashboard-live-rails-epg-any-source.md b/.changes/dashboard-live-rails-epg-any-source.md new file mode 100644 index 000000000..e0dce1935 --- /dev/null +++ b/.changes/dashboard-live-rails-epg-any-source.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 2650dfbb6..fe8b2f26f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/` diff --git a/apps/electron-backend-e2e/src/epg.e2e.ts b/apps/electron-backend-e2e/src/epg.e2e.ts index 58525a4af..25837eb00 100644 --- a/apps/electron-backend-e2e/src/epg.e2e.ts +++ b/apps/electron-backend-e2e/src/epg.e2e.ts @@ -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, }) => { diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 3432628fe..7c7b4a9f7 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -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 diff --git a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts index 9b4193bfd..a73e49dc9 100644 --- a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts +++ b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts @@ -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< diff --git a/libs/epg/data-access/src/lib/epg.service.spec.ts b/libs/epg/data-access/src/lib/epg.service.spec.ts index 69d4b29e0..57e903a35 100644 --- a/libs/epg/data-access/src/lib/epg.service.spec.ts +++ b/libs/epg/data-access/src/lib/epg.service.spec.ts @@ -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; diff --git a/libs/epg/data-access/src/lib/epg.service.ts b/libs/epg/data-access/src/lib/epg.service.ts index d76ae9bf6..46bca11f4 100644 --- a/libs/epg/data-access/src/lib/epg.service.ts +++ b/libs/epg/data-access/src/lib/epg.service.ts @@ -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> | 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 + ): Observable> { + 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> { const resultMap = new Map(); const channelsToFetch: string[] = []; const now = Date.now(); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index 1b851edf3..cb35883ea 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -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 } ) ) )