diff --git a/CLAUDE.md b/CLAUDE.md index fe8b2f26f..dbb4ebbb1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1606,7 +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) +- 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, so a favourite whose guide lives in another playlist's XMLTV gets the same programme its "See all" row already showed; they still issue one lookup per distinct playlist source scope and namespace the answers by it (`liveEpgProgramKey`), since a `tvg-id` is unique inside a guide but not across imports. 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 25837eb00..a1453e3bb 100644 --- a/apps/electron-backend-e2e/src/epg.e2e.ts +++ b/apps/electron-backend-e2e/src/epg.e2e.ts @@ -801,23 +801,24 @@ 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 ({ + test("@epg @electron dashboard live rails find a programme that only another playlist's XMLTV carries", 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( + // The reported case: the favourited channel's own playlist declares + // no guide, Settings hold a global XMLTV that does not know it, and + // the programme exists only in the guide a DIFFERENT playlist + // imported. The rails searched the global scope alone and showed + // nothing, while the "See all" row — resolved unscoped — had it. + const otherPlaylistEpgServer = await createMutableTextServer( createCurrentXmltvFixture( 'playlist-guide-news', 'Playlist Guide News', - 'Playlist Scoped Bulletin' + 'Other Playlist Bulletin' ), { contentType: 'application/xml; charset=utf-8', - resourcePath: '/guides/playlist-guide.xml', + resourcePath: '/guides/other-playlist.xml', } ); const globalEpgServer = await createMutableTextServer( @@ -831,26 +832,45 @@ test.describe('Electron EPG', () => { resourcePath: '/guides/global-guide.xml', } ); - const playlistContent = buildM3uContent([ + // Declares the guide, so importing it is what puts those programmes + // in the database under that source. + const guideOwnerServer = await createMutableTextServer( + buildM3uContent([ + { + name: 'Playlist Guide News', + tvgId: 'playlist-guide-news', + url: 'https://example.com/live/guide-owner.m3u8', + }, + ]).replace( + '#EXTM3U', + `#EXTM3U x-tvg-url="${otherPlaylistEpgServer.resourceUrl}"` + ), { - 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}"` + contentType: 'application/x-mpegurl; charset=utf-8', + resourcePath: '/guide-owner.m3u', + } + ); + // Carries the same XMLTV id under its own display name and declares + // no guide at all — the playlist the dashboard card comes from. + const guidelessServer = await createMutableTextServer( + buildM3uContent([ + { + name: 'Mirror News', + tvgId: 'playlist-guide-news', + url: 'https://example.com/live/mirror-news.m3u8', + }, + ]), + { + contentType: 'application/x-mpegurl; charset=utf-8', + resourcePath: '/guideless.m3u', + } ); - 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 + guideOwnerServer.resourceUrl ); await expect( app.mainWindow.locator( @@ -858,18 +878,6 @@ test.describe('Electron EPG', () => { ) ).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 @@ -886,20 +894,38 @@ test.describe('Electron EPG', () => { }) .toBe(2); + await importM3uPlaylistFromUrl( + app.mainWindow, + guidelessServer.resourceUrl + ); + + await openWorkspaceSection(app.mainWindow, 'All channels'); + const channelItem = channelItemByTitle( + app.mainWindow, + 'Mirror News' + ); + await expect(channelItem).toBeVisible({ timeout: 20000 }); + await channelItem.hover(); + await channelItem.locator('.favorite-button').first().click(); + await expect( + channelItem.locator('.favorite-button mat-icon').first() + ).toHaveText(/star/); + await goToDashboard(app.mainWindow); const card = app.mainWindow .locator('[data-test-id="dashboard-live-favorites-rail-card"]') - .filter({ hasText: 'Playlist Guide News' }) + .filter({ hasText: 'Mirror News' }) .first(); await expect(card).toBeVisible({ timeout: 20000 }); await expect(card.locator('.rail__channel-now')).toContainText( - 'Playlist Scoped Bulletin', + 'Other Playlist Bulletin', { timeout: 30000 } ); } finally { await closeElectronApp(app); - await playlistServer.close(); - await playlistEpgServer.close(); + await guidelessServer.close(); + await guideOwnerServer.close(); + await otherPlaylistEpgServer.close(); await globalEpgServer.close(); } }); diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 7c7b4a9f7..ddb39b3a2 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -982,13 +982,19 @@ These URLs are playlist-scoped by default: `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 + imported source through the source-less batch path and its cache. On a + preload without the batch endpoint that retry uses the unscoped + per-channel lookup; every other caller keeps the historical per-channel + behaviour there, which re-applies the Settings scope. The dashboard live + rails pass the option: 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. They still ask **per source scope**, one lookup per + distinct set of playlist-declared XMLTV URLs, and namespace the answers by + that scope: a `tvg-id` is unique inside a guide, not across imports, so a + single flat map keyed by lookup key alone would hand one playlist's card + the programme another playlist's guide resolved for the same id. Playlists + sharing a guide share one lookup. 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.service.spec.ts b/libs/epg/data-access/src/lib/epg.service.spec.ts index 57e903a35..06e0377f2 100644 --- a/libs/epg/data-access/src/lib/epg.service.spec.ts +++ b/libs/epg/data-access/src/lib/epg.service.spec.ts @@ -651,6 +651,69 @@ describe('EpgService', () => { expect(retryOptions).not.toHaveProperty('sourceUrls'); }); + it('asks the unscoped pool per channel when the any-source retry runs on a preload without the batch endpoint', async () => { + // The legacy per-channel path used to answer through + // `getCurrentProgramForChannel`, which re-applies the Settings scope, + // so the retry re-asked the question the scoped pass had answered. + settingsStore.getSettings.mockReturnValue({ + epgUrl: ['https://global.example.com/guide.xml'], + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + }); + epgBridge.supportsProgramLookup = true; + epgBridge.supportsCurrentProgramBatch = false; + epgBridge.getChannelPrograms = jest.fn().mockResolvedValue([ + { + channel: 'guide-sports', + start: '2026-05-23T10:00:00.000Z', + stop: '2026-05-23T11:00:00.000Z', + title: 'Other Playlist Sports', + }, + ]); + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-05-23T10:30:00.000Z')); + + try { + const result = await firstValueFrom( + service.getCurrentProgramsForChannels(['guide-sports'], { + anySourceFallback: true, + }) + ); + + expect(result.get('guide-sports')?.title).toBe( + 'Other Playlist Sports' + ); + // No source scope reaches the bridge on this path. + expect(epgBridge.getChannelPrograms).toHaveBeenCalledWith( + 'guide-sports' + ); + } finally { + jest.useRealTimers(); + } + }); + + it('keeps the Settings scope on the legacy per-channel path when no any-source retry was asked for', async () => { + settingsStore.getSettings.mockReturnValue({ + epgUrl: ['https://global.example.com/guide.xml'], + trustedPrivateNetworkEpgUrls: [], + trustedInsecureTlsHosts: [], + }); + epgBridge.supportsProgramLookup = true; + epgBridge.supportsCurrentProgramBatch = false; + epgBridge.getChannelPrograms = jest.fn().mockResolvedValue([]); + + await firstValueFrom( + service.getCurrentProgramsForChannels(['guide-sports']) + ); + + expect(epgBridge.getChannelPrograms).toHaveBeenCalledWith( + 'guide-sports', + expect.objectContaining({ + sourceUrls: ['https://global.example.com/guide.xml'], + }) + ); + }); + it('keeps the scoped verdict when the any-source retry finds nothing either', async () => { settingsStore.getSettings.mockReturnValue({ epgUrl: ['https://global.example.com/guide.xml'], diff --git a/libs/epg/data-access/src/lib/epg.service.ts b/libs/epg/data-access/src/lib/epg.service.ts index 46bca11f4..df6ddcae2 100644 --- a/libs/epg/data-access/src/lib/epg.service.ts +++ b/libs/epg/data-access/src/lib/epg.service.ts @@ -198,6 +198,20 @@ export class EpgService { ); } + return this.getUnscopedCurrentProgramForChannel(channelId); + } + + /** + * The lookup across every imported source, cached under the source-less + * key. Kept separate from `getCurrentProgramForChannel`, which re-applies + * the Settings-managed scope whenever global URLs exist: a caller that + * has already decided it wants the unscoped pool — the `anySourceFallback` + * retry on a preload without the batch endpoint — must not have that + * scope put back on. + */ + private getUnscopedCurrentProgramForChannel( + channelId: string + ): Observable { // Check cache first const cacheKey = this.createProgramCacheKey(channelId); @@ -254,7 +268,15 @@ export class EpgService { options ); if (!scoped$) { - return this.getUnscopedCurrentProgramsForChannels(channelIds); + // No scope applies, or the bridge has no batch endpoint. The + // per-channel path below keeps its historical behaviour here + // (`getCurrentProgramForChannel`, which re-applies the global + // scope when Settings hold URLs); only the explicit any-source + // retry asks for a genuinely source-less lookup. + return this.getUnscopedCurrentProgramsForChannels( + channelIds, + Boolean(options?.anySourceFallback) + ); } if (!options?.anySourceFallback) { return scoped$; @@ -314,7 +336,10 @@ export class EpgService { return of(scopedMap); } - return this.getUnscopedCurrentProgramsForChannels(unresolvedIds).pipe( + return this.getUnscopedCurrentProgramsForChannels( + unresolvedIds, + true + ).pipe( map((anySourceMap) => { const mergedMap = new Map(scopedMap); anySourceMap.forEach((program, channelId) => { @@ -329,7 +354,8 @@ export class EpgService { /** Lookup across every imported source, cached under the source-less key. */ private getUnscopedCurrentProgramsForChannels( - channelIds: string[] + channelIds: string[], + perChannelUnscoped = false ): Observable> { const resultMap = new Map(); const channelsToFetch: string[] = []; @@ -387,8 +413,15 @@ export class EpgService { } // Fallback for older preload bundles without the batch endpoint. + // An any-source retry must use the unscoped per-channel lookup: the + // public `getCurrentProgramForChannel` puts the Settings-managed + // scope back on, so the retry would re-ask the question the scoped + // pass already answered and the fallback would do nothing here. const fetchObservables = channelsToFetch.map((channelId) => - this.getCurrentProgramForChannel(channelId).pipe( + (perChannelUnscoped + ? this.getUnscopedCurrentProgramForChannel(channelId) + : this.getCurrentProgramForChannel(channelId) + ).pipe( this.sourceSettings.guard(), timeout(5000), map((program) => ({ channelId, program })), diff --git a/libs/workspace/dashboard/feature/src/index.ts b/libs/workspace/dashboard/feature/src/index.ts index 363b10a9a..767acd00c 100644 --- a/libs/workspace/dashboard/feature/src/index.ts +++ b/libs/workspace/dashboard/feature/src/index.ts @@ -9,12 +9,17 @@ export type { export { buildDashboardLiveEpgDetails, buildLiveEpgCardsForEnabledRails, - buildLiveEpgLookupKeys, + buildLiveEpgLookupGroups, calcEpgProgress, formatEpgTimeRange, getLiveEpgProgramForCard, + liveEpgProgramKey, + liveEpgScopeKey, +} from './lib/rails/dashboard-live-epg.utils'; +export type { + DashboardLiveEpgDetails, + DashboardLiveEpgLookupGroup, } from './lib/rails/dashboard-live-epg.utils'; -export type { DashboardLiveEpgDetails } from './lib/rails/dashboard-live-epg.utils'; export { buildPlaybackPositionReloadKey, formatRemainingLabel, diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts index 99d22d4aa..ed0f41a45 100644 Binary files a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts and b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts differ diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts index e3eaaa589..60d01a07a 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts @@ -40,6 +40,12 @@ export interface DashboardRailCard { state?: Record; actions?: DashboardRailAction[]; epgLookupKey?: string; + /** + * Playlist the live card belongs to. An XMLTV key is only unique inside + * the guide its playlist declares, so the dashboard's EPG lookup is + * grouped and namespaced by that playlist's source scope. + */ + epgPlaylistId?: string; /** * Optional EPG enrichment shown by the 'channel' rail layout. Populated diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts index 3d9d265b5..4417505f1 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts @@ -12,10 +12,12 @@ import { import { buildDashboardLiveEpgDetails, buildLiveEpgCardsForEnabledRails, - buildLiveEpgLookupKeys, + buildLiveEpgLookupGroups, calcEpgProgress, formatEpgTimeRange, getLiveEpgProgramForCard, + liveEpgProgramKey, + liveEpgScopeKey, } from './dashboard-live-epg.utils'; import { buildPlaybackPositionReloadKey, @@ -311,9 +313,15 @@ describe('Live rail helpers', () => { ...overrides, }); + const guideA = ['https://a.example/guide.xml']; + const guideB = ['https://b.example/guide.xml']; + const scopeFor = + (byPlaylist: Record) => (card: DashboardRailCard) => + byPlaylist[card.epgPlaylistId ?? ''] ?? []; + it('uses explicit EPG lookup keys before falling back to card titles', () => { - expect( - buildLiveEpgLookupKeys([ + const groups = buildLiveEpgLookupGroups( + [ channelCard({ title: 'Das Erste HD', epgLookupKey: 'ard.de', @@ -327,8 +335,48 @@ describe('Live rail helpers', () => { id: 'card-3', title: 'Fallback News', }), - ]) - ).toEqual(['ard.de', 'Fallback News']); + ], + () => [] + ); + + expect(groups).toHaveLength(1); + expect(groups[0].lookupKeys).toEqual(['ard.de', 'Fallback News']); + expect(groups[0].sourceUrls).toEqual([]); + expect(groups[0].scopeKey).toBe(''); + }); + + it('asks each XMLTV source scope separately and shares one lookup between playlists on the same guide', () => { + const groups = buildLiveEpgLookupGroups( + [ + channelCard({ epgLookupKey: 'ard.de', epgPlaylistId: 'a' }), + channelCard({ + id: 'card-2', + epgLookupKey: 'ard.de', + epgPlaylistId: 'b', + }), + // Same guide as playlist a: one question, shared answer. + channelCard({ + id: 'card-3', + epgLookupKey: 'zdf.de', + epgPlaylistId: 'a2', + }), + // No guide of its own: answered from Settings. + channelCard({ + id: 'card-4', + epgLookupKey: 'cnn.us', + epgPlaylistId: 'portal', + }), + ], + scopeFor({ a: guideA, a2: guideA, b: guideB }) + ); + + expect( + groups.map((group) => [group.sourceUrls, group.lookupKeys]) + ).toEqual([ + [guideA, ['ard.de', 'zdf.de']], + [guideB, ['ard.de']], + [[], ['cnn.us']], + ]); }); it('reads EPG programs by explicit lookup key instead of display title', () => { @@ -343,13 +391,54 @@ describe('Live rail helpers', () => { getLiveEpgProgramForCard( card, new Map([ - ['Das Erste HD', wrongProgram], - ['ard.de', program], - ]) + [liveEpgProgramKey('', 'Das Erste HD'), wrongProgram], + [liveEpgProgramKey('', 'ard.de'), program], + ]), + '' ) ).toBe(program); }); + it('never hands a card the programme another playlist resolved for the same XMLTV id', () => { + // `ard.de` is unique inside a guide, not across imports. + const fromGuideA = { title: 'Guide A bulletin' } as EpgProgram; + const fromGuideB = { title: 'Guide B bulletin' } as EpgProgram; + const epgMap = new Map([ + [liveEpgProgramKey(liveEpgScopeKey(guideA), 'ard.de'), fromGuideA], + [liveEpgProgramKey(liveEpgScopeKey(guideB), 'ard.de'), fromGuideB], + ]); + + expect( + getLiveEpgProgramForCard( + channelCard({ epgLookupKey: 'ard.de', epgPlaylistId: 'a' }), + epgMap, + liveEpgScopeKey(guideA) + ) + ).toBe(fromGuideA); + expect( + getLiveEpgProgramForCard( + channelCard({ epgLookupKey: 'ard.de', epgPlaylistId: 'b' }), + epgMap, + liveEpgScopeKey(guideB) + ) + ).toBe(fromGuideB); + // A scope with no answer stays empty instead of borrowing one. + expect( + getLiveEpgProgramForCard( + channelCard({ epgLookupKey: 'ard.de' }), + epgMap, + '' + ) + ).toBeNull(); + }); + + it('treats the same URL set as one scope whatever its order or duplicates', () => { + expect(liveEpgScopeKey(['b', 'a'])).toBe( + liveEpgScopeKey(['a', 'b', 'a']) + ); + expect(liveEpgScopeKey(['a'])).not.toBe(liveEpgScopeKey(['a', 'b'])); + }); + it('uses honest, semantically named title keys for favorite and recent live rails', () => { expect(liveRailTitleKeyForSource('favorites')).toBe( 'WORKSPACE.DASHBOARD.LIVE_FAVORITES' 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 cb35883ea..68fc94715 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 @@ -8,7 +8,7 @@ import { untracked, } from '@angular/core'; import { toObservable, toSignal } from '@angular/core/rxjs-interop'; -import { interval, map, of, startWith, switchMap } from 'rxjs'; +import { forkJoin, interval, map, of, startWith, switchMap } from 'rxjs'; import { EpgService } from '@iptvnator/epg/data-access'; import { type EpgProgram, @@ -22,6 +22,7 @@ import { MatIcon } from '@angular/material/icon'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Router, RouterLink } from '@angular/router'; import { isPortalPlaybackWatched } from '@iptvnator/portal/shared/util'; +import { normalizeEpgUrls } from '@iptvnator/shared/m3u-utils'; import { Store } from '@ngrx/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { @@ -66,8 +67,10 @@ import { resolveDashboardHeroArtwork } from './dashboard-hero.utils'; import { buildDashboardLiveEpgDetails, buildLiveEpgCardsForEnabledRails, - buildLiveEpgLookupKeys, + buildLiveEpgLookupGroups, getLiveEpgProgramForCard, + liveEpgProgramKey, + liveEpgScopeKey, LIVE_EPG_TICK_MS, } from './dashboard-live-epg.utils'; import type { DashboardLiveEpgDetails } from './dashboard-live-epg.utils'; @@ -267,25 +270,45 @@ export class WorkspaceDashboardRailsComponent { }) ); + // The XMLTV sources each playlist declares. Same rule as + // `ChannelListContainerComponent`: only an M3U playlist carries its own + // guide; a portal playlist is answered from the Settings-managed URLs. + private readonly liveEpgSourceUrlsByPlaylist = computed(() => { + const byPlaylistId = new Map(); + for (const playlist of this.data.playlists()) { + byPlaylistId.set( + playlist._id, + playlist.serverUrl || playlist.macAddress + ? [] + : normalizeEpgUrls(playlist.epgUrls ?? []) + ); + } + return byPlaylistId; + }); + // Best-effort EPG lookup keyed by the app-wide M3U XMLTV chain // (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(() => { + // + // One lookup per source scope, not one for the whole page: a `tvg-id` is + // unique inside a guide, not across imports, so a card is only ever + // handed the answer resolved in ITS playlist's scope. Each lookup then + // opts into the any-source retry — 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 liveEpgLookupGroups = computed(() => { const heroLiveCard = this.heroLiveCard(); - return buildLiveEpgLookupKeys( + return buildLiveEpgLookupGroups( buildLiveEpgCardsForEnabledRails( this.dashboardRails(), heroLiveCard, this.liveFavoriteCards(), this.recentLiveCards() - ) + ), + (card) => this.liveEpgSourceUrlsForCard(card) ); }); @@ -296,16 +319,52 @@ export class WorkspaceDashboardRailsComponent { // Re-fetch on rail change AND on a 30s heartbeat so the progress bar // catches the boundary between programs without a full page revisit. private readonly liveEpgPrograms = toSignal( - toObservable(this.liveChannelLookupKeys).pipe( - switchMap((keys) => - keys.length === 0 + toObservable(this.liveEpgLookupGroups).pipe( + switchMap((groups) => + groups.length === 0 ? of(new Map()) : interval(LIVE_EPG_TICK_MS).pipe( startWith(0), switchMap(() => - this.epgService.getCurrentProgramsForChannels( - keys, - { anySourceFallback: true } + forkJoin( + groups.map((group) => + this.epgService + .getCurrentProgramsForChannels( + group.lookupKeys, + { + sourceUrls: group.sourceUrls, + anySourceFallback: true, + } + ) + .pipe( + map((programs) => ({ + scopeKey: group.scopeKey, + programs, + })) + ) + ) + ).pipe( + map((answers) => { + const merged = new Map< + string, + EpgProgram | null + >(); + for (const answer of answers) { + for (const [ + lookupKey, + program, + ] of answer.programs) { + merged.set( + liveEpgProgramKey( + answer.scopeKey, + lookupKey + ), + program + ); + } + } + return merged; + }) ) ) ) @@ -608,6 +667,16 @@ export class WorkspaceDashboardRailsComponent { }); } + /** The XMLTV scope a live card's programme must be resolved in. */ + private liveEpgSourceUrlsForCard(card: DashboardRailCard): string[] { + const byPlaylistId = this.liveEpgSourceUrlsByPlaylist(); + return ( + (card.epgPlaylistId + ? byPlaylistId.get(card.epgPlaylistId) + : undefined) ?? [] + ); + } + private enrichLiveCards( cards: readonly DashboardRailCard[] ): DashboardRailCard[] { @@ -626,7 +695,11 @@ export class WorkspaceDashboardRailsComponent { if (!card) { return null; } - const program = getLiveEpgProgramForCard(card, this.liveEpgPrograms()); + const program = getLiveEpgProgramForCard( + card, + this.liveEpgPrograms(), + liveEpgScopeKey(this.liveEpgSourceUrlsForCard(card)) + ); // Recompute the now-window each tick so progress moves between // 30s ticks even if the program identity is unchanged. return buildDashboardLiveEpgDetails( @@ -678,6 +751,7 @@ export class WorkspaceDashboardRailsComponent { icon: this.typeIcon(item.type), contentType: item.type, epgLookupKey: item.epg_lookup_key, + epgPlaylistId: item.playlist_id, link: this.data.getRecentItemLink(item), // Default click is detail-only for every card — an in-progress // series no longer auto-plays on click (issue #1441); resuming @@ -714,6 +788,7 @@ export class WorkspaceDashboardRailsComponent { icon: this.typeIcon(item.type), contentType: item.type, epgLookupKey: item.epg_lookup_key, + epgPlaylistId: item.playlist_id, link: this.data.getGlobalFavoriteLink(item), state: this.data.getGlobalFavoriteNavigationState(item), };