mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
81f78dea8c
commit
bfb89b0910
6 files changed
+169
-33
No files matched your search
@@ -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.
|
||||
+73
-13
@@ -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<boolean>;
|
||||
let playlists: ReturnType<typeof signal<PlaylistMeta[]>>;
|
||||
let recentItems: ReturnType<typeof signal<PortalActivityItem[]>>;
|
||||
let favoriteLiveItems: ReturnType<typeof signal<PortalActivityItem[]>>;
|
||||
@@ -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<PlaylistMeta[]>([
|
||||
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', () => {
|
||||
|
||||
@@ -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<string, EpgProgram | null> | 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)
|
||||
);
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ReadonlyMap<string, SourceExpiryFacts>>(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);
|
||||
});
|
||||
});
|
||||
@@ -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<number> {
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user