mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
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:
118 files changed
+3349
-352
No files matched your search
@@ -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.
|
||||
@@ -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: '' },
|
||||
};
|
||||
|
||||
|
||||
@@ -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": "قائمة",
|
||||
|
||||
@@ -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": "لائحة",
|
||||
|
||||
@@ -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": "Спіс",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Λίστα",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "リスト",
|
||||
|
||||
@@ -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": "목록",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Список",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "列表",
|
||||
|
||||
@@ -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": "清單",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]="
|
||||
|
||||
+2
-33
@@ -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>();
|
||||
}
|
||||
+2
-2
@@ -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">
|
||||
|
||||
+14
-1
@@ -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
|
||||
|
||||
+356
@@ -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;
|
||||
}
|
||||
+1
@@ -33,6 +33,7 @@ describe('GlobalFavoritesListComponent', () => {
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
openStreamOnDoubleClick: signal(false),
|
||||
resolvedEpgOffsetMinutes: signal(0),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
+7
-1
@@ -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;
|
||||
|
||||
+83
@@ -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()
|
||||
);
|
||||
});
|
||||
});
|
||||
+30
-13
@@ -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 {
|
||||
|
||||
+1
@@ -52,6 +52,7 @@ describe('UnifiedLiveTabComponent channelsForList', () => {
|
||||
player: signal(VideoPlayer.VideoJs),
|
||||
stripCountryPrefix: signal(false),
|
||||
resolvedEpgViewMode: signal('timeline'),
|
||||
resolvedEpgOffsetMinutes: signal(0),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
+2
@@ -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]="
|
||||
|
||||
+66
@@ -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');
|
||||
|
||||
+38
-3
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -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 },
|
||||
|
||||
+1
@@ -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.
|
||||
|
||||
+68
@@ -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),
|
||||
|
||||
+41
-3
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+2
@@ -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()
|
||||
"
|
||||
|
||||
+13
-3
@@ -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)
|
||||
|
||||
+24
-7
@@ -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(
|
||||
|
||||
+4
-1
@@ -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,
|
||||
|
||||
+4
-1
@@ -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,
|
||||
|
||||
+1
@@ -150,6 +150,7 @@ describe('StalkerLiveStreamLayoutComponent remote status', () => {
|
||||
useValue: {
|
||||
openStreamOnDoubleClick: signal(false),
|
||||
resolvedEpgViewMode: signal('timeline'),
|
||||
resolvedEpgOffsetMinutes: signal(0),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
+1
@@ -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() });
|
||||
|
||||
+41
-6
@@ -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);
|
||||
|
||||
+2
@@ -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'"
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+8
-3
@@ -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(
|
||||
|
||||
+1
@@ -143,6 +143,7 @@ describe('PortalChannelsListComponent', () => {
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
openStreamOnDoubleClick: signal(false),
|
||||
resolvedEpgOffsetMinutes: signal(0),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
+41
-3
@@ -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?.()
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
+16
-2
@@ -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. */
|
||||
|
||||
+44
-1
@@ -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: [] }));
|
||||
|
||||
+30
-3
@@ -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;
|
||||
}
|
||||
|
||||
+6
-6
@@ -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>
|
||||
|
||||
+62
-1
@@ -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);
|
||||
});
|
||||
});
|
||||
+10
-1
@@ -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);
|
||||
|
||||
+16
-2
@@ -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. */
|
||||
|
||||
+16
-3
@@ -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) {
|
||||
|
||||
+17
-3
@@ -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) {
|
||||
|
||||
@@ -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
Reference in new issue
Block a user