refactor(epg): shrink the two oversized files this PR grew, keep portal cards in scope

Two review follow-ups.

Greptile: the repo requires refactoring an already-oversized production file
before adding to it, and this PR had grown both files it touched. Extracted,
without behaviour change:

- `EpgProgramCache` — the 60 s "currently airing" memory, its in-flight
  request slots and the scope/offset cache keys.
- `EpgChannelMetadataLookup` — the icon/display-name lookup with the same
  playlist-first, Settings-fallback ladder.
- `DashboardLiveEpgPresenter` — the rails' XMLTV lookup and card enrichment,
  component-provided so the 30 s heartbeat dies with the page.

`epg.service.ts` 901 -> 727 and the rails component 1000 -> 842, both now
smaller than before this PR; the three new files are well under the 400-line
limit, and the generated max-lines baseline is unchanged.

Codex P2: the any-source retry now applies only to cards that carry a real
XMLTV key. An Xtream or Stalker card has none, so its lookup key is just its
display title, and searching every imported guide by title would let a
same-named M3U channel answer for a portal channel. Those cards keep the
strict scope — their own programmes come from the portal — and the flag is
part of the scope identity, so a guide-less M3U card and a portal card never
share an answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-09-20 20:27:32 +02:00
1 parent 6579151dcf
commit 2ef0afbeca
9 files changed
+565 -402

No files matched your search

@@ -0,0 +1,183 @@
import {
computed,
inject,
Injectable,
signal,
type Signal,
} from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import {
catchError,
defaultIfEmpty,
forkJoin,
interval,
map,
of,
startWith,
switchMap,
} from 'rxjs';
import { EpgService } from '@iptvnator/epg/data-access';
import type { EpgProgram } from '@iptvnator/shared/interfaces';
import { SettingsStore } from '@iptvnator/services';
import { normalizeEpgUrls } from '@iptvnator/shared/m3u-utils';
import { DashboardDataService } from '@iptvnator/workspace/dashboard/data-access';
import type { DashboardRailCard } from './dashboard-rail.component';
import {
buildDashboardLiveEpgDetails,
buildLiveEpgLookupGroups,
getLiveEpgProgramForCard,
liveEpgAllowsAnySource,
liveEpgProgramKey,
liveEpgScopeKey,
LIVE_EPG_TICK_MS,
type DashboardLiveEpgDetails,
type DashboardLiveEpgLookupGroup,
} from './dashboard-live-epg.utils';
type ScopeAnswer = {
readonly scopeKey: string;
readonly programs: ReadonlyMap<string, EpgProgram | null>;
};
const emptyAnswer = (scopeKey: string): ScopeAnswer => ({
scopeKey,
programs: new Map<string, EpgProgram | null>(),
});
/**
* The dashboard's uploaded-XMLTV "now on air" lookup for live cards.
* Component-provided, so the 30 s heartbeat dies with the page.
*
* Best-effort, keyed by the app-wide M3U 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 simply return null — the card then renders
* without the programme row.
*
* 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.
*/
@Injectable()
export class DashboardLiveEpgPresenter {
private readonly data = inject(DashboardDataService);
private readonly epgService = inject(EpgService);
private readonly settingsStore = inject(SettingsStore);
private readonly cards = signal<Signal<
readonly DashboardRailCard[]
> | null>(null);
// 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 sourceUrlsByPlaylist = 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;
});
private readonly lookupGroups = computed(() =>
buildLiveEpgLookupGroups(this.cards()?.() ?? [], (card) =>
this.sourceUrlsForCard(card)
)
);
// 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 programs = toSignal(
toObservable(this.lookupGroups).pipe(
switchMap((groups) =>
groups.length === 0
? of(new Map<string, EpgProgram | null>())
: interval(LIVE_EPG_TICK_MS).pipe(
startWith(0),
switchMap(() =>
forkJoin(
groups.map((group) => this.askScope(group))
).pipe(map((answers) => mergeAnswers(answers)))
)
)
)
),
{ initialValue: new Map<string, EpgProgram | null>() }
);
/** The live cards whose rails are enabled, hero included. */
connect(cards: Signal<readonly DashboardRailCard[]>): void {
this.cards.set(cards);
}
/** `null` when nothing is known about the card's current programme. */
detailsFor(card: DashboardRailCard | null): DashboardLiveEpgDetails | null {
if (!card) {
return null;
}
const program = getLiveEpgProgramForCard(
card,
this.programs(),
liveEpgScopeKey(
this.sourceUrlsForCard(card),
liveEpgAllowsAnySource(card)
)
);
// Recompute the now-window each tick so progress moves between
// 30s ticks even if the program identity is unchanged.
return buildDashboardLiveEpgDetails(
program,
Date.now(),
this.settingsStore.resolvedEpgOffsetMinutes()
);
}
/** The XMLTV scope a live card's programme must be resolved in. */
private sourceUrlsForCard(card: DashboardRailCard): string[] {
const byPlaylistId = this.sourceUrlsByPlaylist();
return (
(card.epgPlaylistId
? byPlaylistId.get(card.epgPlaylistId)
: undefined) ?? []
);
}
private askScope(group: DashboardLiveEpgLookupGroup) {
const scopeKey = group.scopeKey;
return this.epgService
.getCurrentProgramsForChannels(group.lookupKeys, {
sourceUrls: group.sourceUrls,
anySourceFallback: group.anySourceFallback,
})
.pipe(
map((programs): ScopeAnswer => ({ scopeKey, programs })),
// One scope retired by an EPG source change, or failing, must
// not blank every other scope's answer for the tick:
// `forkJoin` emits nothing at all when one input completes
// without a value.
defaultIfEmpty(emptyAnswer(scopeKey)),
catchError(() => of(emptyAnswer(scopeKey)))
);
}
}
/** Namespaced by scope, so two guides sharing an id stay apart. */
function mergeAnswers(
answers: readonly ScopeAnswer[]
): Map<string, EpgProgram | null> {
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;
}
@@ -339,10 +339,14 @@ describe('Live rail helpers', () => {
() => []
);
expect(groups).toHaveLength(1);
expect(groups[0].lookupKeys).toEqual(['ard.de', 'Fallback News']);
expect(groups).toHaveLength(2);
// 'ard.de' carries an explicit key, 'Fallback News' falls back to its
// title — different any-source eligibility, so different groups.
expect(groups[0].lookupKeys).toEqual(['ard.de']);
expect(groups[0].anySourceFallback).toBe(true);
expect(groups[1].lookupKeys).toEqual(['Fallback News']);
expect(groups[1].anySourceFallback).toBe(false);
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', () => {
@@ -379,6 +383,29 @@ describe('Live rail helpers', () => {
]);
});
it('never widens a portal card to every guide: its key is only a title', () => {
const groups = buildLiveEpgLookupGroups(
[
// M3U: a real XMLTV key from the playlist.
channelCard({ epgLookupKey: 'ard.de', epgPlaylistId: 'm3u' }),
// Xtream/Stalker: no key at all, so the title stands in.
channelCard({
id: 'card-2',
title: 'Das Erste HD',
epgPlaylistId: 'portal',
}),
],
() => []
);
expect(
groups.map((group) => [group.lookupKeys, group.anySourceFallback])
).toEqual([
[['ard.de'], true],
[['Das Erste HD'], false],
]);
});
it('reads EPG programs by explicit lookup key instead of display title', () => {
const program = { title: 'Tagesschau' } as EpgProgram;
const wrongProgram = { title: 'Wrong channel' } as EpgProgram;
@@ -404,22 +431,28 @@ describe('Live rail helpers', () => {
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],
[
liveEpgProgramKey(liveEpgScopeKey(guideA, true), 'ard.de'),
fromGuideA,
],
[
liveEpgProgramKey(liveEpgScopeKey(guideB, true), 'ard.de'),
fromGuideB,
],
]);
expect(
getLiveEpgProgramForCard(
channelCard({ epgLookupKey: 'ard.de', epgPlaylistId: 'a' }),
epgMap,
liveEpgScopeKey(guideA)
liveEpgScopeKey(guideA, true)
)
).toBe(fromGuideA);
expect(
getLiveEpgProgramForCard(
channelCard({ epgLookupKey: 'ard.de', epgPlaylistId: 'b' }),
epgMap,
liveEpgScopeKey(guideB)
liveEpgScopeKey(guideB, true)
)
).toBe(fromGuideB);
// A scope with no answer stays empty instead of borrowing one.
@@ -433,10 +466,15 @@ describe('Live rail helpers', () => {
});
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(['b', 'a'], true)).toBe(
liveEpgScopeKey(['a', 'b', 'a'], true)
);
expect(liveEpgScopeKey(['a'])).not.toBe(liveEpgScopeKey(['a', 'b']));
expect(liveEpgScopeKey(['a'], true)).not.toBe(
liveEpgScopeKey(['a', 'b'], true)
);
// A portal card and a guide-less M3U card both resolve against
// Settings, but their answers must not be interchangeable.
expect(liveEpgScopeKey([], true)).not.toBe(liveEpgScopeKey([], false));
});
it('uses honest, semantically named title keys for favorite and recent live rails', () => {
@@ -7,20 +7,9 @@ import {
signal,
untracked,
} from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { toSignal } from '@angular/core/rxjs-interop';
import { interval, map, startWith } from 'rxjs';
import {
catchError,
defaultIfEmpty,
forkJoin,
interval,
map,
of,
startWith,
switchMap,
} from 'rxjs';
import { EpgService } from '@iptvnator/epg/data-access';
import {
type EpgProgram,
isStalkerAccountPlaylist,
isXtreamAccountPlaylist,
normalizeDashboardRailsSettings,
@@ -31,7 +20,6 @@ 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 {
@@ -73,16 +61,8 @@ import type {
import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
import type { DashboardHeroModel } from './dashboard-hero.utils';
import { resolveDashboardHeroArtwork } from './dashboard-hero.utils';
import {
buildDashboardLiveEpgDetails,
buildLiveEpgCardsForEnabledRails,
buildLiveEpgLookupGroups,
getLiveEpgProgramForCard,
liveEpgProgramKey,
liveEpgScopeKey,
LIVE_EPG_TICK_MS,
} from './dashboard-live-epg.utils';
import type { DashboardLiveEpgDetails } from './dashboard-live-epg.utils';
import { buildLiveEpgCardsForEnabledRails } from './dashboard-live-epg.utils';
import { DashboardLiveEpgPresenter } from './dashboard-live-epg.presenter';
import {
buildPlaybackPositionReloadKey,
formatRemainingLabel,
@@ -122,9 +102,11 @@ import type {
host: {
'[class.rails-page-host--empty]': 'ready() && !hasPlaylists()',
},
providers: [DashboardLiveEpgPresenter],
})
export class WorkspaceDashboardRailsComponent {
readonly data = inject(DashboardDataService);
private readonly liveEpg = inject(DashboardLiveEpgPresenter);
private readonly dialog = inject(MatDialog);
private readonly dialogService = inject(DialogService);
private readonly playlistDeleteAction = inject(PlaylistDeleteActionService);
@@ -140,7 +122,6 @@ export class WorkspaceDashboardRailsComponent {
{ initialValue: null }
);
private readonly shellActions = inject(WORKSPACE_SHELL_ACTIONS);
private readonly epgService = inject(EpgService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly heroTmdb = inject(DashboardHeroTmdbService);
@@ -202,7 +183,7 @@ export class WorkspaceDashboardRailsComponent {
const position = this.data.getPlaybackPositionForItem(item);
const liveEpgDetails =
item.type === 'live'
? this.getLiveEpgDetailsForCard(this.heroLiveCard())
? this.liveEpg.detailsFor(this.heroLiveCard())
: null;
const episodeBadge =
item.type === 'series' &&
@@ -279,132 +260,21 @@ 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.
//
// 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 buildLiveEpgLookupGroups(
buildLiveEpgCardsForEnabledRails(
this.dashboardRails(),
heroLiveCard,
this.liveFavoriteCards(),
this.recentLiveCards()
),
(card) => this.liveEpgSourceUrlsForCard(card)
);
});
// The live cards whose rails are enabled; the presenter looks their
// programmes up per XMLTV source scope.
private readonly enabledLiveCards = computed(() =>
buildLiveEpgCardsForEnabledRails(
this.dashboardRails(),
this.heroLiveCard(),
this.liveFavoriteCards(),
this.recentLiveCards()
)
);
private readonly playbackPositionReloadKey = computed(() =>
buildPlaybackPositionReloadKey(this.data.globalRecentVodItems())
);
// 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.liveEpgLookupGroups).pipe(
switchMap((groups) =>
groups.length === 0
? of(new Map<string, EpgProgram | null>())
: interval(LIVE_EPG_TICK_MS).pipe(
startWith(0),
switchMap(() =>
forkJoin(
groups.map((group) =>
this.epgService
.getCurrentProgramsForChannels(
group.lookupKeys,
{
sourceUrls: group.sourceUrls,
anySourceFallback: true,
}
)
.pipe(
map((programs) => ({
scopeKey: group.scopeKey,
programs,
})),
// One scope retired by an EPG
// source change, or failing,
// must not blank every other
// scope's answer for the tick:
// `forkJoin` emits nothing at
// all when one input completes
// empty.
defaultIfEmpty({
scopeKey: group.scopeKey,
programs: new Map<
string,
EpgProgram | null
>(),
}),
catchError(() =>
of({
scopeKey: group.scopeKey,
programs: new Map<
string,
EpgProgram | null
>(),
})
)
)
)
).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;
})
)
)
)
)
),
{ initialValue: new Map<string, EpgProgram | null>() }
);
readonly liveFavoriteCardsEnriched = computed<DashboardRailCard[]>(() =>
this.enrichLiveCards(this.liveFavoriteCards())
);
@@ -516,6 +386,8 @@ export class WorkspaceDashboardRailsComponent {
void this.data.reloadGlobalRecentItems();
void this.data.reloadGlobalFavorites();
this.liveEpg.connect(this.enabledLiveCards);
// Refresh when Xtream playlist count changes so a newly added provider
// populates the rail without a manual dashboard reload. The Xtream
// recently-added query can be the slowest dashboard worker request on
@@ -699,21 +571,11 @@ 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[] {
return cards.map((card) => {
const details = this.getLiveEpgDetailsForCard(card);
const details = this.liveEpg.detailsFor(card);
if (!details) {
return card;
}
@@ -721,26 +583,6 @@ export class WorkspaceDashboardRailsComponent {
});
}
private getLiveEpgDetailsForCard(
card: DashboardRailCard | null
): DashboardLiveEpgDetails | null {
if (!card) {
return null;
}
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(
program,
Date.now(),
this.settingsStore.resolvedEpgOffsetMinutes()
);
}
private buildNonLiveSeeAllState(
cards: readonly DashboardRailCard[]
): Record<string, unknown> {