From 9c5c256b0d360122563b1312c5529703a4ff868a Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 20 Sep 2026 21:55:33 +0200 Subject: [PATCH] refactor(dashboard): fold the portal EPG wiring into the live-EPG presenter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebase onto #1637, which introduced `DashboardLiveEpgPresenter` for the rails' XMLTV lookup. Rather than wiring a second presenter into the page component, the portal lookup now hangs off that one: - `DashboardLiveEpgPresenter` derives the portal rows from the enabled rails itself, pins the hero key, forwards visible cards, and `enrich()` returns the cards with their "now on air" row filled in — a portal answer first, the XMLTV title match as fallback. - The component only provides both presenters, sets `liveEpgSourceKey` on its live cards, and forwards each rail's visible set. That keeps the page component at 835 lines, seven fewer than before this branch, instead of the 60 it would otherwise have gained on a file the max-lines baseline already covers. The generated baseline is unchanged. Co-Authored-By: Claude Opus 5 --- docs/architecture/workspace-dashboard.md | 23 +++-- .../dashboard-live-epg.presenter.spec.ts | 91 ++++++++++++++++++- .../lib/rails/dashboard-live-epg.presenter.ts | 78 +++++++++++++--- .../workspace-dashboard-rails.component.ts | 63 +------------ 4 files changed, 173 insertions(+), 82 deletions(-) diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index f239b9273..2f9faf810 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -129,9 +129,12 @@ Render rules: `dashboard-recent-live-rail`); there is no fallback from one to the other. M3U cards carry an `epg_lookup_key` using the app-wide XMLTV fallback order (`tvg-id` -> `tvg-name` -> channel name); EPG enrichment - must use that key before falling back to the card title. That XMLTV - lookup is one batched local query for every live card and re-runs on - the 30 s tick. + must use that key before falling back to the card title. Both rails are + enriched by `DashboardLiveEpgPresenter`, the one component-provided + facade for live EPG: it owns the XMLTV lookup described under "Scoped + lookups" in `m3u-playlist-module.md`, forwards everything portal-shaped + to `DashboardPortalLiveEpgPresenter`, and `enrich()` returns the cards + with their "now on air" row filled in. Xtream and Stalker cards have no XMLTV key of their own; their "now on air" line comes from the portal, **lazily and per card**: - `buildDashboardPortalLiveEpgEntry` (dashboard data-access) turns a @@ -149,9 +152,12 @@ Render rules: visible keys of both rails with the pinned hero key and calls `DashboardPortalLiveEpgService.sync()` with exactly those entries — on every change, on the 30 s tick, and on a display-offset change. - The queue lives in the root service, so leaving the dashboard hands - the wanted set back (`sync([])` on destroy); otherwise the queue - would keep asking for cards on a page that is gone. + It is reached through `DashboardLiveEpgPresenter`, which derives the + portal rows itself from the enabled rails and pins the hero, so the + page component only forwards what a rail can see. The queue lives in + the root service, so leaving the dashboard hands the wanted set back + (`sync([])` on destroy); otherwise the queue would keep asking for + cards on a page that is gone. - `DashboardPortalLiveEpgService` (root) owns the queue: at most two requests in flight, 200 ms between starts (the numbers `EpgQueueService` proved against real panels), one card per request, @@ -180,8 +186,9 @@ Render rules: filing an empty answer for every card. Lifting that gate for portal lookups would change the collection pages too and is deliberately out of scope here. - - `enrichLiveCards` prefers the portal answer, falls back to the XMLTV - title match when the portal said "nothing on air", and marks a card + - `DashboardLiveEpgPresenter.enrich()` prefers the portal answer, falls + back to the XMLTV title match when the portal said "nothing on air", + and marks a card `nowPlayingState: 'pending'` only before its FIRST answer — the channel layout then shows a shimmer placeholder in the programme slot; a refresh keeps the previous answer on screen. diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.spec.ts index fa4f72d3a..47bd26b81 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.spec.ts @@ -6,6 +6,7 @@ import type { EpgProgram, PlaylistMeta } from '@iptvnator/shared/interfaces'; import { SettingsStore } from '@iptvnator/services'; import { DashboardDataService } from '@iptvnator/workspace/dashboard/data-access'; import { DashboardLiveEpgPresenter } from './dashboard-live-epg.presenter'; +import { DashboardPortalLiveEpgPresenter } from './dashboard-portal-live-epg.presenter'; import type { DashboardRailCard } from './dashboard-rail.component'; const guideA = 'https://a.example/guide.xml'; @@ -36,6 +37,14 @@ describe('DashboardLiveEpgPresenter', () => { let presenter: DashboardLiveEpgPresenter; let getCurrentProgramsForChannels: jest.Mock; let playlists: ReturnType>; + /** Portal answers are the sibling presenter's job; stub it out here. */ + let portal: { + connect: jest.Mock; + setPinnedKeys: jest.Mock; + setVisibleCards: jest.Mock; + programFor: jest.Mock; + isPending: jest.Mock; + }; const setup = (cards: DashboardRailCard[]) => { presenter.connect(signal(cards)); @@ -55,12 +64,30 @@ describe('DashboardLiveEpgPresenter', () => { { _id: 'portal', serverUrl: 'http://portal' } as PlaylistMeta, ]); + portal = { + connect: jest.fn(), + setPinnedKeys: jest.fn(), + setVisibleCards: jest.fn(), + programFor: jest.fn(() => undefined), + isPending: jest.fn(() => false), + }; + TestBed.configureTestingModule({ providers: [ DashboardLiveEpgPresenter, + { + provide: DashboardPortalLiveEpgPresenter, + useValue: portal, + }, { provide: DashboardDataService, - useValue: { playlists }, + useValue: { + playlists, + // The presenter also derives the portal source list. + globalRecentItems: signal([]), + globalFavoriteLiveItems: signal([]), + globalRecentLiveItems: signal([]), + }, }, { provide: EpgService, @@ -68,7 +95,10 @@ describe('DashboardLiveEpgPresenter', () => { }, { provide: SettingsStore, - useValue: { resolvedEpgOffsetMinutes: () => 0 }, + useValue: { + resolvedEpgOffsetMinutes: () => 0, + dashboardRails: signal(undefined), + }, }, ], }); @@ -136,6 +166,63 @@ describe('DashboardLiveEpgPresenter', () => { ); }); + it('prefers the portal answer and forwards what the portal presenter owns', () => { + const xmltvCard = card({ + id: 'x', + epgLookupKey: 'ard.de', + epgPlaylistId: 'a', + liveEpgSourceKey: 'xtream::p::7', + }); + getCurrentProgramsForChannels.mockImplementation(() => + of( + new Map([ + ['ard.de', program('From XMLTV')], + ]) + ) + ); + portal.programFor.mockImplementation( + (key: string | null | undefined) => + key === 'xtream::p::7' ? program('From the portal') : undefined + ); + + setup([xmltvCard]); + + expect(presenter.detailsFor(xmltvCard)?.nowPlayingTitle).toBe( + 'From the portal' + ); + + // A portal that answered "nothing on air" falls back to XMLTV. + portal.programFor.mockReturnValue(null); + expect(presenter.detailsFor(xmltvCard)?.nowPlayingTitle).toBe( + 'From XMLTV' + ); + + // The placeholder is only for a card still awaiting its FIRST answer. + getCurrentProgramsForChannels.mockImplementation(() => + of(new Map()) + ); + setup([xmltvCard]); + portal.programFor.mockReturnValue(undefined); + portal.isPending.mockReturnValue(true); + expect(presenter.enrich([xmltvCard])[0].nowPlayingState).toBe( + 'pending' + ); + // An answered card keeps what it has instead of flashing. + portal.programFor.mockReturnValue(program('Answered')); + expect(presenter.enrich([xmltvCard])[0].nowPlayingState).toBeNull(); + expect(presenter.enrich([xmltvCard])[0].nowPlayingTitle).toBe( + 'Answered' + ); + + // The portal source list and the hero pin are the presenter's own + // job; the rails only report what they can see. + expect(portal.connect).toHaveBeenCalled(); + presenter.setVisibleCards('favorites', [xmltvCard]); + expect(portal.setVisibleCards).toHaveBeenCalledWith('favorites', [ + xmltvCard, + ]); + }); + it('never hands a card the programme another guide resolved for the same id', () => { const fromA = card({ id: 'a', diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts index 236508129..977ed2cfe 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts @@ -1,8 +1,10 @@ import { computed, + effect, inject, Injectable, signal, + untracked, type Signal, } from '@angular/core'; import { toObservable, toSignal } from '@angular/core/rxjs-interop'; @@ -22,8 +24,12 @@ import type { PortalActivityItem, } from '@iptvnator/shared/interfaces'; import { SettingsStore } from '@iptvnator/services'; +import { normalizeDashboardRailsSettings } from '@iptvnator/shared/interfaces'; import { normalizeEpgUrls } from '@iptvnator/shared/m3u-utils'; -import { DashboardDataService } from '@iptvnator/workspace/dashboard/data-access'; +import { + buildDashboardPortalLiveEpgKey, + DashboardDataService, +} from '@iptvnator/workspace/dashboard/data-access'; import type { DashboardRailCard } from './dashboard-rail.component'; import { DashboardPortalLiveEpgPresenter } from './dashboard-portal-live-epg.presenter'; import { @@ -37,6 +43,7 @@ import { type DashboardLiveEpgDetails, type DashboardLiveEpgLookupGroup, } from './dashboard-live-epg.utils'; +import { RAIL_ITEM_LIMIT } from './dashboard-rail.utils'; type ScopeAnswer = { readonly scopeKey: string; @@ -124,26 +131,73 @@ export class DashboardLiveEpgPresenter { { initialValue: new Map() } ); + // The Xtream/Stalker live rows behind the hero and the two live rails. + // Their programmes come from the portal, asked for lazily per visible + // card; M3U rows stay on the XMLTV batch above. + private readonly portalItems = computed( + () => { + const rails = normalizeDashboardRailsSettings( + this.settingsStore.dashboardRails?.() + ); + const hero = this.data.globalRecentItems()[0] ?? null; + return [ + ...(rails.hero && hero?.type === 'live' ? [hero] : []), + ...(rails.liveFavorites + ? this.data + .globalFavoriteLiveItems() + .slice(0, RAIL_ITEM_LIMIT) + : []), + ...(rails.recentlyWatchedLive + ? this.data + .globalRecentLiveItems() + .slice(0, RAIL_ITEM_LIMIT) + : []), + ]; + } + ); + + constructor() { + this.portal.connect(this.portalItems); + // The hero sits at the top of the page and is never scrolled into + // view, so its key is wanted regardless of what the rails report. + effect(() => { + const [first] = this.portalItems(); + const heroKey = + first?.type === 'live' + ? buildDashboardPortalLiveEpgKey(first) + : null; + untracked(() => this.portal.setPinnedKeys([heroKey])); + }); + } + /** The live cards whose rails are enabled, hero included. */ connect(cards: Signal): void { this.cards.set(cards); } - /** The Xtream/Stalker rows whose programmes come from their portal. */ - connectPortalItems(items: Signal): void { - this.portal.connect(items); - } - - /** Portal keys wanted regardless of scrolling (the hero card). */ - setPinnedPortalKeys(keys: readonly (string | null | undefined)[]): void { - this.portal.setPinnedKeys(keys); - } - /** A rail reported the cards inside its viewport. */ setVisibleCards(railId: string, cards: readonly DashboardRailCard[]): void { this.portal.setVisibleCards(railId, cards); } + /** The rails' cards with their "now on air" row filled in. */ + enrich(cards: readonly DashboardRailCard[]): DashboardRailCard[] { + return cards.map((card) => { + const details = this.detailsFor(card); + // Placeholder only before the FIRST portal answer: a refresh + // keeps the previous answer on screen instead of flashing. + const pending = !details && this.isAwaitingFirstAnswer(card); + if (!details && !pending) { + return card; + } + return { + ...card, + ...(details ?? {}), + nowPlayingState: pending ? 'pending' : null, + }; + }); + } + /** `null` when nothing is known about the card's current programme. */ detailsFor(card: DashboardRailCard | null): DashboardLiveEpgDetails | null { if (!card) { @@ -175,7 +229,7 @@ export class DashboardLiveEpgPresenter { * True only before a card's FIRST portal answer, so a refresh keeps the * previous answer on screen instead of flashing a placeholder. */ - isAwaitingFirstAnswer(card: DashboardRailCard): boolean { + private isAwaitingFirstAnswer(card: DashboardRailCard): boolean { return ( this.portal.programFor(card.liveEpgSourceKey) === undefined && this.portal.isPending(card.liveEpgSourceKey) 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 922a69833..6ab998de7 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 @@ -59,10 +59,7 @@ import type { DashboardRailCard, DashboardRailActionSelection, } from './dashboard-rail.component'; -import type { - PlaylistMeta, - PortalActivityItem, -} from '@iptvnator/shared/interfaces'; +import type { PlaylistMeta } from '@iptvnator/shared/interfaces'; import type { DashboardHeroModel } from './dashboard-hero.utils'; import { DashboardPortalLiveEpgPresenter } from './dashboard-portal-live-epg.presenter'; import { resolveDashboardHeroArtwork } from './dashboard-hero.utils'; @@ -277,40 +274,16 @@ export class WorkspaceDashboardRailsComponent { ) ); - // The Xtream/Stalker live rows behind the hero and the two live rails. - // Their programmes come from the portal, asked for lazily per visible - // card by DashboardPortalLiveEpgPresenter; M3U rows stay on the XMLTV - // batch above. - private readonly portalLiveItems = computed( - () => { - const rails = this.dashboardRails(); - const hero = this.heroRecentItem(); - return [ - ...(rails.hero && hero?.type === 'live' ? [hero] : []), - ...(rails.liveFavorites - ? this.data - .globalFavoriteLiveItems() - .slice(0, RAIL_ITEM_LIMIT) - : []), - ...(rails.recentlyWatchedLive - ? this.data - .globalRecentLiveItems() - .slice(0, RAIL_ITEM_LIMIT) - : []), - ]; - } - ); - private readonly playbackPositionReloadKey = computed(() => buildPlaybackPositionReloadKey(this.data.globalRecentVodItems()) ); readonly liveFavoriteCardsEnriched = computed(() => - this.enrichLiveCards(this.liveFavoriteCards()) + this.liveEpg.enrich(this.liveFavoriteCards()) ); readonly recentLiveCardsEnriched = computed(() => - this.enrichLiveCards(this.recentLiveCards()) + this.liveEpg.enrich(this.recentLiveCards()) ); readonly favoriteMoviesAndSeriesCards = computed(() => @@ -417,17 +390,6 @@ export class WorkspaceDashboardRailsComponent { void this.data.reloadGlobalFavorites(); this.liveEpg.connect(this.enabledLiveCards); - // Portal EPG for the live cards: asked only for cards the rails - // report as visible; the hero sits at the top and is pinned so it - // never waits for a scroll. - this.liveEpg.connectPortalItems(this.portalLiveItems); - effect(() => { - this.liveEpg.setPinnedPortalKeys([ - this.dashboardRails().hero - ? this.heroLiveCard()?.liveEpgSourceKey - : null, - ]); - }); // Refresh when Xtream playlist count changes so a newly added provider // populates the rail without a manual dashboard reload. The Xtream @@ -612,25 +574,6 @@ export class WorkspaceDashboardRailsComponent { }); } - private enrichLiveCards( - cards: readonly DashboardRailCard[] - ): DashboardRailCard[] { - return cards.map((card) => { - const details = this.liveEpg.detailsFor(card); - // Placeholder only before the FIRST portal answer: a refresh - // keeps the previous answer on screen instead of flashing. - const pending = !details && this.liveEpg.isAwaitingFirstAnswer(card); - if (!details && !pending) { - return card; - } - return { - ...card, - ...(details ?? {}), - nowPlayingState: pending ? 'pending' : null, - }; - }); - } - private buildNonLiveSeeAllState( cards: readonly DashboardRailCard[] ): Record {