feat(epg): add global EPG display time offset (#1489)

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 <markjardine27@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-09-04 18:46:10 +02:00
1 parent b2ca85172c
commit d8d36476e6
118 files changed
+3349 -352

No files matched your search

+7
View File
@@ -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.
+1
View File
@@ -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):
@@ -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);
@@ -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,
@@ -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,
@@ -42,6 +42,11 @@ function flattenSql(value: unknown, seen = new Set<unknown>()): 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 = {
@@ -165,7 +165,7 @@ export class EpgQueryService {
async getCurrentProgramsBatch(
channelIds: string[],
options: { sourceUrls?: string[] } = {}
options: { sourceUrls?: string[]; nowMs?: number } = {}
): Promise<Record<string, EpgProgram | null>> {
const result: Record<string, EpgProgram | null> = {};
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
@@ -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<Record<string, EpgProgram | null>> {
return queryByResolvedChannelIds(channelIds, (resolvedIds) =>
epgQueryService.getCurrentProgramsBatch(resolvedIds, options)
@@ -1,8 +1,4 @@
<section
class="settings-group"
id="epg"
[formGroup]="form()"
>
<section class="settings-group" id="epg" [formGroup]="form()">
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>list_alt</mat-icon>
@@ -47,6 +43,32 @@
</div>
</div>
<div class="setting-item setting-item--full-control">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.EPG_OFFSET_TITLE' | translate }}</h4>
<p>{{ 'SETTINGS.EPG_OFFSET_HINT' | translate }}</p>
</div>
<div class="setting-item__control">
<mat-form-field appearance="outline">
<mat-label>{{
'SETTINGS.EPG_OFFSET_LABEL' | translate
}}</mat-label>
<input
matInput
type="number"
min="-720"
max="720"
step="15"
formControlName="epgOffsetMinutes"
data-test-id="epg-offset-minutes"
/>
<mat-error>{{
'SETTINGS.EPG_OFFSET_ERROR' | translate
}}</mat-error>
</mat-form-field>
</div>
</div>
<div class="setting-item setting-item--full-control">
<div class="setting-item__control">
<ng-container formArrayName="epgUrl">
@@ -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();
@@ -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<number>(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 ?? [],
@@ -116,6 +116,7 @@ export const DEFAULT_SETTINGS = {
dashboardRails: DEFAULT_DASHBOARD_RAILS,
preferUploadedEpgOverXtream: false,
epgViewMode: 'timeline',
epgOffsetMinutes: 0,
tmdb: { enabled: false, apiKey: '' },
};
+4
View File
@@ -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": "قائمة",
+4
View File
@@ -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": "لائحة",
+4
View File
@@ -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": "Спіс",
+4
View File
@@ -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",
+4
View File
@@ -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": "Λίστα",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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": "リスト",
+4
View File
@@ -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": "목록",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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": "Список",
+4
View File
@@ -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",
+4
View File
@@ -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": "列表",
+4
View File
@@ -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": "清單",
+55
View File
@@ -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`:
@@ -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<ElectronBridgeApi>,
@@ -158,7 +160,7 @@ export class EpgRuntimeBridgeService {
getCurrentProgramsBatch(
channelIds: string[],
options?: EpgLookupOptions
options?: EpgCurrentProgramsOptions
): Promise<Record<string, EpgProgram | null> | null> {
if (!this.supportsCurrentProgramBatch) {
return Promise.resolve(null);
@@ -10,7 +10,11 @@ describe('EpgService', () => {
let service: EpgService;
let epgBridge: Partial<EpgRuntimeBridgeService>;
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<string, null>) => void = () =>
undefined;
epgBridge.getCurrentProgramsBatch = jest
.fn()
.mockImplementationOnce(
() =>
new Promise<Record<string, null>>((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<string, unknown>) => void)
| undefined;
((programs: Record<string, unknown>) => 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();
+57 -11
View File
@@ -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<string, EpgProgram | null>();
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<Map<string, EpgProgram | null>> {
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(() => {
@@ -213,6 +213,7 @@ describe('VideoPlayerComponent — M3U movie recognition gate', () => {
stripCountryPrefix,
m3uVodDetails,
resolvedEpgViewMode: signal('timeline'),
resolvedEpgOffsetMinutes: signal(0),
epgUrl: signal<string[]>([]),
},
},
@@ -128,6 +128,7 @@
@if (epgViewMode() === 'list') {
<app-epg-list-view
[programs]="epgPrograms()"
[offsetMinutes]="epgOffsetMinutes()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="
@@ -161,6 +162,7 @@
} @else {
<app-epg-timeline
[programs]="epgPrograms()"
[offsetMinutes]="epgOffsetMinutes()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="
@@ -48,11 +48,11 @@ import {
Settings,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
import { Overlay } from '@angular/cdk/overlay';
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component';
import { StubEpgTimelineComponent } from './video-player.spec-stubs';
jest.unstable_mockModule('video.js', () => ({
default: jest.fn(),
@@ -129,38 +129,6 @@ class StubWebPlayerViewComponent {
readonly recordingStopped = output<RecordingStoppedEvent>();
}
// Matches both live-panel selectors so the host's timeline ↔ list swap can be
// asserted by tag name; both branches share the identical contract.
@Component({
selector: 'app-epg-timeline, app-epg-list-view',
standalone: true,
template: '',
})
class StubEpgTimelineComponent {
readonly programs = input<EpgProgram[]>([]);
readonly channelName = input('');
readonly channelLogo = input('');
readonly archivePlaybackAvailable = input(false);
readonly archiveDays = input(0);
readonly activeProgram = input<EpgProgram | null>(null);
readonly isLivePlayback = input(true);
readonly loading = input(false);
readonly emptyReason = input<string | null>(null);
readonly selectedDate = input<string | null>(null);
readonly collapsed = input(false);
readonly summary = input<LiveEpgPanelSummary | null>(null);
readonly summaryLabelKey = input('');
readonly programActivated = output<{
program?: EpgProgram;
type: 'timeshift' | 'live';
}>();
readonly returnToLive = output<void>();
readonly selectedDateChange = output<string>();
readonly openEpgSettings = output<void>();
readonly retry = output<void>();
readonly collapsedChange = output<boolean>();
}
@Directive({
selector: '[appResizable]',
standalone: true,
@@ -420,6 +388,7 @@ describe('VideoPlayerComponent', () => {
showCaptions,
stripCountryPrefix,
resolvedEpgViewMode: epgViewMode,
resolvedEpgOffsetMinutes: signal(0),
epgUrl: epgUrlSetting,
},
},
@@ -112,6 +112,7 @@ import {
Channel,
createDevLogger,
EpgProgram,
epgProviderClockMs,
ExternalPlayerSession,
filterRecordingProgramsOverlap,
OPEN_MPV_PLAYER,
@@ -388,6 +389,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
readonly selectedLiveEpgDate = signal(getTodayEpgDateKey());
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
readonly epgViewMode = this.settingsStore.resolvedEpgViewMode;
readonly epgOffsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes;
readonly isLiveEpgPanelCollapsed = computed(
() => this.liveEpgPanelState() === 'collapsed'
);
@@ -435,7 +437,10 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
// gap must snapshot no program, not a stale one — stop enrichment
// deliberately never overwrites a persisted start title.
const program =
findCurrentEpgProgram(this.epgPrograms(), this.epgNowMs()) ?? null;
findCurrentEpgProgram(
this.epgPrograms(),
epgProviderClockMs(this.epgNowMs(), this.epgOffsetMinutes())
) ?? null;
const playlistName = playlistDisplayLabel(
this.activePlaylistMeta()?.title
);
@@ -483,7 +488,8 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
const programs = filterRecordingProgramsOverlap(
this.epgPrograms().map(toRecordingProgramSnapshot),
event.startedAt,
event.endedAt
event.endedAt,
this.epgOffsetMinutes()
);
if (programs.length === 0) {
return;
@@ -617,7 +623,12 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
EpgActions.setEpgAvailableFlag({ value: programs.length > 0 })
);
const currentProgram = findCurrentEpgProgram(programs, nowMs);
// Raw programme times vs. now in the provider's EPG clock
// (`epg-display-offset.util.ts`, clock form).
const currentProgram = findCurrentEpgProgram(
programs,
epgProviderClockMs(nowMs, this.epgOffsetMinutes())
);
if (currentProgram) {
this.store.dispatch(
EpgActions.setCurrentEpgProgram({ program: currentProgram })
@@ -806,9 +817,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
}
const currentEpgProgram = epgProgram as
| EpgProgram
| null
| undefined;
EpgProgram | null | undefined;
const currentIndex = channels.findIndex(
(channel) => channel.url === activeChannel.url
);
@@ -0,0 +1,40 @@
import { Component, input, output } from '@angular/core';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
/**
* Stand-in for the live EPG panel. Matches both selectors so the host's
* timeline ↔ list swap can be asserted by tag name; both branches share the
* identical input/output contract, which this stub has to mirror — an input
* missing here fails the host template with NG0303 at compile time.
*/
@Component({
selector: 'app-epg-timeline, app-epg-list-view',
standalone: true,
template: '',
})
export class StubEpgTimelineComponent {
readonly programs = input<EpgProgram[]>([]);
readonly offsetMinutes = input(0);
readonly channelName = input('');
readonly channelLogo = input('');
readonly archivePlaybackAvailable = input(false);
readonly archiveDays = input(0);
readonly activeProgram = input<EpgProgram | null>(null);
readonly isLivePlayback = input(true);
readonly loading = input(false);
readonly emptyReason = input<string | null>(null);
readonly selectedDate = input<string | null>(null);
readonly collapsed = input(false);
readonly summary = input<LiveEpgPanelSummary | null>(null);
readonly summaryLabelKey = input('');
readonly programActivated = output<{
program?: EpgProgram;
type: 'timeshift' | 'live';
}>();
readonly returnToLive = output<void>();
readonly selectedDateChange = output<string>();
readonly openEpgSettings = output<void>();
readonly retry = output<void>();
readonly collapsedChange = output<boolean>();
}
@@ -167,8 +167,8 @@
@for (program of coveredPrograms(); track program.start) {
<li class="recording-detail__program">
<time>
{{ program.start | date: 'HH:mm' }} –
{{ program.stop | date: 'HH:mm' }}
{{ programDisplayMs(program.start) | date: 'HH:mm' }} –
{{ programDisplayMs(program.stop) | date: 'HH:mm' }}
</time>
<div>
<div class="recording-detail__program-title">
@@ -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
@@ -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<Playlist>)
),
},
},
{ 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<Playlist>)
);
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<Playlist>)
);
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<Playlist>);
});
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');
});
});
@@ -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,
@@ -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<string, UnifiedCollectionItem[]>();
const stalkerByPlaylist = new Map<string, UnifiedCollectionItem[]>();
@@ -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<string, string | undefined | null>).epgChannelId?.trim();
const epgKey = (
item as unknown as Record<string, string | undefined | null>
).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<EpgItem[]> {
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<string, EpgProgram | null>,
now: number
clock: EpgLoadClock
): Promise<void> {
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<string, string | undefined | null>).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<EpgItem[]> {
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<string, EpgProgram | null>,
now: number
clock: EpgLoadClock
): Promise<void> {
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;
}
@@ -33,6 +33,7 @@ describe('GlobalFavoritesListComponent', () => {
provide: SettingsStore,
useValue: {
openStreamOnDoubleClick: signal(false),
resolvedEpgOffsetMinutes: signal(0),
},
},
],
@@ -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;
@@ -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()
);
});
});
@@ -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 {
@@ -52,6 +52,7 @@ describe('UnifiedLiveTabComponent channelsForList', () => {
player: signal(VideoPlayer.VideoJs),
stripCountryPrefix: signal(false),
resolvedEpgViewMode: signal('timeline'),
resolvedEpgOffsetMinutes: signal(0),
},
},
{
@@ -87,6 +87,7 @@
@if (epgViewMode() === 'list') {
<app-epg-list-view
[programs]="timelinePrograms()"
[offsetMinutes]="epgOffsetMinutes()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="
@@ -116,6 +117,7 @@
} @else {
<app-epg-timeline
[programs]="timelinePrograms()"
[offsetMinutes]="epgOffsetMinutes()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="
@@ -45,6 +45,7 @@ describe('UnifiedLiveTabComponent', () => {
let component: UnifiedLiveTabComponent;
let player: ReturnType<typeof signal<VideoPlayer>>;
let epgViewMode: ReturnType<typeof signal<'timeline' | 'list'>>;
let epgOffsetMinutes: ReturnType<typeof signal<number>>;
let stripCountryPrefix: ReturnType<typeof signal<boolean>>;
let streamResolver: {
resolveLiveDetail: jest.Mock;
@@ -83,6 +84,7 @@ describe('UnifiedLiveTabComponent', () => {
};
player = signal(VideoPlayer.VideoJs);
epgViewMode = signal<'timeline' | 'list'>('timeline');
epgOffsetMinutes = signal(0);
stripCountryPrefix = signal(false);
portalPlayer = {
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
@@ -111,6 +113,7 @@ describe('UnifiedLiveTabComponent', () => {
player,
stripCountryPrefix,
resolvedEpgViewMode: epgViewMode,
resolvedEpgOffsetMinutes: epgOffsetMinutes,
},
},
{ provide: PORTAL_PLAYER, useValue: portalPlayer },
@@ -318,6 +321,69 @@ describe('UnifiedLiveTabComponent', () => {
subscription.unsubscribe();
});
it('snapshots the recording-start programme in the provider clock under a display offset', async () => {
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
const item = buildLiveItem('m3u');
const providerNow = buildCurrentProgram('Provider Now');
const earlier: EpgProgram = {
...buildCurrentProgram('Really On Air'),
start: new Date(Date.now() - 130 * 60 * 1000).toISOString(),
stop: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
};
streamResolver.resolveM3uPlaybackDetail.mockResolvedValue({
epgMode: 'm3u',
playback: {
streamUrl: 'https://example.com/m3u.m3u8',
title: 'M3U Live',
},
channel: {
id: 'm3u-channel',
name: 'M3U Live',
url: 'https://example.com/m3u.m3u8',
group: { title: 'News' },
tvg: {
id: 'm3u-channel',
name: 'M3U Live',
url: '',
logo: 'm3u.png',
rec: '',
},
http: { referrer: '', 'user-agent': '', origin: '' },
radio: 'false',
epgParams: '',
},
epgPrograms: [earlier, providerNow],
});
streamResolver.loadM3uProgramsForItem.mockResolvedValue([
earlier,
providerNow,
]);
recentData.recordLivePlayback.mockResolvedValue({
...item,
viewedAt: '2026-03-26T12:00:00.000Z',
});
fixture.componentRef.setInput('items', [item]);
fixture.detectChanges();
await fixture.whenStable();
await component.onChannelSelected(component.channelsForList()[0]);
fixture.detectChanges();
await fixture.whenStable();
expect(component.recordingMetadata()?.currentProgram?.title).toBe(
'Provider Now'
);
// The guide runs an hour ahead: the recording must be filed under the
// programme really on air, which the provider lists as an hour ago.
epgOffsetMinutes.set(60);
fixture.detectChanges();
expect(component.recordingMetadata()?.currentProgram?.title).toBe(
'Really On Air'
);
});
it('renders inline M3U EPG in the timeline with shared date navigation', async () => {
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
const item = buildLiveItem('m3u');
@@ -8,6 +8,7 @@ import {
input,
output,
signal,
untracked,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
@@ -71,6 +72,7 @@ import {
buildStalkerEpgMappingKey,
buildXtreamEpgMappingKey,
EpgProgram,
epgProviderClockMs,
filterRecordingProgramsOverlap,
playlistDisplayLabel,
RecordingStartMetadata,
@@ -275,6 +277,7 @@ export class UnifiedLiveTabComponent {
);
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
readonly epgViewMode = this.settingsStore.resolvedEpgViewMode;
readonly epgOffsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes;
readonly liveEpgPanelSummary = computed(() => {
const timeshift = this.activeTimeshift();
if (timeshift) {
@@ -282,7 +285,10 @@ export class UnifiedLiveTabComponent {
return toLiveEpgPanelSummary(timeshift.program);
}
this.progressTick();
return getLiveEpgPanelSummary(this.activeDetail());
return getLiveEpgPanelSummary(
this.activeDetail(),
this.epgOffsetMinutes()
);
});
readonly liveEpgPanelSummaryLabelKey = computed(() =>
this.activeTimeshift() ? 'EPG.ARCHIVE_PLAYBACK' : 'EPG.CURRENT_PROGRAM'
@@ -298,7 +304,10 @@ export class UnifiedLiveTabComponent {
// Date.now() verdict, and a recording started after an EPG boundary
// would snapshot the previous show.
this.progressTick();
const now = Date.now();
// Raw programme times vs. now in the provider's EPG clock, like the
// panel summary — the start snapshot is authoritative for the
// recording's title, so it must name the same programme.
const now = epgProviderClockMs(Date.now(), this.epgOffsetMinutes());
const program =
this.timelinePrograms().find((candidate) => {
const start = Date.parse(candidate.start);
@@ -350,7 +359,8 @@ export class UnifiedLiveTabComponent {
const programs = filterRecordingProgramsOverlap(
this.timelinePrograms().map(toRecordingProgramSnapshot),
event.startedAt,
event.endedAt
event.endedAt,
this.epgOffsetMinutes()
);
if (programs.length === 0) {
return;
@@ -433,6 +443,23 @@ export class UnifiedLiveTabComponent {
}
});
// The map answers "what is on at the provider clock", so a changed
// display offset reloads it; the request id in loadEpgMap drops the
// older load. The first run only records the initial value.
let appliedEpgOffsetMinutes: number | null = null;
effect(() => {
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<void> {
// 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);
}
@@ -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 },
@@ -81,6 +81,7 @@ export class StubEpgTimelineComponent {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly summary = input<any>(null);
readonly summaryLabelKey = input('');
readonly offsetMinutes = input(0);
readonly selectedDateChange = output<string>();
readonly collapsedChange = output<boolean>();
readonly programActivated = output<EpgProgramActivationEvent>();
@@ -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.
@@ -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<Promise<EpgProgram[]>, [string]>()
.mockImplementationOnce(
() =>
new Promise<EpgProgram[]>((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),
@@ -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<EpgProgram[]>;
/** 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<void> {
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();
}
}
}
/**
@@ -225,6 +225,7 @@
@if (epgViewMode() === 'list') {
<app-epg-list-view
[programs]="activeEpgPrograms()"
[offsetMinutes]="epgOffsetMinutes()"
[channelName]="
controlledChannel()?.name ?? activePlaybackTitle()
"
@@ -247,6 +248,7 @@
} @else {
<app-epg-timeline
[programs]="activeEpgPrograms()"
[offsetMinutes]="epgOffsetMinutes()"
[channelName]="
controlledChannel()?.name ?? activePlaybackTitle()
"
@@ -9,7 +9,10 @@ import {
PORTAL_PLAYER,
ResizableDirective,
} from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import {
ACTIVE_EPG_FALLBACK_SIZE,
StalkerStore,
} from '@iptvnator/portal/stalker/data-access';
import { EpgListViewComponent, EpgTimelineComponent } from '@iptvnator/ui/epg';
import { AudioPlayerComponent } from '@iptvnator/ui/playback';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
@@ -178,6 +181,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
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)
@@ -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<ElementRef>('scrollContainer');
private scrollListener: (() => void) | null = null;
private epgPreviewRefreshTimer: ReturnType<typeof setTimeout> | null =
null;
private epgPreviewRefreshTimer: ReturnType<typeof setTimeout> | 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(
@@ -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,
@@ -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,
@@ -150,6 +150,7 @@ describe('StalkerLiveStreamLayoutComponent remote status', () => {
useValue: {
openStreamOnDoubleClick: signal(false),
resolvedEpgViewMode: signal('timeline'),
resolvedEpgOffsetMinutes: signal(0),
},
},
{
@@ -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<string>();
readonly programActivated = output<EpgProgram>();
readonly returnToLive = output<void>();
@@ -49,6 +49,7 @@ describe('EpgQueueService invalidation', () => {
provide: SettingsStore,
useValue: {
preferUploadedEpgOverXtream: jest.fn(() => false),
resolvedEpgOffsetMinutes: jest.fn(() => 0),
},
},
],
@@ -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<EpgItem[]>((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<EpgItem[]>((_, 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<number> }
).visibleSet;
const internal = (service as unknown as { visibleSet: Set<number> })
.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.
@@ -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<number, number>();
/** 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<number>,
credentials: XtreamCredentials
): Promise<void> {
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() });
@@ -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<Promise<EpgProgram[]>, [string]>;
getCurrentProgramsBatch: jest.Mock<
Promise<Record<string, EpgProgram | null>>,
[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 () => {
@@ -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<EpgProgram[]>;
getCurrentProgramsBatch?: (
channelIds: string[]
channelIds: string[],
options?: { nowMs?: number }
) => Promise<Record<string, EpgProgram | null>>;
};
@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<string, EpgItem> = {};
for (const id of ids) {
const row = rows?.[id];
@@ -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 },
@@ -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<EpgState>(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);
@@ -169,6 +169,7 @@
@if (epgViewMode() === 'list') {
<app-epg-list-view
[programs]="controlledEpgPrograms()"
[offsetMinutes]="epgOffsetMinutes()"
[channelName]="activePlayback()?.title ?? ''"
[channelLogo]="activePlayback()?.thumbnail ?? ''"
[sourceLabel]="'Xtream'"
@@ -189,6 +190,7 @@
} @else {
<app-epg-timeline
[programs]="controlledEpgPrograms()"
[offsetMinutes]="epgOffsetMinutes()"
[channelName]="activePlayback()?.title ?? ''"
[channelLogo]="activePlayback()?.thumbnail ?? ''"
[sourceLabel]="'Xtream'"
@@ -117,6 +117,7 @@ class StubEpgTimelineComponent {
readonly collapsed = input(false);
readonly summary = input<EpgTimelineSummary | null>(null);
readonly summaryLabelKey = input('');
readonly offsetMinutes = input(0);
readonly programActivated = output<EpgProgramActivationEvent>();
readonly returnToLive = output<void>();
readonly selectedDateChange = output<string>();
@@ -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;
@@ -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(
@@ -143,6 +143,7 @@ describe('PortalChannelsListComponent', () => {
provide: SettingsStore,
useValue: {
openStreamOnDoubleClick: signal(false),
resolvedEpgOffsetMinutes: signal(0),
},
},
{
@@ -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) -
@@ -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);
@@ -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<EpgViewMode>(
() => store.epgViewMode?.() ?? 'timeline'
),
resolvedEpgOffsetMinutes: computed(() =>
normalizeEpgOffsetMinutes(store.epgOffsetMinutes?.())
),
})),
withMethods((store, storage = inject(StorageMap)) => {
let settingsLoadPromise: Promise<void> | 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?.()
),
+1
View File
@@ -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';
@@ -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<EpgProgram[]>;
getCurrentProgramsBatch: (
channelIds: string[],
options?: ElectronBridgeEpgLookupOptions
options?: ElectronBridgeCurrentProgramsOptions
) => Promise<Record<string, EpgProgram | null>>;
getEpgChannelMetadata: (
channelIds: string[],
@@ -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']);
});
});
});
@@ -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);
}
@@ -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,
@@ -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) ||
@@ -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;
/**
@@ -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. */
@@ -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<Map<string, EpgProgram | null>>();
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: [] }));
@@ -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;
}
@@ -53,9 +53,9 @@
</div>
<div class="epg-timeline">
<span class="epg-time">{{
(program.startTimestamp
? program.startTimestamp * 1000
: program.start
programDisplayMs(
program.start,
program.startTimestamp
) | date: 'HH:mm'
}}</span>
<div class="epg-progress-track">
@@ -67,9 +67,9 @@
></div>
</div>
<span class="epg-time">{{
(program.stopTimestamp
? program.stopTimestamp * 1000
: program.stop
programDisplayMs(
program.stop,
program.stopTimestamp
) | date: 'HH:mm'
}}</span>
</div>
@@ -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);
});
});
@@ -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<string | null | undefined>('');
@@ -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);
@@ -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. */
@@ -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<HTMLElement>);
@@ -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) {
@@ -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) {
+2
View File
@@ -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';
@@ -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);
});
});
@@ -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<EpgItemDialogData>(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);
}
@@ -59,8 +59,8 @@
<span class="cn-title">{{ summary()?.title }}</span>
@if (hasTimeRange()) {
<span class="cn-meta"
>{{ summary()?.start | date: 'HH:mm' }} –
{{ summary()?.stop | date: 'HH:mm' }}</span
>{{ summaryStartMs() | date: 'HH:mm' }} –
{{ summaryStopMs() | date: 'HH:mm' }}</span
>
}
}
@@ -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<EpgTimelineSummary | null>(null);
readonly summaryLabelKey = input('EPG.CURRENT_PROGRAM');
readonly offsetMinutes = input(0);
readonly programActivated = output<EpgProgramActivationEvent>();
readonly returnToLive = output<void>();
@@ -128,6 +130,7 @@ export class EpgListViewComponent {
archivePlaybackAvailable: this.archivePlaybackAvailable(),
archiveDays: this.archiveDays(),
activeProgram: this.activeProgram(),
offsetMinutes: this.offsetMinutes(),
})
);
readonly nowRow = computed<EpgListRow | null>(
@@ -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);
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more