mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
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:
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;
|
||||
}
|
||||
Binary file not shown.
+48
-10
@@ -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', () => {
|
||||
|
||||
+20
-178
@@ -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> {
|
||||
|
||||
Reference in new issue
Block a user