From d8d36476e691db86a441c3cea81635093f8bbc47 Mon Sep 17 00:00:00 2001 From: Mark Jardine <77744624+mark-jardine@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:46:10 +0100 Subject: [PATCH] feat(epg): add global EPG display time offset (#1489) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a global EPG display-time offset (Settings → EPG, whole minutes, ±720) for guides whose provider labels programme times with the wrong timezone. Display-only: parsed XMLTV values, SQLite rows, catch-up URLs and recording snapshots keep the provider's own times, so changing it needs no guide refresh. Closes the global part of #50. The contract lives in `libs/shared/interfaces/src/lib/epg-display-offset.util.ts` with two equivalent forms: `epgDisplayTimeMs` shifts a programme for display, `epgProviderClockMs` shifts "now" into the provider's clock for every "currently airing" decision — the batched `GET_CURRENT_PROGRAMS_BATCH` lookup takes an explicit `nowMs`, and the channel lists, the Xtream/Stalker previews, the M3U player's current-programme mirror, the unified collection resolver, the dashboard live cards and the recording overlap all pick the same programme the guide renders as "now". Portal short-EPG windows start at the provider's own "now", so under a non-zero offset the Xtream preview surfaces cut their window from the full guide at the provider clock, Stalker short-EPG requests are widened for negative offsets, and every per-stream memory of the previous offset is retired together when the setting changes. Co-authored-by: Mark Jardine Co-authored-by: Claude Fable 5.1 --- .changes/epg-display-offset.md | 7 + CLAUDE.md | 1 + apps/electron-backend-e2e/src/settings.e2e.ts | 33 ++ .../src/xtream-epg.e2e.ts | 89 +++++ .../src/app/api/main.preload.ts | 3 +- .../src/app/events/epg-query.service.spec.ts | 79 ++++ .../src/app/events/epg-query.service.ts | 20 +- .../src/app/events/epg.events.ts | 5 +- .../settings-epg-section.component.html | 32 +- .../app/settings/settings-form.utils.spec.ts | 48 +++ .../src/app/settings/settings-form.utils.ts | 10 + .../test-stubs/settings-test-harness.stub.ts | 1 + apps/web/src/assets/i18n/ar.json | 4 + apps/web/src/assets/i18n/ary.json | 4 + apps/web/src/assets/i18n/by.json | 4 + apps/web/src/assets/i18n/de.json | 4 + apps/web/src/assets/i18n/el.json | 4 + apps/web/src/assets/i18n/en.json | 4 + apps/web/src/assets/i18n/es.json | 4 + apps/web/src/assets/i18n/fr.json | 4 + apps/web/src/assets/i18n/hu.json | 4 + apps/web/src/assets/i18n/it.json | 4 + apps/web/src/assets/i18n/ja.json | 4 + apps/web/src/assets/i18n/ko.json | 4 + apps/web/src/assets/i18n/nl.json | 4 + apps/web/src/assets/i18n/pl.json | 4 + apps/web/src/assets/i18n/pt.json | 4 + apps/web/src/assets/i18n/ru.json | 4 + apps/web/src/assets/i18n/tr.json | 4 + apps/web/src/assets/i18n/zh.json | 4 + apps/web/src/assets/i18n/zhtw.json | 4 + docs/architecture/m3u-playlist-module.md | 55 +++ .../src/lib/epg-runtime-bridge.service.ts | 4 +- .../data-access/src/lib/epg.service.spec.ts | 155 +++++++- libs/epg/data-access/src/lib/epg.service.ts | 68 +++- .../video-player-movie-gate.spec.ts | 1 + .../video-player/video-player.component.html | 2 + .../video-player.component.spec.ts | 35 +- .../video-player/video-player.component.ts | 21 +- .../video-player/video-player.spec-stubs.ts | 40 ++ .../recording-detail.component.html | 4 +- .../recording-detail.component.ts | 15 +- ...stream-resolver.service.epg-offset.spec.ts | 356 ++++++++++++++++++ .../stream-resolver.service.spec.ts | 12 +- .../lib/collection/stream-resolver.service.ts | 224 ++++++++--- .../global-favorites-list.component.spec.ts | 1 + .../global-favorites-list.component.ts | 8 +- .../unified-live-epg-summary.util.spec.ts | 83 ++++ .../unified-live-epg-summary.util.ts | 43 ++- ...unified-live-tab.channels-for-list.spec.ts | 1 + .../unified-live-tab.component.html | 2 + .../unified-live-tab.component.spec.ts | 66 ++++ .../unified-live-tab.component.ts | 41 +- .../unified-live-tab.remote-control.spec.ts | 1 + .../unified-live-tab.spec-stubs.ts | 1 + .../features/with-stalker-epg.feature.ts | 3 +- .../stalker-live-epg-preview.spec.ts | 68 ++++ .../stalker-live-epg-preview.ts | 44 ++- .../stalker-live-stream-layout.component.html | 2 + ...alker-live-stream-layout.component.spec.ts | 16 +- .../stalker-live-stream-layout.component.ts | 31 +- ...alker-live-stream-layout.epg-races.spec.ts | 5 +- ...stream-layout.playback-session-key.spec.ts | 5 +- ...r-live-stream-layout.remote-status.spec.ts | 1 + .../stalker-live-stream-layout.spec-stubs.ts | 1 + .../services/epg-queue-invalidation.spec.ts | 1 + .../lib/services/epg-queue.service.spec.ts | 194 +++++++++- .../src/lib/services/epg-queue.service.ts | 140 ++++++- .../xtream-xmltv-fallback.service.spec.ts | 47 ++- .../services/xtream-xmltv-fallback.service.ts | 32 +- .../stores/features/with-epg.feature.spec.ts | 21 ++ .../lib/stores/features/with-epg.feature.ts | 27 +- .../live-stream-layout.component.html | 2 + .../live-stream-layout.component.spec.ts | 2 + .../live-stream-layout.component.ts | 11 +- .../portal-channels-list.component.spec.ts | 1 + .../portal-channels-list.component.ts | 44 ++- .../src/lib/settings-store.service.spec.ts | 52 +++ .../src/lib/settings-store.service.ts | 19 + libs/shared/interfaces/src/index.ts | 1 + .../src/lib/electron-api.interface.ts | 15 +- .../src/lib/epg-display-offset.util.spec.ts | 151 ++++++++ .../src/lib/epg-display-offset.util.ts | 140 +++++++ .../recording-program-overlap.util.spec.ts | 22 ++ .../src/lib/recording-program-overlap.util.ts | 16 +- .../interfaces/src/lib/settings.interface.ts | 8 + .../all-channels-view.component.ts | 18 +- .../channel-list-container.component.spec.ts | 45 ++- .../channel-list-container.component.ts | 33 +- .../channel-list-item.component.html | 12 +- .../channel-list-item.component.spec.ts | 63 +++- .../channel-list-item.component.ts | 11 +- .../favorites-view.component.ts | 18 +- .../groups-view/groups-view.component.ts | 19 +- .../recent-view/recent-view.component.ts | 20 +- libs/ui/epg/src/index.ts | 2 + .../epg-item-description.component.spec.ts | 15 + .../epg-item-description.component.ts | 30 +- .../epg-list-view.component.html | 4 +- .../epg-list-view/epg-list-view.component.ts | 35 +- .../epg-list-view/epg-list-view.utils.spec.ts | 28 ++ .../lib/epg-list-view/epg-list-view.utils.ts | 43 ++- libs/ui/epg/src/lib/epg-program.utils.spec.ts | 46 +++ libs/ui/epg/src/lib/epg-program.utils.ts | 63 +++- .../lib/epg-timeline/epg-summary.util.spec.ts | 25 ++ .../src/lib/epg-timeline/epg-summary.util.ts | 29 +- .../epg-timeline-scroll.controller.spec.ts | 29 ++ .../epg-timeline-scroll.controller.ts | 5 +- .../epg-timeline/epg-timeline.component.html | 4 +- .../epg-timeline/epg-timeline.component.ts | 60 ++- .../epg-timeline/epg-timeline.utils.spec.ts | 24 ++ .../lib/epg-timeline/epg-timeline.utils.ts | 69 +++- .../multi-epg-container.component.html | 6 +- .../multi-epg-container.component.spec.ts | 49 +++ .../multi-epg-container.component.ts | 48 ++- .../rails/dashboard-live-epg.utils.spec.ts | 44 +++ .../src/lib/rails/dashboard-live-epg.utils.ts | 33 +- .../workspace-dashboard-rails.component.ts | 6 +- 118 files changed, 3349 insertions(+), 352 deletions(-) create mode 100644 .changes/epg-display-offset.md create mode 100644 libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts create mode 100644 libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.epg-offset.spec.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-summary.util.spec.ts create mode 100644 libs/shared/interfaces/src/lib/epg-display-offset.util.spec.ts create mode 100644 libs/shared/interfaces/src/lib/epg-display-offset.util.ts create mode 100644 libs/ui/epg/src/lib/epg-program.utils.spec.ts create mode 100644 libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.spec.ts diff --git a/.changes/epg-display-offset.md b/.changes/epg-display-offset.md new file mode 100644 index 000000000..f90a8987a --- /dev/null +++ b/.changes/epg-display-offset.md @@ -0,0 +1,7 @@ +--- +type: feature +area: epg +issues: [50] +--- + +EPG settings now include a display-only time offset, making it easy to correct provider feeds with missing or incorrect timezone information without re-importing the guide. diff --git a/CLAUDE.md b/CLAUDE.md index 02661c4aa..a061c5748 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1427,6 +1427,7 @@ stream_id`); it drops `series_id`/`movie_id`, so the builder pins the - XMLTV format support - Background parsing in worker thread - Stored in database for quick lookup +- Global display-time offset (`Settings.epgOffsetMinutes`, Settings → EPG, ±720 min, Electron only): display-only, provider data is never rewritten. Two equivalent forms in `libs/shared/interfaces/src/lib/epg-display-offset.util.ts` — `epgDisplayTimeMs` (shift the programme; `ui/epg` rendering via the `offsetMinutes` input, channel rows, dashboard/recording labels; the programme dialog and multi-EPG overlay read the store themselves) and `epgProviderClockMs` (shift "now"; every "currently airing" decision: the `GET_CURRENT_PROGRAMS_BATCH` lookup takes an explicit `nowMs` and `EpgService` tags its cache with the offset, Xtream/Stalker/M3U current-programme selection and previews, the unified collection resolver, dashboard progress, recording overlap). A consumer applies exactly one form per comparison. Contract: `docs/architecture/m3u-playlist-module.md` ("EPG display offset") - Manual EPG mapping (Electron only): right-click a channel in any list (M3U views, Xtream portal list, Stalker ITV sidebar, global favorites) → "Map EPG channel" attaches it to an uploaded-XMLTV channel; stored in `epg_channel_mappings` keyed by the M3U lookup key or a playlist-scoped portal key (`xtream:{playlistId}:{id}` / `stalker:{playlistId}:{id}`, helpers in `libs/shared/interfaces/src/lib/epg-mapping-key.util.ts`); resolved on every EPG path (single + batch IPC lookups, portal detail views, preview queues); dialog: `libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/` **TMDB Metadata Enrichment** (opt-in): diff --git a/apps/electron-backend-e2e/src/settings.e2e.ts b/apps/electron-backend-e2e/src/settings.e2e.ts index ea958fc65..156f0a71b 100644 --- a/apps/electron-backend-e2e/src/settings.e2e.ts +++ b/apps/electron-backend-e2e/src/settings.e2e.ts @@ -581,6 +581,39 @@ test.describe('Electron Settings', () => { } }); + test('@settings @persistence @electron persists the EPG display offset across app restart', async ({ + dataDir, + }) => { + const firstLaunch = await launchElectronApp(dataDir); + + try { + await openSettings(firstLaunch.mainWindow); + await openSettingsSection(firstLaunch.mainWindow, 'epg'); + const offsetInput = firstLaunch.mainWindow.locator( + '[data-test-id="epg-offset-minutes"]' + ); + await expect(offsetInput).toHaveValue('0'); + await offsetInput.fill('-90'); + await saveSettings(firstLaunch.mainWindow); + } finally { + await closeElectronApp(firstLaunch); + } + + const secondLaunch = await launchElectronApp(dataDir); + + try { + await openSettings(secondLaunch.mainWindow); + await openSettingsSection(secondLaunch.mainWindow, 'epg'); + await expect( + secondLaunch.mainWindow.locator( + '[data-test-id="epg-offset-minutes"]' + ) + ).toHaveValue('-90'); + } finally { + await closeElectronApp(secondLaunch); + } + }); + test('@settings @electron starts on sources when dashboard is disabled', async ({ dataDir }) => { const firstLaunch = await launchElectronApp(dataDir); diff --git a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts index f7d11ab2b..bb83cfc21 100644 --- a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts @@ -291,6 +291,95 @@ test('@radio @stalker @electron keeps radio rows compact at narrow widths', asyn } }); +test('@epg @xtream @electron shifts the sidebar preview and the timeline "now" block by the EPG display offset', async ({ + dataDir, + request, +}) => { + await resetMockServers(request, ['xtream']); + const fixture = await fetchXtreamEpgFixture(request, epgCredentials); + const offsetMinutes = 60; + // The guide runs an hour ahead of the real schedule, so the programme + // actually on air is the one the provider files under "an hour ago" — + // and every surface must agree on that same programme. + const providerNowSeconds = Math.floor(Date.now() / 1000) - offsetMinutes * 60; + const expectedProgram = fixture.fullEpg.find( + (listing) => + listing.startTimestamp <= providerNowSeconds && + providerNowSeconds < listing.stopTimestamp + ); + if (!expectedProgram) { + throw new Error( + 'Expected the Xtream EPG fixture to cover the shifted current time.' + ); + } + const app = await launchElectronApp(dataDir, { env: { TZ: 'UTC' } }); + + try { + await openSettings(app.mainWindow); + await openSettingsSection(app.mainWindow, 'epg'); + await app.mainWindow + .locator('[data-test-id="epg-offset-minutes"]') + .fill(String(offsetMinutes)); + await saveSettings(app.mainWindow); + await goToDashboard(app.mainWindow); + + await addXtreamPortal(app.mainWindow, { + name: `${epgPortalName} Offset`, + username: epgCredentials.username, + password: epgCredentials.password, + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await clickCategoryByNameExact(app.mainWindow, fixture.categoryName); + const channelRow = channelItemByTitle( + app.mainWindow, + fixture.stream.name ?? '' + ).first(); + await expect(channelRow).toBeVisible({ timeout: 20000 }); + + // Sidebar preview: the shifted programme, with shifted times. + await expect + .poll(async () => + ( + (await channelRow.locator('.epg-title').textContent()) ?? + '' + ).trim() + ) + .toBe(expectedProgram.title); + await expect + .poll(async () => + (await channelRow.locator('.epg-time').allInnerTexts()).map( + (value) => value.trim() + ) + ) + .toEqual([ + formatTimeInZone( + expectedProgram.startTimestamp + offsetMinutes * 60, + 'UTC' + ), + formatTimeInZone( + expectedProgram.stopTimestamp + offsetMinutes * 60, + 'UTC' + ), + ]); + + // Timeline: the same programme is the highlighted "now" block. + await channelRow.click(); + await expect(app.mainWindow.locator('app-epg-timeline')).toBeVisible({ + timeout: 20000, + }); + await expect( + app.mainWindow + .locator( + 'app-epg-timeline .epg-timeline__block.is-now .epg-timeline__block-title' + ) + .first() + ).toHaveText(expectedProgram.title); + } finally { + await closeElectronApp(app); + } +}); + test('@epg @xtream @electron renders the vertical list view when the setting is "list"', async ({ dataDir, request, diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 40baa5aa3..ac0987de1 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -36,6 +36,7 @@ import type { ElectronBridgeAppUpdateStatus, ElectronBridgeDbOperationEvent, ElectronBridgeDownloadStartPayload, + ElectronBridgeCurrentProgramsOptions, ElectronBridgeEpgLookupOptions, ElectronBridgeEpgProgress, ElectronBridgePlaybackPositionInput, @@ -662,7 +663,7 @@ const electronApi: ElectronBridgeApi = { ) => ipcRenderer.invoke('GET_CHANNEL_PROGRAMS', { channelId, options }), getCurrentProgramsBatch: ( channelIds: string[], - options?: ElectronBridgeEpgLookupOptions + options?: ElectronBridgeCurrentProgramsOptions ) => ipcRenderer.invoke('GET_CURRENT_PROGRAMS_BATCH', { channelIds, diff --git a/apps/electron-backend/src/app/events/epg-query.service.spec.ts b/apps/electron-backend/src/app/events/epg-query.service.spec.ts index 577096476..b0bb4f0be 100644 --- a/apps/electron-backend/src/app/events/epg-query.service.spec.ts +++ b/apps/electron-backend/src/app/events/epg-query.service.spec.ts @@ -42,6 +42,11 @@ function flattenSql(value: unknown, seen = new Set()): string { return sqlLike.value.join(' '); } + // Bound parameters (`sql\`${now}\``) carry a scalar value. + if (typeof sqlLike.value === 'string') { + return sqlLike.value; + } + if (typeof sqlLike.name === 'string') { return sqlLike.name; } @@ -289,6 +294,80 @@ describe('EpgQueryService', () => { ); }); + it('evaluates "currently airing" at the caller-supplied instant', async () => { + const whereCalls: unknown[] = []; + const select = jest + .fn() + .mockReturnValue(createLimitedSelectChain([], whereCalls)); + getDatabase.mockResolvedValue({ select }); + // The renderer hands over its wall clock shifted into the provider's + // EPG clock (display offset); the SQL must compare against exactly that. + const nowMs = Date.parse('2026-06-29T20:30:00.000Z'); + + await service.getCurrentProgramsBatch(['ch'], { + sourceUrls: ['https://example.com/guide.xml'], + nowMs, + }); + + const flattened = whereCalls.map((condition) => flattenSql(condition)); + expect( + flattened.some((condition) => + condition.includes('2026-06-29T20:30:00.000Z') + ) + ).toBe(true); + }); + + it('falls back to the main-process clock for a missing or corrupt instant', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-06-29T21:00:00.000Z')); + try { + for (const nowMs of [undefined, Number.NaN, 1e20, -1e20]) { + const whereCalls: unknown[] = []; + const select = jest.fn().mockReturnValue( + createLimitedSelectChain( + [ + { + id: 1, + channelId: 'ch', + start: '2026-06-29T20:00:00Z', + stop: '2026-06-29T22:00:00Z', + title: 'Now', + description: null, + category: null, + iconUrl: null, + rating: null, + episodeNum: null, + sourceUrl: 'https://example.com/guide.xml', + }, + ], + whereCalls + ) + ); + getDatabase.mockResolvedValue({ select }); + + await expect( + service.getCurrentProgramsBatch(['ch'], { + sourceUrls: ['https://example.com/guide.xml'], + nowMs, + }) + ).resolves.toEqual({ + ch: expect.objectContaining({ title: 'Now' }), + }); + + const flattened = whereCalls.map((condition) => + flattenSql(condition) + ); + expect( + flattened.some((condition) => + condition.includes('2026-06-29T21:00:00.000Z') + ) + ).toBe(true); + } + } finally { + jest.useRealTimers(); + } + }); + it('falls back to an unscoped current-programme lookup when programmes are tagged with an out-of-scope source', async () => { const whereCalls: unknown[] = []; const currentProgram = { diff --git a/apps/electron-backend/src/app/events/epg-query.service.ts b/apps/electron-backend/src/app/events/epg-query.service.ts index 8ed16023e..d064188bb 100644 --- a/apps/electron-backend/src/app/events/epg-query.service.ts +++ b/apps/electron-backend/src/app/events/epg-query.service.ts @@ -165,7 +165,7 @@ export class EpgQueryService { async getCurrentProgramsBatch( channelIds: string[], - options: { sourceUrls?: string[] } = {} + options: { sourceUrls?: string[]; nowMs?: number } = {} ): Promise> { const result: Record = {}; if (!Array.isArray(channelIds) || channelIds.length === 0) { @@ -185,7 +185,7 @@ export class EpgQueryService { try { const db = await getDatabase(); - const now = new Date().toISOString(); + const now = this.toAiringInstantIso(options.nowMs); const sourceUrls = this.normalizeSourceUrls(options.sourceUrls); this.assignCurrentProgramRows( @@ -618,6 +618,22 @@ export class EpgQueryService { .limit(channelIds.length); } + /** + * The instant "currently airing" is evaluated at, as the UTC ISO string + * `isAiringAt` expects. The renderer passes its wall clock shifted into + * the provider's EPG clock (the display offset setting) so the row it + * receives is the one it will also render as "now"; without a usable + * value the main process clock applies. Bounded to the range `Date` can + * serialize so a corrupt payload cannot throw inside the query. + */ + private toAiringInstantIso(nowMs?: number): string { + const usable = + typeof nowMs === 'number' && + Number.isFinite(nowMs) && + Math.abs(nowMs) <= 8.64e15; + return new Date(usable ? nowMs : Date.now()).toISOString(); + } + /** * Timezone-aware "currently airing at `now`" predicate. EPG start/stop are * stored as ISO strings that often carry a UTC offset (e.g. `+03:00`) while diff --git a/apps/electron-backend/src/app/events/epg.events.ts b/apps/electron-backend/src/app/events/epg.events.ts index c7085939a..c7149728c 100644 --- a/apps/electron-backend/src/app/events/epg.events.ts +++ b/apps/electron-backend/src/app/events/epg.events.ts @@ -1,5 +1,6 @@ import { ipcMain } from 'electron'; import { + ElectronBridgeCurrentProgramsOptions, ElectronBridgeTrustOptions, EpgChannelMetadata, EpgProgram, @@ -57,7 +58,7 @@ export default class EpgEvents { _event, args: { channelIds: string[]; - options?: { sourceUrls?: string[] }; + options?: ElectronBridgeCurrentProgramsOptions; } ) => { return this.handleGetCurrentProgramsBatch( @@ -219,7 +220,7 @@ export default class EpgEvents { private static async handleGetCurrentProgramsBatch( channelIds: string[], - options?: { sourceUrls?: string[] } + options?: ElectronBridgeCurrentProgramsOptions ): Promise> { return queryByResolvedChannelIds(channelIds, (resolvedIds) => epgQueryService.getCurrentProgramsBatch(resolvedIds, options) diff --git a/apps/web/src/app/settings/settings-epg-section.component.html b/apps/web/src/app/settings/settings-epg-section.component.html index 43e3b3bdc..c3b7bb875 100644 --- a/apps/web/src/app/settings/settings-epg-section.component.html +++ b/apps/web/src/app/settings/settings-epg-section.component.html @@ -1,8 +1,4 @@ -
+
list_alt @@ -47,6 +43,32 @@
+
+
+

{{ 'SETTINGS.EPG_OFFSET_TITLE' | translate }}

+

{{ 'SETTINGS.EPG_OFFSET_HINT' | translate }}

+
+
+ + {{ + 'SETTINGS.EPG_OFFSET_LABEL' | translate + }} + + {{ + 'SETTINGS.EPG_OFFSET_ERROR' | translate + }} + +
+
+
diff --git a/apps/web/src/app/settings/settings-form.utils.spec.ts b/apps/web/src/app/settings/settings-form.utils.spec.ts index e3e8a187a..de8010dc2 100644 --- a/apps/web/src/app/settings/settings-form.utils.spec.ts +++ b/apps/web/src/app/settings/settings-form.utils.spec.ts @@ -71,6 +71,54 @@ describe('settings form utils — startup window mode', () => { }); }); +describe('settings form utils — EPG display offset', () => { + const formBuilder = new FormBuilder(); + + it('defaults the form control to zero and only exists when EPG is supported', () => { + expect( + createSettingsForm(formBuilder, true).getRawValue().epgOffsetMinutes + ).toBe(0); + expect( + 'epgOffsetMinutes' in + createSettingsForm(formBuilder, false).controls + ).toBe(false); + }); + + it('carries a whole-minute offset into the settings object', () => { + const form = createSettingsForm(formBuilder, true); + form.patchValue({ epgOffsetMinutes: -90 }); + + expect(form.valid).toBe(true); + expect( + createSettingsFromFormValue(form, {} as Settings).epgOffsetMinutes + ).toBe(-90); + }); + + it('rejects blanks, fractions and out-of-range values in the form', () => { + const form = createSettingsForm(formBuilder, true); + const control = form.get('epgOffsetMinutes'); + + control?.setValue(null); + expect(control?.valid).toBe(false); + control?.setValue(15.5); + expect(control?.valid).toBe(false); + control?.setValue(721); + expect(control?.valid).toBe(false); + control?.setValue(-720); + expect(control?.valid).toBe(true); + }); + + it('keeps the stored offset when EPG is unsupported and the control is absent', () => { + const form = createSettingsForm(formBuilder, false); + + expect( + createSettingsFromFormValue(form, { + epgOffsetMinutes: 45, + } as Settings).epgOffsetMinutes + ).toBe(45); + }); +}); + describe('settings form utils — embedded MPV session options', () => { const formBuilder = new FormBuilder(); diff --git a/apps/web/src/app/settings/settings-form.utils.ts b/apps/web/src/app/settings/settings-form.utils.ts index f8610a2e6..e689bd9e9 100644 --- a/apps/web/src/app/settings/settings-form.utils.ts +++ b/apps/web/src/app/settings/settings-form.utils.ts @@ -15,6 +15,7 @@ import { normalizeDashboardRailsSettings, normalizeEmbeddedMpvExtraOptions, normalizeExternalPlayerArguments, + normalizeEpgOffsetMinutes, normalizeStartupWindowMode, Settings, StartupBehavior, @@ -111,6 +112,12 @@ export function createSettingsForm( ? { preferUploadedEpgOverXtream: false, epgViewMode: 'timeline' as EpgViewMode, + epgOffsetMinutes: new FormControl(0, [ + Validators.required, + Validators.min(-720), + Validators.max(720), + Validators.pattern(/^-?\d+$/), + ]), } : {}), tmdb: formBuilder.group({ @@ -196,6 +203,9 @@ export function createSettingsFromFormValue( false, epgViewMode: value.epgViewMode ?? currentSettings.epgViewMode ?? 'timeline', + epgOffsetMinutes: normalizeEpgOffsetMinutes( + value.epgOffsetMinutes ?? currentSettings.epgOffsetMinutes + ), trustedPrivateNetworkEpgUrls: currentSettings.trustedPrivateNetworkEpgUrls ?? [], trustedInsecureTlsHosts: currentSettings.trustedInsecureTlsHosts ?? [], diff --git a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts index fd300c748..d4875a837 100644 --- a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts +++ b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts @@ -116,6 +116,7 @@ export const DEFAULT_SETTINGS = { dashboardRails: DEFAULT_DASHBOARD_RAILS, preferUploadedEpgOverXtream: false, epgViewMode: 'timeline', + epgOffsetMinutes: 0, tmdb: { enabled: false, apiKey: '' }, }; diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index e5a2750b9..4e33d4818 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -361,6 +361,10 @@ "COVER_SIZE_MEDIUM": "متوسط", "COVER_SIZE_LARGE": "كبير", "EPG_VIEW_MODE": "عرض الدليل", + "EPG_OFFSET_TITLE": "إزاحة وقت دليل البرامج", + "EPG_OFFSET_HINT": "اضبط أوقات البرامج المعروضة دون تحديث الدليل. استخدم الدقائق من -720 إلى 720.", + "EPG_OFFSET_LABEL": "الإزاحة (بالدقائق)", + "EPG_OFFSET_ERROR": "أدخل عددًا صحيحًا من الدقائق بين -720 و720.", "EPG_VIEW_MODE_DESCRIPTION": "اختر طريقة عرض دليل البرامج أسفل المشغل: شريط جدول زمني أفقي، أو قائمة رأسية ليوم واحد.", "EPG_VIEW_MODE_TIMELINE": "الجدول الزمني", "EPG_VIEW_MODE_LIST": "قائمة", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 3e7cff115..7ff2c5f33 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -361,6 +361,10 @@ "COVER_SIZE_MEDIUM": "متوسط", "COVER_SIZE_LARGE": "كبير", "EPG_VIEW_MODE": "عرض الدليل", + "EPG_OFFSET_TITLE": "إزاحة وقت دليل البرامج", + "EPG_OFFSET_HINT": "اضبط أوقات البرامج المعروضة دون تحديث الدليل. استخدم الدقائق من -720 إلى 720.", + "EPG_OFFSET_LABEL": "الإزاحة (بالدقائق)", + "EPG_OFFSET_ERROR": "دخّل عدد صحيح ديال الدقايق ما بين -720 و720.", "EPG_VIEW_MODE_DESCRIPTION": "اختار كيفاش يبان دليل البرامج تحت المشغل: شريط زمني أفقي ولا لائحة عمودية ديال نهار واحد.", "EPG_VIEW_MODE_TIMELINE": "الخط الزمني", "EPG_VIEW_MODE_LIST": "لائحة", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 98f0243f8..af8641217 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -361,6 +361,10 @@ "COVER_SIZE_MEDIUM": "Сярэдні", "COVER_SIZE_LARGE": "Вялікі", "EPG_VIEW_MODE": "Выгляд праграмы перадач", + "EPG_OFFSET_TITLE": "Зрушэнне часу праграмы", + "EPG_OFFSET_HINT": "Змяняйце час паказу праграм без абнаўлення даведніка. Выкарыстоўвайце хвіліны ад -720 да 720.", + "EPG_OFFSET_LABEL": "Зрушэнне (хвіліны)", + "EPG_OFFSET_ERROR": "Увядзіце цэлы лік хвілін ад -720 да 720.", "EPG_VIEW_MODE_DESCRIPTION": "Выберыце, як паказваць праграму перадач пад прайгравальнікам: гарызантальнай стужкай часу або вертыкальным спісам на адзін дзень.", "EPG_VIEW_MODE_TIMELINE": "Стужка часу", "EPG_VIEW_MODE_LIST": "Спіс", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index f9d0f2981..4ad91e64a 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -361,6 +361,10 @@ "COVER_SIZE_MEDIUM": "Mittel", "COVER_SIZE_LARGE": "Groß", "EPG_VIEW_MODE": "Programmführer-Ansicht", + "EPG_OFFSET_TITLE": "EPG-Zeitverschiebung", + "EPG_OFFSET_HINT": "Angezeigte Sendezeiten verschieben, ohne den Programmführer zu aktualisieren. Minuten von -720 bis 720 verwenden.", + "EPG_OFFSET_LABEL": "Verschiebung (Minuten)", + "EPG_OFFSET_ERROR": "Geben Sie eine ganze Minutenzahl zwischen -720 und 720 ein.", "EPG_VIEW_MODE_DESCRIPTION": "Wählen Sie, wie der Programmführer unter dem Player angezeigt wird: als horizontales Zeitleisten-Band oder als vertikale Tagesliste.", "EPG_VIEW_MODE_TIMELINE": "Zeitleiste", "EPG_VIEW_MODE_LIST": "Liste", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 80a7a2d22..d379e9dcb 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -361,6 +361,10 @@ "COVER_SIZE_MEDIUM": "Μεσαίο", "COVER_SIZE_LARGE": "Μεγάλο", "EPG_VIEW_MODE": "Προβολή οδηγού", + "EPG_OFFSET_TITLE": "Μετατόπιση ώρας EPG", + "EPG_OFFSET_HINT": "Μετατοπίστε τις εμφανιζόμενες ώρες προγραμμάτων χωρίς ανανέωση του οδηγού. Χρησιμοποιήστε λεπτά από -720 έως 720.", + "EPG_OFFSET_LABEL": "Μετατόπιση (λεπτά)", + "EPG_OFFSET_ERROR": "Εισαγάγετε έναν ακέραιο αριθμό λεπτών από -720 έως 720.", "EPG_VIEW_MODE_DESCRIPTION": "Επιλέξτε πώς εμφανίζεται ο οδηγός προγράμματος κάτω από το πρόγραμμα αναπαραγωγής: ως οριζόντια λωρίδα χρονολογίου ή ως κατακόρυφη λίστα μίας ημέρας.", "EPG_VIEW_MODE_TIMELINE": "Χρονολόγιο", "EPG_VIEW_MODE_LIST": "Λίστα", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 999f63abc..0464b6b39 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -361,6 +361,10 @@ "COVER_SIZE_MEDIUM": "Medium", "COVER_SIZE_LARGE": "Large", "EPG_VIEW_MODE": "Guide view", + "EPG_OFFSET_TITLE": "EPG time offset", + "EPG_OFFSET_HINT": "Shift displayed programme times without refreshing the guide. Use minutes, from -720 to 720.", + "EPG_OFFSET_LABEL": "Offset (minutes)", + "EPG_OFFSET_ERROR": "Enter a whole number of minutes between -720 and 720.", "EPG_VIEW_MODE_DESCRIPTION": "Choose how the programme guide under the player is shown: a horizontal timeline ribbon or a vertical, single-day list.", "EPG_VIEW_MODE_TIMELINE": "Timeline", "EPG_VIEW_MODE_LIST": "List", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index f33fb9fd1..09161cf36 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -361,6 +361,10 @@ "COVER_SIZE_MEDIUM": "Mediano", "COVER_SIZE_LARGE": "Grande", "EPG_VIEW_MODE": "Vista de la guía", + "EPG_OFFSET_TITLE": "Desfase horario de la EPG", + "EPG_OFFSET_HINT": "Desplaza las horas mostradas de los programas sin actualizar la guía. Usa minutos, de -720 a 720.", + "EPG_OFFSET_LABEL": "Desfase (minutos)", + "EPG_OFFSET_ERROR": "Introduce un número entero de minutos entre -720 y 720.", "EPG_VIEW_MODE_DESCRIPTION": "Elige cómo se muestra la guía de programas bajo el reproductor: una cinta de cronología horizontal o una lista vertical de un solo día.", "EPG_VIEW_MODE_TIMELINE": "Cronología", "EPG_VIEW_MODE_LIST": "Lista", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index ef39020bf..38f22a43f 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -361,6 +361,10 @@ "COVER_SIZE_MEDIUM": "Moyenne", "COVER_SIZE_LARGE": "Grande", "EPG_VIEW_MODE": "Vue du guide", + "EPG_OFFSET_TITLE": "Décalage horaire de l’EPG", + "EPG_OFFSET_HINT": "Décalez les horaires affichés des programmes sans actualiser le guide. Utilisez des minutes de -720 à 720.", + "EPG_OFFSET_LABEL": "Décalage (minutes)", + "EPG_OFFSET_ERROR": "Saisissez un nombre entier de minutes compris entre -720 et 720.", "EPG_VIEW_MODE_DESCRIPTION": "Choisissez comment le guide des programmes sous le lecteur est affiché : une frise chronologique horizontale ou une liste verticale d'une seule journée.", "EPG_VIEW_MODE_TIMELINE": "Chronologie", "EPG_VIEW_MODE_LIST": "Liste", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 5ef80aa78..5694503ad 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -361,6 +361,10 @@ "COVER_SIZE_MEDIUM": "Közepes", "COVER_SIZE_LARGE": "Nagy", "EPG_VIEW_MODE": "Műsorújság nézete", + "EPG_OFFSET_TITLE": "EPG-időeltolás", + "EPG_OFFSET_HINT": "A műsorok megjelenített idejének eltolása a műsorújság frissítése nélkül. Percben adja meg, -720 és 720 között.", + "EPG_OFFSET_LABEL": "Eltolás (perc)", + "EPG_OFFSET_ERROR": "Adjon meg egy egész percértéket -720 és 720 között.", "EPG_VIEW_MODE_DESCRIPTION": "Válassza ki, hogyan jelenjen meg a lejátszó alatti műsorújság: vízszintes idősávként vagy függőleges, egynapos listaként.", "EPG_VIEW_MODE_TIMELINE": "Idővonal", "EPG_VIEW_MODE_LIST": "Lista", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 47e81bdb9..94b15d7f4 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -361,6 +361,10 @@ "COVER_SIZE_MEDIUM": "Media", "COVER_SIZE_LARGE": "Grande", "EPG_VIEW_MODE": "Vista guida", + "EPG_OFFSET_TITLE": "Scostamento orario EPG", + "EPG_OFFSET_HINT": "Sposta gli orari dei programmi visualizzati senza aggiornare la guida. Usa minuti da -720 a 720.", + "EPG_OFFSET_LABEL": "Scostamento (minuti)", + "EPG_OFFSET_ERROR": "Inserisci un numero intero di minuti compreso tra -720 e 720.", "EPG_VIEW_MODE_DESCRIPTION": "Scegli come mostrare la guida programmi sotto il lettore: una fascia con timeline orizzontale o un elenco verticale di un singolo giorno.", "EPG_VIEW_MODE_TIMELINE": "Timeline", "EPG_VIEW_MODE_LIST": "Elenco", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 38b623843..a7b8c555f 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -361,6 +361,10 @@ "COVER_SIZE_MEDIUM": "中", "COVER_SIZE_LARGE": "大", "EPG_VIEW_MODE": "番組表の表示", + "EPG_OFFSET_TITLE": "EPG 時刻の補正", + "EPG_OFFSET_HINT": "番組表を更新せずに表示時刻をずらします。-720 から 720 までの分単位で指定してください。", + "EPG_OFFSET_LABEL": "補正(分)", + "EPG_OFFSET_ERROR": "-720 から 720 までの整数(分)を入力してください。", "EPG_VIEW_MODE_DESCRIPTION": "プレーヤー下部の番組表の表示方法を選択します。横方向のタイムライン形式か、縦方向の1日分のリスト形式かを選べます。", "EPG_VIEW_MODE_TIMELINE": "タイムライン", "EPG_VIEW_MODE_LIST": "リスト", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 58561539c..120b074e8 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -361,6 +361,10 @@ "COVER_SIZE_MEDIUM": "중간", "COVER_SIZE_LARGE": "크게", "EPG_VIEW_MODE": "가이드 보기", + "EPG_OFFSET_TITLE": "EPG 시간 오프셋", + "EPG_OFFSET_HINT": "가이드를 새로 고치지 않고 표시되는 프로그램 시간을 조정합니다. -720에서 720 사이의 분 단위로 입력하세요.", + "EPG_OFFSET_LABEL": "오프셋(분)", + "EPG_OFFSET_ERROR": "-720에서 720 사이의 정수(분)를 입력하세요.", "EPG_VIEW_MODE_DESCRIPTION": "플레이어 아래 프로그램 가이드를 표시하는 방식을 선택합니다: 가로 타임라인 리본 또는 하루 단위의 세로 목록입니다.", "EPG_VIEW_MODE_TIMELINE": "타임라인", "EPG_VIEW_MODE_LIST": "목록", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 175f49ef2..1070bdc97 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -361,6 +361,10 @@ "COVER_SIZE_MEDIUM": "Gemiddeld", "COVER_SIZE_LARGE": "Groot", "EPG_VIEW_MODE": "Gidsweergave", + "EPG_OFFSET_TITLE": "EPG-tijdverschuiving", + "EPG_OFFSET_HINT": "Verschuif weergegeven programmatijden zonder de gids te vernieuwen. Gebruik minuten van -720 tot 720.", + "EPG_OFFSET_LABEL": "Verschuiving (minuten)", + "EPG_OFFSET_ERROR": "Voer een geheel aantal minuten in tussen -720 en 720.", "EPG_VIEW_MODE_DESCRIPTION": "Kies hoe de programmagids onder de speler wordt getoond: een horizontale tijdlijn of een verticale lijst voor één dag.", "EPG_VIEW_MODE_TIMELINE": "Tijdlijn", "EPG_VIEW_MODE_LIST": "Lijst", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index f72af9cc9..ca75a2d34 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -361,6 +361,10 @@ "COVER_SIZE_MEDIUM": "Średni", "COVER_SIZE_LARGE": "Duży", "EPG_VIEW_MODE": "Widok przewodnika", + "EPG_OFFSET_TITLE": "Przesunięcie czasu EPG", + "EPG_OFFSET_HINT": "Przesuń wyświetlane godziny programów bez odświeżania przewodnika. Użyj minut od -720 do 720.", + "EPG_OFFSET_LABEL": "Przesunięcie (minuty)", + "EPG_OFFSET_ERROR": "Wpisz całkowitą liczbę minut z zakresu od -720 do 720.", "EPG_VIEW_MODE_DESCRIPTION": "Wybierz sposób wyświetlania przewodnika po programach pod odtwarzaczem: pozioma oś czasu lub pionowa lista dla jednego dnia.", "EPG_VIEW_MODE_TIMELINE": "Oś czasu", "EPG_VIEW_MODE_LIST": "Lista", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 67847470a..70a33ef7b 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -361,6 +361,10 @@ "COVER_SIZE_MEDIUM": "Médio", "COVER_SIZE_LARGE": "Grande", "EPG_VIEW_MODE": "Visualização do guia", + "EPG_OFFSET_TITLE": "Deslocamento de horário do EPG", + "EPG_OFFSET_HINT": "Desloque os horários dos programas exibidos sem atualizar o guia. Use minutos de -720 a 720.", + "EPG_OFFSET_LABEL": "Deslocamento (minutos)", + "EPG_OFFSET_ERROR": "Insira um número inteiro de minutos entre -720 e 720.", "EPG_VIEW_MODE_DESCRIPTION": "Escolha como o guia de programação abaixo do reprodutor é exibido: uma faixa horizontal em linha do tempo ou uma lista vertical de um único dia.", "EPG_VIEW_MODE_TIMELINE": "Linha do tempo", "EPG_VIEW_MODE_LIST": "Lista", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 96e69f8e5..77768e2a2 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -361,6 +361,10 @@ "COVER_SIZE_MEDIUM": "Средний", "COVER_SIZE_LARGE": "Большой", "EPG_VIEW_MODE": "Вид программы передач", + "EPG_OFFSET_TITLE": "Смещение времени телепрограммы", + "EPG_OFFSET_HINT": "Смещайте время отображения программ без обновления телепрограммы. Укажите минуты от -720 до 720.", + "EPG_OFFSET_LABEL": "Смещение (минуты)", + "EPG_OFFSET_ERROR": "Введите целое число минут от -720 до 720.", "EPG_VIEW_MODE_DESCRIPTION": "Выберите, как отображается программа передач под плеером: горизонтальная лента-таймлайн или вертикальный список на один день.", "EPG_VIEW_MODE_TIMELINE": "Таймлайн", "EPG_VIEW_MODE_LIST": "Список", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 02a8d7475..7c21b8ccb 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -361,6 +361,10 @@ "COVER_SIZE_MEDIUM": "Orta", "COVER_SIZE_LARGE": "Büyük", "EPG_VIEW_MODE": "Rehber görünümü", + "EPG_OFFSET_TITLE": "EPG zaman kayması", + "EPG_OFFSET_HINT": "Rehberi yenilemeden görüntülenen program saatlerini kaydırın. -720 ile 720 arasında dakika kullanın.", + "EPG_OFFSET_LABEL": "Kayma (dakika)", + "EPG_OFFSET_ERROR": "-720 ile 720 arasında tam sayı olarak dakika girin.", "EPG_VIEW_MODE_DESCRIPTION": "Oynatıcının altındaki program rehberinin nasıl gösterileceğini seçin: yatay bir zaman çizelgesi şeridi veya dikey, tek günlük bir liste.", "EPG_VIEW_MODE_TIMELINE": "Zaman çizelgesi", "EPG_VIEW_MODE_LIST": "Liste", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 532bf6550..466b15dea 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -361,6 +361,10 @@ "COVER_SIZE_MEDIUM": "中", "COVER_SIZE_LARGE": "大", "EPG_VIEW_MODE": "节目指南视图", + "EPG_OFFSET_TITLE": "EPG 时间偏移", + "EPG_OFFSET_HINT": "无需刷新节目指南即可调整显示的节目时间。请输入 -720 到 720 之间的分钟数。", + "EPG_OFFSET_LABEL": "偏移(分钟)", + "EPG_OFFSET_ERROR": "请输入 -720 到 720 之间的整数分钟数。", "EPG_VIEW_MODE_DESCRIPTION": "选择播放器下方的节目指南如何显示:横向时间轴条,或纵向的单日列表。", "EPG_VIEW_MODE_TIMELINE": "时间轴", "EPG_VIEW_MODE_LIST": "列表", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 9108a629e..7f277bf24 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -361,6 +361,10 @@ "COVER_SIZE_MEDIUM": "中", "COVER_SIZE_LARGE": "大", "EPG_VIEW_MODE": "節目表檢視方式", + "EPG_OFFSET_TITLE": "EPG 時間偏移", + "EPG_OFFSET_HINT": "無需重新整理節目表即可調整顯示的節目時間。請輸入 -720 到 720 之間的分鐘數。", + "EPG_OFFSET_LABEL": "偏移(分鐘)", + "EPG_OFFSET_ERROR": "請輸入 -720 到 720 之間的整數分鐘數。", "EPG_VIEW_MODE_DESCRIPTION": "選擇播放器下方節目表的顯示方式:橫向時間軸條,或縱向的單日清單。", "EPG_VIEW_MODE_TIMELINE": "時間軸", "EPG_VIEW_MODE_LIST": "清單", diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index d9461e0a8..9f837783d 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -581,6 +581,61 @@ panel live. The setting flows end-to-end (`Settings.epgViewMode` → is gated behind `supportsEpg`, which is false in PWA; there the stored value simply stays at the `'timeline'` default. +### EPG display offset + +EPG display times also support a global **`Settings.epgOffsetMinutes`** +correction (Settings → EPG → _EPG time offset_; whole minutes, clamped to ±720, +default 0) for guides whose provider labels programme times with the wrong +timezone. It is **display-only**: parsed XMLTV values, SQLite rows, catch-up +URLs and recording snapshots keep the provider's own times, so changing it +never needs a guide refresh. + +The contract lives in `libs/shared/interfaces/src/lib/epg-display-offset.util.ts` +and has two equivalent forms — **display** (`epgDisplayTimeMs`: shift a +programme by `+offset`, then compare with wall-clock now or format it) and +**clock** (`epgProviderClockMs`: express now as `now − offset` and compare with +the raw programme times). Every consumer applies exactly one form per +comparison; applying both doubles the shift. + +- **Display form** — everything in `ui/epg` (`getProgramTimeMs` feeds the + timeline geometry, list rows, date keys, dedup/equality, the collapsed + header's range and progress) receives the offset as the `offsetMinutes` + input from each host; `app-channel-list-item`, the dashboard time range and + the recording detail shift their labels the same way. The programme dialog + and the multi-EPG overlay are opened imperatively, so they read + `SettingsStore.resolvedEpgOffsetMinutes` themselves. +- **Clock form** — every "currently airing" decision, so the row picked as + "now" is the one the UI renders as "now": the batched + `GET_CURRENT_PROGRAMS_BATCH` lookup takes an explicit `nowMs` + (`EpgService` passes its provider clock and tags its 60 s cache with the + offset; `ChannelListContainerComponent` refetches when the setting changes), + the channel-list progress bars, `XtreamStore.currentEpgItem`, the Xtream and + Stalker sidebar previews (re-picked on a change; whenever the offset is + non-zero the Xtream preview queue and the collection resolver cut their + window out of the full guide at the provider clock with + `windowEpgItemsAtProviderClock`, because `get_short_epg` always starts at + the provider's own "now" and cannot reach the programme actually on air; a + changed setting drops the queue's cut windows, cached empty results and + failure cooldowns as one, and a request that completes after the change is + retired and re-queued whatever its outcome), the M3U player's + `setCurrentEpgProgram` mirror and recording-start snapshot, the unified + collection resolver (`StreamResolverService.loadEpgForItems`), the dashboard + live cards' progress, and the recording stop-time overlap + (`filterRecordingProgramsOverlap`). + +Deliberately unshifted: catch-up URLs (built from the raw `programActivated` +programme), recording snapshots (provider data; only their display is +shifted), and the remote-control payload. Known limit: Stalker's bulk +`get_epg_info` and `get_short_epg` also start at the portal's "now" and the app +uses no past-reaching Stalker endpoint, so with a positive offset a Stalker +sidebar row whose true programme lies in the portal's past shows no preview +rather than a wrong one; under a negative offset the short-EPG window is widened +instead (`shortEpgWindowSize`, 15-minute slots, capped at 50 — the sidebar queue, +the active channel's panel fallback and the collection resolver all use it) so +it still reaches the programme on air, and preview cache entries are tagged with +the offset they were fetched for. Like the view mode, the control is Electron-only in practice — +it sits behind `supportsEpg`. + Both components stay presentation-focused; the reusable, view-agnostic pieces (shared by the timeline and the list) are split out and re-exported from `@iptvnator/ui/epg`: diff --git a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts index b8c3d5815..6a518ae34 100644 --- a/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts +++ b/libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts @@ -2,6 +2,7 @@ import { inject, Injectable } from '@angular/core'; import { ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES, ElectronBridgeApi, + ElectronBridgeCurrentProgramsOptions, ElectronBridgeEpgFetchResult, ElectronBridgeEpgMapping, ElectronBridgeEpgProgress, @@ -26,6 +27,7 @@ export type EpgFetchResult = ElectronBridgeEpgFetchResult; export type EpgFreshnessResult = ElectronBridgeEpgFreshnessResult; export type EpgClearResult = ElectronBridgeResult; export type EpgLookupOptions = ElectronBridgeEpgLookupOptions; +export type EpgCurrentProgramsOptions = ElectronBridgeCurrentProgramsOptions; type EpgElectronBridge = Pick< Partial, @@ -158,7 +160,7 @@ export class EpgRuntimeBridgeService { getCurrentProgramsBatch( channelIds: string[], - options?: EpgLookupOptions + options?: EpgCurrentProgramsOptions ): Promise | null> { if (!this.supportsCurrentProgramBatch) { return Promise.resolve(null); diff --git a/libs/epg/data-access/src/lib/epg.service.spec.ts b/libs/epg/data-access/src/lib/epg.service.spec.ts index daa521543..b26412205 100644 --- a/libs/epg/data-access/src/lib/epg.service.spec.ts +++ b/libs/epg/data-access/src/lib/epg.service.spec.ts @@ -10,7 +10,11 @@ describe('EpgService', () => { let service: EpgService; let epgBridge: Partial; let snackBar: { open: jest.Mock }; - let settingsStore: { getSettings: jest.Mock; getTrustOptions: jest.Mock }; + let settingsStore: { + getSettings: jest.Mock; + getTrustOptions: jest.Mock; + resolvedEpgOffsetMinutes: jest.Mock; + }; beforeEach(() => { epgBridge = { @@ -34,6 +38,7 @@ describe('EpgService', () => { trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'], trustedInsecureTlsHosts: ['playlist.local'], })), + resolvedEpgOffsetMinutes: jest.fn(() => 0), }; TestBed.configureTestingModule({ @@ -135,6 +140,131 @@ describe('EpgService', () => { jest.useRealTimers(); }); + it('asks the desktop bridge for programmes airing at the provider clock', async () => { + epgBridge.supportsProgramLookup = true; + epgBridge.supportsCurrentProgramBatch = true; + epgBridge.getCurrentProgramsBatch = jest.fn().mockResolvedValue({}); + settingsStore.resolvedEpgOffsetMinutes.mockReturnValue(90); + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-05-23T10:30:00.000Z')); + + try { + await firstValueFrom( + service.getCurrentProgramsForChannels(['channel-1']) + ); + + // +90 min display offset → the guide runs 90 min ahead, so the + // row airing "now" is the one the provider files under 09:00. + expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledWith( + ['channel-1'], + { nowMs: Date.parse('2026-05-23T09:00:00.000Z') } + ); + } finally { + jest.useRealTimers(); + } + }); + + it('picks the current programme in the provider clock for the per-channel lookup', async () => { + epgBridge.supportsProgramLookup = true; + epgBridge.getChannelPrograms = jest.fn().mockResolvedValue([ + { + channel: 'channel-1', + start: '2026-05-23T09:00:00.000Z', + stop: '2026-05-23T10:00:00.000Z', + title: 'Really On Air', + }, + { + channel: 'channel-1', + start: '2026-05-23T10:00:00.000Z', + stop: '2026-05-23T11:00:00.000Z', + title: 'Provider Says Now', + }, + ]); + settingsStore.resolvedEpgOffsetMinutes.mockReturnValue(60); + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-05-23T10:30:00.000Z')); + + try { + await expect( + firstValueFrom(service.getCurrentProgramForChannel('channel-1')) + ).resolves.toMatchObject({ title: 'Really On Air' }); + } finally { + jest.useRealTimers(); + } + }); + + it('drops cached current programmes when the display offset changes', async () => { + epgBridge.supportsProgramLookup = true; + epgBridge.supportsCurrentProgramBatch = true; + epgBridge.getCurrentProgramsBatch = jest.fn().mockResolvedValue({ + 'guide-news': null, + }); + + await firstValueFrom( + service.getCurrentProgramsForChannels(['guide-news'], { + sourceUrls: ['https://playlist.example.com/guide.xml'], + }) + ); + await firstValueFrom( + service.getCurrentProgramsForChannels(['guide-news'], { + sourceUrls: ['https://playlist.example.com/guide.xml'], + }) + ); + expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(1); + + settingsStore.resolvedEpgOffsetMinutes.mockReturnValue(30); + await firstValueFrom( + service.getCurrentProgramsForChannels(['guide-news'], { + sourceUrls: ['https://playlist.example.com/guide.xml'], + }) + ); + + // A different offset is a different "now", so the 60 s cache must + // not answer with the previous verdict. + expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(2); + }); + + it('does not join an in-flight batch that was evaluated for another display offset', async () => { + epgBridge.supportsProgramLookup = true; + epgBridge.supportsCurrentProgramBatch = true; + let resolveFirst: (value: Record) => void = () => + undefined; + epgBridge.getCurrentProgramsBatch = jest + .fn() + .mockImplementationOnce( + () => + new Promise>((resolve) => { + resolveFirst = resolve; + }) + ) + .mockResolvedValueOnce({ 'guide-news': null }); + const options = { + sourceUrls: ['https://playlist.example.com/guide.xml'], + }; + + const first = firstValueFrom( + service.getCurrentProgramsForChannels(['guide-news'], options) + ); + // The setting changes while the first batch is still on the wire: + // the refresh it triggers must evaluate at the new provider clock, + // not reuse the pending request computed for the old one. + settingsStore.resolvedEpgOffsetMinutes.mockReturnValue(30); + const second = firstValueFrom( + service.getCurrentProgramsForChannels(['guide-news'], options) + ); + resolveFirst({ 'guide-news': null }); + await Promise.all([first, second]); + + expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(2); + const [, secondOptions] = ( + epgBridge.getCurrentProgramsBatch as jest.Mock + ).mock.calls[1]; + const [, firstOptions] = ( + epgBridge.getCurrentProgramsBatch as jest.Mock + ).mock.calls[0]; + expect(secondOptions.nowMs).toBeLessThan(firstOptions.nowMs); + }); + it('caches current program lookups separately by EPG source URL scope', async () => { settingsStore.getSettings.mockReturnValue({ epgUrl: ['https://global.example.com/guide.xml'], @@ -305,12 +435,16 @@ describe('EpgService', () => { expect(epgBridge.getCurrentProgramsBatch).toHaveBeenNthCalledWith( 1, ['guide-news', 'guide-sports'], - { sourceUrls: ['https://playlist.example.com/guide.xml'] } + expect.objectContaining({ + sourceUrls: ['https://playlist.example.com/guide.xml'], + }) ); expect(epgBridge.getCurrentProgramsBatch).toHaveBeenNthCalledWith( 2, ['guide-sports'], - { sourceUrls: ['https://global.example.com/guide.xml'] } + expect.objectContaining({ + sourceUrls: ['https://global.example.com/guide.xml'], + }) ); }); @@ -331,7 +465,9 @@ describe('EpgService', () => { expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(1); expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledWith( ['guide-sports'], - { sourceUrls: ['https://playlist.example.com/guide.xml'] } + expect.objectContaining({ + sourceUrls: ['https://playlist.example.com/guide.xml'], + }) ); }); @@ -395,7 +531,9 @@ describe('EpgService', () => { ); expect(beforeImport.get('guide-news')).toBeNull(); - const availability = firstValueFrom(service.epgAvailable$.pipe(skip(1))); + const availability = firstValueFrom( + service.epgAvailable$.pipe(skip(1)) + ); service.fetchEpg(['https://playlist.example.com/guide.xml']); await expect(availability).resolves.toBe(true); @@ -468,8 +606,7 @@ describe('EpgService', () => { epgBridge.supportsProgramLookup = true; epgBridge.supportsCurrentProgramBatch = true; let resolveBatch: - | ((programs: Record) => void) - | undefined; + ((programs: Record) => void) | undefined; epgBridge.getCurrentProgramsBatch = jest.fn( () => new Promise((resolve) => { @@ -557,7 +694,9 @@ describe('EpgService', () => { expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(1); expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledWith( ['guide-news'], - { sourceUrls: ['https://playlist.example.com/guide.xml'] } + expect.objectContaining({ + sourceUrls: ['https://playlist.example.com/guide.xml'], + }) ); } finally { consoleError.mockRestore(); diff --git a/libs/epg/data-access/src/lib/epg.service.ts b/libs/epg/data-access/src/lib/epg.service.ts index 78e458698..b53502d99 100644 --- a/libs/epg/data-access/src/lib/epg.service.ts +++ b/libs/epg/data-access/src/lib/epg.service.ts @@ -15,6 +15,7 @@ import { createDevLogger, EpgChannelMetadata, EpgProgram, + epgProviderClockMs, } from '@iptvnator/shared/interfaces'; import { SettingsStore } from '@iptvnator/services'; import { @@ -27,6 +28,8 @@ import { normalizeEpgUrls } from '@iptvnator/shared/m3u-utils'; interface CachedProgram { program: EpgProgram | null; timestamp: number; + /** Display offset the "now" verdict was computed with; a changed setting invalidates the entry. */ + offsetMinutes: number; } const debugEpgService = createDevLogger('EpgService'); @@ -55,6 +58,21 @@ export class EpgService { >(); private readonly CACHE_TTL = 60000; // 60 seconds + /** Display offset every "currently airing" decision in here is made with. */ + private epgOffsetMinutes(): number { + return this.settingsStore.resolvedEpgOffsetMinutes(); + } + + /** + * Wall-clock now expressed in the provider's uncorrected EPG clock. Raw + * programme rows are compared against this instant, so the row picked + * as "now" is the one the UI also renders as "now" once it shifts the + * times for display (`epg-display-offset.util.ts`, clock form). + */ + private epgClockMs(): number { + return epgProviderClockMs(Date.now(), this.epgOffsetMinutes()); + } + readonly epgAvailable$ = this.epgAvailable.asObservable(); readonly currentEpgPrograms$ = this.currentEpgPrograms.asObservable(); @@ -185,12 +203,12 @@ export class EpgService { * Finds the current program from a list of programs */ private findCurrentProgram(programs: EpgProgram[]): EpgProgram | null { - const now = new Date(); + const now = this.epgClockMs(); return ( programs.find((program) => { - const start = new Date(program.start); - const stop = new Date(program.stop); + const start = new Date(program.start).getTime(); + const stop = new Date(program.stop).getTime(); return start <= now && now <= stop; }) || null ); @@ -239,11 +257,16 @@ export class EpgService { const resultMap = new Map(); const channelsToFetch: string[] = []; const now = Date.now(); + const offsetMinutes = this.epgOffsetMinutes(); // Check cache for each channel channelIds.forEach((channelId) => { const cached = this.programCache.get(channelId); - if (cached && now - cached.timestamp < this.CACHE_TTL) { + if ( + cached && + now - cached.timestamp < this.CACHE_TTL && + cached.offsetMinutes === offsetMinutes + ) { resultMap.set(channelId, cached.program); } else { channelsToFetch.push(channelId); @@ -260,7 +283,9 @@ export class EpgService { // GET_CHANNEL_PROGRAMS round-trip. if (this.epgBridge.supportsCurrentProgramBatch) { return from( - this.epgBridge.getCurrentProgramsBatch(channelsToFetch) + this.epgBridge.getCurrentProgramsBatch(channelsToFetch, { + nowMs: this.epgClockMs(), + }) ).pipe( timeout(5000), map((batchResult) => { @@ -271,6 +296,7 @@ export class EpgService { this.programCache.set(channelId, { program, timestamp: cacheTimestamp, + offsetMinutes, }); }); return resultMap; @@ -336,6 +362,9 @@ export class EpgService { ); const existingRequest = this.fetchingCurrentProgramBatches.get(batchCacheKey); + // Tag entries with the offset the verdict was computed with, not the + // one current when the response lands. + const offsetMinutes = this.epgOffsetMinutes(); const request$ = existingRequest ?? this.fetchScopedCurrentProgramsBatch( @@ -351,6 +380,7 @@ export class EpgService { { program: fetchedMap.get(channelId) ?? null, timestamp: cacheTimestamp, + offsetMinutes, } ); }); @@ -384,9 +414,11 @@ export class EpgService { sourceUrls: string[], fallbackSourceUrls: string[] ): Observable> { + const nowMs = this.epgClockMs(); return from( this.epgBridge.getCurrentProgramsBatch(channelIds, { sourceUrls, + nowMs, }) ).pipe( timeout(5000), @@ -413,6 +445,7 @@ export class EpgService { return from( this.epgBridge.getCurrentProgramsBatch(fallbackChannelIds, { sourceUrls: fallbackSourceUrls, + nowMs, }) ).pipe( timeout(5000), @@ -543,16 +576,23 @@ export class EpgService { ); } + /** + * Cache and in-flight identity of a lookup. The display offset is part of + * it: a request evaluated at another provider clock answers a different + * question, so a lookup issued after the setting changed must neither + * read the previous entry nor join a batch still in flight for it. + */ private createProgramCacheKey( channelId: string, sourceUrls: string[] = [] ): string { const normalizedSourceUrls = normalizeEpgUrls(sourceUrls); - if (normalizedSourceUrls.length === 0) { - return channelId; - } - - return `source:${channelId}:${JSON.stringify(normalizedSourceUrls)}`; + const key = + normalizedSourceUrls.length === 0 + ? channelId + : `source:${channelId}:${JSON.stringify(normalizedSourceUrls)}`; + const offsetMinutes = this.epgOffsetMinutes(); + return offsetMinutes === 0 ? key : `${key}|offset:${offsetMinutes}`; } private createProgramBatchCacheKey( @@ -564,6 +604,7 @@ export class EpgService { channelIds: [...channelIds].sort(), sourceUrls: normalizeEpgUrls(sourceUrls), fallbackSourceUrls: normalizeEpgUrls(fallbackSourceUrls), + offsetMinutes: this.epgOffsetMinutes(), }); } @@ -573,7 +614,10 @@ export class EpgService { return undefined; } - if (Date.now() - cached.timestamp >= this.CACHE_TTL) { + if ( + Date.now() - cached.timestamp >= this.CACHE_TTL || + cached.offsetMinutes !== this.epgOffsetMinutes() + ) { this.programCache.delete(cacheKey); return undefined; } @@ -595,11 +639,13 @@ export class EpgService { return existingRequest; } + const offsetMinutes = this.epgOffsetMinutes(); const request$ = fetchProgram().pipe( tap((program) => { this.programCache.set(cacheKey, { program, timestamp: Date.now(), + offsetMinutes, }); }), finalize(() => { diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-movie-gate.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-movie-gate.spec.ts index 4633093cc..1ccf2ba79 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-movie-gate.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-movie-gate.spec.ts @@ -213,6 +213,7 @@ describe('VideoPlayerComponent — M3U movie recognition gate', () => { stripCountryPrefix, m3uVodDetails, resolvedEpgViewMode: signal('timeline'), + resolvedEpgOffsetMinutes: signal(0), epgUrl: signal([]), }, }, diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index 5f03f2152..409496588 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -128,6 +128,7 @@ @if (epgViewMode() === 'list') {
diff --git a/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.ts b/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.ts index fc98f503e..62f7a8b6d 100644 --- a/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.ts +++ b/libs/portal/downloads/feature/src/lib/recording-detail/recording-detail.component.ts @@ -12,7 +12,8 @@ import { MatIcon } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslatePipe } from '@ngx-translate/core'; -import { RecordingsService } from '@iptvnator/services'; +import { RecordingsService, SettingsStore } from '@iptvnator/services'; +import { epgDisplayTimeMs } from '@iptvnator/shared/interfaces'; import { DetailActionsTemplateDirective, DetailMetaTemplateDirective, @@ -61,6 +62,7 @@ export class RecordingDetailComponent { private readonly location = inject(Location); private readonly recordings = inject(RecordingsService); private readonly actions = inject(RecordingManagerActionsService); + private readonly settingsStore = inject(SettingsStore); private readonly returnUrl: string | null = (this.router.getCurrentNavigation()?.extras.state?.[ @@ -109,6 +111,17 @@ export class RecordingDetailComponent { return programs.length >= 2 ? programs : []; }); + /** + * Snapshots keep the provider's raw times; the list shows them in display + * time like every other guide surface (`epg-display-offset.util.ts`). + */ + programDisplayMs(iso: string): number { + return epgDisplayTimeMs( + Date.parse(iso), + this.settingsStore.resolvedEpgOffsetMinutes() + ); + } + constructor() { void this.recordings.loadRecordings(); // Row gone after an authoritative load (removed here, removed diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.epg-offset.spec.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.epg-offset.spec.ts new file mode 100644 index 000000000..d137ebcfc --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.epg-offset.spec.ts @@ -0,0 +1,356 @@ +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { + XtreamApiService, + XtreamUrlService, +} from '@iptvnator/portal/xtream/data-access'; +import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access'; +import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; +import { + DataService, + PlaylistsService, + SettingsStore, +} from '@iptvnator/services'; +import { Playlist } from '@iptvnator/shared/interfaces'; +import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; +import { StreamResolverService } from './stream-resolver.service'; + +/** + * EPG display-offset behaviour of the collection resolver. Kept apart from + * `stream-resolver.service.spec.ts`, which sits at the test line cap. + */ +describe('StreamResolverService EPG display offset', () => { + let service: StreamResolverService; + let xtreamApi: { getShortEpg: jest.Mock; getFullEpg: jest.Mock }; + let epgOffsetMinutes = 0; + + beforeEach(() => { + epgOffsetMinutes = 0; + xtreamApi = { getShortEpg: jest.fn(), getFullEpg: jest.fn() }; + + TestBed.configureTestingModule({ + providers: [ + StreamResolverService, + { + provide: PlaylistsService, + useValue: { + getPlaylistById: jest.fn(() => + of({ + _id: 'xtream-1', + serverUrl: 'https://xtream.example.com', + username: 'user', + password: 'pass', + } satisfies Partial) + ), + }, + }, + { provide: XtreamApiService, useValue: xtreamApi }, + { + provide: XtreamUrlService, + useValue: { constructLiveUrl: jest.fn() }, + }, + { provide: DataService, useValue: { sendIpcEvent: jest.fn() } }, + { + provide: SettingsStore, + useValue: { + resolvedEpgOffsetMinutes: () => epgOffsetMinutes, + }, + }, + { + provide: EpgRuntimeBridgeService, + useValue: { + getChannelPrograms: jest.fn(), + getEpgMapping: jest.fn().mockResolvedValue(null), + getEpgMappingsBatch: jest.fn().mockResolvedValue(null), + supportsProgramLookup: true, + }, + }, + { + provide: StalkerSessionService, + useValue: { + getCachedToken: jest.fn(() => null), + ensureToken: jest + .fn() + .mockResolvedValue({ token: null }), + makeAuthenticatedRequest: jest.fn(), + }, + }, + ], + }); + + service = TestBed.inject(StreamResolverService); + }); + + it('cuts Xtream collection previews from the full guide at the provider clock', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const listing = ( + title: string, + startOffsetMin: number, + durationMin: number + ) => { + const start = nowSeconds + startOffsetMin * 60; + const stop = start + durationMin * 60; + return { + id: title, + epg_id: title, + title, + lang: 'en', + description: '', + channel_id: '1', + start: new Date(start * 1000).toISOString(), + end: new Date(stop * 1000).toISOString(), + stop: new Date(stop * 1000).toISOString(), + start_timestamp: String(start), + stop_timestamp: String(stop), + }; + }; + xtreamApi.getFullEpg.mockResolvedValue([ + listing('Provider now', -15, 30), + listing('Really on air', -75, 60), + listing('Long gone', -180, 60), + ]); + // The guide runs an hour ahead: the show really on air is the one + // the provider files as finished 15 minutes ago, which get_short_epg + // (starting at provider-now) never returns. + epgOffsetMinutes = 60; + + const epgMap = await service.loadEpgForItems([ + { + uid: 'xtream::xtream-1::1', + name: 'Xtream Live', + contentType: 'live', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream', + xtreamId: 1, + } satisfies UnifiedCollectionItem, + ]); + + expect(xtreamApi.getShortEpg).not.toHaveBeenCalled(); + expect(xtreamApi.getFullEpg).toHaveBeenCalledTimes(1); + expect(epgMap.get('Xtream Live')?.title).toBe('Really on air'); + }); + + it('widens Stalker collection previews under a negative offset and picks the entry covering the provider clock', async () => { + TestBed.inject(PlaylistsService).getPlaylistById = jest.fn(() => + of({ + _id: 'stalker-1', + portalUrl: 'https://stalker.example.com', + macAddress: '00:11:22:33:44:55', + isFullStalkerPortal: false, + } satisfies Partial) + ); + const nowSeconds = Math.floor(Date.now() / 1000); + const entry = (id: string, name: string, startOffsetMin: number) => ({ + id, + name, + descr: '', + time: '2026-03-26T11:00:00.000Z', + time_to: '2026-03-26T12:00:00.000Z', + ch_id: '77', + start_timestamp: String(nowSeconds + startOffsetMin * 60), + stop_timestamp: String(nowSeconds + (startOffsetMin + 30) * 60), + }); + const sendIpcEvent = TestBed.inject(DataService) + .sendIpcEvent as jest.Mock; + sendIpcEvent.mockResolvedValue({ + js: [ + entry('10', 'Provider now', -10), + entry('11', 'Soon', 20), + entry('12', 'Really on air', 50), + ], + }); + // The guide runs an hour behind: the show really on air is the one + // the portal files as starting in 50 minutes. + epgOffsetMinutes = -60; + + const epgMap = await service.loadEpgForItems([ + { + uid: 'stalker::stalker-1::77', + name: 'Stalker Channel', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker', + stalkerId: '77', + tvgId: '77', + stalkerCmd: 'ffmpeg http://stalker/77', + } satisfies UnifiedCollectionItem, + ]); + + expect(epgMap.get('77')?.title).toBe('Really on air'); + // 1 entry + ⌈60 / 15⌉ = 5 requested instead of the usual single one. + expect(sendIpcEvent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + params: expect.objectContaining({ size: '5' }), + }) + ); + }); + + it('judges timestamp-less Stalker entries by their ISO boundaries', async () => { + TestBed.inject(PlaylistsService).getPlaylistById = jest.fn(() => + of({ + _id: 'stalker-1', + portalUrl: 'https://stalker.example.com', + macAddress: '00:11:22:33:44:55', + isFullStalkerPortal: false, + } satisfies Partial) + ); + const nowMs = Date.now(); + const entry = (id: string, name: string, startOffsetMin: number) => ({ + id, + name, + descr: '', + time: new Date(nowMs + startOffsetMin * 60_000).toISOString(), + time_to: new Date( + nowMs + (startOffsetMin + 30) * 60_000 + ).toISOString(), + ch_id: '77', + }); + ( + TestBed.inject(DataService).sendIpcEvent as jest.Mock + ).mockResolvedValue({ + js: [ + entry('10', 'Provider now', -10), + entry('12', 'Really on air', 50), + ], + }); + epgOffsetMinutes = -60; + + const epgMap = await service.loadEpgForItems([ + { + uid: 'stalker::stalker-1::77', + name: 'Stalker Channel', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker', + stalkerId: '77', + tvgId: '77', + stalkerCmd: 'ffmpeg http://stalker/77', + } satisfies UnifiedCollectionItem, + ]); + + // Without unix timestamps the ISO window still rules out the + // portal's own "now" entry under the shifted clock. + expect(epgMap.get('77')?.title).toBe('Really on air'); + }); + + it('cuts the Xtream window at the instant the batch was evaluated, not when the guide arrives', async () => { + jest.useFakeTimers(); + try { + const startMs = Date.parse('2026-05-23T10:00:00.000Z'); + jest.setSystemTime(startMs); + const nowSeconds = Math.floor(startMs / 1000); + const listing = ( + title: string, + startOffsetMin: number, + durationMin: number + ) => { + const start = nowSeconds + startOffsetMin * 60; + const stop = start + durationMin * 60; + return { + id: title, + epg_id: title, + title, + lang: 'en', + description: '', + channel_id: '1', + start: new Date(start * 1000).toISOString(), + end: new Date(stop * 1000).toISOString(), + stop: new Date(stop * 1000).toISOString(), + start_timestamp: String(start), + stop_timestamp: String(stop), + }; + }; + // +60: the provider clock at the start of the load is 09:00. "A" + // covers it and ends at 09:35; the guide takes 40 minutes to + // arrive, so a window cut at arrival time would already have + // dropped "A" and the row would come back empty. + xtreamApi.getFullEpg.mockImplementation(async () => { + jest.setSystemTime(startMs + 40 * 60_000); + return [listing('A', -90, 65), listing('B', -25, 30)]; + }); + epgOffsetMinutes = 60; + + const epgMap = await service.loadEpgForItems([ + { + uid: 'xtream::xtream-1::1', + name: 'Xtream Live', + contentType: 'live', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream', + xtreamId: 1, + } satisfies UnifiedCollectionItem, + ]); + + expect(epgMap.get('Xtream Live')?.title).toBe('A'); + } finally { + jest.useRealTimers(); + } + }); + + it('evaluates a whole collection load against the offset it started with', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const listing = ( + title: string, + startOffsetMin: number, + durationMin: number + ) => { + const start = nowSeconds + startOffsetMin * 60; + const stop = start + durationMin * 60; + return { + id: title, + epg_id: title, + title, + lang: 'en', + description: '', + channel_id: '1', + start: new Date(start * 1000).toISOString(), + end: new Date(stop * 1000).toISOString(), + stop: new Date(stop * 1000).toISOString(), + start_timestamp: String(start), + stop_timestamp: String(stop), + }; + }; + epgOffsetMinutes = 60; + // The setting flips back to 0 while the batch is still resolving + // credentials — before any guide request went out. + TestBed.inject(PlaylistsService).getPlaylistById = jest.fn(() => { + epgOffsetMinutes = 0; + return of({ + _id: 'xtream-1', + serverUrl: 'https://xtream.example.com', + username: 'user', + password: 'pass', + } satisfies Partial); + }); + xtreamApi.getFullEpg.mockResolvedValue([ + listing('Really on air', -75, 60), + listing('Provider now', -15, 30), + ]); + xtreamApi.getShortEpg.mockResolvedValue([ + listing('Provider now', -15, 30), + ]); + + const epgMap = await service.loadEpgForItems([ + { + uid: 'xtream::xtream-1::1', + name: 'Xtream Live', + contentType: 'live', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream', + xtreamId: 1, + } satisfies UnifiedCollectionItem, + ]); + + // The load keeps the +60 snapshot it began with: it fetches the full + // guide and picks the programme covering that provider clock, rather + // than mixing a short-EPG window for offset 0 with a +60 selection. + expect(xtreamApi.getShortEpg).not.toHaveBeenCalled(); + expect(epgMap.get('Xtream Live')?.title).toBe('Really on air'); + }); +}); diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts index 32209a282..f975d7715 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts @@ -12,6 +12,7 @@ import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { DataService, PlaylistsService, + SettingsStore, } from '@iptvnator/services'; import { Playlist } from '@iptvnator/shared/interfaces'; import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; @@ -23,7 +24,8 @@ import { describe('StreamResolverService', () => { let service: StreamResolverService; let playlistsService: { getPlaylistById: jest.Mock }; - let xtreamApi: { getShortEpg: jest.Mock }; + let xtreamApi: { getShortEpg: jest.Mock; getFullEpg: jest.Mock }; + let epgOffsetMinutes = 0; let xtreamUrl: { constructLiveUrl: jest.Mock }; let dataService: { sendIpcEvent: jest.Mock }; let stalkerSession: { @@ -37,8 +39,10 @@ describe('StreamResolverService', () => { playlistsService = { getPlaylistById: jest.fn(), }; + epgOffsetMinutes = 0; xtreamApi = { getShortEpg: jest.fn(), + getFullEpg: jest.fn(), }; xtreamUrl = { constructLiveUrl: jest.fn(), @@ -65,6 +69,12 @@ describe('StreamResolverService', () => { { provide: XtreamApiService, useValue: xtreamApi }, { provide: XtreamUrlService, useValue: xtreamUrl }, { provide: DataService, useValue: dataService }, + { + provide: SettingsStore, + useValue: { + resolvedEpgOffsetMinutes: () => epgOffsetMinutes, + }, + }, { provide: EpgRuntimeBridgeService, useValue: epgBridge, diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts index eedfd3730..7acf46d79 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts @@ -1,6 +1,10 @@ import { inject, Injectable } from '@angular/core'; import { firstValueFrom } from 'rxjs'; -import { DataService, PlaylistsService } from '@iptvnator/services'; +import { + DataService, + PlaylistsService, + SettingsStore, +} from '@iptvnator/services'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { buildStalkerEpgMappingKey, @@ -8,12 +12,15 @@ import { Channel, EpgItem, EpgProgram, + epgProviderClockMs, isFullStalkerPortalPlaylist, Playlist, isStalkerStreamCredentialSafe, ResolvedPortalPlayback, + shortEpgWindowSize, STALKER_REQUEST, StalkerPortalActions, + windowEpgItemsAtProviderClock, } from '@iptvnator/shared/interfaces'; import { XtreamApiService, @@ -73,6 +80,16 @@ export interface ResolvedLiveCollectionDetail { readonly epgItems?: EpgItem[]; } +/** Clock snapshot a whole EPG load is evaluated against; see `epgLoadClock`. */ +interface EpgLoadClock { + /** Wall-clock instant the load started at. */ + wallNowMs: number; + /** Display offset the load was evaluated with. */ + offsetMinutes: number; + /** `wallNowMs` in the provider's EPG clock. */ + nowMs: number; +} + @Injectable({ providedIn: 'root' }) export class StreamResolverService { private readonly playlistsService = inject(PlaylistsService); @@ -80,6 +97,25 @@ export class StreamResolverService { private readonly xtreamUrl = inject(XtreamUrlService); private readonly dataService = inject(DataService); private readonly epgBridge = inject(EpgRuntimeBridgeService); + private readonly settingsStore = inject(SettingsStore); + + /** + * One clock snapshot per EPG load: the wall instant, the display offset + * the load was evaluated with, and the derived provider clock. Every + * window cut and every "is it on now" test inside the load uses this + * snapshot, so a setting change or a programme boundary crossed while a + * request is on the wire cannot make one half of the load disagree with + * the other (`epg-display-offset.util.ts`, clock form). + */ + private epgLoadClock(): EpgLoadClock { + const wallNowMs = Date.now(); + const offsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes(); + return { + wallNowMs, + offsetMinutes, + nowMs: epgProviderClockMs(wallNowMs, offsetMinutes), + }; + } private readonly stalkerSession = inject(StalkerSessionService); private readonly portalRepair = inject(StalkerPortalRepairService); private readonly logger = createLogger('StreamResolver'); @@ -168,7 +204,10 @@ export class StreamResolverService { return epgMap; } - const now = Date.now(); + // The rows stay raw and the list shifts them for display; every + // decision below is made against this one snapshot. + const clock = this.epgLoadClock(); + const now = clock.nowMs; const xtreamByPlaylist = new Map(); const stalkerByPlaylist = new Map(); @@ -206,13 +245,23 @@ export class StreamResolverService { for (const [playlistId, playlistItems] of xtreamByPlaylist.entries()) { tasks.push( - this.loadXtreamEpgBatch(playlistId, playlistItems, epgMap, now) + this.loadXtreamEpgBatch( + playlistId, + playlistItems, + epgMap, + clock + ) ); } for (const [playlistId, playlistItems] of stalkerByPlaylist.entries()) { tasks.push( - this.loadStalkerEpgBatch(playlistId, playlistItems, epgMap, now) + this.loadStalkerEpgBatch( + playlistId, + playlistItems, + epgMap, + clock + ) ); } @@ -460,8 +509,7 @@ export class StreamResolverService { // `js.cmd` resolution and the playback header origin must follow // the endpoint that actually answered. const effectivePortalUrl = playlist - ? (this.portalRepair.applyOverride(playlist).portalUrl ?? - portalUrl) + ? (this.portalRepair.applyOverride(playlist).portalUrl ?? portalUrl) : portalUrl; return this.buildStalkerPlayback(item, playlist, { @@ -658,7 +706,9 @@ export class StreamResolverService { // 1) Check uploaded XMLTV EPG via the provider's epg_channel_id. // The field is populated at runtime from the content table's // epg_channel_id column but is not declared on the TS interface. - const epgKey = (item as unknown as Record).epgChannelId?.trim(); + const epgKey = ( + item as unknown as Record + ).epgChannelId?.trim(); if (this.supportsProgramLookup && epgKey) { const uploaded = await this.epgBridge .getChannelPrograms(epgKey) @@ -716,7 +766,8 @@ export class StreamResolverService { private async loadStalkerEpgItems( item: UnifiedCollectionItem, - size: number + size: number, + clock = this.epgLoadClock() ): Promise { const playlist = (await firstValueFrom( this.playlistsService.getPlaylistById(item.playlistId) @@ -751,12 +802,16 @@ export class StreamResolverService { } } - return this.fetchStalkerShortEpg(playlist, channelId, size); + // Widened under a negative display offset so the window still reaches + // the programme on air (`shortEpgWindowSize`). + return this.fetchStalkerShortEpg( + playlist, + channelId, + shortEpgWindowSize(clock.offsetMinutes, size) + ); } - private async getXtreamCredentials( - playlistId: string - ): Promise<{ + private async getXtreamCredentials(playlistId: string): Promise<{ serverUrl: string; username: string; password: string; @@ -899,8 +954,9 @@ export class StreamResolverService { playlistId: string, channels: UnifiedCollectionItem[], epgMap: Map, - now: number + clock: EpgLoadClock ): Promise { + const now = clock.nowMs; const creds = await this.getXtreamCredentials(playlistId); if (!creds) { return; @@ -932,10 +988,16 @@ export class StreamResolverService { let currentItem: EpgItem | null = null; // 1) Try uploaded XMLTV EPG via the provider's epg_channel_id. - const epgChannelKey = (channel as unknown as Record).epgChannelId?.trim(); + const epgChannelKey = ( + channel as unknown as Record< + string, + string | undefined | null + > + ).epgChannelId?.trim(); if (this.supportsProgramLookup && epgChannelKey) { currentItem = await this.findCurrentInXmltv( - epgChannelKey, nowSeconds + epgChannelKey, + nowSeconds ); } @@ -964,12 +1026,14 @@ export class StreamResolverService { playlistId, creds, channel.xtreamId, - 5 + 5, + clock ); currentItem = items.find( (item) => - Number(item.start_timestamp) <= nowSeconds && + Number(item.start_timestamp) <= + nowSeconds && nowSeconds < Number(item.stop_timestamp) ) ?? null; } @@ -1017,9 +1081,11 @@ export class StreamResolverService { private getXtreamEpgCacheKey( playlistId: string, streamId: number, - limit: number + limit: number, + offsetMinutes: number ): string { - return `${playlistId}:${streamId}:${limit}`; + // A window cut at another provider clock answers a different question. + return `${playlistId}:${streamId}:${limit}:${offsetMinutes}`; } private getCachedXtreamEpgItems(cacheKey: string): EpgItem[] | null { @@ -1058,13 +1124,20 @@ export class StreamResolverService { password: string; }, streamId: number, - limit: number + limit: number, + clock = this.epgLoadClock() ): Promise { if (!this.supportsProgramLookup) { return []; } - const cacheKey = this.getXtreamEpgCacheKey(playlistId, streamId, limit); + const { offsetMinutes, wallNowMs } = clock; + const cacheKey = this.getXtreamEpgCacheKey( + playlistId, + streamId, + limit, + offsetMinutes + ); const cached = this.getCachedXtreamEpgItems(cacheKey); if (cached !== null) { return cached; @@ -1075,14 +1148,28 @@ export class StreamResolverService { } try { - const items = await this.xtreamApi.getShortEpg( - credentials, - streamId, - limit, - { - suppressErrorLog: true, - } - ); + // get_short_epg starts at the provider's own "now" and cannot + // reach the programme on air under a display offset, so the + // same window is cut from the full guide at the provider clock + // instead (`epg-display-offset.util.ts`). + const items = + offsetMinutes === 0 + ? await this.xtreamApi.getShortEpg( + credentials, + streamId, + limit, + { suppressErrorLog: true } + ) + : windowEpgItemsAtProviderClock( + await this.xtreamApi.getFullEpg( + credentials, + streamId, + { suppressErrorLog: true } + ), + offsetMinutes, + limit, + wallNowMs + ); this.xtreamEpgCache.set(cacheKey, { data: items, @@ -1100,8 +1187,9 @@ export class StreamResolverService { playlistId: string, channels: UnifiedCollectionItem[], epgMap: Map, - now: number + clock: EpgLoadClock ): Promise { + const now = clock.nowMs; const playlist = (await firstValueFrom( this.playlistsService.getPlaylistById(playlistId) )) as Playlist | undefined; @@ -1142,23 +1230,33 @@ export class StreamResolverService { if (mappedItem) { epgMap.set( epgKey, - this.toPreviewProgram(mappedItem, channelId, now) + this.toPreviewProgram( + mappedItem, + channelId, + now + ) ); return; } } + // The short EPG starts at the portal's own "now"; under a + // negative display offset the programme on air lies + // further ahead, so the window is widened and the entry + // covering the provider clock is picked, not the first. const items = await this.fetchStalkerShortEpg( playlist, channelId, - 1 - ); - epgMap.set( - epgKey, - items.length > 0 - ? this.toPreviewProgram(items[0], channelId, now) - : null + shortEpgWindowSize(clock.offsetMinutes, 1) ); + let preview: EpgProgram | null = null; + for (const item of items) { + preview = this.toPreviewProgram(item, channelId, now); + if (preview) { + break; + } + } + epgMap.set(epgKey, preview); } catch { epgMap.set(epgKey, null); } @@ -1273,24 +1371,29 @@ export class StreamResolverService { ); } + /** + * The item as a preview programme, or null when it is not airing at + * `now`. The window comes from the unix timestamps when the portal sends + * them and from the ISO boundaries otherwise; an item with no readable + * boundary at all is accepted as-is (the portal's own "current" entry). + */ private toPreviewProgram( item: EpgItem, channelId: string | number, now: number ): EpgProgram | null { - const startTimestamp = Number(item.start_timestamp); - const stopTimestamp = Number(item.stop_timestamp); + const startMs = epgBoundaryMs(item.start_timestamp, item.start); + const stopMs = epgBoundaryMs( + item.stop_timestamp, + item.stop ?? item.end + ); if ( - Number.isFinite(startTimestamp) && - Number.isFinite(stopTimestamp) && - startTimestamp > 0 && - stopTimestamp > 0 + startMs !== null && + stopMs !== null && + (now < startMs || now >= stopMs) ) { - const nowSeconds = Math.floor(now / 1000); - if (nowSeconds < startTimestamp || nowSeconds >= stopTimestamp) { - return null; - } + return null; } return { @@ -1403,10 +1506,25 @@ export class StreamResolverService { .catch(() => null); if (!programs || programs.length === 0) return null; const items = this.mapProgramsToEpgItems(programs); - return items.find( - (item) => - Number(item.start_timestamp) <= nowSeconds && - nowSeconds < Number(item.stop_timestamp) - ) ?? null; + return ( + items.find( + (item) => + Number(item.start_timestamp) <= nowSeconds && + nowSeconds < Number(item.stop_timestamp) + ) ?? null + ); } } + +/** Epoch ms of a portal EPG boundary: unix seconds when present, else the ISO text. */ +function epgBoundaryMs( + timestamp: string | number | null | undefined, + iso: string | null | undefined +): number | null { + const seconds = Number(timestamp); + if (Number.isFinite(seconds) && seconds > 0) { + return seconds * 1000; + } + const parsed = Date.parse(iso ?? ''); + return Number.isFinite(parsed) ? parsed : null; +} diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts index c7f7c6baa..ec8e2ea47 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts @@ -33,6 +33,7 @@ describe('GlobalFavoritesListComponent', () => { provide: SettingsStore, useValue: { openStreamOnDoubleClick: signal(false), + resolvedEpgOffsetMinutes: signal(0), }, }, ], diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts index 98e34a91e..46004be9c 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts @@ -28,6 +28,7 @@ import { buildStalkerEpgMappingKey, buildXtreamEpgMappingKey, EpgProgram, + epgProviderClockMs, } from '@iptvnator/shared/interfaces'; import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state'; import { EpgMappingDialogComponent } from '@iptvnator/ui/components'; @@ -309,7 +310,12 @@ export class GlobalFavoritesListComponent { return 0; } - const now = Date.now(); + // Raw programme vs. now in the provider's EPG clock; the row shifts + // the displayed times by the same offset. + const now = epgProviderClockMs( + Date.now(), + this.settingsStore.resolvedEpgOffsetMinutes() + ); const start = new Date(program.start).getTime(); const stop = new Date(program.stop).getTime(); const total = stop - start; diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-summary.util.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-summary.util.spec.ts new file mode 100644 index 000000000..373e5f6d8 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-summary.util.spec.ts @@ -0,0 +1,83 @@ +import { ResolvedLiveCollectionDetail } from '@iptvnator/portal/shared/data-access'; +import { getLiveEpgPanelSummary } from './unified-live-epg-summary.util'; + +const HOUR_S = 3600; + +describe('getLiveEpgPanelSummary', () => { + const nowSeconds = 1_800_000_000; + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(nowSeconds * 1000); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + function m3uDetail(): ResolvedLiveCollectionDetail { + const program = (title: string, startS: number, stopS: number) => ({ + title, + start: new Date(startS * 1000).toISOString(), + stop: new Date(stopS * 1000).toISOString(), + channel: 'ch', + desc: null, + category: null, + }); + return { + epgMode: 'm3u', + epgPrograms: [ + program( + 'Earlier', + nowSeconds - 2 * HOUR_S, + nowSeconds - HOUR_S + ), + program( + 'Provider now', + nowSeconds - HOUR_S, + nowSeconds + HOUR_S + ), + ], + } as unknown as ResolvedLiveCollectionDetail; + } + + function portalDetail(): ResolvedLiveCollectionDetail { + const item = (title: string, startS: number, stopS: number) => ({ + title, + start: new Date(startS * 1000).toISOString(), + end: new Date(stopS * 1000).toISOString(), + start_timestamp: String(startS), + stop_timestamp: String(stopS), + }); + return { + epgMode: 'portal', + epgItems: [ + item('Earlier', nowSeconds - 2 * HOUR_S, nowSeconds - HOUR_S), + item('Provider now', nowSeconds - HOUR_S, nowSeconds + HOUR_S), + ], + } as unknown as ResolvedLiveCollectionDetail; + } + + it('summarises the programme airing right now without an offset', () => { + expect(getLiveEpgPanelSummary(m3uDetail())?.title).toBe('Provider now'); + expect(getLiveEpgPanelSummary(portalDetail())?.title).toBe( + 'Provider now' + ); + }); + + it('applies the display offset to both EPG shapes before picking the current programme', () => { + // The guide runs 90 minutes ahead: the show the provider filed as + // finished an hour ago is the one really on air. + expect(getLiveEpgPanelSummary(m3uDetail(), 90)?.title).toBe('Earlier'); + expect(getLiveEpgPanelSummary(portalDetail(), 90)?.title).toBe( + 'Earlier' + ); + }); + + it('hands the summary over with the provider raw times', () => { + const summary = getLiveEpgPanelSummary(portalDetail(), 90); + expect(summary?.start).toBe( + new Date((nowSeconds - 2 * HOUR_S) * 1000).toISOString() + ); + }); +}); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-summary.util.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-summary.util.ts index ff0eef7e2..cadc47a90 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-summary.util.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-summary.util.ts @@ -1,5 +1,9 @@ import { ResolvedLiveCollectionDetail } from '@iptvnator/portal/shared/data-access'; -import { EpgItem, EpgProgram } from '@iptvnator/shared/interfaces'; +import { + epgDisplayTimeMs, + EpgItem, + EpgProgram, +} from '@iptvnator/shared/interfaces'; import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals'; /** @@ -8,7 +12,8 @@ import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals'; */ export function getLiveEpgPanelSummary( - detail: ResolvedLiveCollectionDetail | null + detail: ResolvedLiveCollectionDetail | null, + offsetMinutes = 0 ): LiveEpgPanelSummary | null { if (!detail) { return null; @@ -16,12 +21,12 @@ export function getLiveEpgPanelSummary( if (detail.epgMode === 'm3u') { return toLiveEpgPanelSummary( - findCurrentM3uProgram(detail.epgPrograms ?? []) + findCurrentM3uProgram(detail.epgPrograms ?? [], offsetMinutes) ); } return toLiveEpgPanelSummary( - findCurrentPortalProgram(detail.epgItems ?? []) + findCurrentPortalProgram(detail.epgItems ?? [], offsetMinutes) ); } @@ -54,16 +59,22 @@ export function toEpgProgram(item: EpgItem): EpgProgram { } function findCurrentM3uProgram( - programs: readonly EpgProgram[] + programs: readonly EpgProgram[], + offsetMinutes: number ): EpgProgram | null { const now = Date.now(); return ( programs.find((program) => { const start = getProgramTimeMs( program.start, - program.startTimestamp + program.startTimestamp, + offsetMinutes + ); + const stop = getProgramTimeMs( + program.stop, + program.stopTimestamp, + offsetMinutes ); - const stop = getProgramTimeMs(program.stop, program.stopTimestamp); return ( start !== null && stop !== null && now >= start && now < stop @@ -73,18 +84,21 @@ function findCurrentM3uProgram( } function findCurrentPortalProgram( - programs: readonly EpgItem[] + programs: readonly EpgItem[], + offsetMinutes: number ): EpgItem | null { const now = Date.now(); return ( programs.find((program) => { const start = getProgramTimeMs( program.start, - program.start_timestamp + program.start_timestamp, + offsetMinutes ); const stop = getProgramTimeMs( program.stop ?? program.end, - program.stop_timestamp + program.stop_timestamp, + offsetMinutes ); return ( @@ -112,15 +126,18 @@ export function toEpochSeconds( function getProgramTimeMs( rawDate: string | null | undefined, - rawTimestamp?: number | string | null + rawTimestamp?: number | string | null, + offsetMinutes = 0 ): number | null { const timestamp = Number.parseInt(String(rawTimestamp ?? ''), 10); if (Number.isFinite(timestamp) && timestamp > 0) { - return timestamp * 1000; + return epgDisplayTimeMs(timestamp * 1000, offsetMinutes); } const parsedDate = Date.parse(rawDate ?? ''); - return Number.isFinite(parsedDate) ? parsedDate : null; + return Number.isFinite(parsedDate) + ? epgDisplayTimeMs(parsedDate, offsetMinutes) + : null; } function toTimestampSeconds(value: string | null | undefined): number | null { diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.channels-for-list.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.channels-for-list.spec.ts index dc04d7ff0..ccfb01905 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.channels-for-list.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.channels-for-list.spec.ts @@ -52,6 +52,7 @@ describe('UnifiedLiveTabComponent channelsForList', () => { player: signal(VideoPlayer.VideoJs), stripCountryPrefix: signal(false), resolvedEpgViewMode: signal('timeline'), + resolvedEpgOffsetMinutes: signal(0), }, }, { diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html index 8800ce7f8..fda044e24 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html @@ -87,6 +87,7 @@ @if (epgViewMode() === 'list') { { + const offsetMinutes = this.epgOffsetMinutes(); + if (appliedEpgOffsetMinutes === offsetMinutes) { + return; + } + const isInitial = appliedEpgOffsetMinutes === null; + appliedEpgOffsetMinutes = offsetMinutes; + if (isInitial || !this.supportsEpg) { + return; + } + void this.loadEpgMap(untracked(() => this.items())); + }); + effect(() => { const target = this.autoOpenItem(); const items = this.items(); @@ -695,8 +722,16 @@ export class UnifiedLiveTabComponent { void this.loadEpgMap(this.items()); } + private epgMapRequestId = 0; private async loadEpgMap(items: UnifiedCollectionItem[]): Promise { + // Only the latest load may install its map: an older one — started + // under a previous display offset or item set — must not overwrite + // the refreshed result when it completes last. + const requestId = ++this.epgMapRequestId; const epgMap = await this.streamResolver.loadEpgForItems(items); + if (requestId !== this.epgMapRequestId) { + return; + } this.epgMap.set(epgMap); } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.remote-control.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.remote-control.spec.ts index 672e6d66b..4da359c90 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.remote-control.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.remote-control.spec.ts @@ -75,6 +75,7 @@ describe('UnifiedLiveTabComponent remote control', () => { player: signal(VideoPlayer.VideoJs), stripCountryPrefix: signal(false), resolvedEpgViewMode: signal('timeline'), + resolvedEpgOffsetMinutes: signal(0), }, }, { provide: PORTAL_PLAYER, useValue: portalPlayer }, diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts index 7765a60a8..995a5fe15 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts @@ -81,6 +81,7 @@ export class StubEpgTimelineComponent { // eslint-disable-next-line @typescript-eslint/no-explicit-any readonly summary = input(null); readonly summaryLabelKey = input(''); + readonly offsetMinutes = input(0); readonly selectedDateChange = output(); readonly collapsedChange = output(); readonly programActivated = output(); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts index e593d5f14..1fb216a31 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts @@ -68,7 +68,8 @@ const initialEpgState: StalkerEpgState = { isLoadingBulkItvEpg: false, }; -const ACTIVE_EPG_FALLBACK_SIZE = 10; +/** Short-EPG entries fetched for the active channel's panel fallback. */ +export const ACTIVE_EPG_FALLBACK_SIZE = 10; /** * EPG concern methods. diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-epg-preview.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-epg-preview.spec.ts index ecac95bf5..d3719a937 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-epg-preview.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-epg-preview.spec.ts @@ -69,6 +69,74 @@ describe('mergeEpgProgramLists', () => { }); describe('StalkerEpgPreviewQueue', () => { + it('drops cached windows when the display offset changes', async () => { + let offset = 0; + const fetchPrograms = jest.fn(async (channelId: string) => [ + buildProgram(channelId, `Now ${channelId}`, -10), + ]); + const queue = new StalkerEpgPreviewQueue({ + fetchPrograms, + onPrograms: jest.fn(), + epgOffsetMinutes: () => offset, + }); + + queue.sync(['1']); + await flushQueue(); + expect(fetchPrograms).toHaveBeenCalledTimes(1); + expect(queue.getCachedPrograms('1')).toHaveLength(1); + + // A different offset means a different window and a different "now". + offset = -60; + expect(queue.getCachedPrograms('1')).toBeNull(); + queue.sync(['1']); + await flushQueue(); + expect(fetchPrograms).toHaveBeenCalledTimes(2); + queue.destroy(); + }); + + it('discards a window fetched for a previous offset and fetches again', async () => { + let offset = 0; + let resolveFirst!: (programs: EpgProgram[]) => void; + const fetchPrograms = jest + .fn, [string]>() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }) + ) + .mockImplementation(async (channelId: string) => [ + buildProgram(channelId, `Later ${channelId}`, 50), + ]); + const onPrograms = jest.fn(); + const queue = new StalkerEpgPreviewQueue({ + fetchPrograms, + onPrograms, + epgOffsetMinutes: () => offset, + }); + + queue.sync(['1']); + await flushQueue(50); + expect(fetchPrograms).toHaveBeenCalledTimes(1); + + // The setting changes while the request is on the wire; the sync it + // triggers finds the channel in flight and skips it. + offset = -60; + queue.sync(['1']); + resolveFirst([buildProgram('1', 'Now 1', -10)]); + await flushQueue(); + + // The stale window is neither reported nor cached; the channel was + // fetched again for the new offset. + expect(onPrograms).toHaveBeenCalledTimes(1); + expect(onPrograms).toHaveBeenCalledWith('1', [ + expect.objectContaining({ title: 'Later 1' }), + ]); + expect(fetchPrograms).toHaveBeenCalledTimes(2); + expect(queue.getCachedPrograms('1')).toHaveLength(1); + queue.destroy(); + }); + it('fetches each synced channel once and reuses the cache afterwards', async () => { const fetchPrograms = jest.fn(async (channelId: string) => [ buildProgram(channelId, `Now ${channelId}`, -10), diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-epg-preview.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-epg-preview.ts index c0ec5de63..921c986cf 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-epg-preview.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-epg-preview.ts @@ -14,7 +14,6 @@ import type { EpgProgram } from '@iptvnator/shared/interfaces'; /** Programmes requested per channel: current + a small safety margin. */ export const EPG_PREVIEW_FETCH_SIZE = 3; - const PREVIEW_CACHE_TTL_MS = 5 * 60 * 1000; const PREVIEW_MAX_CONCURRENCY = 2; const PREVIEW_DELAY_MS = 200; @@ -32,6 +31,12 @@ interface StalkerEpgPreviewQueueHost { fetchPrograms: (channelId: string) => Promise; /** Called for each non-empty result so the host can update its previews. */ onPrograms: (channelId: string, programs: EpgProgram[]) => void; + /** + * Current EPG display offset. Cached windows are tagged with the offset + * they were fetched for and become stale when it changes, since the + * window size and the "now" they answer both depend on it. + */ + epgOffsetMinutes?: () => number; } interface StalkerEpgPreviewQueueOptions { @@ -43,6 +48,7 @@ interface StalkerEpgPreviewQueueOptions { interface PreviewCacheEntry { programs: EpgProgram[]; timestamp: number; + offsetMinutes: number; } export class StalkerEpgPreviewQueue { @@ -70,13 +76,20 @@ export class StalkerEpgPreviewQueue { if (!entry) { return null; } - if (Date.now() - entry.timestamp > PREVIEW_CACHE_TTL_MS) { + if ( + Date.now() - entry.timestamp > PREVIEW_CACHE_TTL_MS || + entry.offsetMinutes !== this.currentOffsetMinutes() + ) { this.cache.delete(channelId); return null; } return entry.programs; } + private currentOffsetMinutes(): number { + return this.host.epgOffsetMinutes?.() ?? 0; + } + /** * Replace the work list with the currently rendered channels that still * need a preview. Later calls supersede earlier ones, so fast scrolling @@ -146,15 +159,30 @@ export class StalkerEpgPreviewQueue { private async fetchOne(channelId: string): Promise { const generation = this.generation; + const offsetMinutes = this.currentOffsetMinutes(); try { const programs = await this.host.fetchPrograms(channelId); if (this.destroyed || generation !== this.generation) { return; } + // The setting changed while the request was on the wire: its + // window was sized for the previous offset, and the sync the + // change triggered skipped this channel because it was in + // flight. Drop the result and fetch again if the row is still + // visible instead of committing a window nobody asked for. + if (offsetMinutes !== this.currentOffsetMinutes()) { + this.inFlight.delete(channelId); + this.requeue(channelId); + return; + } // Empty results are cached too: they mean the portal has no // short EPG for the channel, and refetching on every render // would hammer it for nothing. - this.cache.set(channelId, { programs, timestamp: Date.now() }); + this.cache.set(channelId, { + programs, + timestamp: Date.now(), + offsetMinutes, + }); if (programs.length > 0) { this.host.onPrograms(channelId, programs); } @@ -164,6 +192,16 @@ export class StalkerEpgPreviewQueue { } } } + + private requeue(channelId: string): void { + if (!this.visibleSet.has(channelId) || this.queue.includes(channelId)) { + return; + } + this.queue.push(channelId); + if (!this.processing) { + void this.processQueue(); + } + } } /** diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index 9dfbfb0cb..010b5ef57 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -225,6 +225,7 @@ @if (epgViewMode() === 'list') { { const settingsStore = { openStreamOnDoubleClick: signal(false), resolvedEpgViewMode: signal<'timeline' | 'list'>('timeline'), + resolvedEpgOffsetMinutes: signal(0), }; const originalElectron = window.electron; @@ -892,7 +896,10 @@ describe('StalkerLiveStreamLayoutComponent', () => { await fixture.whenStable(); expect(fetchChannelEpg).toHaveBeenCalledTimes(1); - expect(fetchChannelEpg).toHaveBeenCalledWith('10001'); + expect(fetchChannelEpg).toHaveBeenCalledWith( + '10001', + ACTIVE_EPG_FALLBACK_SIZE + ); expect(component.isLoadingFallbackEpg()).toBe(true); // Category switch while the short-EPG request is still on the wire. @@ -1161,7 +1168,10 @@ describe('StalkerLiveStreamLayoutComponent', () => { await fixture.whenStable(); fixture.detectChanges(); - expect(fetchChannelEpg).toHaveBeenCalledWith('10001'); + expect(fetchChannelEpg).toHaveBeenCalledWith( + '10001', + ACTIVE_EPG_FALLBACK_SIZE + ); expect(component.currentProgram()?.title).toBe('Now 10001'); expect( component.activeEpgPrograms().map((program) => program.title) diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index cf59687c5..3aafb5fc9 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -40,11 +40,13 @@ import { Channel, EpgItem, EpgProgram, + epgProviderClockMs, filterRecordingProgramsOverlap, playlistDisplayLabel, RecordingStartMetadata, RecordingStoppedEvent, ResolvedPortalPlayback, + shortEpgWindowSize, StalkerPortalItem, toRecordingProgramSnapshot, } from '@iptvnator/shared/interfaces'; @@ -77,6 +79,7 @@ import { } from '@iptvnator/portal/shared/util'; import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui'; import { + ACTIVE_EPG_FALLBACK_SIZE, StalkerFavoriteItem, StalkerItvChannel, StalkerStore, @@ -410,7 +413,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { const programs = filterRecordingProgramsOverlap( this.activeEpgPrograms().map(toRecordingProgramSnapshot), event.startedAt, - event.endedAt + event.endedAt, + this.epgOffsetMinutes() ); if (programs.length === 0) { return; @@ -426,6 +430,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { ); /** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */ readonly epgViewMode = this.settingsStore.resolvedEpgViewMode; + readonly epgOffsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes; readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed; readonly liveEpgPanelSummary = computed(() => this.toLiveEpgPanelSummary(this.currentProgram()) @@ -473,15 +478,21 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { private readonly cdr = inject(ChangeDetectorRef); /** Short-EPG fallback for rows the bulk guide cannot answer. */ private readonly epgPreviewQueue = new StalkerEpgPreviewQueue({ + // The window is widened under a negative display offset so it still + // reaches the programme on air; see shortEpgWindowSize. fetchPrograms: async (channelId) => ( await this.stalkerStore.fetchChannelEpg( channelId, - EPG_PREVIEW_FETCH_SIZE + shortEpgWindowSize( + this.epgOffsetMinutes(), + EPG_PREVIEW_FETCH_SIZE + ) ) ).map((item) => this.toProgram(item, channelId)), onPrograms: (channelId, programs) => this.applyFallbackPreviewPrograms(channelId, programs), + epgOffsetMinutes: () => this.epgOffsetMinutes(), }); /** Favorites */ @@ -490,8 +501,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { /** Scroll */ readonly scrollContainer = viewChild('scrollContainer'); private scrollListener: (() => void) | null = null; - private epgPreviewRefreshTimer: ReturnType | null = - null; + private epgPreviewRefreshTimer: ReturnType | null = null; private unsubscribeRemoteChannelChange?: () => void; private unsubscribeRemoteCommand?: () => void; private epgLoadRequestId = 0; @@ -1157,7 +1167,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { this.isLoadingFallbackEpg.set(true); const fallbackItems = await this.stalkerStore.fetchChannelEpg( - item.id + item.id, + shortEpgWindowSize( + this.epgOffsetMinutes(), + ACTIVE_EPG_FALLBACK_SIZE + ) ); if (!this.isCurrentEpgRequest(requestId, normalizedChannelId)) { return; @@ -1284,7 +1298,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { program.stop, program.stopTimestamp ); - const nowMs = Date.now(); + const nowMs = epgProviderClockMs(Date.now(), this.epgOffsetMinutes()); if ( startMs !== null && @@ -1416,7 +1430,10 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { } private findCurrentProgram(programs: EpgProgram[]): EpgProgram | null { - const now = Date.now(); + // Raw programme times vs. now in the provider's EPG clock + // (`epg-display-offset.util.ts`, clock form); reading the setting + // here also re-runs every computed/effect that picks previews. + const now = epgProviderClockMs(Date.now(), this.epgOffsetMinutes()); return ( programs.find((program) => { const start = this.getProgramTimestampMs( diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.epg-races.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.epg-races.spec.ts index cbcf3b653..e7f72ca05 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.epg-races.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.epg-races.spec.ts @@ -147,7 +147,10 @@ describe('StalkerLiveStreamLayoutComponent EPG fallback races', () => { }, { provide: SettingsStore, - useValue: { openStreamOnDoubleClick: signal(false) }, + useValue: { + openStreamOnDoubleClick: signal(false), + resolvedEpgOffsetMinutes: signal(0), + }, }, { provide: PORTAL_PLAYER, diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.playback-session-key.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.playback-session-key.spec.ts index 75a22b730..408ac3f75 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.playback-session-key.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.playback-session-key.spec.ts @@ -120,7 +120,10 @@ describe('StalkerLiveStreamLayoutComponent playback session ownership', () => { }, { provide: SettingsStore, - useValue: { openStreamOnDoubleClick: signal(false) }, + useValue: { + openStreamOnDoubleClick: signal(false), + resolvedEpgOffsetMinutes: signal(0), + }, }, { provide: PORTAL_PLAYER, diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.remote-status.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.remote-status.spec.ts index 1a24f0a70..c2b2d84d0 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.remote-status.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.remote-status.spec.ts @@ -150,6 +150,7 @@ describe('StalkerLiveStreamLayoutComponent remote status', () => { useValue: { openStreamOnDoubleClick: signal(false), resolvedEpgViewMode: signal('timeline'), + resolvedEpgOffsetMinutes: signal(0), }, }, { diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.spec-stubs.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.spec-stubs.ts index d0e44cf2e..420ac1b62 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.spec-stubs.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.spec-stubs.ts @@ -87,6 +87,7 @@ export class StubEpgTimelineComponent { readonly collapsed = input(false); readonly summary = input<{ title?: string } | null>(null); readonly summaryLabelKey = input(''); + readonly offsetMinutes = input(0); readonly selectedDateChange = output(); readonly programActivated = output(); readonly returnToLive = output(); diff --git a/libs/portal/xtream/data-access/src/lib/services/epg-queue-invalidation.spec.ts b/libs/portal/xtream/data-access/src/lib/services/epg-queue-invalidation.spec.ts index 1318b7729..2a486ffcd 100644 --- a/libs/portal/xtream/data-access/src/lib/services/epg-queue-invalidation.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/services/epg-queue-invalidation.spec.ts @@ -49,6 +49,7 @@ describe('EpgQueueService invalidation', () => { provide: SettingsStore, useValue: { preferUploadedEpgOverXtream: jest.fn(() => false), + resolvedEpgOffsetMinutes: jest.fn(() => 0), }, }, ], diff --git a/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.spec.ts b/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.spec.ts index 3aa315f41..4a06b59e8 100644 --- a/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.spec.ts @@ -7,12 +7,15 @@ import type { EpgItem } from '@iptvnator/shared/interfaces'; describe('EpgQueueService', () => { let service: EpgQueueService; - let xtreamApi: { getShortEpg: jest.Mock }; + let xtreamApi: { getShortEpg: jest.Mock; getFullEpg: jest.Mock }; let fallback: { getProgramsForChannel: jest.Mock; getCurrentProgramsBatch: jest.Mock; }; - let settings: { preferUploadedEpgOverXtream: jest.Mock }; + let settings: { + preferUploadedEpgOverXtream: jest.Mock; + resolvedEpgOffsetMinutes: jest.Mock; + }; const credentials = { serverUrl: 'https://xtream.example.com', @@ -33,12 +36,18 @@ describe('EpgQueueService', () => { beforeEach(() => { jest.useFakeTimers(); - xtreamApi = { getShortEpg: jest.fn().mockResolvedValue([]) }; + xtreamApi = { + getShortEpg: jest.fn().mockResolvedValue([]), + getFullEpg: jest.fn().mockResolvedValue([]), + }; fallback = { getProgramsForChannel: jest.fn().mockResolvedValue([]), getCurrentProgramsBatch: jest.fn().mockResolvedValue({}), }; - settings = { preferUploadedEpgOverXtream: jest.fn(() => false) }; + settings = { + preferUploadedEpgOverXtream: jest.fn(() => false), + resolvedEpgOffsetMinutes: jest.fn(() => 0), + }; TestBed.configureTestingModule({ providers: [ @@ -66,6 +75,165 @@ describe('EpgQueueService', () => { return item; } + function listing( + title: string, + startOffsetMin: number, + durationMin: number + ): EpgItem { + const nowSeconds = Math.floor(Date.now() / 1000); + const start = nowSeconds + startOffsetMin * 60; + const stop = start + durationMin * 60; + return { + id: title, + epg_id: title, + title, + lang: 'en', + start: new Date(start * 1000).toISOString(), + end: new Date(stop * 1000).toISOString(), + stop: new Date(stop * 1000).toISOString(), + description: '', + channel_id: 'channel-42', + start_timestamp: String(start), + stop_timestamp: String(stop), + }; + } + + it('cuts the preview window out of the full guide at the provider clock when a display offset is set', async () => { + settings.resolvedEpgOffsetMinutes.mockReturnValue(60); + xtreamApi.getFullEpg.mockResolvedValue([ + listing('Next', 15, 30), + listing('Provider now', -15, 30), + listing('Really on air', -75, 60), + listing('Long gone', -180, 60), + listing('Later', 45, 30), + ]); + + await priv().fetchEpg(credentials, 42); + + // +60: the programme really on air is the one the provider filed an + // hour ago, which the short EPG (starting at provider-now) never has. + expect(xtreamApi.getShortEpg).not.toHaveBeenCalled(); + expect(service.getCached(42)?.map((item) => item.title)).toEqual([ + 'Really on air', + 'Provider now', + 'Next', + ]); + }); + + it('keeps using the cheap short EPG without an offset and drops the cache when the offset changes', async () => { + xtreamApi.getShortEpg.mockResolvedValue([ + listing('Provider now', -15, 30), + ]); + + await priv().fetchEpg(credentials, 42); + + expect(xtreamApi.getFullEpg).not.toHaveBeenCalled(); + expect(service.getCached(42)).toHaveLength(1); + + settings.resolvedEpgOffsetMinutes.mockReturnValue(30); + + // A different offset is a different window; the cached one is stale. + expect(service.getCached(42)).toBeNull(); + }); + + it('discards a preview window fetched for a previous offset and fetches again', async () => { + let resolveShort!: (items: EpgItem[]) => void; + xtreamApi.getShortEpg.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveShort = resolve; + }) + ); + xtreamApi.getFullEpg.mockResolvedValue([ + listing('Provider now', -15, 30), + listing('Really on air', -75, 60), + ]); + const emitted: string[][] = []; + const sub = service.epgResult$.subscribe(({ items }) => + emitted.push(items.map((item) => item.title)) + ); + const settle = async () => { + for (let i = 0; i < 10; i++) await Promise.resolve(); + }; + + await service.enqueue([{ streamId: 42 }], new Set([42]), credentials); + expect(xtreamApi.getShortEpg).toHaveBeenCalledTimes(1); + + // The setting changes while the short-EPG request is on the wire; + // the reload it triggers finds the stream in flight and skips it. + settings.resolvedEpgOffsetMinutes.mockReturnValue(60); + await service.enqueue([{ streamId: 42 }], new Set([42]), credentials); + resolveShort([listing('Provider now', -15, 30)]); + await settle(); + jest.advanceTimersByTime(201); + await settle(); + + // The stale window is neither committed nor emitted; the stream is + // fetched again from the full guide at the new provider clock. + expect(emitted).toEqual([['Really on air', 'Provider now']]); + expect(xtreamApi.getShortEpg).toHaveBeenCalledTimes(1); + expect(xtreamApi.getFullEpg).toHaveBeenCalledTimes(1); + expect(service.getCached(42)?.map((item) => item.title)).toEqual([ + 'Really on air', + 'Provider now', + ]); + sub.unsubscribe(); + }); + + it('retires a request that fails after the offset changed and fetches again', async () => { + let rejectShort!: (error: Error) => void; + xtreamApi.getShortEpg.mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectShort = reject; + }) + ); + xtreamApi.getFullEpg.mockResolvedValue([ + listing('Provider now', -15, 30), + listing('Really on air', -75, 60), + ]); + const settle = async () => { + for (let i = 0; i < 10; i++) await Promise.resolve(); + }; + + await service.enqueue([{ streamId: 42 }], new Set([42]), credentials); + settings.resolvedEpgOffsetMinutes.mockReturnValue(60); + await service.enqueue([{ streamId: 42 }], new Set([42]), credentials); + rejectShort(new Error('provider down')); + await settle(); + jest.advanceTimersByTime(201); + await settle(); + + // The failure belonged to the retired request: no cooldown, and the + // stream was fetched again at the new provider clock. + expect(xtreamApi.getFullEpg).toHaveBeenCalledTimes(1); + expect(service.getCached(42)?.map((item) => item.title)).toEqual([ + 'Really on air', + 'Provider now', + ]); + }); + + it('drops a failure cooldown together with the cache when the offset changes', async () => { + xtreamApi.getShortEpg.mockRejectedValueOnce(new Error('provider down')); + xtreamApi.getFullEpg.mockResolvedValue([ + listing('Really on air', -75, 60), + ]); + + await priv().fetchEpg(credentials, 42); + expect(priv().shouldFetch(42)).toBe(false); + + // The cooldown answered "no short EPG at the provider's now"; a new + // offset asks a different question and must fetch again at once. + settings.resolvedEpgOffsetMinutes.mockReturnValue(60); + expect(priv().shouldFetch(42)).toBe(true); + + await priv().fetchEpg(credentials, 42); + expect(xtreamApi.getFullEpg).toHaveBeenCalledTimes(1); + expect(service.getCached(42)?.map((item) => item.title)).toEqual([ + 'Really on air', + ]); + }); + it('caches empty EPG responses and does not immediately refetch them', async () => { xtreamApi.getShortEpg.mockResolvedValue([]); @@ -240,12 +408,9 @@ describe('EpgQueueService', () => { xtreamApi.getShortEpg.mockResolvedValue([]); await service.enqueue([1, 2], new Set([1, 2]), credentials); - expect(xtreamApi.getShortEpg).toHaveBeenCalledWith( - credentials, - 1, - 3, - { suppressErrorLog: true } - ); + expect(xtreamApi.getShortEpg).toHaveBeenCalledWith(credentials, 1, 3, { + suppressErrorLog: true, + }); const latestEnqueue = service.enqueue( [{ streamId: 3, epgChannelId: 'three.epg' }], @@ -279,9 +444,8 @@ describe('EpgQueueService', () => { visible.delete(2); // Internal visible set must still contain 1. - const internal = ( - service as unknown as { visibleSet: Set } - ).visibleSet; + const internal = (service as unknown as { visibleSet: Set }) + .visibleSet; expect(internal.has(1)).toBe(true); expect(internal.has(2)).toBe(true); expect(internal.has(3)).toBe(true); @@ -299,7 +463,9 @@ describe('EpgQueueService', () => { new Set([100]), credentials ); - expect(priv().xmltvPreviewByStreamId.get(100)?.title).toBe('Tagesschau'); + expect(priv().xmltvPreviewByStreamId.get(100)?.title).toBe( + 'Tagesschau' + ); // Second enqueue: same stream 100 but no longer has an epgChannelId. // Batch isn't called (no ids to fetch); preview must be cleared. diff --git a/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.ts b/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.ts index 3e3b71e92..75a48d856 100644 --- a/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/epg-queue.service.ts @@ -3,6 +3,7 @@ import { Subject } from 'rxjs'; import { buildXtreamEpgMappingKey, EpgItem, + windowEpgItemsAtProviderClock, } from '@iptvnator/shared/interfaces'; import { SettingsStore } from '@iptvnator/services'; import { XtreamApiService, XtreamCredentials } from './xtream-api.service'; @@ -63,6 +64,8 @@ export class EpgQueueService implements OnDestroy { private processing = false; private enqueueGeneration = 0; private readonly failureTimestamps = new Map(); + /** Display offset every per-stream memory below was recorded under. */ + private stateOffsetMinutes = this.epgOffsetMinutes(); private readonly maxConcurrency = 2; private readonly delayMs = 200; @@ -73,6 +76,7 @@ export class EpgQueueService implements OnDestroy { readonly epgResult$ = new Subject<{ streamId: number; items: EpgItem[] }>(); getCached(streamId: number): EpgItem[] | null { + this.retireStateOfPreviousOffset(); const entry = this.cache.get(streamId); if (!entry) return null; if (Date.now() - entry.timestamp > this.cacheTtlMs) { @@ -82,6 +86,28 @@ export class EpgQueueService implements OnDestroy { return entry.data; } + private epgOffsetMinutes(): number { + return this.settingsStore.resolvedEpgOffsetMinutes(); + } + + /** + * Every per-stream memory here — cut windows, cached empty results and + * failure cooldowns — answers "what is on at the provider clock", so a + * changed display offset retires all of it at once instead of letting + * one map or another keep steering the reload. Run on every entry point + * (`enqueue`, `getCached`, `fetchEpg`); in-flight requests are retired by + * `fetchEpg` itself when they land. + */ + private retireStateOfPreviousOffset(): void { + const current = this.epgOffsetMinutes(); + if (current === this.stateOffsetMinutes) { + return; + } + this.stateOffsetMinutes = current; + this.cache.clear(); + this.failureTimestamps.clear(); + } + /** * Drop every cached artifact for a stream so the next enqueue refetches * it. Used when a manual EPG mapping for the stream changes, since the @@ -138,6 +164,7 @@ export class EpgQueueService implements OnDestroy { visibleIds: Set, credentials: XtreamCredentials ): Promise { + this.retireStateOfPreviousOffset(); const generation = ++this.enqueueGeneration; const normalized: EpgQueueEntry[] = streams.map((entry) => @@ -333,35 +360,51 @@ export class EpgQueueService implements OnDestroy { const startEpoch = this.invalidationEpoch.get(streamId) ?? 0; const isStale = (): boolean => (this.invalidationEpoch.get(streamId) ?? 0) !== startEpoch; + // Captured before the request so the result can be told apart from + // the offset current when it lands. + this.retireStateOfPreviousOffset(); + const offsetMinutes = this.epgOffsetMinutes(); + const outcome = await this.requestPreviewWindow( + credentials, + streamId, + offsetMinutes + ); try { - const apiItems = await this.apiService.getShortEpg( - credentials, - streamId, - this.previewLimit, - { suppressErrorLog: true } - ); - // A mapping change during the request invalidated this result. if (isStale()) { return; } - if (apiItems.length > 0) { - this.recordSuccess(streamId, apiItems); + // The setting changed while the request was on the wire, so this + // window — or its failure — belongs to the previous provider + // clock. The reload the change triggered skipped the stream + // because it was in flight, so retire the request whatever its + // outcome and fetch again if the row is still visible. + if (offsetMinutes !== this.epgOffsetMinutes()) { + this.supersedeForOffsetChange( + credentials, + streamId, + startEpoch + ); + return; + } + + if ('error' in outcome) { + this.failureTimestamps.set(streamId, Date.now()); + this.logger.error( + `Failed to load EPG for stream ${streamId}`, + outcome.error + ); + return; + } + + if (outcome.items.length > 0) { + this.recordSuccess(streamId, outcome.items); return; } const xmltv = this.xmltvPreviewByStreamId.get(streamId); this.recordSuccess(streamId, xmltv ? [xmltv] : []); - } catch (error) { - if (isStale()) { - return; - } - this.failureTimestamps.set(streamId, Date.now()); - this.logger.error( - `Failed to load EPG for stream ${streamId}`, - error - ); } finally { // Only release the in-flight marker if this request still owns it. // When invalidate() cleared it mid-flight, a later re-enqueue may @@ -374,6 +417,67 @@ export class EpgQueueService implements OnDestroy { } } + /** + * The provider round-trip for one stream, settled into a value so the + * caller decides once — for success and failure alike — whether the + * result is still wanted. Without an offset the cheap short EPG is + * enough; with one, the short EPG cannot reach the programme on air + * (it starts at the provider's own "now"), so the same window is cut + * from the full guide at the provider clock + * (`windowEpgItemsAtProviderClock`). + */ + private async requestPreviewWindow( + credentials: XtreamCredentials, + streamId: number, + offsetMinutes: number + ): Promise<{ items: EpgItem[] } | { error: unknown }> { + try { + if (offsetMinutes === 0) { + return { + items: await this.apiService.getShortEpg( + credentials, + streamId, + this.previewLimit, + { suppressErrorLog: true } + ), + }; + } + return { + items: windowEpgItemsAtProviderClock( + await this.apiService.getFullEpg(credentials, streamId, { + suppressErrorLog: true, + }), + offsetMinutes, + this.previewLimit + ), + }; + } catch (error) { + return { error }; + } + } + + /** + * Retire the in-flight request of `streamId` whose window predates an + * offset change and queue a fresh fetch. Bumping the epoch first makes the + * old request's `finally` leave the marker alone, so the replacement it + * starts here cannot lose its own in-flight marker. + */ + private supersedeForOffsetChange( + credentials: XtreamCredentials, + streamId: number, + epoch: number + ): void { + this.invalidationEpoch.set(streamId, epoch + 1); + this.inFlight.delete(streamId); + if (!this.visibleSet.has(streamId) || this.queue.includes(streamId)) { + return; + } + this.queue.push(streamId); + if (!this.processing) { + this.processQueue(credentials); + } + } + private recordSuccess(streamId: number, items: EpgItem[]): void { const previous = this.cache.get(streamId)?.data; this.cache.set(streamId, { data: items, timestamp: Date.now() }); diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-xmltv-fallback.service.spec.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-xmltv-fallback.service.spec.ts index a0953c1f8..f5dcef41c 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-xmltv-fallback.service.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-xmltv-fallback.service.spec.ts @@ -1,4 +1,5 @@ import { TestBed } from '@angular/core/testing'; +import { SettingsStore } from '@iptvnator/services'; import { EpgProgram } from '@iptvnator/shared/interfaces'; import { XtreamXmltvFallbackService } from './xtream-xmltv-fallback.service'; @@ -16,14 +17,16 @@ describe('XtreamXmltvFallbackService', () => { getChannelPrograms: jest.Mock, [string]>; getCurrentProgramsBatch: jest.Mock< Promise>, - [string[]] + [string[], { nowMs?: number }?] >; }; let bridge: Bridge; + let epgOffsetMinutes = 0; const originalElectron = (window as { electron?: unknown }).electron; beforeEach(() => { + epgOffsetMinutes = 0; bridge = { getChannelPrograms: jest.fn(), getCurrentProgramsBatch: jest.fn(), @@ -42,7 +45,15 @@ describe('XtreamXmltvFallbackService', () => { function makeService(): XtreamXmltvFallbackService { TestBed.resetTestingModule(); TestBed.configureTestingModule({ - providers: [XtreamXmltvFallbackService], + providers: [ + XtreamXmltvFallbackService, + { + provide: SettingsStore, + useValue: { + resolvedEpgOffsetMinutes: () => epgOffsetMinutes, + }, + }, + ], }); return TestBed.inject(XtreamXmltvFallbackService); } @@ -134,17 +145,41 @@ describe('XtreamXmltvFallbackService', () => { ' ', ]); - expect(bridge.getCurrentProgramsBatch).toHaveBeenCalledWith(['rtl.de']); + expect(bridge.getCurrentProgramsBatch).toHaveBeenCalledWith( + ['rtl.de'], + { + nowMs: expect.any(Number), + } + ); expect(Object.keys(result)).toEqual(['rtl.de']); }); + it('asks the bridge for programmes airing at the provider clock under a display offset', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-05-23T10:30:00.000Z')); + try { + epgOffsetMinutes = 60; + const service = makeService(); + bridge.getCurrentProgramsBatch.mockResolvedValue({}); + + await service.getCurrentProgramsBatch(['rtl.de']); + + expect(bridge.getCurrentProgramsBatch).toHaveBeenCalledWith( + ['rtl.de'], + { nowMs: Date.parse('2026-05-23T09:30:00.000Z') } + ); + } finally { + jest.useRealTimers(); + } + }); + it('returns [] when the bridge throws', async () => { const service = makeService(); bridge.getChannelPrograms.mockRejectedValue(new Error('db down')); - await expect( - service.getProgramsForChannel('rtl.de') - ).resolves.toEqual([]); + await expect(service.getProgramsForChannel('rtl.de')).resolves.toEqual( + [] + ); }); it('uses getChannelPrograms even when getCurrentProgramsBatch is missing', async () => { diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-xmltv-fallback.service.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-xmltv-fallback.service.ts index 1da388e88..a527d0335 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-xmltv-fallback.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-xmltv-fallback.service.ts @@ -1,17 +1,24 @@ -import { Injectable } from '@angular/core'; -import { EpgItem, EpgProgram } from '@iptvnator/shared/interfaces'; +import { inject, Injectable } from '@angular/core'; +import { + EpgItem, + EpgProgram, + epgProviderClockMs, +} from '@iptvnator/shared/interfaces'; import { createLogger } from '@iptvnator/portal/shared/util'; +import { SettingsStore } from '@iptvnator/services'; type ElectronEpgBridge = { getChannelPrograms?: (channelId: string) => Promise; getCurrentProgramsBatch?: ( - channelIds: string[] + channelIds: string[], + options?: { nowMs?: number } ) => Promise>; }; @Injectable({ providedIn: 'root' }) export class XtreamXmltvFallbackService { private readonly logger = createLogger('XtreamXmltvFallback'); + private readonly settingsStore = inject(SettingsStore); /** * `DataService.isElectron` is intentionally not consulted here: it @@ -23,8 +30,9 @@ export class XtreamXmltvFallbackService { */ private get bridge(): ElectronEpgBridge | null { if (typeof window === 'undefined') return null; - const candidate = (window as unknown as { electron?: ElectronEpgBridge }) - .electron; + const candidate = ( + window as unknown as { electron?: ElectronEpgBridge } + ).electron; return candidate ?? null; } @@ -46,10 +54,7 @@ export class XtreamXmltvFallbackService { const programs = await fn.call(this.bridge, id); return (programs ?? []).map((p) => mapEpgProgramToEpgItem(p, id)); } catch (error) { - this.logger.error( - `Failed to load XMLTV programs for ${id}`, - error - ); + this.logger.error(`Failed to load XMLTV programs for ${id}`, error); return []; } } @@ -70,7 +75,14 @@ export class XtreamXmltvFallbackService { if (ids.length === 0) return {}; try { - const rows = await fn.call(this.bridge, ids); + // "Now" in the provider's EPG clock (`epg-display-offset.util.ts`, + // clock form), like every other current-programme lookup. + const rows = await fn.call(this.bridge, ids, { + nowMs: epgProviderClockMs( + Date.now(), + this.settingsStore.resolvedEpgOffsetMinutes() + ), + }); const out: Record = {}; for (const id of ids) { const row = rows?.[id]; diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.spec.ts index 82c8b31db..9b12bd5f0 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.spec.ts @@ -46,6 +46,7 @@ interface TestStoreSetup { appEnvironment?: 'electron' | 'pwa'; selectedItem: { xtream_id: number; epg_channel_id?: string | null }; preferUploaded?: boolean; + epgOffsetMinutes?: number; } function configureStore(setup: TestStoreSetup) { @@ -72,6 +73,7 @@ function configureStore(setup: TestStoreSetup) { preferUploadedEpgOverXtream: jest.fn( () => setup.preferUploaded ?? false ), + resolvedEpgOffsetMinutes: jest.fn(() => setup.epgOffsetMinutes ?? 0), }; TestBed.configureTestingModule({ @@ -101,6 +103,25 @@ function configureStore(setup: TestStoreSetup) { describe('withEpg', () => { afterEach(() => TestBed.resetTestingModule()); + it('selects the current program in the provider clock when a display offset is set', async () => { + const { store, xtreamApiService } = configureStore({ + selectedItem: { xtream_id: 101 }, + epgOffsetMinutes: 60, + }); + const now = Math.floor(Date.now() / 1000); + // The guide runs an hour ahead of the real schedule, so the show the + // provider files under "an hour ago" is the one actually on air. + const programs = [ + buildProgram('Really On Air', now - 5400, now - 1800), + buildProgram('Provider Says Now', now - 1800, now + 1800), + ]; + xtreamApiService.getFullEpg.mockResolvedValue(programs); + + await store.loadEpg(); + + expect(store.currentEpgItem()).toEqual(programs[0]); + }); + it('loads the full electron epg and derives the current program from timestamps', async () => { const { store, xtreamApiService, fallbackService } = configureStore({ selectedItem: { xtream_id: 101 }, diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts index abf727b57..7d3c44e44 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts @@ -6,7 +6,11 @@ import { withMethods, withState, } from '@ngrx/signals'; -import { buildXtreamEpgMappingKey, EpgItem } from '@iptvnator/shared/interfaces'; +import { + buildXtreamEpgMappingKey, + EpgItem, + epgProviderClockMs, +} from '@iptvnator/shared/interfaces'; import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; import { XtreamApiService, @@ -78,11 +82,22 @@ export function withEpg() { return signalStoreFeature( withState(initialEpgState), - withComputed((store) => ({ - currentEpgItem: computed(() => - findCurrentEpgItem(store.epgItems(), Date.now()) - ), - })), + withComputed((store) => { + const settingsStore = inject(SettingsStore); + return { + currentEpgItem: computed(() => + findCurrentEpgItem( + store.epgItems(), + // Raw provider times vs. now in the provider's clock + // (`epg-display-offset.util.ts`, clock form). + epgProviderClockMs( + Date.now(), + settingsStore.resolvedEpgOffsetMinutes() + ) + ) + ), + }; + }), withMethods((store) => { const apiService = inject(XtreamApiService); diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index a59066935..ea57afdcd 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html @@ -169,6 +169,7 @@ @if (epgViewMode() === 'list') { (null); readonly summaryLabelKey = input(''); + readonly offsetMinutes = input(0); readonly programActivated = output(); readonly returnToLive = output(); readonly selectedDateChange = output(); @@ -206,6 +207,7 @@ describe('LiveStreamLayoutComponent', () => { // Reset in beforeEach: the store is module-scoped, so a test failure // before an in-test restore must not leak 'list' into siblings. resolvedEpgViewMode: signal<'timeline' | 'list'>('timeline'), + resolvedEpgOffsetMinutes: signal(0), }; const originalElectron = window.electron; diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index d1740fdd2..ddcfa42a8 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -67,6 +67,7 @@ import { buildXtreamEpgMappingKey, EpgItem, EpgProgram, + epgProviderClockMs, filterRecordingProgramsOverlap, playlistDisplayLabel, RecordingStartMetadata, @@ -194,12 +195,14 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { // previous show. const program = findCurrentEpgItem( this.epgItems(), - this.currentTimeMs() + epgProviderClockMs(this.currentTimeMs(), this.epgOffsetMinutes()) ); return { channelName: item.title?.trim() || item.name?.trim() || 'Live TV', channelLogoUrl: - item.poster_url?.trim() || item.stream_icon?.trim() || undefined, + item.poster_url?.trim() || + item.stream_icon?.trim() || + undefined, playlistId: playlist?.id, playlistName: playlistDisplayLabel(playlist?.name ?? playlist?.title) || @@ -234,7 +237,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { const programs = filterRecordingProgramsOverlap( this.controlledEpgPrograms().map(toRecordingProgramSnapshot), event.startedAt, - event.endedAt + event.endedAt, + this.epgOffsetMinutes() ); if (programs.length === 0) { return; @@ -281,6 +285,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { ); /** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */ readonly epgViewMode = this.settingsStore.resolvedEpgViewMode; + readonly epgOffsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes; readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed; readonly liveEpgPanelSummary = computed(() => this.toLiveEpgPanelSummary( diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts index 33b6338bb..6afecc309 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts @@ -143,6 +143,7 @@ describe('PortalChannelsListComponent', () => { provide: SettingsStore, useValue: { openStreamOnDoubleClick: signal(false), + resolvedEpgOffsetMinutes: signal(0), }, }, { diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts index 64aa22c35..6724edc5c 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts @@ -29,6 +29,7 @@ import { buildXtreamEpgMappingKey, EpgItem, EpgProgram, + epgProviderClockMs, XtreamCategory, XtreamItem, } from '@iptvnator/shared/interfaces'; @@ -47,7 +48,7 @@ import { EpgQueueService } from '@iptvnator/portal/xtream/data-access'; import { XtreamCredentials } from '@iptvnator/portal/xtream/data-access'; import { FavoritesService } from '@iptvnator/portal/xtream/data-access'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; -import { RuntimeCapabilitiesService } from '@iptvnator/services'; +import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; export interface XtreamChannelListItem { readonly category_id?: string | number; @@ -148,8 +149,27 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { readonly viewport = viewChild(CdkVirtualScrollViewport); private subscriptions = new Subscription(); + private readonly settingsStore = inject(SettingsStore); constructor(private cdr: ChangeDetectorRef) { + // A changed display offset moves "now" in the provider's clock, so + // the visible previews are re-picked from the cached EPG. The first + // run only records the initial value. + let appliedOffsetMinutes: number | null = null; + effect(() => { + const offsetMinutes = + this.settingsStore.resolvedEpgOffsetMinutes(); + if (appliedOffsetMinutes === offsetMinutes) { + return; + } + const isInitial = appliedOffsetMinutes === null; + appliedOffsetMinutes = offsetMinutes; + if (isInitial) { + return; + } + untracked(() => this.repickPreviewsForOffsetChange()); + }); + effect(() => { const selectedItem = this.xtreamStore.selectedItem(); const viewport = this.viewport(); @@ -255,6 +275,24 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { } } + /** Wall-clock now in the provider's EPG clock (`epg-display-offset.util.ts`). */ + private epgClockMs(): number { + return epgProviderClockMs( + Date.now(), + this.settingsStore.resolvedEpgOffsetMinutes() + ); + } + + private repickPreviewsForOffsetChange(): void { + this.epgPrograms.clear(); + this.currentProgramsProgress.clear(); + const visible = this.lastVisibleChannels.length + ? this.lastVisibleChannels + : this.filteredChannels().slice(0, 50); + this.loadEpgForVisibleChannels(visible); + this.cdr.markForCheck(); + } + private loadEpgForVisibleChannels(channels: XtreamChannelListItem[]): void { if (!this.supportsEpg) { return; @@ -309,7 +347,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { } private updateProgramProgress(streamId: number, program: EpgItem) { - const now = Date.now(); + const now = this.epgClockMs(); const start = this.getProgramTimestampMs( program.start, program.start_timestamp @@ -410,7 +448,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { return null; } - const now = Date.now(); + const now = this.epgClockMs(); const normalizedItems = [...items].sort( (a, b) => this.getProgramTimestampMs(a.start, a.start_timestamp) - diff --git a/libs/services/src/lib/settings-store.service.spec.ts b/libs/services/src/lib/settings-store.service.spec.ts index e5f281c2e..d60e72d12 100644 --- a/libs/services/src/lib/settings-store.service.spec.ts +++ b/libs/services/src/lib/settings-store.service.spec.ts @@ -194,6 +194,58 @@ describe('SettingsStore dashboard rail settings', () => { expect(store.getSettings().startupWindowMode).toBe('normal'); }); + it('defaults the EPG display offset to zero when the stored field is missing', async () => { + storedSettings = {}; + const store = injector.get(SettingsStore); + + await store.loadSettings(); + + expect(store.getSettings().epgOffsetMinutes).toBe(0); + expect(store.resolvedEpgOffsetMinutes()).toBe(0); + }); + + it('restores a persisted EPG display offset', async () => { + storedSettings = { epgOffsetMinutes: -90 }; + const store = injector.get(SettingsStore); + + await store.loadSettings(); + + expect(store.getSettings().epgOffsetMinutes).toBe(-90); + expect(store.resolvedEpgOffsetMinutes()).toBe(-90); + }); + + it('clamps a persisted out-of-range EPG display offset', async () => { + storedSettings = { epgOffsetMinutes: 5000 }; + const store = injector.get(SettingsStore); + + await store.loadSettings(); + + expect(store.resolvedEpgOffsetMinutes()).toBe(720); + }); + + it('drops a persisted non-numeric EPG display offset', async () => { + storedSettings = { epgOffsetMinutes: 'soon' as unknown as number }; + const store = injector.get(SettingsStore); + + await store.loadSettings(); + + expect(store.resolvedEpgOffsetMinutes()).toBe(0); + }); + + it('normalizes an updated EPG display offset before persisting it', async () => { + storedSettings = {}; + const store = injector.get(SettingsStore); + await store.loadSettings(); + + await store.updateSettings({ epgOffsetMinutes: 61.7 }); + + expect(store.resolvedEpgOffsetMinutes()).toBe(61); + expect(storage.set).toHaveBeenLastCalledWith( + expect.any(String), + expect.objectContaining({ epgOffsetMinutes: 61 }) + ); + }); + it('defaults ambient player mode to false when the stored field is missing', async () => { storedSettings = {}; const store = injector.get(SettingsStore); diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index 32f791e4a..342015ccd 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -21,6 +21,7 @@ import { StreamFormat, Theme, VideoPlayer, + normalizeEpgOffsetMinutes, normalizeDashboardRailsSettings, normalizeStartupWindowMode, } from '@iptvnator/shared/interfaces'; @@ -58,6 +59,7 @@ const DEFAULT_SETTINGS: Settings = { embeddedMpvAutoReconnect: true, coverSize: 'medium', epgViewMode: 'timeline', + epgOffsetMinutes: 0, dashboardRails: DEFAULT_DASHBOARD_RAILS_SETTINGS, preferUploadedEpgOverXtream: false, trustedPrivateNetworkEpgUrls: [], @@ -134,6 +136,9 @@ export const SettingsStore = signalStore( resolvedEpgViewMode: computed( () => store.epgViewMode?.() ?? 'timeline' ), + resolvedEpgOffsetMinutes: computed(() => + normalizeEpgOffsetMinutes(store.epgOffsetMinutes?.()) + ), })), withMethods((store, storage = inject(StorageMap)) => { let settingsLoadPromise: Promise | undefined; @@ -154,6 +159,10 @@ export const SettingsStore = signalStore( patchState(store, { ...DEFAULT_SETTINGS, ...storedSettings, + epgOffsetMinutes: normalizeEpgOffsetMinutes( + storedSettings.epgOffsetMinutes + ), + // Absent in settings stored before the default // flip means "never chose" — those users get the // new default; only an explicit false opts out. @@ -210,6 +219,13 @@ export const SettingsStore = signalStore( ), } : {}), + ...(settings.epgOffsetMinutes !== undefined + ? { + epgOffsetMinutes: normalizeEpgOffsetMinutes( + settings.epgOffsetMinutes + ), + } + : {}), }); // Save the complete settings object, not just the partial update const completeSettings = this.getSettings(); @@ -288,6 +304,9 @@ export const SettingsStore = signalStore( store.coverSize?.() ?? DEFAULT_SETTINGS.coverSize, epgViewMode: store.epgViewMode?.() ?? DEFAULT_SETTINGS.epgViewMode, + epgOffsetMinutes: normalizeEpgOffsetMinutes( + store.epgOffsetMinutes?.() + ), dashboardRails: normalizeDashboardRailsSettings( store.dashboardRails?.() ), diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index f34091e0a..7be304d71 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -10,6 +10,7 @@ export * from './lib/electron-api.interface'; export * from './lib/epg-channel-metadata.model'; export * from './lib/epg-channel-with-programs.interface'; export * from './lib/epg-channel.model'; +export * from './lib/epg-display-offset.util'; export * from './lib/epg-item.interface'; export * from './lib/epg-mapping-key.util'; export * from './lib/epg-program.model'; diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 78e0c6d7b..7fe39e27c 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -362,6 +362,19 @@ export interface ElectronBridgeEpgLookupOptions { sourceUrls?: string[]; } +/** + * Options for the batched "currently airing" lookup. `nowMs` is the instant + * the lookup is evaluated at: the renderer passes its wall clock shifted into + * the provider's EPG clock (`epgProviderClockMs`, see + * `epg-display-offset.util.ts`) so the row the backend picks is the one the + * renderer will also present as "now". Absent, the main process uses its own + * clock. + */ +export interface ElectronBridgeCurrentProgramsOptions + extends ElectronBridgeEpgLookupOptions { + nowMs?: number; +} + export interface ElectronBridgeEpgProgressStats { totalChannels: number; totalPrograms: number; @@ -778,7 +791,7 @@ export interface ElectronBridgeApi { ) => Promise; getCurrentProgramsBatch: ( channelIds: string[], - options?: ElectronBridgeEpgLookupOptions + options?: ElectronBridgeCurrentProgramsOptions ) => Promise>; getEpgChannelMetadata: ( channelIds: string[], diff --git a/libs/shared/interfaces/src/lib/epg-display-offset.util.spec.ts b/libs/shared/interfaces/src/lib/epg-display-offset.util.spec.ts new file mode 100644 index 000000000..7b4455a80 --- /dev/null +++ b/libs/shared/interfaces/src/lib/epg-display-offset.util.spec.ts @@ -0,0 +1,151 @@ +import type { EpgItem } from './epg-item.interface'; +import { + EPG_OFFSET_MAX_MINUTES, + EPG_OFFSET_MIN_MINUTES, + epgDisplayTimeMs, + epgOffsetMs, + epgProviderClockMs, + normalizeEpgOffsetMinutes, + shortEpgWindowSize, + windowEpgItemsAtProviderClock, +} from './epg-display-offset.util'; + +const NOW = Date.parse('2026-06-28T12:00:00.000Z'); +const HOUR_MS = 60 * 60_000; + +describe('epg-display-offset.util', () => { + describe('normalizeEpgOffsetMinutes', () => { + it('keeps whole minutes inside the supported range', () => { + expect(normalizeEpgOffsetMinutes(0)).toBe(0); + expect(normalizeEpgOffsetMinutes(-60)).toBe(-60); + expect(normalizeEpgOffsetMinutes(EPG_OFFSET_MAX_MINUTES)).toBe(720); + expect(normalizeEpgOffsetMinutes(EPG_OFFSET_MIN_MINUTES)).toBe( + -720 + ); + }); + + it('clamps out-of-range values and truncates fractions', () => { + expect(normalizeEpgOffsetMinutes(1000)).toBe(720); + expect(normalizeEpgOffsetMinutes(-1000)).toBe(-720); + expect(normalizeEpgOffsetMinutes(15.9)).toBe(15); + expect(normalizeEpgOffsetMinutes(-15.9)).toBe(-15); + }); + + it('accepts numeric strings from persisted or form values', () => { + expect(normalizeEpgOffsetMinutes('90')).toBe(90); + expect(normalizeEpgOffsetMinutes(' -30 ')).toBe(-30); + }); + + it('collapses anything that is not a finite number to zero', () => { + expect(normalizeEpgOffsetMinutes(undefined)).toBe(0); + expect(normalizeEpgOffsetMinutes(null)).toBe(0); + expect(normalizeEpgOffsetMinutes('')).toBe(0); + expect(normalizeEpgOffsetMinutes('abc')).toBe(0); + expect(normalizeEpgOffsetMinutes(Number.NaN)).toBe(0); + expect(normalizeEpgOffsetMinutes(Number.POSITIVE_INFINITY)).toBe(0); + expect(normalizeEpgOffsetMinutes(true)).toBe(0); + expect(normalizeEpgOffsetMinutes({})).toBe(0); + }); + }); + + it('converts the normalized offset to milliseconds', () => { + expect(epgOffsetMs(60)).toBe(HOUR_MS); + expect(epgOffsetMs(5000)).toBe(720 * 60_000); + expect(epgOffsetMs(Number.NaN)).toBe(0); + }); + + it('shifts a raw timestamp into display time and now into the provider clock symmetrically', () => { + const rawStart = NOW - HOUR_MS; + // Display form: the programme moves by +offset ... + expect(epgDisplayTimeMs(rawStart, 60)).toBe(NOW); + // ... clock form: "now" moves by -offset. Both comparisons agree. + expect(epgProviderClockMs(NOW, 60)).toBe(rawStart); + expect(epgDisplayTimeMs(rawStart, 60) <= NOW).toBe( + rawStart <= epgProviderClockMs(NOW, 60) + ); + }); + + it('lets NaN pass through so callers keep their own validity checks', () => { + expect(epgDisplayTimeMs(Number.NaN, 60)).toBeNaN(); + expect(epgProviderClockMs(Number.NaN, 60)).toBeNaN(); + }); + + describe('shortEpgWindowSize', () => { + it('keeps the base window without an offset or with a positive one', () => { + // The provider's past is unreachable through a short EPG, so a + // positive offset cannot be served by a bigger window. + expect(shortEpgWindowSize(0, 3)).toBe(3); + expect(shortEpgWindowSize(120, 3)).toBe(3); + expect(shortEpgWindowSize(0, 10)).toBe(10); + }); + + it('widens the window under a negative offset in 15-minute slots and caps it', () => { + expect(shortEpgWindowSize(-30, 3)).toBe(5); + expect(shortEpgWindowSize(-100, 3)).toBe(10); + expect(shortEpgWindowSize(-60, 10)).toBe(14); + expect(shortEpgWindowSize(-720, 3)).toBe(50); + }); + }); + + describe('windowEpgItemsAtProviderClock', () => { + const nowSeconds = Math.floor(NOW / 1000); + const item = ( + title: string, + startOffsetMin: number, + durationMin: number, + withTimestamps = true + ): EpgItem => { + const start = nowSeconds + startOffsetMin * 60; + const stop = start + durationMin * 60; + return { + id: title, + epg_id: title, + title, + lang: 'en', + description: '', + channel_id: 'ch', + start: new Date(start * 1000).toISOString(), + end: new Date(stop * 1000).toISOString(), + stop: new Date(stop * 1000).toISOString(), + start_timestamp: withTimestamps ? String(start) : '', + stop_timestamp: withTimestamps ? String(stop) : '', + }; + }; + const guide = [ + item('Next', 15, 30), + item('Provider now', -15, 30), + item('Really on air', -75, 60), + item('Long gone', -180, 60), + item('Later', 45, 30), + ]; + + it('mirrors a short-EPG window at the provider clock', () => { + // +60: the guide runs an hour ahead, so the window starts at the + // programme the provider filed as finished 15 minutes ago. + expect( + windowEpgItemsAtProviderClock(guide, 60, 3, NOW).map( + (entry) => entry.title + ) + ).toEqual(['Really on air', 'Provider now', 'Next']); + // Without an offset it is the provider's own window. + expect( + windowEpgItemsAtProviderClock(guide, 0, 2, NOW).map( + (entry) => entry.title + ) + ).toEqual(['Provider now', 'Next']); + }); + + it('falls back to the ISO fields when unix timestamps are absent', () => { + const isoOnly = guide.map((entry) => ({ + ...entry, + start_timestamp: '', + stop_timestamp: '', + })); + expect( + windowEpgItemsAtProviderClock(isoOnly, -30, 2, NOW).map( + (entry) => entry.title + ) + ).toEqual(['Next', 'Later']); + }); + }); +}); diff --git a/libs/shared/interfaces/src/lib/epg-display-offset.util.ts b/libs/shared/interfaces/src/lib/epg-display-offset.util.ts new file mode 100644 index 000000000..f4834b084 --- /dev/null +++ b/libs/shared/interfaces/src/lib/epg-display-offset.util.ts @@ -0,0 +1,140 @@ +/** + * Global EPG display-time correction (`Settings.epgOffsetMinutes`). + * + * Some XMLTV and portal feeds label programme times with the wrong timezone. + * The offset corrects that on the DISPLAY side only: parsed XMLTV values, + * SQLite rows, catch-up URLs and recording snapshots keep the provider's own + * times, so changing the value never requires a guide refresh. + * + * The correction has exactly two equivalent forms, and every consumer must + * apply ONE of them per comparison — never both, or the shift doubles: + * + * - **Display form** — `epgDisplayTimeMs(rawMs, offset)`: shift a + * programme's timestamp by `+offset`, then compare it with wall-clock now + * or format it for the user. Used where programmes are rendered (timeline + * geometry, list rows, clock labels). + * - **Clock form** — `epgProviderClockMs(nowMs, offset)`: express wall-clock + * now in the provider's uncorrected clock (`now - offset`) and compare it + * with the raw programme times. Used where a "currently airing" decision is + * made over raw data that is handed on unchanged (the backend SQL lookup, + * store selectors, progress bars, recording windows). + */ + +import type { EpgItem } from './epg-item.interface'; + +export const EPG_OFFSET_MIN_MINUTES = -720; +export const EPG_OFFSET_MAX_MINUTES = 720; + +const MINUTE_MS = 60_000; + +/** + * Clamps a persisted or user-entered offset to whole minutes within the + * supported range. Anything that is not a finite number (absent, `null`, + * a blank string, a boolean) collapses to `0`. + */ +export function normalizeEpgOffsetMinutes(value: unknown): number { + const offset = + typeof value === 'number' + ? value + : typeof value === 'string' && value.trim() !== '' + ? Number(value) + : Number.NaN; + if (!Number.isFinite(offset)) { + return 0; + } + return Math.min( + EPG_OFFSET_MAX_MINUTES, + Math.max(EPG_OFFSET_MIN_MINUTES, Math.trunc(offset)) + ); +} + +/** The normalized offset in milliseconds. */ +export function epgOffsetMs(offsetMinutes: number): number { + return normalizeEpgOffsetMinutes(offsetMinutes) * MINUTE_MS; +} + +/** + * Display form: a raw provider timestamp shifted into the user's corrected + * time. `NaN` passes through so callers keep their own validity checks. + */ +export function epgDisplayTimeMs(rawMs: number, offsetMinutes: number): number { + return rawMs + epgOffsetMs(offsetMinutes); +} + +/** + * Clock form: a wall-clock instant expressed in the provider's uncorrected + * clock, for comparing against raw programme times. Equivalent to shifting + * every programme by `+offset` and comparing with `nowMs`. + */ +export function epgProviderClockMs( + nowMs: number, + offsetMinutes: number +): number { + return nowMs - epgOffsetMs(offsetMinutes); +} + +/** Assumed minimum programme length when widening a short-EPG window. */ +export const SHORT_EPG_SLOT_MINUTES = 15; +/** Upper bound for a widened short-EPG window. */ +export const SHORT_EPG_WINDOW_MAX = 50; + +/** + * Number of short-EPG entries to request so the window still reaches the + * programme on air under a display offset. Portal short-EPG endpoints + * (Xtream `get_short_epg`, Stalker `get_short_epg`) always start at the + * provider's own "now": under a negative offset the programme actually on + * air lies `|offset|` further ahead, so the window is widened assuming + * programmes of at least `SHORT_EPG_SLOT_MINUTES`. A positive offset needs + * the provider's past, which no window size can return — callers that have + * a full guide cut it with `windowEpgItemsAtProviderClock` instead. + */ +export function shortEpgWindowSize( + offsetMinutes: number, + baseSize: number +): number { + if (!(offsetMinutes < 0)) { + return baseSize; + } + return Math.min( + SHORT_EPG_WINDOW_MAX, + baseSize + Math.ceil(-offsetMinutes / SHORT_EPG_SLOT_MINUTES) + ); +} + +function epgItemSeconds(item: EpgItem, side: 'start' | 'stop'): number { + const raw = Number( + side === 'start' ? item.start_timestamp : item.stop_timestamp + ); + if (Number.isFinite(raw) && raw > 0) { + return raw; + } + const parsed = Date.parse( + (side === 'start' ? item.start : (item.stop ?? item.end)) ?? '' + ); + return Number.isFinite(parsed) ? Math.floor(parsed / 1000) : Number.NaN; +} + +/** + * Cuts the same window a short-EPG request would return — the first `limit` + * programmes still airing or upcoming — out of a full guide, but at the + * provider clock (`epgProviderClockMs`) rather than at the provider's own + * "now". This is how preview surfaces stay correct under a display offset: + * the full guide reaches into the provider's past, the short EPG does not. + */ +export function windowEpgItemsAtProviderClock( + items: readonly EpgItem[], + offsetMinutes: number, + limit: number, + nowMs = Date.now() +): EpgItem[] { + const nowSeconds = Math.floor( + epgProviderClockMs(nowMs, offsetMinutes) / 1000 + ); + return [...items] + .sort( + (left, right) => + epgItemSeconds(left, 'start') - epgItemSeconds(right, 'start') + ) + .filter((item) => epgItemSeconds(item, 'stop') > nowSeconds) + .slice(0, limit); +} diff --git a/libs/shared/interfaces/src/lib/recording-program-overlap.util.spec.ts b/libs/shared/interfaces/src/lib/recording-program-overlap.util.spec.ts index 47b1546d8..e71a03cd2 100644 --- a/libs/shared/interfaces/src/lib/recording-program-overlap.util.spec.ts +++ b/libs/shared/interfaces/src/lib/recording-program-overlap.util.spec.ts @@ -44,6 +44,28 @@ describe('filterRecordingProgramsOverlap', () => { ).toEqual([]); }); + it('moves the recorded window into the provider clock by the display offset', () => { + // Recorded 22:00–22:30 wall-clock. With the guide running an hour + // ahead (+60), that window is 21:00–21:30 in the provider's clock and + // covers "News" instead of "Weather" / "Late show". + expect( + filterRecordingProgramsOverlap( + programs, + '2026-08-15T22:00:00Z', + '2026-08-15T22:30:00Z' + ).map((program) => program.title) + ).toEqual(['Weather', 'Late show']); + const shifted = filterRecordingProgramsOverlap( + programs, + '2026-08-15T22:00:00Z', + '2026-08-15T22:30:00Z', + 60 + ); + expect(shifted.map((program) => program.title)).toEqual(['News']); + // The snapshots themselves keep the provider's raw times. + expect(shifted[0].start).toBe('2026-08-15T21:00:00Z'); + }); + it('keeps every overlapping program sorted by start (boundary case)', () => { const result = filterRecordingProgramsOverlap( programs, diff --git a/libs/shared/interfaces/src/lib/recording-program-overlap.util.ts b/libs/shared/interfaces/src/lib/recording-program-overlap.util.ts index f6912800e..b45d8195f 100644 --- a/libs/shared/interfaces/src/lib/recording-program-overlap.util.ts +++ b/libs/shared/interfaces/src/lib/recording-program-overlap.util.ts @@ -1,3 +1,4 @@ +import { epgProviderClockMs } from './epg-display-offset.util'; import type { RecordingProgramSnapshot } from './recording-metadata.interface'; /** @@ -26,17 +27,26 @@ export function toRecordingProgramSnapshot(program: { * handlers, so a recording spanning a program boundary reports every covered * program. Fails closed: an unknown recording start yields an empty list * (the start snapshot then stands). + * + * The recorded window is wall-clock while the snapshots keep the provider's + * raw times, so the window is moved into the provider's EPG clock by the + * display offset (`epg-display-offset.util.ts`, clock form) before the + * overlap test; the snapshots themselves stay unshifted. */ export function filterRecordingProgramsOverlap( programs: readonly RecordingProgramSnapshot[], startedAt: string | null, - endedAt: string + endedAt: string, + offsetMinutes = 0 ): RecordingProgramSnapshot[] { if (!startedAt) { return []; } - const windowStart = Date.parse(startedAt); - const windowEnd = Date.parse(endedAt); + const windowStart = epgProviderClockMs( + Date.parse(startedAt), + offsetMinutes + ); + const windowEnd = epgProviderClockMs(Date.parse(endedAt), offsetMinutes); if ( !Number.isFinite(windowStart) || !Number.isFinite(windowEnd) || diff --git a/libs/shared/interfaces/src/lib/settings.interface.ts b/libs/shared/interfaces/src/lib/settings.interface.ts index fabe21052..11196dee3 100644 --- a/libs/shared/interfaces/src/lib/settings.interface.ts +++ b/libs/shared/interfaces/src/lib/settings.interface.ts @@ -233,6 +233,14 @@ export interface Settings { coverSize?: CoverSize; /** Live EPG panel layout: horizontal timeline (default) or vertical list */ epgViewMode?: EpgViewMode; + /** + * Display-only correction, in whole minutes, for guides whose provider + * labels programme times with the wrong timezone. Clamped to ±720 by + * `normalizeEpgOffsetMinutes`; the two equivalent ways of applying it are + * documented in `epg-display-offset.util.ts`. Electron only in practice + * (the control is gated behind `supportsEpg`, like `epgViewMode`). + */ + epgOffsetMinutes?: number; /** Per-rail dashboard visibility preferences. Missing keys default on. */ dashboardRails?: DashboardRailsSettings; /** diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts index 1516362ea..59aee6257 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts @@ -17,8 +17,13 @@ import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslatePipe } from '@ngx-translate/core'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; +import { SettingsStore } from '@iptvnator/services'; import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state'; -import { Channel, EpgProgram } from '@iptvnator/shared/interfaces'; +import { + Channel, + EpgProgram, + epgProviderClockMs, +} from '@iptvnator/shared/interfaces'; import { PlaylistChannelSortMode, getPlaylistChannelSortModeLabel, @@ -54,6 +59,7 @@ export type { ChannelEpgMetadata } from '../epg-enrichment.util'; export class AllChannelsViewComponent { private readonly dialog = inject(MatDialog); private readonly epgBridge = inject(EpgRuntimeBridgeService); + private readonly settingsStore = inject(SettingsStore); readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping; readonly contextMenuTrigger = @@ -136,7 +142,15 @@ export class AllChannelsViewComponent { readonly epgMetadataMap = computed(() => { // Read progressTick to create a dependency for the ~30s progress refresh. this.progressTick(); - return buildChannelEpgMetadataMap(this.channelEpgMap()); + // Progress is measured in the provider's EPG clock: the map keeps the + // raw programme rows and the item shifts their times for display. + return buildChannelEpgMetadataMap( + this.channelEpgMap(), + epgProviderClockMs( + Date.now(), + this.settingsStore.resolvedEpgOffsetMinutes() + ) + ); }); /** Resolves the EPG lookup key the side-car map is keyed by. */ diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts index 913695bcb..20af4998f 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts @@ -17,7 +17,11 @@ import { RuntimeCapabilitiesService, SettingsStore, } from '@iptvnator/services'; -import { Channel, PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { + Channel, + EpgProgram, + PlaylistMeta, +} from '@iptvnator/shared/interfaces'; import { ChannelListContainerComponent } from './channel-list-container.component'; function createChannel(id: string, url: string): Channel { @@ -134,6 +138,7 @@ describe('ChannelListContainerComponent', () => { provide: SettingsStore, useValue: { openStreamOnDoubleClick: signal(false), + resolvedEpgOffsetMinutes: signal(0), }, }, { @@ -253,6 +258,44 @@ describe('ChannelListContainerComponent', () => { ); }); + it('drops a superseded EPG refresh that completes after the newer one', () => { + runtimeCapabilities.supportsEpg = true; + storageGet.mockReturnValue( + of({ epgUrl: ['https://global.example.com/guide.xml'] }) + ); + const first = new Subject>(); + const program = (title: string): EpgProgram => ({ + start: '2026-04-11T10:00:00.000Z', + stop: '2026-04-11T11:00:00.000Z', + channel: 'guide-news', + title, + desc: null, + category: null, + }); + epgService.getCurrentProgramsForChannels + .mockReturnValueOnce(first.asObservable()) + .mockReturnValueOnce(of(new Map([['guide-news', program('Fresh')]]))); + + fixture.detectChanges(); + const channels = [ + createChannel('guide-news', 'https://example.com/news.m3u8'), + ]; + fixture.componentInstance.channelList = channels; + // A second fetch (e.g. the display offset changed) supersedes the first. + fixture.componentInstance.channelList = channels; + expect( + fixture.componentInstance.channelEpgMap().get('guide-news')?.title + ).toBe('Fresh'); + + // The stale first fetch lands last and must not overwrite the map. + first.next(new Map([['guide-news', program('Stale')]])); + first.complete(); + + expect( + fixture.componentInstance.channelEpgMap().get('guide-news')?.title + ).toBe('Fresh'); + }); + it('refreshes visible channel EPG when playlist EPG URLs arrive after channels', () => { runtimeCapabilities.supportsEpg = true; storageGet.mockReturnValue(of({ epgUrl: [] })); diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index dbaec930d..a1972da16 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -133,6 +133,8 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { /** Interval for refreshing EPG data */ private epgRefreshInterval?: number; + /** The EPG fetch in flight; a newer fetch cancels it so a stale map never lands. */ + private epgFetchSubscription?: Subscription; /** Global progress tick signal - triggers re-computation of progress percentages */ readonly progressTick = signal(0); @@ -240,6 +242,28 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { this.fetchEpgForChannels(this._channelList); }); }); + private appliedEpgOffsetMinutes: number | null = null; + /** + * A changed display offset moves "now" in the provider's clock, so the + * current-programme map is fetched again (`EpgService` tags its cache + * with the offset, so this is a real refetch rather than a cache hit). + * The first run only records the initial value. + */ + private readonly epgOffsetRefreshEffect = effect(() => { + const offsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes(); + if (this.appliedEpgOffsetMinutes === offsetMinutes) { + return; + } + const isInitial = this.appliedEpgOffsetMinutes === null; + this.appliedEpgOffsetMinutes = offsetMinutes; + if (isInitial || this._channelList.length === 0) { + return; + } + + untracked(() => { + this.fetchEpgForChannels(this._channelList); + }); + }); get channelList(): Channel[] { return this._channelList; @@ -393,6 +417,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { } this.epgAvailabilitySubscription?.unsubscribe(); + this.epgFetchSubscription?.unsubscribe(); this.channelList$.complete(); } @@ -400,6 +425,9 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { * Fetches EPG data for all channels */ private fetchEpgForChannels(channels: Channel[]): void { + // A fetch started for a previous channel list, EPG scope or display + // offset must not install its map after this one: cancel it first. + this.epgFetchSubscription?.unsubscribe(); if (!channels || channels.length === 0) { this.channelEpgMap.set(new Map()); this.channelIconMap.set(new Map()); @@ -416,7 +444,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { const epgLookupOptions = this.getPlaylistEpgLookupOptions(); - forkJoin({ + this.epgFetchSubscription = forkJoin({ epgMap: this.epgService.getCurrentProgramsForChannels( channelIds, epgLookupOptions @@ -442,8 +470,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { } private getPlaylistEpgLookupOptions(): - | { sourceUrls: string[] } - | undefined { + { sourceUrls: string[] } | undefined { const sourceUrls = this.playlistEpgUrls(); return sourceUrls.length > 0 ? { sourceUrls } : undefined; } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html index fa0490aa0..94716b295 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html @@ -53,9 +53,9 @@
{{ - (program.startTimestamp - ? program.startTimestamp * 1000 - : program.start + programDisplayMs( + program.start, + program.startTimestamp ) | date: 'HH:mm' }}
@@ -67,9 +67,9 @@ >
{{ - (program.stopTimestamp - ? program.stopTimestamp * 1000 - : program.stop + programDisplayMs( + program.stop, + program.stopTimestamp ) | date: 'HH:mm' }}
diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts index 628f1bace..a8ad0dddb 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts @@ -256,7 +256,10 @@ describe('ChannelListItemComponent', () => { { provide: MatDialog, useValue: dialog }, { provide: SettingsStore, - useValue: { stripCountryPrefix: signal(true) }, + useValue: { + stripCountryPrefix: signal(true), + resolvedEpgOffsetMinutes: signal(0), + }, }, ], }).compileComponents(); @@ -276,3 +279,61 @@ describe('ChannelListItemComponent', () => { }); }); }); + +describe('ChannelListItemComponent with an EPG display offset', () => { + it('shifts the preview times by the offset without touching the programme', async () => { + await TestBed.configureTestingModule({ + imports: [ + ChannelListItemComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + { provide: MatDialog, useValue: { open: jest.fn() } }, + { + provide: SettingsStore, + useValue: { + stripCountryPrefix: signal(false), + resolvedEpgOffsetMinutes: signal(90), + }, + }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(ChannelListItemComponent); + const startTimestamp = Math.floor( + Date.parse('2026-04-05T05:30:00.000Z') / 1000 + ); + const stopTimestamp = Math.floor( + Date.parse('2026-04-05T06:00:00.000Z') / 1000 + ); + const program: EpgProgram = { + start: '2026-04-05T05:30:00.000Z', + stop: '2026-04-05T06:00:00.000Z', + channel: 'channel-1', + title: 'Current Show', + desc: null, + category: null, + startTimestamp, + stopTimestamp, + }; + + fixture.componentRef.setInput('name', 'Cartoon Network'); + fixture.componentRef.setInput('epgProgram', program); + fixture.componentRef.setInput('showProgramInfoButton', false); + fixture.detectChanges(); + + const times = Array.from( + fixture.nativeElement.querySelectorAll('.epg-time'), + (element: Element) => element.textContent?.trim() ?? '' + ); + const datePipe = new DatePipe('en-US'); + const shiftMs = 90 * 60_000; + + expect(times).toEqual([ + datePipe.transform(startTimestamp * 1000 + shiftMs, 'HH:mm') ?? '', + datePipe.transform(stopTimestamp * 1000 + shiftMs, 'HH:mm') ?? '', + ]); + // The row still hands the raw programme to consumers (dialog, catch-up). + expect(program.startTimestamp).toBe(startTimestamp); + }); +}); diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts index 1aa9beea3..af62cc458 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts @@ -15,7 +15,10 @@ import { MatDialog } from '@angular/material/dialog'; import { MatIcon } from '@angular/material/icon'; import { MatTooltip } from '@angular/material/tooltip'; import { TranslatePipe } from '@ngx-translate/core'; -import { EpgItemDescriptionComponent } from '@iptvnator/ui/epg'; +import { + EpgItemDescriptionComponent, + getProgramTimeMs, +} from '@iptvnator/ui/epg'; import { EpgProgram } from '@iptvnator/shared/interfaces'; import { SettingsStore } from '@iptvnator/services'; import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils'; @@ -39,6 +42,7 @@ export class ChannelListItemComponent { private readonly dialog = inject(MatDialog); private readonly logoFailed = signal(false); private readonly settingsStore = inject(SettingsStore); + readonly epgOffsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes; readonly isDraggable = input(false); readonly logo = input(''); @@ -108,6 +112,11 @@ export class ChannelListItemComponent { this.showProgramDescription(program, event); } + /** Programme boundary in display time (raw time + the EPG display offset). */ + programDisplayMs(value: string, timestamp?: number | null): number { + return getProgramTimeMs(value, timestamp, this.epgOffsetMinutes()); + } + onFavoriteClick(event: MouseEvent): void { event.stopPropagation(); this.favoriteToggled.emit(event); diff --git a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts index 125db3b3e..faa4844b7 100644 --- a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts @@ -18,8 +18,13 @@ import { MatIconModule } from '@angular/material/icon'; import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; import { TranslatePipe } from '@ngx-translate/core'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; +import { SettingsStore } from '@iptvnator/services'; import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state'; -import { Channel, EpgProgram } from '@iptvnator/shared/interfaces'; +import { + Channel, + EpgProgram, + epgProviderClockMs, +} from '@iptvnator/shared/interfaces'; import { buildChannelEpgMetadataMap } from '../epg-enrichment.util'; import { EpgMappingDialogComponent } from '../epg-mapping-dialog/epg-mapping-dialog.component'; import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component'; @@ -42,6 +47,7 @@ import { ChannelListItemComponent } from '../channel-list-item/channel-list-item export class FavoritesViewComponent { private readonly dialog = inject(MatDialog); private readonly epgBridge = inject(EpgRuntimeBridgeService); + private readonly settingsStore = inject(SettingsStore); readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping; readonly contextMenuTrigger = @@ -108,7 +114,15 @@ export class FavoritesViewComponent { readonly epgMetadataMap = computed(() => { // Read progressTick to create a dependency for the ~30s progress refresh. this.progressTick(); - return buildChannelEpgMetadataMap(this.channelEpgMap()); + // Progress is measured in the provider's EPG clock: the map keeps the + // raw programme rows and the item shifts their times for display. + return buildChannelEpgMetadataMap( + this.channelEpgMap(), + epgProviderClockMs( + Date.now(), + this.settingsStore.resolvedEpgOffsetMinutes() + ) + ); }); /** Resolves the EPG lookup key the side-car map is keyed by. */ diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts index 596a0f361..0f07b0d0a 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts @@ -21,7 +21,12 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslatePipe } from '@ngx-translate/core'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state'; -import { Channel, EpgProgram } from '@iptvnator/shared/interfaces'; +import { SettingsStore } from '@iptvnator/services'; +import { + Channel, + EpgProgram, + epgProviderClockMs, +} from '@iptvnator/shared/interfaces'; import { buildChannelEpgMetadataMap } from '../epg-enrichment.util'; import { PlaylistChannelSortMode, @@ -75,6 +80,7 @@ interface FilteredGroupView { export class GroupsViewComponent { private readonly dialog = inject(MatDialog); private readonly epgBridge = inject(EpgRuntimeBridgeService); + private readonly settingsStore = inject(SettingsStore); readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping; private readonly hostEl = inject(ElementRef); @@ -351,7 +357,15 @@ export class GroupsViewComponent { readonly epgMetadataMap = computed(() => { // Read progressTick to create a dependency for the ~30s progress refresh. this.progressTick(); - return buildChannelEpgMetadataMap(this.channelEpgMap()); + // Progress is measured in the provider's EPG clock: the map keeps the + // raw programme rows and the item shifts their times for display. + return buildChannelEpgMetadataMap( + this.channelEpgMap(), + epgProviderClockMs( + Date.now(), + this.settingsStore.resolvedEpgOffsetMinutes() + ) + ); }); /** Resolves the EPG lookup key the side-car map is keyed by. */ @@ -512,7 +526,6 @@ export class GroupsViewComponent { }); } - openChannelDetails(): void { const channel = this.contextMenuChannel(); if (!channel) { diff --git a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts index e865ecf02..1446589c9 100644 --- a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts @@ -14,7 +14,12 @@ import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; import { TranslatePipe } from '@ngx-translate/core'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state'; -import { Channel, EpgProgram } from '@iptvnator/shared/interfaces'; +import { SettingsStore } from '@iptvnator/services'; +import { + Channel, + EpgProgram, + epgProviderClockMs, +} from '@iptvnator/shared/interfaces'; import { EpgMappingDialogComponent } from '../epg-mapping-dialog/epg-mapping-dialog.component'; import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component'; import { resolveChannelLogo } from '../channel-logo-fallback.util'; @@ -44,6 +49,7 @@ export interface RecentViewItem { export class RecentViewComponent { private readonly dialog = inject(MatDialog); private readonly epgBridge = inject(EpgRuntimeBridgeService); + private readonly settingsStore = inject(SettingsStore); readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping; readonly contextMenuTrigger = @@ -88,6 +94,12 @@ export class RecentViewComponent { const epgMap = this.channelEpgMap(); const iconMap = this.channelIconMap(); this.progressTick(); + // Progress is measured in the provider's EPG clock: the rows keep the + // raw programme and the item shifts its times for display. + const epgClockMs = epgProviderClockMs( + Date.now(), + this.settingsStore.resolvedEpgOffsetMinutes() + ); return recentItems.map(({ channel, viewedAt }) => { const epgProgram = resolveChannelEpgProgram(channel, epgMap); @@ -97,7 +109,10 @@ export class RecentViewComponent { viewedAt, epgProgram, logo: resolveChannelLogo(channel, iconMap), - progressPercentage: calculateEpgProgress(epgProgram), + progressPercentage: calculateEpgProgress( + epgProgram, + epgClockMs + ), }; }); }); @@ -156,7 +171,6 @@ export class RecentViewComponent { }); } - openChannelDetails(): void { const channel = this.contextMenuChannel(); if (!channel) { diff --git a/libs/ui/epg/src/index.ts b/libs/ui/epg/src/index.ts index 2d5ef66b2..c657ea779 100644 --- a/libs/ui/epg/src/index.ts +++ b/libs/ui/epg/src/index.ts @@ -1,5 +1,6 @@ export * from './lib/epg-item-description/epg-item-description.component'; export * from './lib/epg-program-activation-event'; +export * from './lib/epg-program.utils'; export * from './lib/epg-list-view/epg-list-view.component'; export * from './lib/epg-list-view/epg-list-view-row/epg-list-view-row.component'; export * from './lib/epg-list-view/epg-list-view.utils'; @@ -18,6 +19,7 @@ export { summaryHasTitle, summaryMinutesLeft, summaryProgress, + summaryTimeMs, } from './lib/epg-timeline/epg-summary.util'; export * from './lib/epg-progress-panel/epg-progress-panel.component'; export * from './lib/epg-source-status/epg-source-status.component'; diff --git a/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.spec.ts b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.spec.ts index 72a3e3655..9139aa1a7 100644 --- a/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.spec.ts +++ b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.spec.ts @@ -1,8 +1,10 @@ +import { signal } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; import { By } from '@angular/platform-browser'; import { TranslateModule } from '@ngx-translate/core'; import { MockModule } from 'ng-mocks'; +import { SettingsStore } from '@iptvnator/services'; import { EpgProgram } from '@iptvnator/shared/interfaces'; import { EpgItemDescriptionComponent } from './epg-item-description.component'; @@ -28,6 +30,10 @@ describe('EpgItemDescriptionComponent', () => { category: 'Fun', } as unknown as EpgProgram, }, + { + provide: SettingsStore, + useValue: { resolvedEpgOffsetMinutes: signal(60) }, + }, ], }).compileComponents(); })); @@ -64,4 +70,13 @@ describe('EpgItemDescriptionComponent', () => { 'Highly interesting show about pets' ); }); + + it('applies the stored EPG display offset to the dialog timestamps', () => { + const start = Date.parse('2026-04-05T11:30:00.000Z'); + const stop = Date.parse('2026-04-05T12:30:00.000Z'); + const offsetMinutes = 60; + + expect(component.startMs).toBe(start + offsetMinutes * 60_000); + expect(component.stopMs).toBe(stop + offsetMinutes * 60_000); + }); }); diff --git a/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.ts b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.ts index a4dfb2191..d405b1273 100644 --- a/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.ts +++ b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.ts @@ -8,6 +8,8 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { differenceInMinutes } from 'date-fns'; import { startWith } from 'rxjs'; import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { SettingsStore } from '@iptvnator/services'; +import { getProgramTimeMs } from '../epg-program.utils'; export type EpgItemDialogAction = 'live' | 'timeshift'; @@ -32,6 +34,7 @@ export type EpgItemDialogData = EpgProgram & { export class EpgItemDescriptionComponent { dialogData = inject(MAT_DIALOG_DATA); private readonly translate = inject(TranslateService); + private readonly settingsStore = inject(SettingsStore); private readonly languageTick = toSignal( this.translate.onLangChange.pipe(startWith(null)), { initialValue: null } @@ -45,7 +48,10 @@ export class EpgItemDescriptionComponent { duration: string | null = null; primaryAction: EpgItemDialogAction | null = null; archiveUnavailableNote = false; - /** ms timestamps for the date pipe (prefer unix timestamp when present). */ + /** + * Display-time ms timestamps for the date pipe (unix timestamp preferred + * when present, then shifted by the EPG display offset). + */ startMs = 0; stopMs = 0; readonly currentLocale = computed(() => { @@ -72,11 +78,20 @@ export class EpgItemDescriptionComponent { this.primaryAction = this.dialogData.primaryAction ?? null; this.archiveUnavailableNote = this.dialogData.archiveUnavailableNote ?? false; - this.startMs = toMs( + // Opened imperatively from six surfaces (timeline, list, channel rows, + // the multi-EPG grid and its search), so the dialog reads the display + // offset itself instead of trusting every opener to forward it. + const offsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes(); + this.startMs = getProgramTimeMs( this.epgProgram.start, - this.epgProgram.startTimestamp + this.epgProgram.startTimestamp, + offsetMinutes + ); + this.stopMs = getProgramTimeMs( + this.epgProgram.stop, + this.epgProgram.stopTimestamp, + offsetMinutes ); - this.stopMs = toMs(this.epgProgram.stop, this.epgProgram.stopTimestamp); } private calculateDuration(): string | null { @@ -96,10 +111,3 @@ export class EpgItemDescriptionComponent { } } } - -function toMs(iso: string, timestamp?: number | null): number { - if (Number.isFinite(timestamp) && Number(timestamp) > 0) { - return Number(timestamp) * 1000; - } - return Date.parse(iso); -} diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.html b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.html index b8559e77a..c78b37352 100644 --- a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.html +++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.html @@ -59,8 +59,8 @@ {{ summary()?.title }} @if (hasTimeRange()) { {{ summary()?.start | date: 'HH:mm' }} – - {{ summary()?.stop | date: 'HH:mm' }}{{ summaryStartMs() | date: 'HH:mm' }} – + {{ summaryStopMs() | date: 'HH:mm' }} } } diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.ts b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.ts index ace1429e4..47a84c860 100644 --- a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.ts +++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.ts @@ -34,6 +34,7 @@ import { summaryHasTitle, summaryMinutesLeft, summaryProgress, + summaryTimeMs, } from '../epg-timeline/epg-summary.util'; import { EpgTimelineEmptyReason, @@ -89,6 +90,7 @@ export class EpgListViewComponent { readonly collapsed = input(false); readonly summary = input(null); readonly summaryLabelKey = input('EPG.CURRENT_PROGRAM'); + readonly offsetMinutes = input(0); readonly programActivated = output(); readonly returnToLive = output(); @@ -128,6 +130,7 @@ export class EpgListViewComponent { archivePlaybackAvailable: this.archivePlaybackAvailable(), archiveDays: this.archiveDays(), activeProgram: this.activeProgram(), + offsetMinutes: this.offsetMinutes(), }) ); readonly nowRow = computed( @@ -135,7 +138,9 @@ export class EpgListViewComponent { ); readonly nowRowMinutesLeft = computed(() => { const row = this.nowRow(); - return row ? Math.max(0, Math.round((row.stopMs - this.nowMs()) / 60_000)) : null; + return row + ? Math.max(0, Math.round((row.stopMs - this.nowMs()) / 60_000)) + : null; }); readonly viewDate = computed(() => parseEpgDateKey(this.viewDayKey())); @@ -154,7 +159,13 @@ export class EpgListViewComponent { if (this.programs().length === 0) { return 'channel-unmapped'; } - if (!hasProgramsForDateKey(this.programs(), this.viewDayKey())) { + if ( + !hasProgramsForDateKey( + this.programs(), + this.viewDayKey(), + this.offsetMinutes() + ) + ) { return 'empty-day'; } return 'list'; @@ -180,10 +191,17 @@ export class EpgListViewComponent { readonly hasSummary = computed(() => summaryHasTitle(this.summary())); readonly hasTimeRange = computed(() => summaryHasTimeRange(this.summary())); readonly progress = computed(() => - summaryProgress(this.summary(), this.nowMs()) + summaryProgress(this.summary(), this.nowMs(), this.offsetMinutes()) ); readonly minutesLeft = computed(() => - summaryMinutesLeft(this.summary(), this.nowMs()) + summaryMinutesLeft(this.summary(), this.nowMs(), this.offsetMinutes()) + ); + /** Collapsed-header time range in display time (raw summary times shifted). */ + readonly summaryStartMs = computed(() => + summaryTimeMs(this.summary()?.start, this.offsetMinutes()) + ); + readonly summaryStopMs = computed(() => + summaryTimeMs(this.summary()?.stop, this.offsetMinutes()) ); private readonly scroll = new EpgListScrollController({ @@ -191,7 +209,11 @@ export class EpgListViewComponent { isViewToday: () => this.isViewToday(), setNowStripVisible: (visible) => this.nowStripVisible.set(visible), hasProgramsToday: () => - hasProgramsForDateKey(this.programs(), getTodayEpgDateKey()), + hasProgramsForDateKey( + this.programs(), + getTodayEpgDateKey(), + this.offsetMinutes() + ), commitToday: () => this.commitDay(getTodayEpgDateKey()), }); @@ -226,7 +248,8 @@ export class EpgListViewComponent { jumpToNearestDay(): void { const nearest = nearestDateKeyWithPrograms( this.programs(), - this.nowMs() + this.nowMs(), + this.offsetMinutes() ); if (nearest) { this.commitDay(nearest); diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.utils.spec.ts b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.utils.spec.ts index fdc42bfc0..2b233d90f 100644 --- a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.utils.spec.ts +++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.utils.spec.ts @@ -40,6 +40,34 @@ const DEFAULT_OPTS = { }; describe('buildEpgListRows', () => { + it('filters the day and positions rows in display time when an offset is set', () => { + // 23:30 → 00:30 overlaps today until midnight ... + const edge = programAt(690, 60, 'Edge'); + expect( + buildEpgListRows([edge], TODAY, NOW, DEFAULT_OPTS).map( + (row) => row.program.title + ) + ).toEqual(['Edge']); + // ... but shifted by +60 it airs 00:30 → 01:30 tomorrow and leaves today. + expect( + buildEpgListRows([edge], TODAY, NOW, { + ...DEFAULT_OPTS, + offsetMinutes: 60, + }) + ).toEqual([]); + + // A negative offset keeps it on today, with shifted row times and + // the untouched raw programme handed on for catch-up. + const rows = buildEpgListRows([edge], TODAY, NOW, { + ...DEFAULT_OPTS, + offsetMinutes: -60, + }); + expect(rows).toHaveLength(1); + expect(rows[0].startMs).toBe(NOW + 690 * 60_000 - 60 * 60_000); + expect(rows[0].stopMs).toBe(NOW + 750 * 60_000 - 60 * 60_000); + expect(rows[0].program).toBe(edge); + }); + it('keeps only programmes overlapping the selected day, sorted by start', () => { const rows = buildEpgListRows( [ diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.utils.ts b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.utils.ts index 2b0364112..8137428c5 100644 --- a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.utils.ts +++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.utils.ts @@ -36,6 +36,7 @@ export interface BuildEpgListRowsOptions { readonly archivePlaybackAvailable: boolean; readonly archiveDays: number; readonly activeProgram: EpgProgram | null; + readonly offsetMinutes?: number; } function clampPercent(value: number): number { @@ -70,10 +71,19 @@ export function buildEpgListRows( const dayStart = parseEpgDateKey(selectedDateKey); const dayStartMs = dayStart.getTime(); const dayEndMs = addDays(dayStart, 1).getTime(); // exclusive + const offsetMinutes = options.offsetMinutes ?? 0; const forDay = programs.filter((program) => { - const startMs = getProgramTimeMs(program.start, program.startTimestamp); - const stopMs = getProgramTimeMs(program.stop, program.stopTimestamp); + const startMs = getProgramTimeMs( + program.start, + program.startTimestamp, + offsetMinutes + ); + const stopMs = getProgramTimeMs( + program.stop, + program.stopTimestamp, + offsetMinutes + ); if (!Number.isFinite(startMs) || !Number.isFinite(stopMs)) { return false; } @@ -89,16 +99,33 @@ export function buildEpgListRows( const deduped = deduplicateProgramsByTimeSlot( [...forDay].sort( (left, right) => - getProgramTimeMs(left.start, left.startTimestamp) - - getProgramTimeMs(right.start, right.startTimestamp) - ) + getProgramTimeMs( + left.start, + left.startTimestamp, + offsetMinutes + ) - + getProgramTimeMs( + right.start, + right.startTimestamp, + offsetMinutes + ) + ), + offsetMinutes ); const activeProgram = options.activeProgram; return deduped.map((program, index) => { - const startMs = getProgramTimeMs(program.start, program.startTimestamp); - const stopMs = getProgramTimeMs(program.stop, program.stopTimestamp); + const startMs = getProgramTimeMs( + program.start, + program.startTimestamp, + offsetMinutes + ); + const stopMs = getProgramTimeMs( + program.stop, + program.stopTimestamp, + offsetMinutes + ); const when = classifyTimelineWhen(startMs, stopMs, nowMs); return { @@ -109,7 +136,7 @@ export function buildEpgListRows( when, progress: liveProgress(when, startMs, stopMs, nowMs), isActive: activeProgram - ? areProgramsSame(program, activeProgram) + ? areProgramsSame(program, activeProgram, offsetMinutes) : false, canCatchUp: canCatchUpProgramme( when, diff --git a/libs/ui/epg/src/lib/epg-program.utils.spec.ts b/libs/ui/epg/src/lib/epg-program.utils.spec.ts new file mode 100644 index 000000000..7829b0179 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-program.utils.spec.ts @@ -0,0 +1,46 @@ +import { + areProgramsSame, + deduplicateProgramsByTimeSlot, + getProgramDateKey, + getProgramTimeMs, +} from './epg-program.utils'; + +const PROGRAM = { + start: '2026-06-28T23:30:00.000Z', + stop: '2026-06-29T00:30:00.000Z', + channel: 'channel', + title: 'Program', + desc: null, + category: null, +}; + +describe('epg-program.utils', () => { + it('preserves raw time with the default offset', () => { + expect(getProgramTimeMs(PROGRAM.start)).toBe(Date.parse(PROGRAM.start)); + }); + + it('shifts ISO and numeric timestamps in display time', () => { + expect(getProgramTimeMs(PROGRAM.start, null, 60)).toBe( + Date.parse(PROGRAM.start) + 60 * 60_000 + ); + expect(getProgramTimeMs(PROGRAM.start, 1_000, -30)).toBe( + 1_000_000 - 30 * 60_000 + ); + }); + + it('shifts date keys across midnight', () => { + expect(getProgramDateKey(PROGRAM.start, null, 60)).toBe('2026-06-29'); + }); + + it('uses shifted times consistently for equality and deduplication', () => { + const equivalent = { + ...PROGRAM, + start: '2026-06-29T00:30:00.000Z', + stop: '2026-06-29T01:30:00.000Z', + }; + expect(areProgramsSame(PROGRAM, equivalent, 60)).toBe(false); + expect( + deduplicateProgramsByTimeSlot([PROGRAM, PROGRAM], 60) + ).toHaveLength(1); + }); +}); diff --git a/libs/ui/epg/src/lib/epg-program.utils.ts b/libs/ui/epg/src/lib/epg-program.utils.ts index b7fa29caa..08965da85 100644 --- a/libs/ui/epg/src/lib/epg-program.utils.ts +++ b/libs/ui/epg/src/lib/epg-program.utils.ts @@ -1,23 +1,39 @@ import { format } from 'date-fns'; -import type { EpgProgram } from '@iptvnator/shared/interfaces'; +import { + epgDisplayTimeMs, + type EpgProgram, +} from '@iptvnator/shared/interfaces'; import { EPG_DATE_KEY_FORMAT } from './epg-date'; +/** + * A programme boundary as epoch ms in DISPLAY time: the unix timestamp when + * present (the ISO string may carry a provider-local offset the parser could + * not resolve), else the parsed ISO value, shifted by the EPG display offset + * (`epg-display-offset.util.ts`, display form). Every ui/epg geometry, date + * and equality helper below goes through this one conversion. + */ export function getProgramTimeMs( isoValue: string, - timestampValue?: number | null + timestampValue?: number | null, + offsetMinutes = 0 ): number { - if (Number.isFinite(timestampValue) && Number(timestampValue) > 0) { - return Number(timestampValue) * 1000; - } - - return Date.parse(isoValue); + const baseMs = + Number.isFinite(timestampValue) && Number(timestampValue) > 0 + ? Number(timestampValue) * 1000 + : Date.parse(isoValue); + return epgDisplayTimeMs(baseMs, offsetMinutes); } export function getProgramDateKey( isoValue: string, - timestampValue?: number | null + timestampValue?: number | null, + offsetMinutes = 0 ): string { - const programTimeMs = getProgramTimeMs(isoValue, timestampValue); + const programTimeMs = getProgramTimeMs( + isoValue, + timestampValue, + offsetMinutes + ); if (!Number.isFinite(programTimeMs)) { return ''; @@ -27,12 +43,13 @@ export function getProgramDateKey( } export function deduplicateProgramsByTimeSlot( - programs: EpgProgram[] + programs: EpgProgram[], + offsetMinutes = 0 ): EpgProgram[] { const programsByTimeSlot = new Map(); for (const program of programs) { - const timeSlotKey = buildProgramTimeSlotKey(program); + const timeSlotKey = buildProgramTimeSlotKey(program, offsetMinutes); const existingProgram = programsByTimeSlot.get(timeSlotKey); programsByTimeSlot.set( @@ -48,21 +65,29 @@ export function deduplicateProgramsByTimeSlot( export function areProgramsSame( left: EpgProgram, - right: EpgProgram + right: EpgProgram, + offsetMinutes = 0 ): boolean { return ( (left.channel ?? '') === (right.channel ?? '') && - getProgramTimeMs(left.start, left.startTimestamp) === - getProgramTimeMs(right.start, right.startTimestamp) && - getProgramTimeMs(left.stop, left.stopTimestamp) === - getProgramTimeMs(right.stop, right.stopTimestamp) + getProgramTimeMs(left.start, left.startTimestamp, offsetMinutes) === + getProgramTimeMs( + right.start, + right.startTimestamp, + offsetMinutes + ) && + getProgramTimeMs(left.stop, left.stopTimestamp, offsetMinutes) === + getProgramTimeMs(right.stop, right.stopTimestamp, offsetMinutes) ); } -function buildProgramTimeSlotKey(program: EpgProgram): string { +function buildProgramTimeSlotKey( + program: EpgProgram, + offsetMinutes: number +): string { return [ - getProgramTimeMs(program.start, program.startTimestamp), - getProgramTimeMs(program.stop, program.stopTimestamp), + getProgramTimeMs(program.start, program.startTimestamp, offsetMinutes), + getProgramTimeMs(program.stop, program.stopTimestamp, offsetMinutes), ].join('|'); } diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-summary.util.spec.ts b/libs/ui/epg/src/lib/epg-timeline/epg-summary.util.spec.ts index 3ab036664..b7bbd0e46 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-summary.util.spec.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-summary.util.spec.ts @@ -5,6 +5,7 @@ import { summaryHasTitle, summaryMinutesLeft, summaryProgress, + summaryTimeMs, } from './epg-summary.util'; const HOUR_MS = 60 * 60_000; @@ -17,6 +18,26 @@ function summary(overrides: EpgTimelineSummary = {}): EpgTimelineSummary { } describe('epg-summary.util', () => { + describe('summaryTimeMs', () => { + it('parses every summary shape into epoch ms', () => { + expect(summaryTimeMs(START)).toBe(START); + expect(summaryTimeMs(new Date(START))).toBe(START); + expect(summaryTimeMs(new Date(START).toISOString())).toBe(START); + }); + + it('returns null for blank or unparseable values', () => { + expect(summaryTimeMs(null)).toBeNull(); + expect(summaryTimeMs(undefined)).toBeNull(); + expect(summaryTimeMs('')).toBeNull(); + expect(summaryTimeMs('not a date')).toBeNull(); + }); + + it('shifts the raw provider time into display time by the offset', () => { + expect(summaryTimeMs(START, 30)).toBe(START + 30 * 60_000); + expect(summaryTimeMs(START, -90)).toBe(START - 90 * 60_000); + }); + }); + describe('summaryProgress', () => { it('returns null for a missing summary', () => { expect(summaryProgress(null, NOW)).toBeNull(); @@ -109,6 +130,10 @@ describe('epg-summary.util', () => { expect(summaryMinutesLeft(summary(), NOW)).toBe(60); }); + it('applies the display offset to the remaining time', () => { + expect(summaryMinutesLeft(summary(), NOW, 30)).toBe(90); + }); + it('rounds to the nearest minute', () => { expect( summaryMinutesLeft(summary({ stop: NOW + 90_000 }), NOW) diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-summary.util.ts b/libs/ui/epg/src/lib/epg-timeline/epg-summary.util.ts index cfbed9b86..4d57050fe 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-summary.util.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-summary.util.ts @@ -1,3 +1,5 @@ +import { epgDisplayTimeMs } from '@iptvnator/shared/interfaces'; + /** * Collapsed-summary payload (the current / archive programme shown when the * panel is collapsed). View-agnostic so a future list view can reuse the same @@ -14,8 +16,15 @@ function clampPercent(value: number): number { return Math.min(100, Math.max(0, value)); } -function toTimeMs( - value: string | number | Date | null | undefined +/** + * A summary timestamp as epoch ms in display time (raw value shifted by the + * EPG display offset), or null when it cannot be parsed. Hosts hand the + * summary over with the provider's raw times, so this is the one place the + * collapsed header converts them. + */ +export function summaryTimeMs( + value: string | number | Date | null | undefined, + offsetMinutes = 0 ): number | null { if (value === null || value === undefined || value === '') { return null; @@ -26,7 +35,9 @@ function toTimeMs( : typeof value === 'number' ? value : Date.parse(value); - return Number.isFinite(parsed) ? parsed : null; + return Number.isFinite(parsed) + ? epgDisplayTimeMs(parsed, offsetMinutes) + : null; } /** Whether the summary has a non-blank title. */ @@ -47,7 +58,8 @@ export function summaryHasTimeRange( /** Progress 0–100 for the summary at `nowMs`, or null when it can't be computed. */ export function summaryProgress( summary: EpgTimelineSummary | null | undefined, - nowMs: number + nowMs: number, + offsetMinutes = 0 ): number | null { if (!summary) { return null; @@ -56,8 +68,8 @@ export function summaryProgress( if (Number.isFinite(explicit)) { return clampPercent(explicit); } - const startMs = toTimeMs(summary.start); - const stopMs = toTimeMs(summary.stop); + const startMs = summaryTimeMs(summary.start, offsetMinutes); + const stopMs = summaryTimeMs(summary.stop, offsetMinutes); if (startMs === null || stopMs === null || stopMs <= startMs) { return null; } @@ -67,9 +79,10 @@ export function summaryProgress( /** Whole minutes left until the summary's stop, or null. */ export function summaryMinutesLeft( summary: EpgTimelineSummary | null | undefined, - nowMs: number + nowMs: number, + offsetMinutes = 0 ): number | null { - const stopMs = toTimeMs(summary?.stop); + const stopMs = summaryTimeMs(summary?.stop, offsetMinutes); if (stopMs === null) { return null; } diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.spec.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.spec.ts index 0c4185104..e2f72fea3 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.spec.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.spec.ts @@ -10,6 +10,7 @@ import { TIMELINE_MINUTE_MS, TimelineBlock, } from './epg-timeline.utils'; +import { getTodayEpgDateKey } from '../epg-date'; function programAt( startOffsetMin: number, @@ -199,6 +200,34 @@ describe('TimelineScrollController', () => { expect(commitDaySpy).not.toHaveBeenCalled(); }); + it('asks the host whether today has programmes, so the display offset counts', () => { + // Raw dates sit three days out, but the host's predicate applies + // the EPG display offset and says today is covered: the controller + // must trust it rather than re-checking the raw dates itself. + const nowMs = Date.now(); + const p = [programAt(3 * 1440, 60)]; + const commitDay = jest.fn(); + const offsetAware = new TimelineScrollController({ + ribbon: () => undefined, + scale: () => 1, + axis: () => buildTimelineAxis(p, nowMs), + blocks: () => + buildTimelineBlocks(p, buildTimelineAxis(p, nowMs), nowMs), + nowMs: () => nowMs, + viewDayKey: () => 'today', + commitDay, + hasProgramsForDay: () => true, + }); + const scroll = jest + .spyOn(offsetAware, 'scrollToOffset') + .mockImplementation(() => undefined); + + offsetAware.maybeAutoFocus({} as HTMLElement, p); + + expect(commitDay).toHaveBeenCalledWith(getTodayEpgDateKey()); + expect(scroll).toHaveBeenCalledTimes(1); + }); + it('skips focus entirely when the ribbon is not mounted', () => { focus(undefined, [programAt(0, 120, 'Now')]); expect(scrollSpy).not.toHaveBeenCalled(); diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.ts index 78be6327e..7e323cb55 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.ts @@ -2,7 +2,6 @@ import { EpgProgram } from '@iptvnator/shared/interfaces'; import { getTodayEpgDateKey, parseEpgDateKey } from '../epg-date'; import { dayKeyAtOffset, - hasProgramsForDateKey, TIMELINE_MINUTE_MS, TimelineAxis, TimelineBlock, @@ -138,7 +137,9 @@ export class TimelineScrollController { // Only auto-focus when today actually has a programme to centre on. // Otherwise (today empty, data on other days) leave the user's day // navigation alone instead of forcing the view back to an empty today. - if (!hasProgramsForDateKey(programs, todayKey)) { + // The host's predicate applies the EPG display offset, so a guide + // whose raw dates sit on the adjacent day still counts. + if (!this.ctx.hasProgramsForDay(todayKey)) { return; } this.lastFocusKey = key; diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html index cabb39a71..1b3990b90 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html @@ -75,8 +75,8 @@ }} @if (hasTimeRange()) { {{ summary()?.start | date: 'HH:mm' }} – - {{ summary()?.stop | date: 'HH:mm' }}{{ summaryStartMs() | date: 'HH:mm' }} – + {{ summaryStopMs() | date: 'HH:mm' }} } } diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts index 1c7471de2..9aadde591 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts @@ -36,11 +36,9 @@ import { summaryHasTitle, summaryMinutesLeft, summaryProgress, + summaryTimeMs, } from './epg-summary.util'; -import { - canCatchUpProgramme, - epgDialogActionFor, -} from './epg-archive.util'; +import { canCatchUpProgramme, epgDialogActionFor } from './epg-archive.util'; import { EpgTimelineEmptyReason, EpgTimelineEmptyStateComponent, @@ -100,6 +98,7 @@ export class EpgTimelineComponent { readonly collapsed = input(false); readonly summary = input(null); readonly summaryLabelKey = input('EPG.CURRENT_PROGRAM'); + readonly offsetMinutes = input(0); readonly programActivated = output(); readonly returnToLive = output(); @@ -138,7 +137,11 @@ export class EpgTimelineComponent { viewDayKey: () => this.viewDayKey(), commitDay: (dayKey) => this.commitDay(dayKey), hasProgramsForDay: (dayKey) => - hasProgramsForDateKey(this.programs(), dayKey), + hasProgramsForDateKey( + this.programs(), + dayKey, + this.offsetMinutes() + ), }); private readonly languageTick = toSignal( @@ -153,10 +156,15 @@ export class EpgTimelineComponent { }); readonly axis = computed(() => - buildTimelineAxis(this.programs(), this.nowMs()) + buildTimelineAxis(this.programs(), this.nowMs(), this.offsetMinutes()) ); readonly blocks = computed(() => - buildTimelineBlocks(this.programs(), this.axis(), this.nowMs()) + buildTimelineBlocks( + this.programs(), + this.axis(), + this.nowMs(), + this.offsetMinutes() + ) ); private readonly archiveWindowStartMs = computed(() => { const days = this.archiveDays(); @@ -178,11 +186,15 @@ export class EpgTimelineComponent { readonly dividers = computed(() => buildTimelineDayDividers(this.axis())); readonly trackWidthPx = computed(() => { const axis = this.axis(); - return ((axis.endMs - axis.startMs) / TIMELINE_MINUTE_MS) * this.scale(); + return ( + ((axis.endMs - axis.startMs) / TIMELINE_MINUTE_MS) * this.scale() + ); }); readonly playheadLeftPx = computed(() => { const axis = this.axis(); - return ((this.nowMs() - axis.startMs) / TIMELINE_MINUTE_MS) * this.scale(); + return ( + ((this.nowMs() - axis.startMs) / TIMELINE_MINUTE_MS) * this.scale() + ); }); readonly zoomLabelKey = computed(() => { const scale = this.scale(); @@ -208,7 +220,13 @@ export class EpgTimelineComponent { if (this.programs().length === 0) { return 'channel-unmapped'; } - if (!hasProgramsForDateKey(this.programs(), this.viewDayKey())) { + if ( + !hasProgramsForDateKey( + this.programs(), + this.viewDayKey(), + this.offsetMinutes() + ) + ) { return 'empty-day'; } return 'ribbon'; @@ -225,7 +243,9 @@ export class EpgTimelineComponent { * could become usable, but with no programmes for the day there is nothing * to jump to or zoom. */ - readonly showRibbonControls = computed(() => this.renderState() === 'ribbon'); + readonly showRibbonControls = computed( + () => this.renderState() === 'ribbon' + ); /** * The date stepper navigates between days, which is only meaningful when the @@ -241,14 +261,19 @@ export class EpgTimelineComponent { // ── collapsed-summary state ── readonly hasSummary = computed(() => summaryHasTitle(this.summary())); - readonly hasTimeRange = computed(() => - summaryHasTimeRange(this.summary()) - ); + readonly hasTimeRange = computed(() => summaryHasTimeRange(this.summary())); readonly progress = computed(() => - summaryProgress(this.summary(), this.nowMs()) + summaryProgress(this.summary(), this.nowMs(), this.offsetMinutes()) ); readonly minutesLeft = computed(() => - summaryMinutesLeft(this.summary(), this.nowMs()) + summaryMinutesLeft(this.summary(), this.nowMs(), this.offsetMinutes()) + ); + /** Collapsed-header time range in display time (raw summary times shifted). */ + readonly summaryStartMs = computed(() => + summaryTimeMs(this.summary()?.start, this.offsetMinutes()) + ); + readonly summaryStopMs = computed(() => + summaryTimeMs(this.summary()?.stop, this.offsetMinutes()) ); constructor() { @@ -374,7 +399,8 @@ export class EpgTimelineComponent { jumpToNearestDay(): void { const nearest = nearestDateKeyWithPrograms( this.programs(), - this.nowMs() + this.nowMs(), + this.offsetMinutes() ); if (nearest) { this.commitDay(nearest); diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.utils.spec.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.utils.spec.ts index f8edc1ca5..197d4cd08 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.utils.spec.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.utils.spec.ts @@ -179,6 +179,30 @@ describe('epg-timeline.utils', () => { // Jun 30 10:00 (~1d22h away) is closer to Jun 28 12:00 than Jun 25. expect(nearestDateKeyWithPrograms(programs, NOW)).toBe('2026-06-30'); }); + + it('keys, positions and picks days in display time when an offset is set', () => { + // 29 Jun 23:10 → 23:50 raw; shifted by +60 it airs 30 Jun 00:10 → 00:50. + const programs = [ + program( + localIso(2026, 6, 29, 23, 10), + localIso(2026, 6, 29, 23, 50), + 'Late' + ), + ]; + expect(hasProgramsForDateKey(programs, '2026-06-29', 60)).toBe(false); + expect(hasProgramsForDateKey(programs, '2026-06-30', 60)).toBe(true); + expect(nearestDateKeyWithPrograms(programs, NOW, 60)).toBe( + '2026-06-30' + ); + + const axis = buildTimelineAxis(programs, NOW, 60); + const [block] = buildTimelineBlocks(programs, axis, NOW, 60); + expect(block.startMs).toBe( + Date.parse(localIso(2026, 6, 29, 23, 10)) + 60 * 60_000 + ); + // The block still carries the raw programme for catch-up. + expect(block.program).toBe(programs[0]); + }); }); describe('short-programme strategy', () => { diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.utils.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.utils.ts index e5565e8cf..98ab6e1e6 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.utils.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.utils.ts @@ -50,14 +50,23 @@ function minutesBetween(fromMs: number, toMs: number): number { */ export function buildTimelineAxis( programs: readonly EpgProgram[], - nowMs: number + nowMs: number, + offsetMinutes = 0 ): TimelineAxis { let minMs = nowMs; let maxMs = nowMs; for (const program of programs) { - const startMs = getProgramTimeMs(program.start, program.startTimestamp); - const stopMs = getProgramTimeMs(program.stop, program.stopTimestamp); + const startMs = getProgramTimeMs( + program.start, + program.startTimestamp, + offsetMinutes + ); + const stopMs = getProgramTimeMs( + program.stop, + program.stopTimestamp, + offsetMinutes + ); if (Number.isFinite(startMs)) { minMs = Math.min(minMs, startMs); } @@ -90,13 +99,22 @@ export function classifyTimelineWhen( export function buildTimelineBlocks( programs: readonly EpgProgram[], axis: TimelineAxis, - nowMs: number + nowMs: number, + offsetMinutes = 0 ): TimelineBlock[] { const blocks: TimelineBlock[] = []; programs.forEach((program, index) => { - const startMs = getProgramTimeMs(program.start, program.startTimestamp); - const stopMs = getProgramTimeMs(program.stop, program.stopTimestamp); + const startMs = getProgramTimeMs( + program.start, + program.startTimestamp, + offsetMinutes + ); + const stopMs = getProgramTimeMs( + program.stop, + program.stopTimestamp, + offsetMinutes + ); if ( !Number.isFinite(startMs) || !Number.isFinite(stopMs) || @@ -146,7 +164,9 @@ export function buildTimelineTicks( return ticks; } -export function buildTimelineDayDividers(axis: TimelineAxis): TimelineDayDivider[] { +export function buildTimelineDayDividers( + axis: TimelineAxis +): TimelineDayDivider[] { const dividers: TimelineDayDivider[] = []; let cursor = startOfDay(axis.startMs); @@ -164,10 +184,7 @@ export function buildTimelineDayDividers(axis: TimelineAxis): TimelineDayDivider } /** Local-day key (yyyy-MM-dd) for the day currently centred in the viewport. */ -export function dayKeyAtOffset( - axis: TimelineAxis, - offsetMin: number -): string { +export function dayKeyAtOffset(axis: TimelineAxis, offsetMin: number): string { const ms = axis.startMs + offsetMin * TIMELINE_MINUTE_MS; return getProgramDateKey(new Date(ms).toISOString()); } @@ -183,15 +200,24 @@ export function dayKeyAtOffset( */ export function hasProgramsForDateKey( programs: readonly EpgProgram[], - dateKey: string + dateKey: string, + offsetMinutes = 0 ): boolean { const dayStart = parseEpgDateKey(dateKey); const dayStartMs = dayStart.getTime(); const dayEndMs = addDays(dayStart, 1).getTime(); // exclusive return programs.some((program) => { - const startMs = getProgramTimeMs(program.start, program.startTimestamp); - const stopMs = getProgramTimeMs(program.stop, program.stopTimestamp); + const startMs = getProgramTimeMs( + program.start, + program.startTimestamp, + offsetMinutes + ); + const stopMs = getProgramTimeMs( + program.stop, + program.stopTimestamp, + offsetMinutes + ); if (!Number.isFinite(startMs) || !Number.isFinite(stopMs)) { return false; } @@ -202,20 +228,29 @@ export function hasProgramsForDateKey( /** Nearest date key (yyyy-MM-dd) that actually has programmes, or null. */ export function nearestDateKeyWithPrograms( programs: readonly EpgProgram[], - referenceMs: number + referenceMs: number, + offsetMinutes = 0 ): string | null { let bestKey: string | null = null; let bestDelta = Number.POSITIVE_INFINITY; for (const program of programs) { - const startMs = getProgramTimeMs(program.start, program.startTimestamp); + const startMs = getProgramTimeMs( + program.start, + program.startTimestamp, + offsetMinutes + ); if (!Number.isFinite(startMs)) { continue; } const delta = Math.abs(startMs - referenceMs); if (delta < bestDelta) { bestDelta = delta; - bestKey = getProgramDateKey(program.start, program.startTimestamp); + bestKey = getProgramDateKey( + program.start, + program.startTimestamp, + offsetMinutes + ); } } diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.html b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.html index 83901224d..7231f4238 100644 --- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.html +++ b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.html @@ -108,12 +108,12 @@ {{ program.title }} - {{ (program.startTimestamp ? program.startTimestamp * 1000 : program.start) | date: 'HH:mm' }} + {{ programDisplayMs(program, 'start') | date: 'HH:mm' }} - - {{ (program.stopTimestamp ? program.stopTimestamp * 1000 : program.stop) | date: 'HH:mm' }} + {{ programDisplayMs(program, 'stop') | date: 'HH:mm' }} - {{ (program.startTimestamp ? program.startTimestamp * 1000 : program.start) | date: 'MMM d' : undefined : currentLocale() }} + {{ programDisplayMs(program, 'start') | date: 'MMM d' : undefined : currentLocale() }}
diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts index 20af0ab29..9535f69b6 100644 --- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts +++ b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts @@ -1,9 +1,15 @@ +import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { OverlayRef } from '@angular/cdk/overlay'; import { MatDialog } from '@angular/material/dialog'; import { TranslateService } from '@ngx-translate/core'; import { of } from 'rxjs'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; +import { SettingsStore } from '@iptvnator/services'; +import { + ElectronBridgeEpgChannelWithPrograms, + EpgProgram, +} from '@iptvnator/shared/interfaces'; import { MultiEpgContainerComponent, isSelectedEpgDayToday, @@ -20,12 +26,25 @@ describe('isSelectedEpgDayToday', () => { }); }); +function programAt(start: Date, minutes: number, title: string): EpgProgram { + return { + start: start.toISOString(), + stop: new Date(start.getTime() + minutes * 60_000).toISOString(), + channel: 'c1', + title, + desc: null, + category: null, + }; +} + describe('MultiEpgContainerComponent runtime gates', () => { let fixture: ComponentFixture; let component: MultiEpgContainerComponent; let epgBridge: Partial; + const epgOffsetMinutes = signal(0); beforeEach(async () => { + epgOffsetMinutes.set(0); epgBridge = { getChannelsByRange: jest.fn().mockResolvedValue([]), searchPrograms: jest.fn().mockResolvedValue([]), @@ -49,6 +68,10 @@ describe('MultiEpgContainerComponent runtime gates', () => { provide: EpgRuntimeBridgeService, useValue: epgBridge, }, + { + provide: SettingsStore, + useValue: { resolvedEpgOffsetMinutes: epgOffsetMinutes }, + }, { provide: TranslateService, useValue: { @@ -74,6 +97,32 @@ describe('MultiEpgContainerComponent runtime gates', () => { jest.useRealTimers(); }); + it('lays programmes out in display time when an EPG offset is set', () => { + const morning = new Date(); + morning.setHours(10, 0, 0, 0); + const late = new Date(); + late.setHours(23, 30, 0, 0); + component.originalEpgData.set([ + { + id: 'c1', + displayName: 'Channel 1', + programs: [ + programAt(morning, 60, 'Morning'), + programAt(late, 60, 'Late'), + ], + } as unknown as ElectronBridgeEpgChannelWithPrograms, + ]); + + epgOffsetMinutes.set(90); + + // 23:30 + 90 min lands on the next day and leaves today's column; + // 10:00 + 90 min is drawn under 11:30. + const programs = component.channels()[0].programs; + expect(programs.map((program) => program.title)).toEqual(['Morning']); + expect(programs[0].startPosition).toBe(11.5 * component.hourWidth()); + expect(programs[0].width).toBe(component.hourWidth()); + }); + it('does not request EPG channel ranges when the EPG bridge cannot browse channels', async () => { jest.spyOn(console, 'warn').mockImplementation(() => undefined); diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts index 796fde481..a43449ce8 100644 --- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts +++ b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts @@ -27,9 +27,12 @@ import { Channel, ElectronBridgeEpgChannelWithPrograms, EpgChannel, + epgDisplayTimeMs, EpgProgram, } from '@iptvnator/shared/interfaces'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; +import { SettingsStore } from '@iptvnator/services'; +import { getProgramTimeMs } from '../epg-program.utils'; import { EpgItemDescriptionComponent } from '../epg-item-description/epg-item-description.component'; import { COMPONENT_OVERLAY_REF } from './overlay-ref.token'; @@ -175,6 +178,13 @@ export class MultiEpgContainerComponent private readonly dialog = inject(MatDialog); private readonly overlayRef = inject(COMPONENT_OVERLAY_REF); private readonly epgBridge = inject(EpgRuntimeBridgeService); + private readonly settingsStore = inject(SettingsStore); + /** + * Attached to an overlay without host inputs, so the grid reads the EPG + * display offset itself (`epg-display-offset.util.ts`, display form). + */ + private readonly epgOffsetMinutes = + this.settingsStore.resolvedEpgOffsetMinutes; ngOnInit() { // Update current time line every minute @@ -316,23 +326,55 @@ export class MultiEpgContainerComponent return date; } + /** The cached raw date shifted into display time. */ + private getDisplayDate(dateStr: string, offsetMinutes: number): Date { + const raw = this.getCachedDate(dateStr); + return offsetMinutes === 0 + ? raw + : new Date(epgDisplayTimeMs(raw.getTime(), offsetMinutes)); + } + + /** Display-time epoch ms of a search-result boundary for the date pipe. */ + programDisplayMs(program: EpgProgram, side: 'start' | 'stop'): number { + return side === 'start' + ? getProgramTimeMs( + program.start, + program.startTimestamp, + this.epgOffsetMinutes() + ) + : getProgramTimeMs( + program.stop, + program.stopTimestamp, + this.epgOffsetMinutes() + ); + } + private enrichProgramData(): EnrichedChannel[] { const hourWidthValue = this.hourWidth(); const todayValue = this.today(); const data = this.originalEpgData(); + // Day filter and x-position both work in display time, so a shifted + // programme lands in the right day column under the right hour. + const offsetMinutes = this.epgOffsetMinutes(); return data.map((channel) => { const filteredPrograms = (channel.programs || []) .filter((item: EpgProgram) => { const itemDate = format( - this.getCachedDate(item.start), + this.getDisplayDate(item.start, offsetMinutes), 'yyyyMMdd' ); return itemDate === todayValue; }) .map((program: EpgProgram) => { - const startDate = this.getCachedDate(program.start); - const stopDate = this.getCachedDate(program.stop); + const startDate = this.getDisplayDate( + program.start, + offsetMinutes + ); + const stopDate = this.getDisplayDate( + program.stop, + offsetMinutes + ); const startPosition = (startDate.getHours() + startDate.getMinutes() / 60) * hourWidthValue; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.spec.ts new file mode 100644 index 000000000..27a8c9b43 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.spec.ts @@ -0,0 +1,44 @@ +import type { EpgProgram } from '@iptvnator/shared/interfaces'; +import { + buildDashboardLiveEpgDetails, + formatEpgTimeRange, +} from './dashboard-live-epg.utils'; + +const HOUR_MS = 60 * 60_000; +// Local 12:00 so the HH:mm expectations below are timezone-independent. +const START = new Date(2026, 5, 28, 12, 0, 0, 0).getTime(); +const NOW = START + HOUR_MS / 2; // 12:30 +const program: EpgProgram = { + start: new Date(START).toISOString(), + stop: new Date(START + HOUR_MS).toISOString(), + channel: 'ch', + title: 'Noon Show', + desc: null, + category: null, +}; + +describe('dashboard-live-epg.utils', () => { + it('formats the range and progress in wall-clock terms by default', () => { + expect(formatEpgTimeRange(program)).toBe('12:00 – 13:00'); + expect(buildDashboardLiveEpgDetails(program, NOW)).toEqual({ + nowPlayingTitle: 'Noon Show', + nowPlayingTimeRange: '12:00 – 13:00', + nowPlayingProgress: 50, + }); + }); + + it('shifts the label and measures progress in the provider clock with a display offset', () => { + // Offset +60: the guide runs an hour ahead, so the 12:00 row really + // airs 13:00–14:00 and at 12:30 has not started yet. + expect(formatEpgTimeRange(program, 60)).toBe('13:00 – 14:00'); + expect(buildDashboardLiveEpgDetails(program, NOW, 60)).toEqual({ + nowPlayingTitle: 'Noon Show', + nowPlayingTimeRange: '13:00 – 14:00', + nowPlayingProgress: 0, + }); + // Offset -30: it really started at 11:30 and is two thirds through. + expect( + buildDashboardLiveEpgDetails(program, NOW, -30)?.nowPlayingProgress + ).toBe(100); + }); +}); 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 5e3099c45..548e46e9e 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 @@ -1,6 +1,8 @@ -import type { - DashboardRailsSettings, - EpgProgram, +import { + epgDisplayTimeMs, + epgProviderClockMs, + type DashboardRailsSettings, + type EpgProgram, } from '@iptvnator/shared/interfaces'; import type { DashboardRailCard } from './dashboard-rail.component'; @@ -34,13 +36,19 @@ function formatEpgTime(ms: number): string { .padStart(2, '0')}`; } -export function formatEpgTimeRange(program: EpgProgram): string | null { +/** "HH:mm – HH:mm" in display time (raw times + the EPG display offset). */ +export function formatEpgTimeRange( + program: EpgProgram, + offsetMinutes = 0 +): string | null { const start = epgTimestampMs(program, 'start'); const stop = epgTimestampMs(program, 'stop'); if (start == null || stop == null) { return null; } - return `${formatEpgTime(start)} – ${formatEpgTime(stop)}`; + return `${formatEpgTime(epgDisplayTimeMs(start, offsetMinutes))} – ${formatEpgTime( + epgDisplayTimeMs(stop, offsetMinutes) + )}`; } export function calcEpgProgress( @@ -65,9 +73,15 @@ export interface DashboardLiveEpgDetails { readonly nowPlayingProgress: number | null; } +/** + * `nowMs` is wall-clock; `offsetMinutes` is the EPG display offset, applied in + * its two forms (`epg-display-offset.util.ts`): the range is shifted for + * display while progress compares the raw times with the provider clock. + */ export function buildDashboardLiveEpgDetails( program: EpgProgram | null, - nowMs: number + nowMs: number, + offsetMinutes = 0 ): DashboardLiveEpgDetails | null { if (!program) { return null; @@ -75,8 +89,11 @@ export function buildDashboardLiveEpgDetails( const details: DashboardLiveEpgDetails = { nowPlayingTitle: program.title?.trim() || null, - nowPlayingTimeRange: formatEpgTimeRange(program), - nowPlayingProgress: calcEpgProgress(program, nowMs), + nowPlayingTimeRange: formatEpgTimeRange(program, offsetMinutes), + nowPlayingProgress: calcEpgProgress( + program, + epgProviderClockMs(nowMs, offsetMinutes) + ), }; return details.nowPlayingTitle || 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 18ffa2c45..eab93674f 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 @@ -622,7 +622,11 @@ export class WorkspaceDashboardRailsComponent { const program = getLiveEpgProgramForCard(card, this.liveEpgPrograms()); // Recompute the now-window each tick so progress moves between // 30s ticks even if the program identity is unchanged. - return buildDashboardLiveEpgDetails(program, Date.now()); + return buildDashboardLiveEpgDetails( + program, + Date.now(), + this.settingsStore.resolvedEpgOffsetMinutes() + ); } private buildNonLiveSeeAllState(