From bfb89b0910beeca685ce106768802dc1ae2ab904 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 28 Sep 2026 21:51:46 +0200 Subject: [PATCH] fix(dashboard): refresh corrected guides and pause expiry timers while hidden Review follow-ups: - A programme still on air was not asked for again until it ended, so a guide import that corrected it could leave the dashboard wrong for hours. Re-ask at once on EpgService.epgAvailable$ (guide import or source change), and at least every five minutes (LIVE_EPG_MAX_ANSWER_AGE_MS) for changes that arrive by other paths. - The source-expiry clock scheduled from its last tick, so facts that arrived long after it could be scheduled up to an hour late. Schedule from Date.now(). - The expiry timer stayed armed while the page was hidden. Arm none until the page is visible again; becoming visible moves the clock at once. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/workspace-dashboard.md | 13 ++- .../dashboard-live-epg.presenter.spec.ts | 86 ++++++++++++++++--- .../lib/rails/dashboard-live-epg.presenter.ts | 45 +++++++--- .../src/lib/rails/dashboard-live-epg.utils.ts | 5 ++ .../dashboard-source-expiry-clock.spec.ts | 38 ++++++++ .../rails/dashboard-source-expiry-clock.ts | 15 +++- 6 files changed, 169 insertions(+), 33 deletions(-) diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index ca6ff2ec7..4c46108b0 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -207,9 +207,12 @@ hooks: `dashboard-hero`, `dashboard-hero-slide` (`data-hero-kind`), the XMLTV lookup has cards or the portal presenter wants one, and only while the document is visible; it reads the clock at once when it starts again. A tick re-reads progress for every live card. It - re-asks an XMLTV scope only after one of its programmes has ended or - while a key has no programme, and an unchanged answer is not - re-emitted. + re-asks an XMLTV scope only after one of its programmes has ended, + while a key has no programme, or once the answer is five minutes old + (`LIVE_EPG_MAX_ANSWER_AGE_MS`), because a guide refreshed elsewhere + can correct a programme still on air. A guide import or source change + (`EpgService.epgAvailable$`) re-asks at once. An unchanged answer is + not re-emitted. 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 @@ -404,5 +407,7 @@ full-playlist read. The chip is not a separate account-refresh request. The badge only changes at day boundaries, so the rails do not poll the clock: `createSourceExpiryClock` arms one timer for the earliest boundary among the known facts (`nextSourceExpiryChangeMs`), capped at an hour because timers do -not follow system sleep, and re-reads the clock when the page becomes visible. +not follow system sleep. It arms no timer while the page is hidden and +re-reads the clock when the page becomes visible. It schedules from the real +time, so facts that arrive long after the last tick are not scheduled late. Facts whose badge can no longer change arm no timer. 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 80a36e6e6..2d9ccceba 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 @@ -1,6 +1,6 @@ import { computed, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { EMPTY, of, throwError } from 'rxjs'; +import { BehaviorSubject, EMPTY, of, throwError } from 'rxjs'; import { EpgService } from '@iptvnator/epg/data-access'; import { DEFAULT_DASHBOARD_RAILS_SETTINGS, @@ -42,6 +42,7 @@ const program = (title: string): EpgProgram => describe('DashboardLiveEpgPresenter', () => { let presenter: DashboardLiveEpgPresenter; let getCurrentProgramsForChannels: jest.Mock; + let epgAvailable: BehaviorSubject; let playlists: ReturnType>; let recentItems: ReturnType>; let favoriteLiveItems: ReturnType>; @@ -69,6 +70,7 @@ describe('DashboardLiveEpgPresenter', () => { jest.useFakeTimers(); jest.setSystemTime(new Date('2026-05-23T10:30:00.000Z')); getCurrentProgramsForChannels = jest.fn(() => of(new Map())); + epgAvailable = new BehaviorSubject(false); playlists = signal([ m3uPlaylist('a', [guideA]), m3uPlaylist('a2', [guideA]), @@ -108,7 +110,10 @@ describe('DashboardLiveEpgPresenter', () => { }, { provide: EpgService, - useValue: { getCurrentProgramsForChannels }, + useValue: { + getCurrentProgramsForChannels, + epgAvailable$: epgAvailable, + }, }, { provide: SettingsStore, @@ -347,7 +352,66 @@ describe('DashboardLiveEpgPresenter', () => { ); }); - it('asks a guide again only once a programme on air has ended', () => { + /** One clock tick: the interval fires, then effects flush. */ + const tick = (count = 1) => { + for (let index = 0; index < count; index++) { + jest.advanceTimersByTime(30_000); + TestBed.tick(); + } + }; + + it('asks a guide again once a programme on air has ended', () => { + const fromA = card({ + id: 'a', + epgLookupKey: 'ard.de', + epgPlaylistId: 'a', + }); + getCurrentProgramsForChannels.mockImplementation(() => + of( + new Map([ + [ + 'ard.de', + { + ...program('Short'), + stop: '2026-05-23T10:32:00.000Z', + }, + ], + ]) + ) + ); + setup([fromA]); + expect(getCurrentProgramsForChannels).toHaveBeenCalledTimes(1); + + // 10:30 → 10:31:30: still on air. + tick(3); + expect(getCurrentProgramsForChannels).toHaveBeenCalledTimes(1); + + // The 10:32 tick sees it ended and asks again. + tick(); + expect(getCurrentProgramsForChannels).toHaveBeenCalledTimes(2); + }); + + it('asks again at least every five minutes while a programme is on air', () => { + const fromA = card({ + id: 'a', + epgLookupKey: 'ard.de', + epgPlaylistId: 'a', + }); + getCurrentProgramsForChannels.mockImplementation(() => + of(new Map([['ard.de', program('Tagesschau')]])) + ); + setup([fromA]); + + // 10:30 → 10:34:30: the 10:00–11:00 programme is on air and fresh. + tick(9); + expect(getCurrentProgramsForChannels).toHaveBeenCalledTimes(1); + + // 10:35: the answer is five minutes old; a guide may have changed. + tick(); + expect(getCurrentProgramsForChannels).toHaveBeenCalledTimes(2); + }); + + it('asks again at once when a guide import or source change lands', () => { const fromA = card({ id: 'a', epgLookupKey: 'ard.de', @@ -359,18 +423,14 @@ describe('DashboardLiveEpgPresenter', () => { setup([fromA]); expect(getCurrentProgramsForChannels).toHaveBeenCalledTimes(1); - // 10:30 → 10:59:30, one tick at a time: the 10:00–11:00 programme is - // still on air on every one of them. - for (let tick = 0; tick < 59; tick++) { - jest.advanceTimersByTime(30_000); - TestBed.tick(); - } - expect(getCurrentProgramsForChannels).toHaveBeenCalledTimes(1); - - // The 11:00 tick sees it ended and asks again. - jest.advanceTimersByTime(30_000); + getCurrentProgramsForChannels.mockImplementation(() => + of(new Map([['ard.de', program('Corrected')]])) + ); + epgAvailable.next(true); TestBed.tick(); + expect(getCurrentProgramsForChannels).toHaveBeenCalledTimes(2); + expect(presenter.detailsFor(fromA)?.nowPlayingTitle).toBe('Corrected'); }); it('moves progress on every clock tick while the programme is unchanged', () => { 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 9470b916b..93d553c73 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 @@ -16,7 +16,9 @@ import { filter, forkJoin, map, + merge, of, + skip, switchMap, tap, } from 'rxjs'; @@ -44,6 +46,7 @@ import { liveEpgAnswersNeedRefresh, liveEpgProgramKey, liveEpgScopeKey, + LIVE_EPG_MAX_ANSWER_AGE_MS, sameLiveEpgAnswers, type DashboardLiveEpgDetails, type DashboardLiveEpgLookupGroup, @@ -152,11 +155,18 @@ export class DashboardLiveEpgPresenter { /** Created once: `toObservable` owns an effect for the injector's life. */ private readonly now$ = toObservable(this.clock.now); - // Asked on rail or offset change, then on clock ticks only once an - // answer can be stale: a programme ended, or a key is still without - // one. A programme that is still on air is not asked for again, and an - // unchanged answer is not re-emitted, so the rails rebuild on a tick - // only for the progress bars. + /** A guide import or source change can replace a programme on air. */ + private readonly guideChanged$ = this.epgService.epgAvailable$.pipe( + skip(1), + filter(Boolean), + map(() => Date.now()) + ); + + // Asked on rail or offset change and whenever the guide changes. On + // clock ticks it is asked again only once an answer can be stale: a + // programme ended, a key is still without one, or the answer is older + // than LIVE_EPG_MAX_ANSWER_AGE_MS. An unchanged answer is not + // re-emitted, so the rails rebuild on a tick only for the progress bars. private readonly programs = toSignal( combineLatest([ toObservable(this.lookupGroups), @@ -168,20 +178,29 @@ export class DashboardLiveEpgPresenter { } let answers: ReadonlyMap | null = null; - return this.now$.pipe( - filter((nowMs) => - liveEpgAnswersNeedRefresh( - answers, - groups, - epgProviderClockMs(nowMs, offsetMinutes) - ) + let answeredAt = 0; + return merge( + this.now$, + this.guideChanged$.pipe(tap(() => (answers = null))) + ).pipe( + filter( + (nowMs) => + nowMs - answeredAt >= LIVE_EPG_MAX_ANSWER_AGE_MS || + liveEpgAnswersNeedRefresh( + answers, + groups, + epgProviderClockMs(nowMs, offsetMinutes) + ) ), switchMap(() => forkJoin( groups.map((group) => this.askScope(group)) ).pipe(map((scopes) => mergeAnswers(scopes))) ), - tap((merged) => (answers = merged)), + tap((merged) => { + answers = merged; + answeredAt = Date.now(); + }), distinctUntilChanged(sameLiveEpgAnswers) ); }) 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 5f43c345c..dd7257044 100644 --- 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 @@ -11,6 +11,11 @@ import type { DashboardRailCard } from './dashboard-rail.component'; // the SQLite backend with a batched IPC every animation frame. export const LIVE_EPG_TICK_MS = 30_000; +// A programme still on air is asked for again at least this often: a guide +// refreshed outside this page's view can correct or replace it, and nothing +// else tells the dashboard. +export const LIVE_EPG_MAX_ANSWER_AGE_MS = 5 * 60_000; + // Reads either an ISO `start`/`stop` or the pre-computed `startTimestamp` // when present. The parsed XMLTV pipeline populates both, but legacy rows // only carry the strings. `startTimestamp`/`stopTimestamp` are unix SECONDS diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-source-expiry-clock.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-source-expiry-clock.spec.ts index c442c62d6..f4c51ce6c 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-source-expiry-clock.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-source-expiry-clock.spec.ts @@ -20,9 +20,16 @@ describe('createSourceExpiryClock', () => { reportedExpired: false, }); + let hidden: boolean; + beforeEach(() => { jest.useFakeTimers(); jest.setSystemTime(nowMs); + hidden = false; + Object.defineProperty(document, 'hidden', { + configurable: true, + get: () => hidden, + }); facts = signal>(new Map()); clock = TestBed.runInInjectionContext(() => createSourceExpiryClock(facts) @@ -32,6 +39,7 @@ describe('createSourceExpiryClock', () => { afterEach(() => { TestBed.resetTestingModule(); + delete (document as { hidden?: boolean }).hidden; jest.useRealTimers(); }); @@ -75,4 +83,34 @@ describe('createSourceExpiryClock', () => { expect(clock()).toBe(before + SOURCE_EXPIRY_MAX_WAIT_MS); }); + + it('schedules from the real time when facts arrive long after the last tick', () => { + // No facts yet, so no timer: the clock stays at its start value. + jest.advanceTimersByTime(50 * 60_000); + const before = clock(); + + // The boundary is 5 minutes from the real time, 55 from the clock. + facts.set(new Map([['xtream', expiringIn(2 * DAY_MS + 55 * 60_000)]])); + TestBed.tick(); + + jest.advanceTimersByTime(6 * 60_000); + TestBed.tick(); + expect(clock()).toBeGreaterThan(before); + }); + + it('arms no timer while hidden and catches up as the page returns', () => { + facts.set(new Map([['xtream', expiringIn(2 * DAY_MS + 20 * 60_000)]])); + hidden = true; + document.dispatchEvent(new Event('visibilitychange')); + TestBed.tick(); + expect(jest.getTimerCount()).toBe(0); + + jest.advanceTimersByTime(3 * 60 * 60_000); + hidden = false; + document.dispatchEvent(new Event('visibilitychange')); + TestBed.tick(); + + expect(clock()).toBe(Date.now()); + expect(jest.getTimerCount()).toBe(1); + }); }); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-source-expiry-clock.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-source-expiry-clock.ts index de27f5b6f..847af6f99 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-source-expiry-clock.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-source-expiry-clock.ts @@ -15,8 +15,9 @@ const BOUNDARY_SLACK_MS = 1_000; * Badges move at day granularity, so instead of polling the clock this arms * one timer for the earliest badge boundary among the known facts (none at * all when no badge can change), capped at {@link SOURCE_EXPIRY_MAX_WAIT_MS} - * because timers do not follow system sleep or clock changes. Becoming - * visible re-reads the clock at once for the same reason. Must be created in + * because timers do not follow system sleep or clock changes. No timer is + * armed while the document is hidden; becoming visible re-reads the clock + * at once. Must be created in * an injection context. */ export function createSourceExpiryClock( @@ -24,9 +25,16 @@ export function createSourceExpiryClock( ): Signal { const document = inject(DOCUMENT); const now = signal(Date.now()); + const visible = signal(!document.hidden); effect((onCleanup) => { - const nowMs = now(); + // Read for the dependency; schedule from the real time, because the + // facts can change long after the clock last moved. + now(); + // Hidden: no badge is on screen. Becoming visible moves the clock, + // which re-runs this effect. + if (!visible()) return; + const nowMs = Date.now(); let next: number | null = null; for (const entry of facts().values()) { const change = nextSourceExpiryChangeMs(entry, nowMs); @@ -45,6 +53,7 @@ export function createSourceExpiryClock( }); const onVisibilityChange = () => { + visible.set(!document.hidden); if (!document.hidden) now.set(Date.now()); }; document.addEventListener('visibilitychange', onVisibilityChange);