fix(dashboard): keep each live card in its own XMLTV scope before the any-source retry

Review follow-ups on the any-source EPG fallback.

The rails asked one flat, source-less question for every live card, so with
two imported guides reusing an XMLTV id — `ard.de` is unique inside a guide,
not across imports — a card could be handed the programme the other
playlist's guide resolved. The lookup is now grouped by the source scope its
playlist declares (the channel list's rule: only an M3U playlist carries its
own guide) and the answers are namespaced by that scope, so a card only ever
reads its own. Playlists on the same guide still share one lookup, and each
group keeps the any-source retry that fixes the original bug (Greptile P1).

On a preload without the batch endpoint the retry now uses the unscoped
per-channel lookup; `getCurrentProgramForChannel` re-applies the Settings
scope, so the retry was asking the question the scoped pass had already
answered. Every other caller keeps that historical behaviour (Codex P2).

The regression E2E was rewritten around the reported case: the favourite's
own playlist declares no guide, Settings hold a global XMLTV that does not
know the channel, and the programme exists only in the guide another playlist
imported. The old fixture let the card resolve in its own playlist scope, so
it would have passed without the fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-09-20 13:37:51 +02:00
1 parent 54071e2a4d
commit 104c6e01c8
10 files changed
+380 -77

No files matched your search

+1 -1
View File
@@ -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/`
+63 -37
View File
@@ -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();
}
});
+13 -7
View File
@@ -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
@@ -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'],
+37 -4
View File
@@ -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<EpgProgram | null> {
// 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<Map<string, EpgProgram | null>> {
const resultMap = new Map<string, EpgProgram | null>();
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 })),
@@ -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,
@@ -40,6 +40,12 @@ export interface DashboardRailCard {
state?: Record<string, unknown>;
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
@@ -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<string, string[]>) => (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<string, EpgProgram | null>([
['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<string, EpgProgram | null>([
[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'
@@ -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<string, string[]>();
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<string, EpgProgram | null>())
: 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),
};