fix(xtream): render catch-up start times in the panel timezone (#1563)

* fix(xtream): render catch-up start times in the panel timezone

The `{Y-m-d:H-M}` segment of an Xtream timeshift URL is read by the panel
with `strtotime()` in ITS timezone (`server_info.timezone`), never the
viewer's. The timezone was learned in memory only, by the store's
`checkPortalStatus()`, so the Favorites / Recent catch-up resolver — which
reads the STORED playlist row — always fell back to the viewer's local
clock and asked the panel for the wrong programme (#1562).

- Normalize the panel's clock once (`resolveXtreamServerTimezone`): an
  ICU-resolvable name is kept, otherwise a `UTC±HH:MM` offset is derived
  from the `time_now` / `timestamp_now` clock pair, so spellings such as
  `UTC+3` no longer silently mean "local time".
- Persist it on the playlist row through `transformPlaylistMeta` (no-op
  when unchanged) and project it back from the payload in
  `DB_GET_PLAYLIST`, so both catch-up entry points and a restart see it.
- Format with `hourCycle: 'h23'` (server midnight is `00`, never `24`) and
  read timestamp-less EPG `start`/`end` strings in the panel's clock.
- Mock: `tzoffset:tzoffset` scenario with an unusable timezone name and a
  +03:00 clock pair; Electron e2e covers Live TV, Favorites, a restart into
  Global favorites, and the clock-pair derivation at a UTC-3 viewer.

Closes #1562

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(xtream): guard the account-info answer by playlist identity and reject rolled-over dates

Review follow-ups (Greptile):

- A source switch while `get_account_info` is in flight no longer hands
  playlist A's status or clock to playlist B: the store is patched only
  while the asking playlist is still selected, the timezone is persisted
  under the asking playlist's id regardless, and a late failure cannot mark
  the newly selected playlist unavailable.
- `parseNaiveUtcMs` reads the constructed date back, so out-of-range panel
  strings (`2026-13-01 25:00:00`) are rejected instead of silently rolling
  over into a real instant.
- Document that a clock-derived fixed offset is a DST-less snapshot, refreshed
  by every account-info check and only ever used for non-standard servers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(xtream): drop a panel clock that no longer belongs to the source

Review follow-ups (Codex + Greptile):

- A metadata update or DB_UPDATE_PLAYLIST that points the source at another
  server drops the persisted `serverTimezone` (payload-only) until the next
  account-info check, so Favorites / Recent cannot keep rendering the OLD
  panel's clock; an update that supplies a clock keeps it.
- A late account-info answer is persisted only onto a row that still points
  at the panel it came from — an edit that moved the source during the
  request keeps the clock the edit flow dropped.
- The PWA data source and the route-session converter carry the persisted
  timezone into the store playlist, so a later response without a usable
  clock has a previous value to preserve.
- Mirror the catch-up timezone contract into AGENTS.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(xtream): drop the stale panel clock inside the UPDATE statement

Review follow-up (Codex): the database worker interleaves requests, so a
read-modify-write of the playlist payload could hand a concurrent upsert's
newer payload back to the past. The `serverTimezone` removal on a server
URL change is now one `CASE … json_remove(payload, '$.serverTimezone')`
expression inside the same UPDATE, guarded by `json_valid`; the spec runs
the real statement against Electron's SQLite on the actual `playlists`
table (moved, renamed, clock-less, malformed-payload and NULL-URL rows).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(xtream): split the server-clock primitives out of the timezone util

Review follow-up (Greptile): `xtream-server-timezone.util.ts` had grown past
the 300-line file guideline. The zone-agnostic wall-clock primitives (stored
forms, Intl parts, naive parsing) now live in `xtream-server-clock.util.ts`;
the timezone util keeps the Xtream policy and re-exports the public helpers,
so every import and the spec are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(xtream): offer the learned panel clock to storage on every check

Review follow-up (Codex): a transient storage failure left the clock in the
store but not on the row, and the next check compared the answer with the
in-memory value and never retried. The resolved timezone is now always
handed to `transformPlaylistMeta`, whose row-level equality check keeps the
common case a read without a write; a failed write is retried by the next
account-info check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(xtream): apply an account-info answer only to the panel it came from

Review follow-up (Codex): an in-place edit keeps the playlist id while
moving the source, so an answer already on the wire for the OLD panel
passed the id-only guard and patched the new panel's status and clock into
the store. One `answersFor(candidate, credentials)` predicate now gates the
store patch, the error path and the persisted-row transform alike.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(xtream): never report another panel's status for the selected playlist

Review follow-up (Greptile): callers gate content initialization on the
value `checkPortalStatus()` returns for whatever is selected NOW. When the
answer no longer describes the selected playlist (source switch or in-place
edit during the request), the store's own verdict about the current
selection is returned instead of the old panel's status — on success and on
failure alike.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(xtream): persist the panel clock with one conditional UPDATE

Review follow-up (Codex): `transformPlaylistMeta` reads the row and then
upserts it whole, while the Xtream edit dialog saves through
`DB_UPDATE_PLAYLIST` outside `PlaylistsService`'s queue and the database
worker interleaves requests — an edit landing between that read and the
upsert was silently undone.

Persistence now goes through `IXtreamDataSource.rememberServerTimezone`:

- Electron: new `DB_SET_PLAYLIST_SERVER_TIMEZONE` worker op — one UPDATE
  that `json_set`s the payload only while the row still points at the
  request's connection and does not already carry the value; a malformed
  payload is never rewritten (CASE, not AND, so json_extract cannot run
  before json_valid). Wired through the worker types, main handler,
  preload, bridge interface, both IPC contract tables and
  `DatabaseService.setXtreamPlaylistServerTimezone`.
- PWA: `transformPlaylistMeta`, whose read and write share one IndexedDB
  readwrite cursor transaction, plus the localStorage copy.

The store no longer injects `PlaylistsService`; it offers the resolved clock
to the data source and keeps only its in-memory guards. Real-SQLite coverage
for the op (fresh / same / moved / NULL / malformed / missing rows),
delegation specs for both data sources, docs updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(xtream): keep the stored panel clock across clockless full upserts

Review follow-up (Codex): a `PlaylistsService` mutation that read the row
before `DB_SET_PLAYLIST_SERVER_TIMEZONE` landed and upserted afterwards
replaced the payload with its clockless snapshot. `DB_UPSERT_APP_PLAYLIST(S)`
now carry the STORED clock into a snapshot that has none while the row still
points at the same connection (`playlistConflictUpdate`, nested CASE so the
json_* readers never run on a malformed payload); a snapshot with its own
clock, or one that moves the source, wins as is. Real-SQLite coverage for
kept / moved / own-clock / batch rows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(release): split capture-navigation under the max-lines cap

`tools/release/capture-navigation.ts` had grown to 567 counted lines, past
the 400-line rule, which failed `release-tools:lint` and — because the file
was not in the baseline — the max-lines baseline test on master and on
every PR branched from it. The 19 named setup actions are now grouped by
subject over one leaf module of shared page helpers:

- `capture-navigation-helpers.ts`: playlist-id registry, dialog handling,
  navigation moves, `settleUi`
- `capture-navigation-setup-actions.ts`: add-playlist dialogs, settings
  sections, remote control
- `capture-navigation-portal-actions.ts`: portal catalogs, live lists,
  alternative sources (the two identical live-category flows share one
  helper)
- `capture-navigation-download-actions.ts`: the download manager shots
- `capture-navigation.ts`: the `runAction` dispatcher, theme switching and
  the re-exported API the seeding driver and the capture script import

Actions call their siblings directly instead of recursing through
`runAction`, so no module depends on the dispatcher. The action vocabulary
is unchanged (same 19 names, same waits and timeouts); every file is under
300 lines and the new modules are listed in the `release-tools` lint target.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(electron): move the panel-clock SQL into its own operations module

Review follow-up (Greptile): the timezone persistence, invalidation,
upsert-preservation and row projection had landed in
`playlist.operations.ts`, a baselined 1,000-line file. They now live in
`playlist-server-timezone.operations.ts` (155 lines) — the three SQL
shapes plus the payload projection — and the playlist operations compose
them; the baselined file shrinks by 107 lines. Behaviour and the
real-SQLite coverage are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-09-07 19:40:47 +02:00
1 parent 98da686cea
commit 97b0264dee
51 files changed
+3259 -844

No files matched your search

@@ -0,0 +1,7 @@
---
type: fix
area: xtream
issues: [1562]
---
Catch-up (timeshift) from the Favorites and Recent tabs now asks the panel for the programme you clicked: the start time is rendered in the panel's own timezone instead of your computer's. The panel's timezone is remembered per source, survives restarts, and panels that report an unusual timezone name are handled through their clock.
+20
View File
@@ -323,6 +323,26 @@ MPV/VLC and Embedded MPV retain manual TS; Video.js segment retry cycles without
a terminal diagnostic also need manual TS. Contract and full support matrix:
`docs/architecture/xtream-portal-compatibility.md` (Initial Auto HLS failure).
## Xtream Catch-Up Server Timezone
The `{Y-m-d:H-M}` segment of a timeshift URL is read by the panel in ITS
timezone (`server_info.timezone`), never the viewer's (issue #1562).
`withPortal.checkPortalStatus()` normalizes it with
`resolveXtreamServerTimezone()` (`libs/shared/interfaces`, an ICU-resolvable
name, else a `UTC±HH:MM` derived from the `time_now`/`timestamp_now` clock
pair) and persists it on the playlist row through
`IXtreamDataSource.rememberServerTimezone` — Electron: one conditional
`json_set` UPDATE (`DB_SET_PLAYLIST_SERVER_TIMEZONE`) guarded by the row's
current connection; PWA: `PlaylistsService.transformPlaylistMeta` — because
the Favorites / Recent resolver reads the STORED row, not the store, and the
worker interleaves requests, so no read may precede the write.
`DB_GET_PLAYLIST` projects it back from the row payload, and a server URL
change drops it until the next account-info check. The same value converts
timestamp-less EPG
`start`/`end` strings. Contract:
`docs/architecture/xtream-portal-compatibility.md` ("Start time is the
panel's clock, not the viewer's").
## Radio / Audio Player
M3U playlists can contain radio channels identified by the `radio="true"` attribute on `#EXTINF` lines. When a radio channel is selected:
+20
View File
@@ -805,6 +805,26 @@ MPV/VLC and Embedded MPV retain manual TS; Video.js segment retry cycles without
a terminal diagnostic also need manual TS. Contract and full support matrix:
`docs/architecture/xtream-portal-compatibility.md` (Initial Auto HLS failure).
#### Xtream Catch-Up Server Timezone
The `{Y-m-d:H-M}` segment of a timeshift URL is read by the panel in ITS
timezone (`server_info.timezone`), never the viewer's (issue #1562).
`withPortal.checkPortalStatus()` normalizes it with
`resolveXtreamServerTimezone()` (`libs/shared/interfaces`, an ICU-resolvable
name, else a `UTC±HH:MM` derived from the `time_now`/`timestamp_now` clock
pair) and persists it on the playlist row through
`IXtreamDataSource.rememberServerTimezone` — Electron: one conditional
`json_set` UPDATE (`DB_SET_PLAYLIST_SERVER_TIMEZONE`) guarded by the row's
current connection; PWA: `PlaylistsService.transformPlaylistMeta` — because
the Favorites / Recent resolver reads the STORED row, not the store, and the
worker interleaves requests, so no read may precede the write.
`DB_GET_PLAYLIST` projects it back from the row payload, and a server URL
change drops it until the next account-info check. The same value converts
timestamp-less EPG
`start`/`end` strings. Contract:
`docs/architecture/xtream-portal-compatibility.md` ("Start time is the
panel's clock, not the viewer's").
#### M3U URL User-Agent
- The URL import form accepts an optional User-Agent and stores it as
@@ -0,0 +1,241 @@
import type { Page } from '@playwright/test';
import {
addXtreamPortal,
channelItemByTitle,
clickCategoryByNameExact,
closeElectronApp,
expect,
launchElectronApp,
LaunchedElectronApp,
openGlobalFavorites,
openPlaylistFavorites,
openWorkspaceSection,
resetMockServers,
restartElectronApp,
switchUnifiedCollectionScope,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import { fetchXtreamEpgFixture } from './portal-mock-fixtures';
/**
* Issue #1562: the `{Y-m-d:H-M}` segment of an Xtream timeshift URL is read
* by the panel in ITS timezone (`server_info.timezone`), never the viewer's.
* The viewer here sits at UTC-3 while the mock panel runs at UTC (or, in the
* clock-pair scenario, at an unusable `UTC+3` name with a +03:00 clock), so
* a URL rendered in the viewer's clock is unambiguously wrong.
*/
const VIEWER_TIMEZONE = 'America/Sao_Paulo';
const CHANNEL = 'Timezone News';
const PAST_PROGRAM = 'Earlier Bulletin';
const TIMESHIFT_SEGMENT =
/\/timeshift\/[^/]+\/[^/]+\/\d+\/(\d{4}-\d{2}-\d{2}:\d{2}-\d{2})\//;
function formatWallClock(epochSeconds: number, offsetMinutes: number): string {
const date = new Date((epochSeconds + offsetMinutes * 60) * 1000);
const pad = (value: number) => String(value).padStart(2, '0');
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}:${pad(date.getUTCHours())}-${pad(date.getUTCMinutes())}`;
}
function formatInZone(epochSeconds: number, timeZone: string): string {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).formatToParts(new Date(epochSeconds * 1000));
const read = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value ?? '??';
return `${read('year')}-${read('month')}-${read('day')}:${read('hour')}-${read('minute')}`;
}
function captureTimeshiftRequests(page: Page): string[] {
const captured: string[] = [];
page.on('request', (request) => {
if (request.url().includes('/timeshift/')) {
captured.push(request.url());
}
});
return captured;
}
async function activatePastProgram(
page: Page,
captured: string[]
): Promise<string> {
captured.length = 0;
await expect(page.locator('app-epg-timeline')).toBeVisible({
timeout: 20000,
});
const block = page
.locator('app-epg-timeline .epg-timeline__block')
.filter({ hasText: PAST_PROGRAM })
.first();
await expect(block).toBeVisible({ timeout: 20000 });
await block.click();
await expect
.poll(() => captured.length, { timeout: 30000 })
.toBeGreaterThan(0);
const segment = TIMESHIFT_SEGMENT.exec(captured[0] ?? '')?.[1];
if (!segment) {
throw new Error(`Unexpected timeshift URL shape: ${captured[0]}`);
}
return segment;
}
type PastProgramWindow = { startTimestamp: number };
/**
* The fixture rounds "now" to 15 minutes per request, so the app's own
* schedule may straddle a boundary the test's snapshot did not. Both
* snapshots are accepted as the server rendering; the viewer rendering of
* either is the regression.
*/
async function expectServerClock(
segment: string,
windows: PastProgramWindow[],
serverOffsetMinutes: number
): Promise<void> {
const serverRenderings = windows.map((window) =>
formatWallClock(window.startTimestamp, serverOffsetMinutes)
);
const viewerRenderings = windows.map((window) =>
formatInZone(window.startTimestamp, VIEWER_TIMEZONE)
);
expect(viewerRenderings).not.toContain(segment);
expect(serverRenderings).toContain(segment);
}
async function pastProgramWindows(
request: Parameters<typeof fetchXtreamEpgFixture>[0],
credentials: { username: string; password: string }
): Promise<PastProgramWindow> {
const fixture = await fetchXtreamEpgFixture(request, credentials);
const past = fixture.fullEpg.find(
(listing) => listing.title === PAST_PROGRAM
);
if (!past) {
throw new Error(
'The EPG fixture has no past programme to catch up on.'
);
}
return { startTimestamp: past.startTimestamp };
}
async function openTimezoneNewsInLiveTv(
page: Page,
categoryName: string
): Promise<void> {
await openWorkspaceSection(page, 'Live TV');
await clickCategoryByNameExact(page, categoryName);
const row = channelItemByTitle(page, CHANNEL).first();
await expect(row).toBeVisible({ timeout: 20000 });
await row.click();
}
test('@epg @xtream @electron renders catch-up start times in the panel timezone from Live TV, Favorites, and after a restart', async ({
dataDir,
request,
}) => {
test.setTimeout(240000);
const credentials = { username: 'epg', password: 'epg' };
await resetMockServers(request, ['xtream']);
const fixture = await fetchXtreamEpgFixture(request, credentials);
const before = await pastProgramWindows(request, credentials);
let app: LaunchedElectronApp = await launchElectronApp(dataDir, {
env: { TZ: VIEWER_TIMEZONE },
});
let captured = captureTimeshiftRequests(app.mainWindow);
try {
await addXtreamPortal(app.mainWindow, {
name: 'Catch-up timezone',
...credentials,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
// Live TV layout: the store learned the timezone from account info.
await openTimezoneNewsInLiveTv(app.mainWindow, fixture.categoryName);
const liveSegment = await activatePastProgram(app.mainWindow, captured);
await expectServerClock(liveSegment, [before], 0);
// Favorites: the resolver reads the persisted row instead.
const row = channelItemByTitle(app.mainWindow, CHANNEL).first();
await row.hover();
await row.locator('.favorite-button').first().click();
await expect(
row.locator('.favorite-button mat-icon').first()
).toHaveText(/star/);
await openPlaylistFavorites(app.mainWindow);
const favoriteRow = channelItemByTitle(app.mainWindow, CHANNEL).first();
await expect(favoriteRow).toBeVisible({ timeout: 20000 });
await favoriteRow.click();
const favoritesSegment = await activatePastProgram(
app.mainWindow,
captured
);
const afterFavorites = await pastProgramWindows(request, credentials);
await expectServerClock(favoritesSegment, [before, afterFavorites], 0);
// Restart and go straight to the global collection: no portal route
// bootstraps, so only the persisted timezone can be right.
app = await restartElectronApp(app, dataDir, {
env: { TZ: VIEWER_TIMEZONE },
});
captured = captureTimeshiftRequests(app.mainWindow);
await openGlobalFavorites(app.mainWindow);
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
const restoredRow = channelItemByTitle(app.mainWindow, CHANNEL).first();
await expect(restoredRow).toBeVisible({ timeout: 20000 });
await restoredRow.click();
const restartSegment = await activatePastProgram(
app.mainWindow,
captured
);
const afterRestart = await pastProgramWindows(request, credentials);
await expectServerClock(
restartSegment,
[before, afterFavorites, afterRestart],
0
);
} finally {
await closeElectronApp(app);
}
});
test('@epg @xtream @electron derives the panel offset from its clock pair when the timezone name is unusable', async ({
dataDir,
request,
}) => {
test.setTimeout(120000);
const credentials = { username: 'tzoffset', password: 'tzoffset' };
await resetMockServers(request, ['xtream']);
const fixture = await fetchXtreamEpgFixture(request, credentials);
const before = await pastProgramWindows(request, credentials);
const app = await launchElectronApp(dataDir, {
env: { TZ: VIEWER_TIMEZONE },
});
const captured = captureTimeshiftRequests(app.mainWindow);
try {
await addXtreamPortal(app.mainWindow, {
name: 'Catch-up offset clock',
...credentials,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await openTimezoneNewsInLiveTv(app.mainWindow, fixture.categoryName);
const segment = await activatePastProgram(app.mainWindow, captured);
const after = await pastProgramWindows(request, credentials);
// `server_info.timezone` is `UTC+3` (no ICU knows it); the clock pair
// says +03:00, so the URL must be three hours ahead of UTC.
await expectServerClock(segment, [before, after], 180);
} finally {
await closeElectronApp(app);
}
});
@@ -130,6 +130,20 @@ export const dbPreloadCases: PreloadInvokeCase[] = [
channel: 'DB_UPDATE_PLAYLIST',
forwardedArgs: [playlistId, playlistUpdates],
},
{
method: 'dbSetPlaylistServerTimezone',
args: [
playlistId,
{ serverUrl: 'http://panel.example', username: 'u', password: 'p' },
'Europe/London',
],
channel: 'DB_SET_PLAYLIST_SERVER_TIMEZONE',
forwardedArgs: [
playlistId,
{ serverUrl: 'http://panel.example', username: 'u', password: 'p' },
'Europe/London',
],
},
{
method: 'dbDeletePlaylist',
args: [playlistId, operationId],
@@ -749,6 +749,17 @@ const electronApi: ElectronBridgeApi = {
ipcRenderer.invoke('DB_CREATE_PLAYLIST', playlist),
dbGetPlaylist: (playlistId: string) =>
ipcRenderer.invoke('DB_GET_PLAYLIST', playlistId),
dbSetPlaylistServerTimezone: (
playlistId: string,
connection: { serverUrl: string; username: string; password: string },
serverTimezone: string
) =>
ipcRenderer.invoke(
'DB_SET_PLAYLIST_SERVER_TIMEZONE',
playlistId,
connection,
serverTimezone
),
dbUpsertAppPlaylist: (playlist: Playlist, _operationId?: string) =>
ipcRenderer.invoke('DB_UPSERT_APP_PLAYLIST', playlist),
dbMigrateAppPlaylists: (playlists: Playlist[]) =>
@@ -0,0 +1,155 @@
/**
* The panel clock an Xtream account-info check learns (`serverTimezone`)
* lives in the playlist row's JSON payload — it has no column — and every
* write that touches it must stay atomic against the row's CURRENT
* connection, because the database worker interleaves requests and the
* Xtream edit dialog saves through `DB_UPDATE_PLAYLIST` outside
* `PlaylistsService`'s queue (issue #1562). This module owns the three SQL
* shapes and the one projection the playlist operations compose:
*
* - `setPlaylistServerTimezone`: record a learned clock, conditionally.
* - `serverTimezoneInvalidation`: drop it when an update moves the source.
* - `playlistConflictUpdate`: keep it across a clockless full upsert.
* - `readPayloadServerTimezone`: surface it on a raw row read.
*/
import { sql, type SQL } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
import type { AppDatabase } from '../database.types';
export interface PlaylistConnectionIdentity {
serverUrl: string;
username: string;
password: string;
}
export type PlaylistConflictUpdate = {
[K in keyof schema.NewPlaylist]?: schema.NewPlaylist[K] | SQL;
};
const stored = schema.playlists;
/**
* Records the panel clock a successful account-info check learned as ONE
* conditional UPDATE: the row must still point at the panel the answer
* came from (`DB_UPDATE_PLAYLIST` may have moved it meanwhile), a payload
* already carrying the value is left untouched, and a malformed payload is
* never rewritten. No read precedes the write, so it can neither hand a
* concurrent upsert's newer payload back to the past nor undo an edit that
* landed in between.
*/
export async function setPlaylistServerTimezone(
db: AppDatabase,
playlistId: string,
connection: PlaylistConnectionIdentity,
serverTimezone: string
): Promise<{ updated: boolean }> {
const result = await db
.update(stored)
.set({
payload: sql`CASE
WHEN ${stored.payload} IS NULL
THEN json_object('serverTimezone', ${serverTimezone})
ELSE json_set(${stored.payload}, '$.serverTimezone', ${serverTimezone})
END`,
})
.where(
// CASE, not AND: SQLite may reorder AND terms, and json_extract
// raises on a malformed payload unless json_valid ran first.
sql`${stored.id} = ${playlistId}
AND ${stored.serverUrl} IS ${connection.serverUrl}
AND ${stored.username} IS ${connection.username}
AND ${stored.password} IS ${connection.password}
AND CASE
WHEN ${stored.payload} IS NULL THEN 1
WHEN json_valid(${stored.payload})
THEN json_extract(${stored.payload}, '$.serverTimezone') IS NOT ${serverTimezone}
ELSE 0
END`
)
.run();
return { updated: result.changes > 0 };
}
/**
* The payload expression for a `DB_UPDATE_PLAYLIST` that sets `serverUrl`:
* a learned clock belongs to the panel it was learned from, so pointing the
* row at another server drops it until the next account-info check, and
* Favorites / Recent catch-up cannot keep rendering the OLD panel's clock.
*/
export function serverTimezoneInvalidation(nextServerUrl: string): SQL {
return sql`CASE
WHEN ${stored.serverUrl} IS NOT ${nextServerUrl}
AND json_valid(${stored.payload})
THEN json_remove(${stored.payload}, '$.serverTimezone')
ELSE ${stored.payload}
END`;
}
/**
* The `DO UPDATE` half of an app-playlist upsert. A full upsert is built
* from a snapshot the caller read earlier; when that snapshot carries no
* panel clock while the stored row does — `setPlaylistServerTimezone`
* landed in between — the stored clock is carried over as long as the row
* still points at the same panel, so a favorites, recent-items or metadata
* write cannot hand a clockless payload back to the past. A snapshot that
* carries its own clock, or moves the source, wins as is. Nested CASE, not
* AND: SQLite may reorder AND terms, and the json_* readers raise on a
* malformed payload unless json_valid ran first.
*/
export function playlistConflictUpdate(
row: schema.NewPlaylist,
playlist: Record<string, unknown>
): PlaylistConflictUpdate {
if (readString(playlist.serverTimezone)) {
return row;
}
const excludedPayload = sql.raw(`excluded.${stored.payload.name}`);
const payload = sql`CASE
WHEN json_valid(${stored.payload})
THEN CASE
WHEN json_type(${stored.payload}, '$.serverTimezone') = 'text'
AND ${stored.serverUrl} IS excluded.${sql.raw(stored.serverUrl.name)}
AND ${stored.username} IS excluded.${sql.raw(stored.username.name)}
AND ${stored.password} IS excluded.${sql.raw(stored.password.name)}
THEN json_set(
${excludedPayload},
'$.serverTimezone',
json_extract(${stored.payload}, '$.serverTimezone')
)
ELSE ${excludedPayload}
END
ELSE ${excludedPayload}
END`;
return { ...row, payload };
}
/**
* The clock stored in a raw row's payload, for `DB_GET_PLAYLIST`: the
* Xtream store seeds `currentPlaylist` from that read before (or without)
* the account-info check that learns the value.
*/
export function readPayloadServerTimezone(
payload: string | null | undefined
): string | undefined {
if (!payload) {
return undefined;
}
try {
const parsed: unknown = JSON.parse(payload);
return parsed && typeof parsed === 'object'
? readString((parsed as Record<string, unknown>).serverTimezone)
: undefined;
} catch {
return undefined;
}
}
function readString(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim();
return normalized.length > 0 ? normalized : undefined;
}
@@ -1,6 +1,11 @@
import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import {
getAppPlaylistFavoriteChannels,
getAppPlaylistMetas,
getPlaylist,
parseAppPlaylist,
} from './playlist.operations';
import type { AppDatabase } from '../database.types';
@@ -22,6 +27,60 @@ function createPlaylistFavoriteChannelsDbMock(row: unknown | null) {
};
}
/**
* Runs `updatePlaylist` against Electron's native SQLite on the real
* `playlists` table (create + column migrations), the way the database
* worker executes it; the scenario prints one JSON document.
*/
function runPlaylistUpdateScenario(scenario: string): unknown {
const operationsUrl = pathToFileURL(
resolve(__dirname, 'playlist.operations.ts')
).href;
const timezoneOperationsUrl = pathToFileURL(
resolve(__dirname, 'playlist-server-timezone.operations.ts')
).href;
const connectionUrl = pathToFileURL(
resolve(process.cwd(), 'libs/shared/database/src/lib/connection.ts')
).href;
const script = `
const { default: Database } = await import('better-sqlite3');
const { drizzle } = await import('drizzle-orm/better-sqlite3');
const schema = await import('@iptvnator/shared/database/schema');
const { updatePlaylist, upsertAppPlaylist, upsertAppPlaylists } = await import(${JSON.stringify(operationsUrl)});
const { setPlaylistServerTimezone } = await import(${JSON.stringify(timezoneOperationsUrl)});
const { __databaseConnectionTestHooks } = await import(${JSON.stringify(connectionUrl)});
const sqlite = new Database(':memory:');
const statements = [
...__databaseConnectionTestHooks.createTableStatements,
...__databaseConnectionTestHooks.columnMigrationStatements,
].filter((statement) => /TABLE IF NOT EXISTS playlists\\b|ALTER TABLE playlists\\b/.test(statement));
for (const statement of statements) {
try { sqlite.exec(statement); } catch { /* column already in the base table */ }
}
const db = drizzle(sqlite, { schema });
${scenario}
sqlite.close();
`;
return JSON.parse(
execFileSync(
createRequire(__filename)('electron'),
['--import', 'tsx', '--eval', script],
{
cwd: process.cwd(),
encoding: 'utf8',
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
TSX_TSCONFIG_PATH: resolve(
process.cwd(),
'tsconfig.base.json'
),
},
}
)
);
}
describe('playlist.operations', () => {
it('hydrates updateDate from lastUpdated when payload is stale', async () => {
const parsed = parseAppPlaylist({
@@ -56,6 +115,208 @@ describe('playlist.operations', () => {
);
});
it('projects the persisted server timezone from the payload onto DB_GET_PLAYLIST rows', async () => {
const row = {
id: 'playlist-1',
name: 'Xtream Source',
serverUrl: 'http://localhost:8080',
username: 'demo',
password: 'secret',
type: 'xtream',
payload: JSON.stringify({
_id: 'playlist-1',
title: 'Xtream Source',
serverTimezone: 'Europe/London',
}),
};
const { db } = createPlaylistFavoriteChannelsDbMock(row);
await expect(getPlaylist(db, 'playlist-1')).resolves.toEqual({
...row,
serverTimezone: 'Europe/London',
});
});
it('returns DB_GET_PLAYLIST rows untouched when the payload carries no server timezone', async () => {
const row = {
id: 'playlist-1',
name: 'Xtream Source',
type: 'xtream',
payload: JSON.stringify({ _id: 'playlist-1', serverTimezone: ' ' }),
};
const { db } = createPlaylistFavoriteChannelsDbMock(row);
await expect(getPlaylist(db, 'playlist-1')).resolves.toBe(row);
await expect(
getPlaylist(createPlaylistFavoriteChannelsDbMock(null).db, 'gone')
).resolves.toBeNull();
});
it('drops the persisted server timezone atomically when DB_UPDATE_PLAYLIST moves the source to another server', () => {
// Runs the real UPDATE against Electron's SQLite: the invalidation is
// one CASE/json_remove expression inside the statement, never a
// read-modify-write that could hand a concurrent upsert's newer
// payload back to the past.
const rows = runPlaylistUpdateScenario(`
const seed = (id, serverUrl, payload) => sqlite.prepare(
'INSERT INTO playlists (id, name, serverUrl, type, payload) VALUES (?, ?, ?, ?, ?)'
).run(id, id, serverUrl, 'xtream', payload);
seed('moved', 'http://old.example', JSON.stringify({ _id: 'moved', serverTimezone: 'Europe/London', favorites: ['1'] }));
seed('renamed', 'http://old.example', JSON.stringify({ _id: 'renamed', serverTimezone: 'Europe/London' }));
seed('no-clock', 'http://old.example', JSON.stringify({ _id: 'no-clock' }));
seed('broken', 'http://old.example', 'not json');
seed('null-url', null, JSON.stringify({ _id: 'null-url', serverTimezone: 'UTC' }));
seed('untouched', 'http://old.example', JSON.stringify({ _id: 'untouched', serverTimezone: 'UTC' }));
await updatePlaylist(db, 'moved', { name: 'Moved', serverUrl: 'http://new.example' });
await updatePlaylist(db, 'renamed', { name: 'Renamed', serverUrl: 'http://old.example' });
await updatePlaylist(db, 'no-clock', { serverUrl: 'http://new.example' });
await updatePlaylist(db, 'broken', { serverUrl: 'http://new.example' });
await updatePlaylist(db, 'null-url', { serverUrl: 'http://new.example' });
await updatePlaylist(db, 'untouched', { name: 'Only renamed' });
const rows = Object.fromEntries(
sqlite.prepare('SELECT id, name, serverUrl, payload FROM playlists').all().map((row) => [row.id, row])
);
process.stdout.write(JSON.stringify(rows));
`);
expect(rows).toEqual({
moved: {
id: 'moved',
name: 'Moved',
serverUrl: 'http://new.example',
payload: JSON.stringify({ _id: 'moved', favorites: ['1'] }),
},
renamed: {
id: 'renamed',
name: 'Renamed',
serverUrl: 'http://old.example',
payload: JSON.stringify({
_id: 'renamed',
serverTimezone: 'Europe/London',
}),
},
'no-clock': {
id: 'no-clock',
name: 'no-clock',
serverUrl: 'http://new.example',
payload: JSON.stringify({ _id: 'no-clock' }),
},
broken: {
id: 'broken',
name: 'broken',
serverUrl: 'http://new.example',
payload: 'not json',
},
'null-url': {
id: 'null-url',
name: 'null-url',
serverUrl: 'http://new.example',
payload: JSON.stringify({ _id: 'null-url' }),
},
untouched: {
id: 'untouched',
name: 'Only renamed',
serverUrl: 'http://old.example',
payload: JSON.stringify({
_id: 'untouched',
serverTimezone: 'UTC',
}),
},
});
});
it('records the learned panel timezone with one conditional UPDATE guarded by the row connection (issue #1562)', () => {
const result = runPlaylistUpdateScenario(`
const conn = { serverUrl: 'http://panel.example', username: 'u', password: 'p' };
const seed = (id, serverUrl, payload) => sqlite.prepare(
'INSERT INTO playlists (id, name, serverUrl, username, password, type, payload) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(id, id, serverUrl, 'u', 'p', 'xtream', payload);
seed('fresh', conn.serverUrl, JSON.stringify({ _id: 'fresh', favorites: ['1'] }));
seed('same', conn.serverUrl, JSON.stringify({ _id: 'same', serverTimezone: 'Europe/London' }));
seed('moved', 'http://other.example', JSON.stringify({ _id: 'moved' }));
seed('legacy', conn.serverUrl, null);
seed('broken', conn.serverUrl, 'not json');
const results = {
fresh: await setPlaylistServerTimezone(db, 'fresh', conn, 'Europe/London'),
same: await setPlaylistServerTimezone(db, 'same', conn, 'Europe/London'),
moved: await setPlaylistServerTimezone(db, 'moved', conn, 'Europe/London'),
legacy: await setPlaylistServerTimezone(db, 'legacy', conn, 'UTC+03:00'),
broken: await setPlaylistServerTimezone(db, 'broken', conn, 'Europe/London'),
missing: await setPlaylistServerTimezone(db, 'missing', conn, 'Europe/London'),
};
const rows = Object.fromEntries(
sqlite.prepare('SELECT id, payload FROM playlists').all().map((row) => [row.id, row.payload])
);
process.stdout.write(JSON.stringify({ results, rows }));
`) as {
results: Record<string, { updated: boolean }>;
rows: Record<string, string | null>;
};
expect(result.results).toEqual({
fresh: { updated: true },
same: { updated: false },
moved: { updated: false },
legacy: { updated: true },
broken: { updated: false },
missing: { updated: false },
});
expect(result.rows).toEqual({
fresh: JSON.stringify({
_id: 'fresh',
favorites: ['1'],
serverTimezone: 'Europe/London',
}),
same: JSON.stringify({
_id: 'same',
serverTimezone: 'Europe/London',
}),
moved: JSON.stringify({ _id: 'moved' }),
legacy: JSON.stringify({ serverTimezone: 'UTC+03:00' }),
broken: 'not json',
});
});
it('keeps the stored panel timezone when a clockless full upsert lands after it on the same connection (issue #1562)', () => {
const result = runPlaylistUpdateScenario(`
const conn = { serverUrl: 'http://panel.example', username: 'u', password: 'p' };
const stale = (id, extra = {}) => ({ _id: id, title: id, ...conn, type: 'xtream', favorites: ['1'], ...extra });
for (const id of ['kept', 'moved', 'own-clock', 'batch']) {
await upsertAppPlaylist(db, stale(id));
await setPlaylistServerTimezone(db, id, conn, 'Europe/London');
}
// Snapshots read BEFORE the clock landed, written after it.
await upsertAppPlaylist(db, stale('kept', { favorites: ['1', '2'] }));
await upsertAppPlaylist(db, stale('moved', { serverUrl: 'http://other.example' }));
await upsertAppPlaylist(db, stale('own-clock', { serverTimezone: 'UTC+03:00' }));
await upsertAppPlaylists(db, [stale('batch', { favorites: ['9'] })]);
const rows = Object.fromEntries(
sqlite.prepare('SELECT id, serverUrl, payload FROM playlists').all()
.map((row) => [row.id, { serverUrl: row.serverUrl, payload: JSON.parse(row.payload) }])
);
process.stdout.write(JSON.stringify(rows));
`) as Record<
string,
{ serverUrl: string; payload: Record<string, unknown> }
>;
expect(result['kept'].payload).toEqual(
expect.objectContaining({
favorites: ['1', '2'],
serverTimezone: 'Europe/London',
})
);
expect(result['batch'].payload).toEqual(
expect.objectContaining({
favorites: ['9'],
serverTimezone: 'Europe/London',
})
);
expect(result['own-clock'].payload.serverTimezone).toBe('UTC+03:00');
expect(result['moved'].serverUrl).toBe('http://other.example');
expect(result['moved'].payload).not.toHaveProperty('serverTimezone');
});
it('loads app playlist metadata without selecting the large payload column', async () => {
const from = jest.fn().mockResolvedValue([
{
@@ -23,6 +23,11 @@ import {
reportOperationProgress,
} from './operation-control';
import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture';
import {
playlistConflictUpdate,
readPayloadServerTimezone,
serverTimezoneInvalidation,
} from './playlist-server-timezone.operations';
const PLAYLIST_TYPES = {
XTREAM: 'xtream',
@@ -371,10 +376,13 @@ export async function upsertAppPlaylist(
}
const write = async () => {
await db.insert(schema.playlists).values(row).onConflictDoUpdate({
target: schema.playlists.id,
set: row,
});
await db
.insert(schema.playlists)
.values(row)
.onConflictDoUpdate({
target: schema.playlists.id,
set: playlistConflictUpdate(row, playlist),
});
};
if (capturePhase) {
await capturePhase.captureAsync(
@@ -400,20 +408,27 @@ export async function upsertAppPlaylists(
}
const rows = playlists
.map((playlist) => buildPlaylistRow(playlist))
.filter((row): row is NonNullable<typeof row> => row !== null);
.map((playlist) => ({ playlist, row: buildPlaylistRow(playlist) }))
.filter(
(
entry
): entry is {
playlist: Record<string, unknown>;
row: NonNullable<typeof entry.row>;
} => entry.row !== null
);
if (rows.length === 0) {
return { success: true, count: 0 };
}
await db.transaction((tx) => {
for (const row of rows) {
for (const { playlist, row } of rows) {
tx.insert(schema.playlists)
.values(row)
.onConflictDoUpdate({
target: schema.playlists.id,
set: row,
set: playlistConflictUpdate(row, playlist),
})
.run();
}
@@ -563,14 +578,25 @@ export async function getAppPlaylistFavoriteChannels(
return resolved.sort((a, b) => a.favoriteIndex - b.favoriteIndex);
}
/**
* The raw row plus the fields the Xtream store needs from the JSON payload:
* `serverTimezone` has no column, and the store seeds `currentPlaylist`
* from this read before (or without) the account-info check that learns
* it (issue #1562).
*/
export async function getPlaylist(db: AppDatabase, playlistId: string) {
const result = await db
.select()
.from(schema.playlists)
.where(eq(schema.playlists.id, playlistId))
.limit(1);
const row = result[0];
if (!row) {
return null;
}
return result[0] || null;
const serverTimezone = readPayloadServerTimezone(row.payload);
return serverTimezone ? { ...row, serverTimezone } : row;
}
export async function updatePlaylist(
@@ -586,7 +612,12 @@ export async function updatePlaylist(
): Promise<{ success: boolean }> {
await db
.update(schema.playlists)
.set(updates)
.set({
...updates,
...(updates.serverUrl === undefined
? {}
: { payload: serverTimezoneInvalidation(updates.serverUrl) }),
})
.where(eq(schema.playlists.id, playlistId));
return { success: true };
@@ -16,7 +16,10 @@ handleWorkerRequest(
'DB_MIGRATE_APP_PLAYLISTS',
(playlists: Record<string, unknown>[]) => ({ playlists })
);
handleWorkerRequest('DB_CREATE_PLAYLIST', (playlist: Record<string, unknown>) => playlist);
handleWorkerRequest(
'DB_CREATE_PLAYLIST',
(playlist: Record<string, unknown>) => playlist
);
handleWorkerRequest(
'DB_UPSERT_APP_PLAYLIST',
(playlist: Record<string, unknown>) => playlist
@@ -27,11 +30,26 @@ handleWorkerRequest(
);
handleWorkerRequest('DB_GET_APP_PLAYLISTS', () => ({}));
handleWorkerRequest('DB_GET_APP_PLAYLIST_METAS', () => ({}));
handleWorkerRequest('DB_GET_APP_PLAYLIST', (playlistId: string) => ({ playlistId }));
handleWorkerRequest('DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS', (playlistId: string) => ({
handleWorkerRequest('DB_GET_APP_PLAYLIST', (playlistId: string) => ({
playlistId,
}));
handleWorkerRequest('DB_GET_PLAYLIST', (playlistId: string) => ({ playlistId }));
handleWorkerRequest(
'DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS',
(playlistId: string) => ({
playlistId,
})
);
handleWorkerRequest('DB_GET_PLAYLIST', (playlistId: string) => ({
playlistId,
}));
handleWorkerRequest(
'DB_SET_PLAYLIST_SERVER_TIMEZONE',
(
playlistId: string,
connection: { serverUrl: string; username: string; password: string },
serverTimezone: string
) => ({ playlistId, connection, serverTimezone })
);
handleWorkerRequest(
'DB_UPDATE_PLAYLIST',
(
@@ -56,20 +74,12 @@ handleWorkerRequest('DB_SET_APP_STATE', (key: string, value: string) => ({
ipcMain.handle(
'DB_DELETE_PLAYLIST',
async (
event,
playlistId: string,
operationId?: string
) => {
async (event, playlistId: string, operationId?: string) => {
try {
return await requestWorkerWithEvents(
event,
'DB_DELETE_PLAYLIST',
{
playlistId,
operationId,
}
);
return await requestWorkerWithEvents(event, 'DB_DELETE_PLAYLIST', {
playlistId,
operationId,
});
} catch (error) {
console.error('Error handling DB_DELETE_PLAYLIST:', error);
throw error;
@@ -41,7 +41,9 @@ const playbackBatchItems = [
durationSeconds: 1200,
},
];
const playbackClearBatchItems = [{ contentXtreamId: 42, contentType: 'episode' }];
const playbackClearBatchItems = [
{ contentXtreamId: 42, contentType: 'episode' },
];
const tmdbCacheEntry = {
mediaType: 'movie',
lookupKey: 'id:603',
@@ -113,6 +115,23 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [
args: [playlistId, playlistUpdates],
payload: { playlistId, updates: playlistUpdates },
},
{
operation: 'DB_SET_PLAYLIST_SERVER_TIMEZONE',
args: [
playlistId,
{ serverUrl: 'http://panel.example', username: 'u', password: 'p' },
'Europe/London',
],
payload: {
playlistId,
connection: {
serverUrl: 'http://panel.example',
username: 'u',
password: 'p',
},
serverTimezone: 'Europe/London',
},
},
{
operation: 'DB_GET_APP_STATE',
args: ['workspace:last-route'],
@@ -25,6 +25,7 @@ export const DB_WORKER_OPERATIONS = [
'DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS',
'DB_GET_PLAYLIST',
'DB_UPDATE_PLAYLIST',
'DB_SET_PLAYLIST_SERVER_TIMEZONE',
'DB_DELETE_PLAYLIST',
'DB_GET_APP_STATE',
'DB_SET_APP_STATE',
@@ -75,6 +75,7 @@ import {
upsertAppPlaylist,
upsertAppPlaylists,
} from '../database/operations/playlist.operations';
import { setPlaylistServerTimezone } from '../database/operations/playlist-server-timezone.operations';
import {
addRecentItem,
clearPlaylistRecentItems,
@@ -735,6 +736,24 @@ async function executeRequest(
return getPlaylist(db, payload.playlistId);
}
case 'DB_SET_PLAYLIST_SERVER_TIMEZONE': {
const payload = message.payload as {
playlistId: string;
connection: {
serverUrl: string;
username: string;
password: string;
};
serverTimezone: string;
};
return setPlaylistServerTimezone(
db,
payload.playlistId,
payload.connection,
payload.serverTimezone
);
}
case 'DB_UPDATE_PLAYLIST': {
const payload = message.payload as {
playlistId: string;
+18 -16
View File
@@ -104,22 +104,23 @@ media. Barriers and delays are coordination tools, not timing inputs.
## Available Scenarios (credential pairs)
| Username | Password | Scenario | Live cats | VOD cats | Series cats | Items/cat | Status |
| ------------- | ------------- | ---------------------- | --------- | -------- | ----------- | --------- | -------- |
| `user1` | `pass1` | default | 8 | 8 | 8 | 40 | active |
| `large` | `large` | large catalog | 20 | 20 | 20 | 200 | active |
| `stress` | `stress` | stress catalog | 16 | 16 | 16 | 120 | active |
| `performance` | `performance` | performance-100k | 60 | 20 | 20 | 1,000 | active |
| `series` | `series` | series-heavy | 3 | 4 | 15 | 30 | active |
| `minimal` | `minimal` | minimal (edge cases) | 2 | 2 | 2 | 5 | active |
| `epg` | `epg` | EPG fixture | 2 | 1 | 1 | 3 | active |
| `emptyvod` | `emptyvod` | empty VOD metadata | 2 | 2 | 2 | 5 | active |
| `marketing` | `marketing` | fictional release demo | 4 | 4 | 4 | curated | active |
| `marketing2` | `marketing2` | same catalog, 2nd copy | 4 | 4 | 4 | curated | active |
| `multisrc1` | `multisrc1` | multi-source portal A | 1 | 2 | 1 | 5 | active |
| `multisrc2` | `multisrc2` | multi-source portal B | 1 | 2 | 1 | 5 | active |
| `expired` | `expired` | expired account | 4 | 4 | 4 | 10 | Expired |
| `inactive` | `inactive` | disabled account | 4 | 4 | 4 | 10 | Disabled |
| Username | Password | Scenario | Live cats | VOD cats | Series cats | Items/cat | Status |
| ------------- | ------------- | -------------------------- | --------- | -------- | ----------- | --------- | -------- |
| `user1` | `pass1` | default | 8 | 8 | 8 | 40 | active |
| `large` | `large` | large catalog | 20 | 20 | 20 | 200 | active |
| `stress` | `stress` | stress catalog | 16 | 16 | 16 | 120 | active |
| `performance` | `performance` | performance-100k | 60 | 20 | 20 | 1,000 | active |
| `series` | `series` | series-heavy | 3 | 4 | 15 | 30 | active |
| `minimal` | `minimal` | minimal (edge cases) | 2 | 2 | 2 | 5 | active |
| `epg` | `epg` | EPG fixture | 2 | 1 | 1 | 3 | active |
| `tzoffset` | `tzoffset` | EPG fixture, `UTC+3` clock | 2 | 1 | 1 | 3 | active |
| `emptyvod` | `emptyvod` | empty VOD metadata | 2 | 2 | 2 | 5 | active |
| `marketing` | `marketing` | fictional release demo | 4 | 4 | 4 | curated | active |
| `marketing2` | `marketing2` | same catalog, 2nd copy | 4 | 4 | 4 | curated | active |
| `multisrc1` | `multisrc1` | multi-source portal A | 1 | 2 | 1 | 5 | active |
| `multisrc2` | `multisrc2` | multi-source portal B | 1 | 2 | 1 | 5 | active |
| `expired` | `expired` | expired account | 4 | 4 | 4 | 10 | Expired |
| `inactive` | `inactive` | disabled account | 4 | 4 | 4 | 10 | Disabled |
Any other credential pair is auto-generated using a hash of `username:password` as the faker seed (6 categories, 30 items each, active account).
@@ -230,6 +231,7 @@ application code.
- **Cached per session**: Data generated once on first request, reused until `/reset`
- **EPG**: Titles and descriptions are base64-encoded (matches real Xtream API)
- **Dedicated EPG fixture**: `epg:epg` returns stable live channels plus deterministic `get_short_epg` and `get_simple_data_table` payloads for timezone-focused tests
- **Unusable timezone name**: `tzoffset:tzoffset` serves the same EPG fixture behind a panel whose `server_info.timezone` is the ICU-unknown spelling `UTC+3`, while its `time_now` / `timestamp_now` clock pair reveals a +03:00 offset — the catch-up URL must be derived from the clock pair (issue #1562)
- **Release screenshot fixture**: `marketing:marketing` returns fictional live, VOD, and series data with local generated artwork under `apps/xtream-mock-server/public/marketing`
- **Alternative-source fixture**: `marketing2:marketing2` returns the identical marketing catalog under a second credential pair, so a movie added from both looks like the same film in two playlists (the premise of the VOD multi-source chip); guide screenshots seed it as a "backup subscription"
- **Local download media**: `marketing:marketing` also serves `/movie/...`, `/series/...` and `/live/...` stream URLs from generated bytes (`downloadStreamFixture: 'local-media'`; movies finish in under a second, episodes trickle for about 20 s) so release and guide screenshots of the download manager complete without any request leaving the machine. Other scenarios keep redirecting streams to the public HLS stub.
@@ -5,6 +5,19 @@ export function handleGetAccountInfo(req: Request, res: Response): void {
const { username = '', password = '' } = req.query as Record<string, string>;
const data = getPortalData(username, password);
const { scenario } = data;
const serverClock = scenario.serverClock ?? {
timezone: 'UTC',
utcOffsetMinutes: 0,
};
const nowSeconds = Math.floor(Date.now() / 1000);
// A real panel formats `time_now` with `date()` in its own timezone:
// the same instant as `timestamp_now`, shifted by the panel's offset.
const timeNow = new Date(
(nowSeconds + serverClock.utcOffsetMinutes * 60) * 1000
)
.toISOString()
.replace('T', ' ')
.replace('.000Z', '');
res.json({
user_info: {
@@ -26,9 +39,9 @@ export function handleGetAccountInfo(req: Request, res: Response): void {
https_port: '',
server_protocol: 'http',
rtmp_port: '',
timezone: 'UTC',
timestamp_now: Math.floor(Date.now() / 1000),
time_now: new Date().toISOString().replace('T', ' ').replace('.000Z', ''),
timezone: serverClock.timezone,
timestamp_now: nowSeconds,
time_now: timeNow,
},
});
}
@@ -13,6 +13,14 @@ export interface ScenarioConfig {
expiryDate: string;
/** Optional deterministic EPG fixture profile for scenario-specific tests. */
epgFixture?: 'timezone-focus';
/**
* Optional `server_info` clock override. `timezone` is reported
* verbatim (real panels sometimes send spellings such as `UTC+3` that
* no ICU knows), while `time_now` is rendered at `utcOffsetMinutes`
* from the epoch so a client can derive the offset from the clock pair
* the way it must for such a panel (issue #1562). Default: `UTC`.
*/
serverClock?: { timezone: string; utcOffsetMinutes: number };
/** Optional deterministic VOD detail profile for metadata fallback tests. */
vodDetailsFixture?: 'empty-metadata';
/** Optional fictional release-marketing dataset with local demo artwork. */
@@ -144,6 +152,20 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
expiryDate: '2099-12-31',
epgFixture: 'timezone-focus',
},
'tzoffset:tzoffset': {
name: 'epg-fixture-offset-clock',
description:
'The EPG fixture behind a panel whose timezone name is unusable (`UTC+3`) but whose clock pair reveals a +03:00 offset',
seed: 6006,
categoryCount: { live: 2, vod: 1, series: 1 },
itemsPerCategory: 3,
seasonsPerSeries: 1,
episodesPerSeason: 3,
accountStatus: 'Active',
expiryDate: '2099-12-31',
epgFixture: 'timezone-focus',
serverClock: { timezone: 'UTC+3', utcOffsetMinutes: 180 },
},
'emptyvod:emptyvod': {
name: 'empty-vod-metadata',
description:
+1
View File
@@ -399,6 +399,7 @@ sometimes only respond to that misspelled action.
| `series:series` | 2002 | live:3, vod:4, series:15 | 30 | active |
| `minimal:minimal` | 3003 | 2 each | 5 | active |
| `epg:epg` | 6006 | live:2, vod:1, series:1 | 3 | active |
| `tzoffset:tzoffset` | 6006 | live:2, vod:1, series:1 | 3 | active |
| `emptyvod:emptyvod` | 7007 | 2 each | 5 | active |
| `marketing:marketing` | 8020 | live:4, vod:4, series:4 | curated | active |
| `expired:expired` | 4004 | 4 each | 10 | Expired |
@@ -256,3 +256,58 @@ favorites and recently-viewed DB projections and mapped onto
tab can gate the timeline's archive window. `tv_archive_duration` is
interpreted as **days** everywhere, matching
`live-stream-layout.controlledArchiveDays` (issue #1138).
### Start time is the panel's clock, not the viewer's
The `{start}` segment (`Y-m-d:H-M`) is read by the panel with `strtotime()`
in ITS OWN timezone — the one it reports as `server_info.timezone` in the
account-info response — never the viewer's local clock (issue #1562). The
timezone is learned by `withPortal.checkPortalStatus()` and normalized by
`resolveXtreamServerTimezone()` (`libs/shared/interfaces/src/lib/xtream-server-timezone.util.ts`):
- a timezone name the runtime's ICU resolves (`Europe/London`) is kept as is;
- otherwise (`UTC+3`, a typo, an unknown alias) the offset is derived from
the clock pair the same response carries — `time_now` read as a naive UTC
wall clock minus `timestamp_now`, snapped to 15 minutes — and stored as
`UTC±HH:MM`. This is a snapshot without DST rules: for such a panel,
programmes on the far side of a DST switch are off by an hour until the
next account-info check refreshes the offset. Xtream Codes reports PHP
timezone identifiers (IANA names), so the snapshot only serves
non-standard servers, where the alternative was the viewer's clock;
- with neither, nothing is stored and the URL falls back to the viewer's
clock, the only remaining guess.
The value is persisted on the playlist row (`Playlist.serverTimezone`)
because the two catch-up entry points read different sources: the Live TV
layout uses the store's `currentPlaylist`, while the Favorites / Recent
resolver (`StreamResolverService.resolveXtreamCatchupUrl`) reads the stored
row through `dbGetAppPlaylist` / IndexedDB. The write goes through
`IXtreamDataSource.rememberServerTimezone` and is atomic against the row's
CURRENT connection in both runtimes — Electron: one conditional UPDATE
(`DB_SET_PLAYLIST_SERVER_TIMEZONE` → `setPlaylistServerTimezone`,
`json_set(payload, '$.serverTimezone', …)` only while `serverUrl`/`username`/
`password` still match the request and the payload does not already carry
the value; a malformed payload is never rewritten); PWA:
`PlaylistsService.transformPlaylistMeta`, whose read and write share one
IndexedDB readwrite cursor transaction. No read precedes the write, because
the database worker interleaves requests and the Xtream edit dialog saves
through `DB_UPDATE_PLAYLIST` outside `PlaylistsService`'s queue: a
read-modify-write could hand a concurrent upsert's newer payload back to the
past or undo an edit that landed in between. The reverse ordering is covered
on the upsert side: `DB_UPSERT_APP_PLAYLIST(S)` (`playlistConflictUpdate`)
carries the STORED clock into a snapshot that has none while the row still
points at the same connection, so a favorites, recent-items or metadata write
built from a pre-clock snapshot cannot strip it; a snapshot carrying its own
clock, or moving the source, wins as is. The store offers the resolved
value on every check (a transient write failure is retried by the next one),
patches its own state only while the selected playlist is still the panel the
answer came from (`answersFor`: id + connection), and returns the store's
verdict about the current selection when it is not. An update that moves
`serverUrl` (`mergePlaylistMeta`, `DB_UPDATE_PLAYLIST`) drops the clock until
the next account-info check. Electron's `DB_GET_PLAYLIST` projects the
persisted value from the row payload so the store is seeded with it before,
or without, the account-info check. The same timezone lets
`XtreamApiService` read timestamp-less EPG `start`/`end` strings in the
clock the panel wrote them in (`parseXtreamServerLocalDateTime`);
`start_timestamp` still wins whenever it is present. Formatting uses
`hourCycle: 'h23'`, so server midnight renders as `00`, never `24`.
@@ -0,0 +1,159 @@
import { TestBed } from '@angular/core/testing';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
XtreamApiService,
XtreamUrlService,
} from '@iptvnator/portal/xtream/data-access';
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
import {
DataService,
PlaylistsService,
SettingsStore,
} from '@iptvnator/services';
import { Playlist } from '@iptvnator/shared/interfaces';
import { of } from 'rxjs';
import { StreamResolverService } from './stream-resolver.service';
/**
* Catch-up from Favorites / Recent resolves its credentials from the STORED
* playlist row, not from the Xtream store; the persisted server timezone
* must therefore reach the URL builder from that row (issue #1562).
*/
describe('StreamResolverService catch-up (issue #1562)', () => {
const item: UnifiedCollectionItem = {
uid: 'xtream::xtream-1::7',
name: 'Timezone News',
contentType: 'live',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream',
xtreamId: 7,
logo: '',
};
const storedRow = {
_id: 'xtream-1',
serverUrl: 'https://xtream.example.com',
username: 'user',
password: 'pass',
serverTimezone: 'Europe/London',
} satisfies Partial<Playlist>;
let service: StreamResolverService;
let playlistsService: { getPlaylistById: jest.Mock };
let xtreamUrl: { resolveCatchupUrl: jest.Mock };
let dbGetAppPlaylist: jest.Mock;
const originalElectron = window.electron;
beforeEach(() => {
playlistsService = { getPlaylistById: jest.fn(() => of(undefined)) };
xtreamUrl = {
resolveCatchupUrl: jest
.fn()
.mockResolvedValue(
'https://xtream.example.com/timeshift/user/pass/60/2026-09-06:20-30/7.ts'
),
};
dbGetAppPlaylist = jest.fn().mockResolvedValue(storedRow);
window.electron = {
dbGetAppPlaylist,
} as unknown as typeof window.electron;
TestBed.configureTestingModule({
providers: [
StreamResolverService,
{ provide: PlaylistsService, useValue: playlistsService },
{
provide: XtreamApiService,
useValue: { getShortEpg: jest.fn(), getFullEpg: jest.fn() },
},
{ provide: XtreamUrlService, useValue: xtreamUrl },
{ provide: DataService, useValue: { sendIpcEvent: jest.fn() } },
{
provide: SettingsStore,
useValue: { resolvedEpgOffsetMinutes: () => 0 },
},
{
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);
});
afterEach(() => {
window.electron = originalElectron;
});
it('hands the timezone persisted on the Electron playlist row to the catch-up URL builder', async () => {
const url = await service.resolveXtreamCatchupUrl(
item,
1_788_723_000,
1_788_726_600
);
expect(dbGetAppPlaylist).toHaveBeenCalledWith('xtream-1');
expect(xtreamUrl.resolveCatchupUrl).toHaveBeenCalledWith(
'xtream-1',
expect.objectContaining({
serverUrl: 'https://xtream.example.com',
username: 'user',
password: 'pass',
serverTimezone: 'Europe/London',
}),
7,
1_788_723_000,
1_788_726_600,
'Europe/London'
);
expect(url).toContain('/2026-09-06:20-30/');
});
it('reads the same timezone from the IndexedDB row in the PWA', async () => {
window.electron = undefined as unknown as typeof window.electron;
playlistsService.getPlaylistById.mockReturnValue(
of({ ...storedRow, serverTimezone: 'UTC+03:00' })
);
await service.resolveXtreamCatchupUrl(
item,
1_788_723_000,
1_788_726_600
);
expect(xtreamUrl.resolveCatchupUrl).toHaveBeenCalledWith(
'xtream-1',
expect.objectContaining({ serverTimezone: 'UTC+03:00' }),
7,
1_788_723_000,
1_788_726_600,
'UTC+03:00'
);
});
it('returns null instead of guessing when the row lacks credentials', async () => {
dbGetAppPlaylist.mockResolvedValue({ _id: 'xtream-1' });
await expect(
service.resolveXtreamCatchupUrl(item, 1_788_723_000, 1_788_726_600)
).resolves.toBeNull();
expect(xtreamUrl.resolveCatchupUrl).not.toHaveBeenCalled();
});
});
@@ -1122,6 +1122,7 @@ export class StreamResolverService {
serverUrl: string;
username: string;
password: string;
serverTimezone?: string;
},
streamId: number,
limit: number,
@@ -70,6 +70,31 @@ describe('ElectronXtreamDataSource (delegation)', () => {
});
});
it('persists the panel timezone through the conditional worker UPDATE with the request connection (issue #1562)', async () => {
await harness.dataSource.rememberServerTimezone(
playlistId,
{
serverUrl: 'http://portal.example',
username: 'demo',
password: 'secret',
allowedOutputFormats: ['ts'],
},
'Europe/London'
);
expect(
harness.dbService.setXtreamPlaylistServerTimezone
).toHaveBeenCalledWith(
playlistId,
{
serverUrl: 'http://portal.example',
username: 'demo',
password: 'secret',
},
'Europe/London'
);
});
it('deletes playlists via the DB and propagates failures', async () => {
await harness.dataSource.deletePlaylist(playlistId);
expect(harness.dbService.deletePlaylist).toHaveBeenCalledWith(
@@ -48,6 +48,7 @@ export function createDbServiceMock() {
clearPlaylistRecentItems: jest.fn().mockResolvedValue(undefined),
getContentByXtreamId: jest.fn().mockResolvedValue(null),
setContentMetadataIfMissing: jest.fn().mockResolvedValue(undefined),
setXtreamPlaylistServerTimezone: jest.fn().mockResolvedValue(true),
deleteXtreamPlaylistContent: jest.fn().mockResolvedValue({
hiddenCategories: [],
favorites: [],
@@ -107,6 +107,25 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
});
}
async rememberServerTimezone(
playlistId: string,
credentials: XtreamCredentials,
serverTimezone: string
): Promise<void> {
// One conditional UPDATE in the worker (`DB_SET_PLAYLIST_SERVER_TIMEZONE`):
// the row-level connection match and the no-op-when-equal check
// happen inside the statement, never as a read here.
await this.dbService.setXtreamPlaylistServerTimezone(
playlistId,
{
serverUrl: credentials.serverUrl,
username: credentials.username,
password: credentials.password,
},
serverTimezone
);
}
async deletePlaylist(playlistId: string): Promise<void> {
await this.dbService.deletePlaylist(playlistId);
}
@@ -336,9 +355,7 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
await this.dbService.saveXtreamContent(
playlistId,
remoteData as
| XtreamLiveStream[]
| XtreamVodStream[]
| XtreamSerieItem[],
XtreamLiveStream[] | XtreamVodStream[] | XtreamSerieItem[],
type,
onProgress,
options
@@ -15,6 +15,7 @@ describe('PwaXtreamDataSource', () => {
};
let playlistsService: {
getPlaylistById: jest.Mock;
transformPlaylistMeta: jest.Mock;
};
const credentials: XtreamCredentials = {
@@ -31,6 +32,7 @@ describe('PwaXtreamDataSource', () => {
};
playlistsService = {
getPlaylistById: jest.fn(() => of(undefined)),
transformPlaylistMeta: jest.fn(() => of(null)),
};
TestBed.configureTestingModule({
@@ -110,6 +112,73 @@ describe('PwaXtreamDataSource', () => {
);
});
it('persists the panel timezone through one IndexedDB transform guarded by the request connection (issue #1562)', async () => {
const credentials = {
serverUrl: 'http://panel.example:8080',
username: 'user',
password: 'pass',
};
const row = {
_id: 'playlist-1',
title: 'Xtream',
...credentials,
};
await dataSource.createPlaylist({
id: 'playlist-1',
name: 'Xtream',
type: 'xtream',
...credentials,
});
playlistsService.transformPlaylistMeta.mockImplementation(
(_id: string, transform: (current: unknown) => unknown) =>
of(transform(row))
);
await dataSource.rememberServerTimezone(
'playlist-1',
credentials,
'UTC+03:00'
);
const [, transform] = playlistsService.transformPlaylistMeta.mock
.calls[0] as [string, (current: unknown) => unknown];
// The row still points at the panel → written, with the clock.
expect(transform(row)).toEqual({ ...row, serverTimezone: 'UTC+03:00' });
// Already carrying it, or moved to another panel → no write.
expect(transform({ ...row, serverTimezone: 'UTC+03:00' })).toBeNull();
expect(
transform({ ...row, serverUrl: 'http://moved.example:8080' })
).toBeNull();
// The localStorage copy follows the successful write.
await expect(dataSource.getPlaylist('playlist-1')).resolves.toEqual(
expect.objectContaining({ serverTimezone: 'UTC+03:00' })
);
});
it('carries the persisted panel timezone from the IndexedDB row into the store playlist (issue #1562)', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'playlist-1',
title: 'Xtream',
importDate: '2026-04-01T00:00:00.000Z',
lastUsage: '2026-04-01T00:00:00.000Z',
count: 0,
autoRefresh: false,
serverUrl: 'http://panel.example:8080',
username: 'user',
password: 'pass',
serverTimezone: 'UTC+03:00',
})
);
await expect(dataSource.getPlaylist('playlist-1')).resolves.toEqual(
expect.objectContaining({
id: 'playlist-1',
serverTimezone: 'UTC+03:00',
})
);
});
it('uses current playlist metadata before stale PWA storage when fetching playlist details', async () => {
await dataSource.createPlaylist({
id: 'playlist-1',
@@ -656,12 +725,8 @@ describe('PwaXtreamDataSource', () => {
expect(setItemSpy).toHaveBeenCalledTimes(1);
setItemSpy.mockRestore();
const stored = JSON.parse(
localStorage.getItem(storageKey) || '{}'
);
const playlistRows = stored[
'playlist-1'
] as PlaybackPositionData[];
const stored = JSON.parse(localStorage.getItem(storageKey) || '{}');
const playlistRows = stored['playlist-1'] as PlaybackPositionData[];
expect(playlistRows).toHaveLength(2);
const replaced = playlistRows.find(
@@ -721,9 +786,7 @@ describe('PwaXtreamDataSource', () => {
expect(setItemSpy).toHaveBeenCalledTimes(1);
setItemSpy.mockRestore();
const stored = JSON.parse(
localStorage.getItem(storageKey) || '{}'
);
const stored = JSON.parse(localStorage.getItem(storageKey) || '{}');
expect(stored['playlist-1']).toEqual([
expect.objectContaining({
contentXtreamId: 202,
@@ -128,6 +128,34 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
}
}
async rememberServerTimezone(
playlistId: string,
credentials: XtreamCredentials,
serverTimezone: string
): Promise<void> {
try {
// `transformPlaylistMeta` runs the read and the write inside
// one IndexedDB readwrite cursor transaction, so the
// connection check below cannot be overtaken by an edit.
const playlistsService = this.injector.get(PlaylistsService);
const written = await firstValueFrom(
playlistsService.transformPlaylistMeta(playlistId, (row) =>
row.serverUrl !== credentials.serverUrl ||
row.username !== credentials.username ||
row.password !== credentials.password ||
row.serverTimezone === serverTimezone
? null
: { ...row, serverTimezone }
)
);
if (written) {
await this.updatePlaylist(playlistId, { serverTimezone });
}
} catch (error) {
this.logger.error('Failed to persist the portal timezone', error);
}
}
async deletePlaylist(playlistId: string): Promise<void> {
const playlists = this.getPlaylistsFromStorage();
const filtered = playlists.filter((p) => p.id !== playlistId);
@@ -212,6 +240,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
userAgent: playlist.userAgent,
referrer: playlist.referrer,
origin: playlist.origin,
serverTimezone: playlist.serverTimezone,
};
}
@@ -388,9 +417,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
const cachedContent = this.contentCache.get(cacheKey);
if (cachedContent) {
return cachedContent as
| XtreamLiveStream[]
| XtreamVodStream[]
| XtreamSerieItem[];
XtreamLiveStream[] | XtreamVodStream[] | XtreamSerieItem[];
}
// Fetch from API
@@ -164,6 +164,23 @@ export interface IXtreamDataSource {
updates: Partial<XtreamPlaylistData>
): Promise<void>;
/**
* Persist the panel clock a successful account-info check learned
* (`resolveXtreamServerTimezone`) onto the stored playlist row, which
* the Favorites / Recent catch-up resolver reads instead of the store
* (issue #1562). Each runtime applies it atomically against the row's
* current connection: the write lands only while the row still points
* at `credentials`, so an edit that moved the source meanwhile keeps
* the clock the edit flow dropped, and a row already carrying the value
* is left untouched. Never rejects — a failed write is retried by the
* next check.
*/
rememberServerTimezone(
playlistId: string,
credentials: XtreamCredentials,
serverTimezone: string
): Promise<void>;
/**
* Delete a playlist and all its data
*/
@@ -321,6 +321,46 @@ describe('XtreamApiService', () => {
]);
});
it('reads timestamp-less epg date strings in the server timezone the credentials carry (issue #1562)', async () => {
// A panel at UTC+3 wrote 22:30 for 19:30 UTC; the viewer's clock
// must not enter the conversion.
const listing = {
id: 'current',
epg_id: 'channel-101.mock',
title: Buffer.from('Current Show').toString('base64'),
description: '',
start: '2026-09-06 22:30:00',
end: '2026-09-06 23:00:00',
channel_id: 'channel-101.mock',
};
dataService.sendIpcEvent.mockResolvedValue({
payload: { epg_listings: [listing] },
});
const shortItems = await service.getShortEpg(
{ ...credentials, serverTimezone: 'UTC+03:00' },
101,
4
);
const fullItems = await service.getFullEpg(
{ ...credentials, serverTimezone: 'Europe/Moscow' },
101
);
expect(shortItems[0]).toEqual(
expect.objectContaining({
start: '2026-09-06T19:30:00.000Z',
stop: '2026-09-06T20:00:00.000Z',
})
);
expect(fullItems[0]).toEqual(
expect.objectContaining({
start: '2026-09-06T19:30:00.000Z',
stop: '2026-09-06T20:00:00.000Z',
})
);
});
it('normalizes short and full epg items consistently for the same timestamps', async () => {
const startTimestamp = Math.floor(
Date.parse('2026-04-05T05:30:00.000Z') / 1000
@@ -12,6 +12,7 @@ import {
XtreamVodStream,
XTREAM_REQUEST,
normalizeXtreamServerUrl,
parseXtreamServerLocalDateTime,
} from '@iptvnator/shared/interfaces';
import { XtreamAccountInfo } from '../account-info/account-info.interface';
@@ -23,6 +24,13 @@ export interface XtreamCredentials {
serverUrl: string;
username: string;
password: string;
/**
* The panel's timezone as persisted from its account info
* (`resolveXtreamServerTimezone`); lets EPG `start`/`end` strings that
* arrive without `start_timestamp` be read in the clock the panel wrote
* them in instead of the viewer's.
*/
serverTimezone?: string;
}
/**
@@ -331,7 +339,10 @@ export class XtreamApiService {
options
);
return this.normalizeShortEpgItems(response);
return this.normalizeShortEpgItems(
response,
credentials.serverTimezone
);
}
/**
@@ -354,7 +365,10 @@ export class XtreamApiService {
},
options
);
const items = this.normalizeFullEpgItems(response);
const items = this.normalizeFullEpgItems(
response,
credentials.serverTimezone
);
if (items.length > 0) {
return items;
}
@@ -373,7 +387,10 @@ export class XtreamApiService {
options
);
return this.normalizeFullEpgItems(fallbackResponse);
return this.normalizeFullEpgItems(
fallbackResponse,
credentials.serverTimezone
);
}
/**
@@ -411,7 +428,10 @@ export class XtreamApiService {
return Object.values(listings);
}
private normalizeShortEpgItems(response: EpgResponse): EpgItem[] {
private normalizeShortEpgItems(
response: EpgResponse,
serverTimezone?: string
): EpgItem[] {
return this.getEpgListings(response)
.map((item, index) => {
const startTimestamp = this.parseUnixTimestamp(
@@ -422,10 +442,13 @@ export class XtreamApiService {
);
const normalizedStart =
this.toIsoString(startTimestamp) ??
this.normalizeDateString(item.start);
this.normalizeDateString(item.start, serverTimezone);
const normalizedStop =
this.toIsoString(stopTimestamp) ??
this.normalizeDateString(item.stop ?? item.end);
this.normalizeDateString(
item.stop ?? item.end,
serverTimezone
);
return {
id: String(item.id ?? index),
@@ -459,7 +482,10 @@ export class XtreamApiService {
);
}
private normalizeFullEpgItems(response: EpgResponse): EpgItem[] {
private normalizeFullEpgItems(
response: EpgResponse,
serverTimezone?: string
): EpgItem[] {
return this.getEpgListings(response)
.map((item, index) => {
const startTimestamp = this.parseUnixTimestamp(
@@ -470,10 +496,13 @@ export class XtreamApiService {
);
const normalizedStart =
this.toIsoString(startTimestamp) ??
this.normalizeDateString(item.start);
this.normalizeDateString(item.start, serverTimezone);
const normalizedStop =
this.toIsoString(stopTimestamp) ??
this.normalizeDateString(item.stop ?? item.end);
this.normalizeDateString(
item.stop ?? item.end,
serverTimezone
);
return {
id: String(item.id ?? index),
@@ -516,12 +545,29 @@ export class XtreamApiService {
return timestamp ? new Date(timestamp * 1000).toISOString() : null;
}
private normalizeDateString(value: unknown): string {
/**
* A panel writes `start`/`end` in its own timezone. With that timezone
* known the string is converted exactly; without it, `Date.parse` reads
* it in the viewer's clock — the best remaining guess, and the reason
* `start_timestamp` is preferred whenever present.
*/
private normalizeDateString(
value: unknown,
serverTimezone?: string
): string {
const rawValue = String(value ?? '').trim();
if (!rawValue) {
return '';
}
const serverLocal = parseXtreamServerLocalDateTime(
rawValue,
serverTimezone
);
if (serverLocal !== null) {
return new Date(serverLocal * 1000).toISOString();
}
const parsed = Date.parse(rawValue.replace(' ', 'T'));
return Number.isFinite(parsed)
? new Date(parsed).toISOString()
@@ -411,5 +411,44 @@ describe('XtreamUrlService', () => {
const expected = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}:${pad(date.getHours())}-${pad(date.getMinutes())}`;
expect(url).toContain(expected);
});
it('formats in the clock-derived fixed offset persisted for a panel with an unusable timezone name', () => {
// 02:00 UTC at UTC+03:00 is 05:00 the same day; at UTC-03:30 it
// is 22:30 the previous day.
expect(
service.constructCatchupUrl(
credentials,
101,
timestamp,
timestamp + 3600,
'rest',
'UTC+03:00'
)
).toContain('/60/2025-03-01:05-00/101.ts');
expect(
service.constructCatchupUrl(
credentials,
101,
timestamp,
timestamp + 3600,
'legacy',
'UTC-03:30'
)
).toContain('start=2025-02-28%3A22-30');
});
it('renders server midnight as 00, never 24', () => {
// 2025-03-01 05:00:00 UTC = 2025-03-01 00:00 America/New_York
const midnightInNewYork = 1740805200;
const url = service.constructCatchupUrl(
credentials,
101,
midnightInNewYork,
midnightInNewYork + 1800,
'rest',
'America/New_York'
);
expect(url).toContain('/30/2025-03-01:00-00/101.ts');
});
});
});
@@ -1,5 +1,6 @@
import { inject, Injectable } from '@angular/core';
import {
formatXtreamCatchupStart,
normalizeXtreamServerUrl,
StreamFormat,
XtreamSerieEpisode,
@@ -517,32 +518,17 @@ export class XtreamUrlService {
return allowedFormats[0];
}
/**
* The panel reads the start segment with `strtotime()` in its own
* timezone, so the epoch is rendered in the timezone the account info
* reported (IANA name or clock-derived `UTC±HH:MM`,
* `xtream-server-timezone.util.ts`). Without one, the viewer's local
* clock is the only remaining guess.
*/
private formatCatchupStartTime(
timestamp: number,
timezone?: string
): string {
const date = new Date(timestamp * 1000);
if (timezone) {
try {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).formatToParts(date);
const get = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((p) => p.type === type)?.value ?? '00';
return `${get('year')}-${get('month')}-${get('day')}:${get('hour')}-${get('minute')}`;
} catch {
// Invalid timezone string — fall through to local-time formatting
}
}
const pad = (value: number) => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}:${pad(date.getHours())}-${pad(date.getMinutes())}`;
return formatXtreamCatchupStart(timestamp, timezone);
}
}
@@ -129,6 +129,7 @@ export function withEpg() {
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
serverTimezone: playlist.serverTimezone,
};
};
@@ -22,6 +22,15 @@ const PLAYLIST: XtreamPlaylistData = {
username: 'user',
};
const PLAYLIST_CONNECTION = {
serverUrl: PLAYLIST.serverUrl,
username: PLAYLIST.username,
password: PLAYLIST.password,
};
// 2026-09-06 19:30:00 UTC
const SERVER_EPOCH = 1_788_723_000;
const TestPortalStore = signalStore(withPortal());
describe('withPortal', () => {
@@ -29,11 +38,13 @@ describe('withPortal', () => {
let apiService: {
getAccountInfo: jest.Mock;
};
let rememberServerTimezone: jest.Mock;
beforeEach(() => {
apiService = {
getAccountInfo: jest.fn(),
};
rememberServerTimezone = jest.fn().mockResolvedValue(undefined);
TestBed.configureTestingModule({
providers: [
@@ -46,6 +57,7 @@ describe('withPortal', () => {
provide: XTREAM_DATA_SOURCE,
useValue: {
getPlaylist: jest.fn(),
rememberServerTimezone,
},
},
],
@@ -55,6 +67,17 @@ describe('withPortal', () => {
store.setCurrentPlaylist(PLAYLIST);
});
function respondWith(serverInfo: Record<string, unknown> | undefined) {
apiService.getAccountInfo.mockResolvedValue({
user_info: {
auth: 1,
exp_date: '0',
status: 'Active',
},
...(serverInfo ? { server_info: serverInfo } : {}),
});
}
it('accepts lowercase active account status and unlimited expiration', async () => {
apiService.getAccountInfo.mockResolvedValue({
user_info: {
@@ -100,4 +123,171 @@ describe('withPortal', () => {
expect(store.currentPlaylist()?.allowedOutputFormats).toBeUndefined();
});
describe('server timezone (issue #1562)', () => {
it('keeps the learned IANA timezone in the store AND hands it to the data source for the stored row', async () => {
respondWith({ timezone: 'Europe/London' });
await store.checkPortalStatus();
expect(store.currentPlaylist()?.serverTimezone).toBe(
'Europe/London'
);
expect(rememberServerTimezone).toHaveBeenCalledWith(
PLAYLIST.id,
PLAYLIST_CONNECTION,
'Europe/London'
);
});
it('derives a fixed offset from the panel clock when the timezone name is unusable', async () => {
respondWith({
timezone: 'UTC+3',
time_now: '2026-09-06 22:30:00',
timestamp_now: SERVER_EPOCH,
});
await store.checkPortalStatus();
expect(store.currentPlaylist()?.serverTimezone).toBe('UTC+03:00');
expect(rememberServerTimezone).toHaveBeenCalledWith(
PLAYLIST.id,
PLAYLIST_CONNECTION,
'UTC+03:00'
);
});
it('offers the value to the data source even when the store already carries it', async () => {
// The store cannot know whether the ROW has it (a transient
// write failure leaves them apart); the data source decides.
store.setCurrentPlaylist({ ...PLAYLIST, serverTimezone: 'UTC' });
respondWith({ timezone: 'UTC' });
await store.checkPortalStatus();
expect(rememberServerTimezone).toHaveBeenCalledWith(
PLAYLIST.id,
PLAYLIST_CONNECTION,
'UTC'
);
});
it('keeps the previously known timezone when the response carries no usable clock', async () => {
store.setCurrentPlaylist({
...PLAYLIST,
serverTimezone: 'Europe/London',
});
respondWith({ timezone: '' });
await store.checkPortalStatus();
expect(store.currentPlaylist()?.serverTimezone).toBe(
'Europe/London'
);
expect(rememberServerTimezone).not.toHaveBeenCalled();
});
it('hands a late answer to the data source under the playlist and connection that asked, never the one selected meanwhile', async () => {
const other: XtreamPlaylistData = {
...PLAYLIST,
id: 'playlist-2',
name: 'Other portal',
serverUrl: 'https://other.example.com',
};
let answer!: (value: unknown) => void;
apiService.getAccountInfo.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
})
);
const pending = store.checkPortalStatus();
store.setCurrentPlaylist(other);
answer({
user_info: { auth: 1, exp_date: '0', status: 'Active' },
server_info: { timezone: 'Europe/London' },
});
// The answer describes A; callers gating content init on the
// result get the store's verdict about B instead.
await expect(pending).resolves.toBe('unavailable');
expect(store.currentPlaylist()).toEqual(other);
expect(store.portalStatus()).toBe('unavailable');
expect(rememberServerTimezone).toHaveBeenCalledWith(
PLAYLIST.id,
PLAYLIST_CONNECTION,
'Europe/London'
);
});
it('does not patch the store with a late answer when the playlist was edited in place meanwhile', async () => {
let answer!: (value: unknown) => void;
apiService.getAccountInfo.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
})
);
const pending = store.checkPortalStatus();
// Same id, new panel: the in-place edit flow.
const moved: XtreamPlaylistData = {
...PLAYLIST,
serverUrl: 'https://moved.example.com',
};
store.setCurrentPlaylist(moved);
answer({
user_info: { auth: 1, exp_date: '0', status: 'Active' },
server_info: { timezone: 'Europe/London' },
});
await expect(pending).resolves.toBe('unavailable');
expect(store.currentPlaylist()).toEqual(moved);
expect(store.portalStatus()).toBe('unavailable');
// The row-level guard belongs to the data source, which only
// ever sees the ORIGINAL connection the answer came from.
expect(rememberServerTimezone).toHaveBeenCalledWith(
PLAYLIST.id,
PLAYLIST_CONNECTION,
'Europe/London'
);
});
it('does not mark a playlist selected meanwhile unavailable for the earlier playlist’s failure', async () => {
const other: XtreamPlaylistData = { ...PLAYLIST, id: 'playlist-2' };
let fail!: (reason: unknown) => void;
apiService.getAccountInfo.mockReturnValue(
new Promise((_resolve, reject) => {
fail = reject;
})
);
const pending = store.checkPortalStatus();
store.setCurrentPlaylist(other);
respondWith({ timezone: 'UTC' });
await store.checkPortalStatus();
expect(store.portalStatus()).toBe('active');
fail(new Error('panel down'));
// A's failure says nothing about B, so the caller gets B's verdict.
await expect(pending).resolves.toBe('active');
expect(store.portalStatus()).toBe('active');
});
it('still reports the portal status and retries on the next check when the data source rejects', async () => {
rememberServerTimezone.mockRejectedValueOnce(
new Error('storage unavailable')
);
respondWith({ timezone: 'Europe/London' });
await expect(store.checkPortalStatus()).resolves.toBe('active');
expect(store.currentPlaylist()?.serverTimezone).toBe(
'Europe/London'
);
await store.checkPortalStatus();
expect(rememberServerTimezone).toHaveBeenCalledTimes(2);
});
});
});
@@ -15,7 +15,10 @@ import {
} from '../../services/xtream-api.service';
import { PortalStatusType } from '../../xtream-state';
import { createLogger } from '@iptvnator/portal/shared/util';
import { resolveXtreamPortalStatus } from '@iptvnator/shared/interfaces';
import {
resolveXtreamPortalStatus,
resolveXtreamServerTimezone,
} from '@iptvnator/shared/interfaces';
/**
* Portal state for managing playlist and portal status
@@ -52,6 +55,54 @@ export function withPortal() {
const apiService = inject(XtreamApiService);
const dataSource = inject(XTREAM_DATA_SOURCE);
/**
* Whether a playlist (the store's current one, or the stored
* row) still points at the panel an account-info answer came
* from. The playlist id alone is not enough: an in-place edit
* keeps the id while moving the source, and an answer already on
* the wire for the OLD panel must not describe the new one.
*/
const answersFor = (
candidate: {
serverUrl?: string;
username?: string;
password?: string;
} | null,
credentials: XtreamCredentials
): boolean =>
!!candidate &&
candidate.serverUrl === credentials.serverUrl &&
candidate.username === credentials.username &&
candidate.password === credentials.password;
/**
* The Favorites / Recent catch-up resolver reads the STORED
* playlist row, not this store, so a timezone learned here has
* to reach storage or that path keeps rendering programme
* start times in the viewer's clock (issue #1562). The data
* source applies it atomically against the row's current
* connection (see `IXtreamDataSource.rememberServerTimezone`);
* a failed write never fails the status check that learned it.
*/
const rememberServerTimezone = async (
playlistId: string,
credentials: XtreamCredentials,
serverTimezone: string
): Promise<void> => {
try {
await dataSource.rememberServerTimezone(
playlistId,
credentials,
serverTimezone
);
} catch (error) {
logger.error(
'Failed to persist the portal timezone',
error
);
}
};
return {
/**
* Set the current playlist ID
@@ -118,18 +169,32 @@ export function withPortal() {
await apiService.getAccountInfo(credentials);
const portalStatus =
resolveXtreamPortalStatus(response);
const serverTimezone =
response?.server_info?.timezone ?? undefined;
// A response without a usable clock keeps whatever
// the row already knows; only a learned value
// replaces it.
const serverTimezone = resolveXtreamServerTimezone(
response?.server_info
);
const allowedOutputFormats = response?.user_info
?.allowed_output_formats?.length
? response.user_info.allowed_output_formats
.map((format) => format.trim())
.filter(Boolean)
: undefined;
patchState(store, { portalStatus });
// The answer belongs to the panel whose credentials
// were sent: its timezone is offered to THAT row
// regardless, while the store is patched only if the
// selected playlist is still that panel — neither a
// source switch nor an in-place edit during the
// request may hand the old panel's status or clock
// to the new one.
const current = store.currentPlaylist();
if (current) {
const describesCurrent =
current?.id === playlist.id &&
answersFor(current, credentials);
if (describesCurrent) {
patchState(store, {
portalStatus,
currentPlaylist: {
...current,
allowedOutputFormats,
@@ -139,11 +204,36 @@ export function withPortal() {
},
});
}
return portalStatus;
// Always offered to storage, never gated on the
// in-memory value: after a transient write failure
// the store already carries the clock, and only the
// row-level check inside the transform knows whether
// the row does too.
if (serverTimezone) {
await rememberServerTimezone(
playlist.id,
credentials,
serverTimezone
);
}
// Callers gate content initialization on this value
// for whatever is selected NOW; an answer about
// another panel must not unblock it.
return describesCurrent
? portalStatus
: store.portalStatus();
} catch (error) {
logger.error('Error checking portal status', error);
patchState(store, { portalStatus: 'unavailable' });
return 'unavailable';
const current = store.currentPlaylist();
if (
current?.id === playlist.id &&
answersFor(current, credentials)
) {
patchState(store, { portalStatus: 'unavailable' });
}
// The old panel's failure says nothing about a
// playlist selected or edited meanwhile.
return store.portalStatus();
}
},
@@ -391,6 +391,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
serverTimezone: playlist.serverTimezone,
};
const visibleIds = new Set<number>(channels.map((ch) => ch.xtream_id));
@@ -53,6 +53,9 @@ function toXtreamPlaylistData(
...(userAgent ? { userAgent } : {}),
...(referrer ? { referrer } : {}),
...(origin ? { origin } : {}),
...(playlist.serverTimezone
? { serverTimezone: playlist.serverTimezone }
: {}),
};
}
@@ -117,9 +120,7 @@ function getXtreamRouteTarget(url: string): {
playlistId: string | null;
section: PortalRailSection | null;
} {
const match = url.match(
/^\/workspace\/xtreams\/([^/?]+)(?:\/([^/?]+))?/
);
const match = url.match(/^\/workspace\/xtreams\/([^/?]+)(?:\/([^/?]+))?/);
return {
playlistId: match?.[1] ?? null,
@@ -144,7 +145,9 @@ function hasPlaylistConnectionChanges(
const currentReferrer = normalizeOptionalConnectionValue(
currentPlaylist.referrer
);
const nextReferrer = normalizeOptionalConnectionValue(nextPlaylist.referrer);
const nextReferrer = normalizeOptionalConnectionValue(
nextPlaylist.referrer
);
const currentOrigin = normalizeOptionalConnectionValue(
currentPlaylist.origin
);
@@ -171,11 +174,11 @@ function shouldBootstrapXtreamPlaylist(
return Boolean(
playlistId &&
routePlaylist &&
(storePlaylistId !== playlistId ||
currentPlaylist?.id !== playlistId ||
currentPlaylistUpdateDate !== routePlaylistUpdateDate ||
hasPlaylistConnectionChanges(currentPlaylist, routePlaylist))
routePlaylist &&
(storePlaylistId !== playlistId ||
currentPlaylist?.id !== playlistId ||
currentPlaylistUpdateDate !== routePlaylistUpdateDate ||
hasPlaylistConnectionChanges(currentPlaylist, routePlaylist))
);
}
@@ -183,11 +186,7 @@ function getXtreamRouteCategoryId(
url: string,
section: PortalRailSection | null
): number | null {
if (
section !== 'live' &&
section !== 'vod' &&
section !== 'series'
) {
if (section !== 'live' && section !== 'vod' && section !== 'series') {
return null;
}
@@ -330,10 +329,9 @@ export class XtreamWorkspaceRouteSession {
? this.xtreamStore.isCachedContentScopeReady(cacheScope) ||
(await this.xtreamStore.hasUsableOfflineCache(cacheScope))
: false;
const nextBlockReason =
canUseCachedContent
? null
: toContentInitBlockReason(portalStatus);
const nextBlockReason = canUseCachedContent
? null
: toContentInitBlockReason(portalStatus);
const currentBlockReason =
this.xtreamStore.contentInitBlockReason();
@@ -349,11 +347,7 @@ export class XtreamWorkspaceRouteSession {
section = this.syncRouteState(routeSection);
}
if (
portalStatus !== 'active' &&
!canUseCachedContent &&
cacheScope
) {
if (portalStatus !== 'active' && !canUseCachedContent && cacheScope) {
canUseCachedContent =
this.xtreamStore.isCachedContentScopeReady(cacheScope) ||
(await this.xtreamStore.hasUsableOfflineCache(cacheScope));
@@ -57,6 +57,8 @@ export interface XtreamPlaylist {
username: string;
password: string;
type: string;
/** Projected from the row payload by `DB_GET_PLAYLIST` (issue #1562). */
serverTimezone?: string;
}
type XtreamDatabasePlaylistUpdate = {
@@ -120,11 +122,7 @@ export interface DbOperationOptions {
}
export type XtreamImportStatus =
| 'idle'
| 'importing'
| 'completed'
| 'cancelled'
| 'failed';
'idle' | 'importing' | 'completed' | 'cancelled' | 'failed';
export function isDbAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError';
@@ -133,11 +131,7 @@ export function isDbAbortError(error: unknown): boolean {
export type GlobalRecentlyAddedKind = 'all' | 'vod' | 'series';
export type GlobalRecentlyAddedPlaylistType =
| 'xtream'
| 'stalker'
| 'm3u-file'
| 'm3u-text'
| 'm3u-url';
'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url';
export interface GlobalRecentlyAddedItem extends XtreamContent {
playlist_id: string;
@@ -379,6 +373,34 @@ export class DatabaseService {
}
}
/**
* Records the panel clock a successful account-info check learned, as
* one conditional UPDATE on the row payload (issue #1562). A no-op when
* the row no longer points at the given connection or already carries
* the value; never reads before writing, so it cannot undo a concurrent
* edit or upsert. Resolves `true` when the row now carries the clock.
*/
async setXtreamPlaylistServerTimezone(
playlistId: string,
connection: { serverUrl: string; username: string; password: string },
serverTimezone: string
): Promise<boolean> {
if (!window.electron?.dbSetPlaylistServerTimezone) {
return false;
}
try {
await window.electron.dbSetPlaylistServerTimezone(
playlistId,
connection,
serverTimezone
);
return true;
} catch (error) {
console.error('Error persisting the portal timezone:', error);
return false;
}
}
/**
* Check if categories exist
*/
@@ -652,6 +652,60 @@ describe('PlaylistsService', () => {
);
});
it('drops the learned panel timezone when a metadata update points the source at another server (issue #1562)', async () => {
const existingPlaylist = {
_id: 'xtream-1',
title: 'Portal',
serverUrl: 'https://old.example.com',
username: 'user',
password: 'pass',
serverTimezone: 'Europe/London',
} as Playlist;
const dbService = {
getAll: jest.fn(() => of([])),
getByID: jest.fn(() => of(existingPlaylist)),
update: jest.fn((_storeName: string, playlist: Playlist) =>
of(playlist)
),
};
testWindow.electron = undefined;
const service = createService(dbService);
await firstValueFrom(
service.updatePlaylistMeta({
_id: 'xtream-1',
title: 'Portal',
serverUrl: 'https://new.example.com',
} as PlaylistMeta)
);
const [, moved] = dbService.update.mock.calls[0];
expect(moved.serverUrl).toBe('https://new.example.com');
expect(moved.serverTimezone).toBeUndefined();
dbService.update.mockClear();
await firstValueFrom(
service.updatePlaylistMeta({
_id: 'xtream-1',
title: 'Renamed portal',
serverUrl: 'https://old.example.com',
} as PlaylistMeta)
);
const [, renamed] = dbService.update.mock.calls[0];
expect(renamed.serverTimezone).toBe('Europe/London');
dbService.update.mockClear();
await firstValueFrom(
service.updatePlaylistMeta({
_id: 'xtream-1',
title: 'Portal',
serverUrl: 'https://new.example.com',
serverTimezone: 'UTC+03:00',
} as PlaylistMeta)
);
const [, relearned] = dbService.update.mock.calls[0];
expect(relearned.serverTimezone).toBe('UTC+03:00');
});
it('aborts a guarded metadata update in one readwrite transaction when the row no longer matches', async () => {
const replacementPlaylist = {
_id: 'stalker-replaced',
@@ -755,6 +755,16 @@ export class PlaylistsService {
...(updatedPlaylist.portalUrl != null
? { portalUrl: updatedPlaylist.portalUrl }
: {}),
// A learned panel clock belongs to the panel it was learned
// from: an update that points the source at another server
// drops it until the next account-info check, unless the
// update itself supplies one (issue #1562).
...(updatedPlaylist.serverTimezone != null
? { serverTimezone: updatedPlaylist.serverTimezone }
: updatedPlaylist.serverUrl != null &&
updatedPlaylist.serverUrl !== playlist.serverUrl
? { serverTimezone: undefined }
: {}),
...(updatedPlaylist.isFullStalkerPortal !== undefined
? {
isFullStalkerPortal: updatedPlaylist.isFullStalkerPortal,
+1
View File
@@ -70,6 +70,7 @@ export * from './lib/xtream-live-stream.interface';
export * from './lib/xtream-response.interface';
export * from './lib/xtream-portal.utils';
export * from './lib/xtream-restore-state.util';
export * from './lib/xtream-server-timezone.util';
export * from './lib/xtream-recently-added.utils';
export * from './lib/xtream-performance-marker.interface';
export * from './lib/xtream-serie-details.interface';
@@ -896,6 +896,16 @@ export interface ElectronBridgeApi {
dbGetPlaylist: (
playlistId: string
) => Promise<ElectronBridgePlaylistRow | null>;
/**
* Records the panel clock learned from account info as one conditional
* UPDATE on the row payload; a no-op unless the row still points at the
* given connection (issue #1562).
*/
dbSetPlaylistServerTimezone: (
playlistId: string,
connection: { serverUrl: string; username: string; password: string },
serverTimezone: string
) => Promise<{ updated: boolean }>;
dbUpsertAppPlaylist: (
playlist: Playlist,
/**
@@ -28,6 +28,7 @@ export type PlaylistMeta = Pick<
| 'hiddenGroupTitles'
| 'portalUrl'
| 'recentlyViewed'
| 'serverTimezone'
| 'isFullStalkerPortal'
| 'stalkerSerialNumber'
| 'stalkerDeviceId1'
@@ -54,7 +54,14 @@ export interface Playlist {
recentlyViewed?: PlaylistRecentlyViewedItem[];
/** Indicates if this is a full stalker portal URL (e.g., /stalker_portal/c) requiring handshake authentication */
isFullStalkerPortal?: boolean;
/** Xtream server timezone string for catch-up URL construction */
/**
* The Xtream panel's own timezone, learned from `server_info` of the
* account-info response and normalized by `resolveXtreamServerTimezone`
* (an IANA name, or a clock-derived `UTC±HH:MM`). Persisted on the row so
* every catch-up URL builder — the Live TV layout AND the Favorites /
* Recent resolver, which reads the stored row — renders programme start
* times in the clock the panel's `strtotime()` expects (issue #1562).
*/
serverTimezone?: string;
/** Session token for full stalker portal authentication - persisted for session */
stalkerToken?: string;
@@ -0,0 +1,203 @@
/**
* Wall-clock primitives behind `xtream-server-timezone.util.ts`: the two
* stored timezone forms (an ICU-resolvable name, or a fixed `UTC±HH:MM`
* snapshot), and the conversions between an instant and the wall clock a
* zone shows for it. Nothing here knows about Xtream — that policy lives in
* the timezone util.
*/
/** Stored form for a panel whose timezone name is unusable: `UTC`, `UTC+03:00`, `UTC-03:30`. */
const FIXED_OFFSET_TIMEZONE_PATTERN = /^UTC(?:([+-])(\d{2}):(\d{2}))?$/;
const SERVER_LOCAL_DATE_TIME_PATTERN =
/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/;
export const MINUTE_MS = 60_000;
const timeZoneSupportCache = new Map<string, boolean>();
export interface WallClockParts {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
}
/**
* Whether the runtime can format dates in the named zone. `Intl` accepts
* IANA names and a few aliases (`UTC`, `GMT`, `Etc/GMT+3`) and throws a
* `RangeError` for anything else (`UTC+3`, an empty string, garbage).
*/
export function isSupportedTimeZoneName(name: string): boolean {
const cached = timeZoneSupportCache.get(name);
if (cached !== undefined) {
return cached;
}
let supported = false;
try {
new Intl.DateTimeFormat('en-US', { timeZone: name });
supported = true;
} catch {
supported = false;
}
timeZoneSupportCache.set(name, supported);
return supported;
}
/** Parses the stored `UTC±HH:MM` form; `null` for anything else. */
export function parseFixedOffsetTimeZone(value: string): number | null {
const match = FIXED_OFFSET_TIMEZONE_PATTERN.exec(value);
if (!match) {
return null;
}
if (!match[1]) {
return 0;
}
const minutes = Number(match[2]) * 60 + Number(match[3]);
return match[1] === '-' ? -minutes : minutes;
}
export function formatFixedOffsetTimeZone(offsetMinutes: number): string {
if (offsetMinutes === 0) {
return 'UTC';
}
const sign = offsetMinutes < 0 ? '-' : '+';
const absolute = Math.abs(offsetMinutes);
return `UTC${sign}${padTwo(Math.floor(absolute / 60))}:${padTwo(absolute % 60)}`;
}
/**
* The wall clock a zone shows for an instant. Accepts both stored forms;
* with no usable zone the viewer's local clock is the only remaining guess.
*/
export function wallClockPartsAt(
instantMs: number,
timeZone: string | null | undefined
): WallClockParts {
if (timeZone) {
const fixedOffset = parseFixedOffsetTimeZone(timeZone);
if (fixedOffset !== null) {
return utcParts(new Date(instantMs + fixedOffset * MINUTE_MS));
}
if (isSupportedTimeZoneName(timeZone)) {
return zoneParts(instantMs, timeZone);
}
}
const date = new Date(instantMs);
return {
year: date.getFullYear(),
month: date.getMonth() + 1,
day: date.getDate(),
hour: date.getHours(),
minute: date.getMinutes(),
second: date.getSeconds(),
};
}
/** The named zone's UTC offset, in minutes, at a given instant. */
export function zoneOffsetMinutesAt(
instantMs: number,
timeZone: string
): number {
const parts = zoneParts(instantMs, timeZone);
const asUtcMs = Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second
);
return Math.round((asUtcMs - instantMs) / MINUTE_MS);
}
/**
* `YYYY-MM-DD HH:mm[:ss]` read as if it were UTC; `null` when malformed.
* `Date.UTC` rolls out-of-range fields over (`2026-13-01 25:00` becomes a
* real instant in 2027), so only a string that reads back unchanged counts.
*/
export function parseNaiveUtcMs(
value: string | null | undefined
): number | null {
const match = SERVER_LOCAL_DATE_TIME_PATTERN.exec(
String(value ?? '').trim()
);
if (!match) {
return null;
}
const [, year, month, day, hour, minute, second = '0'] = match;
const fields = [
Number(year),
Number(month) - 1,
Number(day),
Number(hour),
Number(minute),
Number(second),
];
const ms = Date.UTC(
fields[0],
fields[1],
fields[2],
fields[3],
fields[4],
fields[5]
);
if (!Number.isFinite(ms)) {
return null;
}
const readBack = utcParts(new Date(ms));
const unchanged = [
readBack.year,
readBack.month - 1,
readBack.day,
readBack.hour,
readBack.minute,
readBack.second,
].every((field, index) => field === fields[index]);
return unchanged ? ms : null;
}
export function padTwo(value: number): string {
return String(value).padStart(2, '0');
}
function zoneParts(instantMs: number, timeZone: string): WallClockParts {
// `hourCycle: 'h23'` — the `hour12: false` spelling still yields "24"
// for midnight in some ICU/locale combinations. The option is typed
// from es2020.intl while the web app compiles against an es2018 lib,
// hence the assertion; every supported runtime honours it.
const options = {
timeZone,
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
hourCycle: 'h23',
} as Intl.DateTimeFormatOptions;
const parts = new Intl.DateTimeFormat('en-US', options).formatToParts(
new Date(instantMs)
);
const read = (type: Intl.DateTimeFormatPartTypes) =>
Number(parts.find((part) => part.type === type)?.value ?? 0);
return {
year: read('year'),
month: read('month'),
day: read('day'),
hour: read('hour') % 24,
minute: read('minute'),
second: read('second'),
};
}
function utcParts(date: Date): WallClockParts {
return {
year: date.getUTCFullYear(),
month: date.getUTCMonth() + 1,
day: date.getUTCDate(),
hour: date.getUTCHours(),
minute: date.getUTCMinutes(),
second: date.getUTCSeconds(),
};
}
@@ -0,0 +1,240 @@
import {
deriveXtreamServerUtcOffsetMinutes,
formatFixedOffsetTimeZone,
formatXtreamCatchupStart,
isSupportedTimeZoneName,
parseFixedOffsetTimeZone,
parseXtreamServerLocalDateTime,
resolveXtreamServerTimezone,
} from './xtream-server-timezone.util';
// 2026-09-06 19:30:00 UTC — the mock EPG fixture's shape, a Saturday in BST.
const EPOCH = 1_788_723_000;
describe('xtream-server-timezone.util', () => {
describe('isSupportedTimeZoneName', () => {
it('accepts IANA names and the UTC alias', () => {
expect(isSupportedTimeZoneName('Europe/London')).toBe(true);
expect(isSupportedTimeZoneName('America/Sao_Paulo')).toBe(true);
expect(isSupportedTimeZoneName('UTC')).toBe(true);
});
it('rejects the offset spellings panels sometimes send', () => {
expect(isSupportedTimeZoneName('UTC+3')).toBe(false);
expect(isSupportedTimeZoneName('')).toBe(false);
expect(isSupportedTimeZoneName('Mars/Olympus')).toBe(false);
});
});
describe('fixed offset form', () => {
it('round-trips positive, negative, zero and half-hour offsets', () => {
expect(formatFixedOffsetTimeZone(180)).toBe('UTC+03:00');
expect(formatFixedOffsetTimeZone(-210)).toBe('UTC-03:30');
expect(formatFixedOffsetTimeZone(0)).toBe('UTC');
expect(parseFixedOffsetTimeZone('UTC+03:00')).toBe(180);
expect(parseFixedOffsetTimeZone('UTC-03:30')).toBe(-210);
expect(parseFixedOffsetTimeZone('UTC')).toBe(0);
});
it('does not mistake other strings for the stored form', () => {
expect(parseFixedOffsetTimeZone('UTC+3')).toBeNull();
expect(parseFixedOffsetTimeZone('Europe/London')).toBeNull();
expect(parseFixedOffsetTimeZone('utc')).toBeNull();
});
});
describe('deriveXtreamServerUtcOffsetMinutes', () => {
it('reads the offset from the panel clock pair', () => {
// 19:30 UTC shown as 22:30 → the panel runs at UTC+3.
expect(
deriveXtreamServerUtcOffsetMinutes('2026-09-06 22:30:00', EPOCH)
).toBe(180);
expect(
deriveXtreamServerUtcOffsetMinutes(
'2026-09-06 16:30:00',
String(EPOCH)
)
).toBe(-180);
});
it('snaps a second of clock skew to the real offset', () => {
expect(
deriveXtreamServerUtcOffsetMinutes('2026-09-06 22:29:59', EPOCH)
).toBe(180);
});
it('rejects missing, malformed or impossible values', () => {
expect(
deriveXtreamServerUtcOffsetMinutes(undefined, EPOCH)
).toBeNull();
expect(
deriveXtreamServerUtcOffsetMinutes('2026-09-06 22:30:00', null)
).toBeNull();
expect(
deriveXtreamServerUtcOffsetMinutes('yesterday', EPOCH)
).toBeNull();
expect(
deriveXtreamServerUtcOffsetMinutes('2026-09-06 22:30:00', 0)
).toBeNull();
// 20 hours ahead is not a real offset — a broken clock pair.
expect(
deriveXtreamServerUtcOffsetMinutes('2026-09-07 15:30:00', EPOCH)
).toBeNull();
});
});
describe('resolveXtreamServerTimezone', () => {
it('prefers a resolvable IANA name', () => {
expect(
resolveXtreamServerTimezone({
timezone: ' Europe/London ',
time_now: '2026-09-06 22:30:00',
timestamp_now: EPOCH,
})
).toBe('Europe/London');
});
it('falls back to the clock-derived fixed offset for an unusable name', () => {
expect(
resolveXtreamServerTimezone({
timezone: 'UTC+3',
time_now: '2026-09-06 22:30:00',
timestamp_now: EPOCH,
})
).toBe('UTC+03:00');
expect(
resolveXtreamServerTimezone({
timezone: '',
time_now: '2026-09-06 19:30:00',
timestamp_now: String(EPOCH),
})
).toBe('UTC');
});
it('reports nothing when neither field is trustworthy', () => {
expect(resolveXtreamServerTimezone(undefined)).toBeUndefined();
expect(resolveXtreamServerTimezone({})).toBeUndefined();
expect(
resolveXtreamServerTimezone({
timezone: 'UTC+3',
time_now: 'n/a',
timestamp_now: EPOCH,
})
).toBeUndefined();
});
});
describe('formatXtreamCatchupStart', () => {
it('formats in the IANA zone, honouring DST', () => {
expect(formatXtreamCatchupStart(EPOCH, 'Europe/London')).toBe(
'2026-09-06:20-30'
);
expect(formatXtreamCatchupStart(EPOCH, 'America/Sao_Paulo')).toBe(
'2026-09-06:16-30'
);
});
it('formats in a stored fixed offset', () => {
expect(formatXtreamCatchupStart(EPOCH, 'UTC+03:00')).toBe(
'2026-09-06:22-30'
);
expect(formatXtreamCatchupStart(EPOCH, 'UTC-03:30')).toBe(
'2026-09-06:16-00'
);
expect(formatXtreamCatchupStart(EPOCH, 'UTC')).toBe(
'2026-09-06:19-30'
);
});
it('renders midnight as 00, never 24', () => {
const midnightUtc = Date.UTC(2026, 8, 7, 0, 0, 0) / 1000;
expect(formatXtreamCatchupStart(midnightUtc, 'UTC')).toBe(
'2026-09-07:00-00'
);
expect(formatXtreamCatchupStart(midnightUtc, 'Etc/GMT')).toBe(
'2026-09-07:00-00'
);
});
it('falls back to the local clock only when no timezone is usable', () => {
const date = new Date(EPOCH * 1000);
const pad = (value: number) => String(value).padStart(2, '0');
const local = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}:${pad(date.getHours())}-${pad(date.getMinutes())}`;
expect(formatXtreamCatchupStart(EPOCH, undefined)).toBe(local);
expect(formatXtreamCatchupStart(EPOCH, 'UTC+3')).toBe(local);
});
});
describe('parseXtreamServerLocalDateTime', () => {
it('converts a server-local string back to the epoch it came from', () => {
expect(
parseXtreamServerLocalDateTime(
'2026-09-06 20:30:00',
'Europe/London'
)
).toBe(EPOCH);
expect(
parseXtreamServerLocalDateTime(
'2026-09-06 16:30:00',
'America/Sao_Paulo'
)
).toBe(EPOCH);
expect(
parseXtreamServerLocalDateTime('2026-09-06 22:30', 'UTC+03:00')
).toBe(EPOCH);
expect(
parseXtreamServerLocalDateTime('2026-09-06T19:30:00', 'UTC')
).toBe(EPOCH);
});
it('resolves wall clocks right after a DST switch on the correct side', () => {
// Europe/Berlin left DST at 2026-10-25 03:00 CEST → 02:00 CET.
// 03:30 local is unambiguous CET (UTC+1) → 02:30 UTC.
expect(
parseXtreamServerLocalDateTime(
'2026-10-25 03:30:00',
'Europe/Berlin'
)
).toBe(Date.UTC(2026, 9, 25, 2, 30, 0) / 1000);
// 2026-03-29 03:30 CEST (first hour after the spring switch) →
// 01:30 UTC.
expect(
parseXtreamServerLocalDateTime(
'2026-03-29 03:30:00',
'Europe/Berlin'
)
).toBe(Date.UTC(2026, 2, 29, 1, 30, 0) / 1000);
});
it('rejects out-of-range fields instead of letting Date.UTC roll them over', () => {
expect(
parseXtreamServerLocalDateTime('2026-13-01 25:00:00', 'UTC')
).toBeNull();
expect(
parseXtreamServerLocalDateTime('2026-02-30 10:00:00', 'UTC')
).toBeNull();
expect(
parseXtreamServerLocalDateTime('2026-09-06 23:60:00', 'UTC')
).toBeNull();
expect(
deriveXtreamServerUtcOffsetMinutes('2026-09-06 24:00:00', EPOCH)
).toBeNull();
// A leap day is a real date.
expect(
parseXtreamServerLocalDateTime('2028-02-29 00:00:00', 'UTC')
).toBe(Date.UTC(2028, 1, 29) / 1000);
});
it('returns null for malformed strings or an unusable timezone', () => {
expect(
parseXtreamServerLocalDateTime('2026-09-06', 'UTC')
).toBeNull();
expect(
parseXtreamServerLocalDateTime('2026-09-06 20:30:00', 'UTC+3')
).toBeNull();
expect(
parseXtreamServerLocalDateTime('2026-09-06 20:30:00', undefined)
).toBeNull();
});
});
});
@@ -0,0 +1,150 @@
/**
* Xtream server clock policy.
*
* An Xtream panel interprets every wall-clock string it exchanges with a
* client in ITS OWN timezone: the `start` / `end` strings of
* `get_simple_data_table` are `date('Y-m-d H:i:s')` output, and the
* `{Y-m-d:H-M}` segment of a timeshift URL goes through `strtotime()` with
* the same PHP default timezone. That timezone is reported once, in
* `server_info` of the account-info response, as an IANA name plus the
* server's current wall clock (`time_now`) and epoch (`timestamp_now`).
*
* `resolveXtreamServerTimezone` turns that report into ONE persistable
* string — the IANA name when the runtime's ICU knows it, otherwise a fixed
* `UTC±HH:MM` offset derived from the two clock fields — and the two
* conversion helpers below accept either form, so a panel whose timezone
* name the runtime cannot resolve (`UTC+3`, a typo, an unknown alias) still
* gets its catch-up requests and EPG strings converted correctly instead of
* silently falling back to the viewer's local clock (issue #1562). The
* zone-agnostic primitives live in `xtream-server-clock.util.ts`.
*/
import {
formatFixedOffsetTimeZone,
isSupportedTimeZoneName,
MINUTE_MS,
padTwo,
parseFixedOffsetTimeZone,
parseNaiveUtcMs,
wallClockPartsAt,
zoneOffsetMinutesAt,
} from './xtream-server-clock.util';
export {
formatFixedOffsetTimeZone,
isSupportedTimeZoneName,
parseFixedOffsetTimeZone,
} from './xtream-server-clock.util';
export interface XtreamServerClockInfo {
timezone?: string | null;
time_now?: string | null;
timestamp_now?: number | string | null;
}
/** Real-world UTC offsets span -12:00 … +14:00. */
const MAX_UTC_OFFSET_MINUTES = 14 * 60;
/** Offsets are multiples of 15 min; snapping absorbs second-level clock skew. */
const OFFSET_GRANULARITY_MINUTES = 15;
/**
* The server's UTC offset in minutes derived from its own clock report:
* `time_now` read as a naive UTC wall clock minus `timestamp_now`. Both
* fields are produced by the same request, so the difference IS the offset
* (snapped to 15 minutes to absorb a second of skew). `null` when either
* field is missing, malformed, or the result is not a real-world offset.
*/
export function deriveXtreamServerUtcOffsetMinutes(
timeNow: string | null | undefined,
timestampNow: number | string | null | undefined
): number | null {
const wallClockMs = parseNaiveUtcMs(timeNow);
const epochSeconds = Number(timestampNow);
if (
wallClockMs === null ||
!Number.isFinite(epochSeconds) ||
epochSeconds <= 0
) {
return null;
}
const rawMinutes = (wallClockMs / 1000 - epochSeconds) / 60;
const snapped =
Math.round(rawMinutes / OFFSET_GRANULARITY_MINUTES) *
OFFSET_GRANULARITY_MINUTES;
return Math.abs(snapped) <= MAX_UTC_OFFSET_MINUTES ? snapped : null;
}
/**
* The single persistable timezone string for a panel: its IANA name when
* the runtime resolves it, else a fixed offset derived from the clock
* fields, else `undefined` (nothing trustworthy was reported).
*
* The fixed offset is a snapshot of the panel's clock and carries no DST
* rules: for such a panel, programmes on the far side of a DST switch are
* off by an hour until the next account-info check refreshes the offset.
* That is the most a panel with an unusable name gives away — Xtream Codes
* itself reports PHP timezone identifiers, which are IANA names, so the
* snapshot only ever serves non-standard servers, where the alternative is
* the viewer's clock, wrong in every season.
*/
export function resolveXtreamServerTimezone(
serverInfo: XtreamServerClockInfo | null | undefined
): string | undefined {
const name = serverInfo?.timezone?.trim();
if (name && isSupportedTimeZoneName(name)) {
return name;
}
const offsetMinutes = deriveXtreamServerUtcOffsetMinutes(
serverInfo?.time_now,
serverInfo?.timestamp_now
);
return offsetMinutes === null
? undefined
: formatFixedOffsetTimeZone(offsetMinutes);
}
/**
* The `Y-m-d:H-M` segment of a timeshift URL for an epoch, expressed in the
* server's clock. Accepts both stored forms; with no usable timezone the
* viewer's local clock is the only remaining guess.
*/
export function formatXtreamCatchupStart(
timestampSeconds: number,
serverTimezone: string | null | undefined
): string {
const parts = wallClockPartsAt(timestampSeconds * 1000, serverTimezone);
return `${parts.year}-${padTwo(parts.month)}-${padTwo(parts.day)}:${padTwo(parts.hour)}-${padTwo(parts.minute)}`;
}
/**
* Epoch seconds for a `YYYY-MM-DD HH:mm[:ss]` string that the SERVER wrote
* in its own timezone. `null` when the string is malformed or the timezone
* is unusable — callers keep their previous interpretation then.
*/
export function parseXtreamServerLocalDateTime(
value: string | null | undefined,
serverTimezone: string | null | undefined
): number | null {
const naiveUtcMs = parseNaiveUtcMs(value);
if (naiveUtcMs === null || !serverTimezone) {
return null;
}
const fixedOffset = parseFixedOffsetTimeZone(serverTimezone);
if (fixedOffset !== null) {
return Math.floor((naiveUtcMs - fixedOffset * MINUTE_MS) / 1000);
}
if (!isSupportedTimeZoneName(serverTimezone)) {
return null;
}
// The zone's offset depends on the instant (DST), which is what we are
// solving for: take the offset at the naive guess, then re-read it at
// the corrected instant so a string right after a DST switch lands on
// the correct side of it.
const firstGuessMs =
naiveUtcMs -
zoneOffsetMinutesAt(naiveUtcMs, serverTimezone) * MINUTE_MS;
const resolvedMs =
naiveUtcMs -
zoneOffsetMinutesAt(firstGuessMs, serverTimezone) * MINUTE_MS;
return Math.floor(resolvedMs / 1000);
}
@@ -0,0 +1,122 @@
/**
* Setup actions for the offline-downloads guide shots: the download manager
* with the isolated capture folder authorized, one finished movie and two
* queued episodes, and the focused offline-movie detail.
*/
import type { Page } from '@playwright/test';
import { CAPTURE_DOWNLOAD_FOLDER_NAME } from './capture-fixtures';
import {
type CaptureAction,
openXtreamSection,
settleUi,
} from './capture-navigation-helpers';
import { openXtreamSeries } from './capture-navigation-portal-actions';
/** Header shortcut into `/workspace/downloads`, the same button the e2e suite uses. */
async function openDownloadsPage(page: Page): Promise<void> {
await page.getByRole('button', { name: 'Open downloads' }).click();
await page.waitForURL(/\/workspace\/downloads(?:\?.*)?$/, {
timeout: 20_000,
});
await page
.locator('[data-test-id="downloads-content"]')
.waitFor({ state: 'visible', timeout: 20_000 });
await settleUi(page);
}
/**
* The download manager authorizes the OS Downloads folder by default, which
* would put fixture bytes into the maintainer's real Downloads directory and
* show a personal home path in the frame. The capture stubs the folder dialog
* (`installDownloadFolderDialogStub`), so "Change Folder" lands on the
* isolated folder without any native UI.
*/
async function ensureCaptureDownloadFolder(page: Page): Promise<void> {
const folder = page.locator('[data-test-id="downloads-folder"]');
await folder.waitFor({ state: 'visible', timeout: 20_000 });
if ((await folder.innerText()).includes(CAPTURE_DOWNLOAD_FOLDER_NAME)) {
return;
}
await page.getByRole('button', { name: 'Change Folder' }).click();
await page
.locator('[data-test-id="downloads-folder"]')
.filter({ hasText: CAPTURE_DOWNLOAD_FOLDER_NAME })
.waitFor({ state: 'visible', timeout: 20_000 });
}
/**
* Puts the download manager into the state the offline guide describes: the
* authorized folder inside the isolated data dir, one finished movie in the
* library and two episodes trickling through the queue. Every step
* is idempotent, because the manifest runs each shot once per theme: a movie
* already saved shows the done state instead of the download button, and a
* queued or saved episode no longer offers a download label.
*/
async function prepareGuideDownloads(page: Page): Promise<void> {
await openDownloadsPage(page);
await ensureCaptureDownloadFolder(page);
await openXtreamSection(page, 'vod', 'Action & Mystery');
await page
.locator('app-content-hero')
.waitFor({ state: 'visible', timeout: 30_000 });
const downloadMovie = page.locator('[data-testid="vod-download-start"]');
if (await downloadMovie.isVisible().catch(() => false)) {
await downloadMovie.click();
await page
.locator(
'[data-testid="vod-download-progress"], [data-testid="vod-download-done"]'
)
.first()
.waitFor({ state: 'visible', timeout: 30_000 });
}
await openXtreamSeries(page, 'Urban Drama');
// Two single episodes rather than "Download season": a six-episode queue
// fills the whole frame and pushes the finished movie below the fold. An
// episode button whose label no longer starts with "Download" is already
// queued or saved (it would play the local file), so it is left alone.
const episodeButtons = page.locator(
'[data-test-id^="episode-download-"][aria-label^="Download "]:not([disabled])'
);
const toQueue = Math.min(await episodeButtons.count(), 2);
for (let index = 0; index < toQueue; index += 1) {
await episodeButtons.first().click();
await page.waitForTimeout(800);
}
await settleUi(page);
}
async function openDownloadsManager(page: Page): Promise<void> {
await prepareGuideDownloads(page);
await openDownloadsPage(page);
}
async function openDownloadsOfflineMovie(page: Page): Promise<void> {
await prepareGuideDownloads(page);
await openDownloadsPage(page);
const card = page
.locator('[data-test-id^="download-library-movie-"]')
.first();
await card.waitFor({ state: 'visible', timeout: 30_000 });
await card.locator('.download-library__artwork-button').click();
await page
.locator('[data-testid="offline-play"]')
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForTimeout(700);
}
export const DOWNLOAD_ACTIONS: Readonly<Record<string, CaptureAction>> = {
'open-downloads-manager': openDownloadsManager,
'open-downloads-offline-movie': openDownloadsOfflineMovie,
};
+191
View File
@@ -0,0 +1,191 @@
/**
* Page helpers shared by the capture setup actions and the seeding driver:
* the playlist-id registry the actions navigate with, dialog handling, and
* the small navigation moves every action is built from. Leaf module — it
* imports nothing from the action modules, so they can all depend on it.
*/
import type { Page } from '@playwright/test';
/** One manifest step: `page` plus the optional `param` the step carries. */
export type CaptureAction = (
page: Page,
param: string | null
) => Promise<void>;
/** Route segment of each seeded source, as it appears in `/workspace/<provider>/<id>`. */
export type PlaylistProvider =
| 'playlists'
| 'xtreams'
| 'xtreams-secondary'
| 'stalker';
const playlistIds = new Map<PlaylistProvider, string>();
export function registerPlaylistId(
provider: PlaylistProvider,
id: string
): void {
playlistIds.set(provider, id);
}
export function requirePlaylistId(provider: PlaylistProvider): string {
const id = playlistIds.get(provider);
if (!id) {
throw new Error(`No captured ${provider} playlist id — seeding failed?`);
}
return id;
}
/* ------------------------------------------------------------------ */
/* Dialog helpers (shared with the seeding driver) */
/* ------------------------------------------------------------------ */
export async function openAddPlaylistDialog(page: Page): Promise<void> {
await page.getByRole('button', { name: /add playlist/i }).first().click();
await page
.locator('mat-dialog-container')
.last()
.waitFor({ state: 'visible', timeout: 15_000 });
}
export async function clickDialogOption(
dialog: ReturnType<Page['locator']>,
label: RegExp
): Promise<void> {
// The add-playlist dialog has changed shape across releases: source
// methods were tabs, then plain buttons, now a radio group.
for (const role of ['radio', 'tab', 'button'] as const) {
const option = dialog.getByRole(role, { name: label }).first();
if ((await option.count()) > 0) {
await option.click();
return;
}
}
throw new Error(`Dialog option matching ${label} not found`);
}
/**
* Settings edits staged by a shot (the EPG source row) must never persist:
* saving would start a fetch, and a dirty form arms the app's close guard,
* which blocks `app.close()` until someone answers the save/discard prompt.
*/
export async function discardUnsavedSettings(page: Page): Promise<void> {
const discard = page.locator('[data-test-id="discard-settings"]').first();
if ((await discard.count()) === 0 || !(await discard.isVisible())) {
return;
}
await discard.click({ timeout: 10_000 });
await discard
.waitFor({ state: 'hidden', timeout: 10_000 })
.catch(() => undefined);
}
export async function dismissDialogs(page: Page): Promise<void> {
const dialogs = page.locator('mat-dialog-container');
if ((await dialogs.count()) === 0) {
return;
}
await page.keyboard.press('Escape');
await dialogs
.first()
.waitFor({ state: 'detached', timeout: 10_000 })
.catch(async () => {
await page.getByRole('button', { name: /^cancel$/i }).last().click();
await dialogs.first().waitFor({ state: 'detached', timeout: 10_000 });
});
}
/* ------------------------------------------------------------------ */
/* Navigation moves */
/* ------------------------------------------------------------------ */
/** Returns to the dashboard via the always-visible brand link. */
export async function goHome(page: Page): Promise<void> {
if (/\/workspace\/dashboard/.test(page.url())) {
return;
}
await page.locator('a.brand[href$="/workspace/dashboard"]').first().click();
await page.waitForURL(/\/workspace\/dashboard/, { timeout: 20_000 });
await settleUi(page);
}
export async function clickHrefSuffix(
page: Page,
suffix: string
): Promise<void> {
await page.locator(`a[href$="${suffix}"]`).first().click();
// Predicate rather than a RegExp built from the suffix: the value carries
// playlist ids and path separators, and hand-escaping only some
// metacharacters is how incomplete-sanitization bugs are born.
await page.waitForURL((url) => url.href.includes(suffix), {
timeout: 20_000,
});
}
/**
* Opens the primary Xtream portal's `vod` or `series` section, selects the
* named category and clicks its first card. Manifest steps must be
* order-independent, so every portal action starts from the dashboard,
* whose sources rail links into the portal.
*/
export async function openXtreamSection(
page: Page,
section: 'vod' | 'series',
category: string
): Promise<void> {
await goHome(page);
await clickHrefSuffix(
page,
`/workspace/xtreams/${requirePlaylistId('xtreams')}/vod`
);
if (section !== 'vod') {
await clickHrefSuffix(
page,
`/workspace/xtreams/${requirePlaylistId('xtreams')}/${section}`
);
}
const item = page
.locator('app-workspace-context-panel .category-item')
.filter({ hasText: category })
.first();
await item.waitFor({ state: 'visible', timeout: 30_000 });
await item.click();
await page.waitForTimeout(600);
const card = page.locator('.category-content-layout mat-card').first();
await card.waitFor({ state: 'visible', timeout: 30_000 });
await card.click();
}
export async function settleUi(page: Page): Promise<void> {
await page
.locator('.mat-mdc-snack-bar-container')
.first()
.waitFor({ state: 'detached', timeout: 10_000 })
.catch(() => undefined);
// Park the cursor so no nav item keeps its hover tooltip in frame.
await page.mouse.move(640, 700);
await page.evaluate(() => {
document
.querySelectorAll(
'.mat-mdc-snack-bar-container, simple-snack-bar, .mat-mdc-tooltip, .cdk-describedby-message-container'
)
.forEach((element) => {
(element.closest('.cdk-overlay-pane') ?? element).remove();
});
});
await page.waitForTimeout(250);
}
@@ -0,0 +1,210 @@
/**
* Setup actions that browse the seeded sources for a shot: portal catalogs
* and live lists, the M3U groups view, and alternative sources. Actions
* here call each other directly rather than through `runAction`, which
* keeps this module free of a dependency on the dispatcher.
*/
import type { Page } from '@playwright/test';
import {
type CaptureAction,
clickHrefSuffix,
goHome,
openXtreamSection,
requirePlaylistId,
} from './capture-navigation-helpers';
/* ------------------------------------------------------------------ */
/* Portal catalogs */
/* ------------------------------------------------------------------ */
async function openXtreamVod(page: Page, param: string | null): Promise<void> {
await openXtreamSection(page, 'vod', param ?? 'Action & Mystery');
await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+/, {
timeout: 30_000,
});
await page
.locator('app-content-hero')
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForTimeout(700);
}
export async function openXtreamSeries(
page: Page,
param: string | null
): Promise<void> {
await openXtreamSection(page, 'series', param ?? 'Urban Drama');
await page.waitForURL(
/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+/,
{ timeout: 30_000 }
);
await page
.locator('app-season-container')
.waitFor({ state: 'visible', timeout: 30_000 });
// Season tabs auto-select a season; click the first pill only
// when no episodes rendered on their own.
const episode = page.locator('.episode-card, .episode-list-item').first();
if (!(await episode.isVisible().catch(() => false))) {
await page
.locator('.season-tabs__pill, [data-testid="season-dropdown"]')
.first()
.click();
}
await episode.waitFor({ state: 'visible', timeout: 20_000 });
await page.waitForTimeout(700);
}
async function openM3uGroups(page: Page): Promise<void> {
const playlistId = requirePlaylistId('playlists');
await goHome(page);
await page
.locator(`a[href*="/workspace/playlists/${playlistId}"]`)
.first()
.click();
await page.waitForURL(
(url) => url.href.includes(`/workspace/playlists/${playlistId}/`),
{ timeout: 20_000 }
);
await clickHrefSuffix(page, `/workspace/playlists/${playlistId}/groups`);
await page
.locator('.group-nav-item')
.first()
.waitFor({ state: 'visible', timeout: 20_000 });
await page.locator('.group-nav-item').first().click();
// Deliberately no channel click: starting playback would pull a
// real HLS stream (the mock redirects to a public demo stream),
// and third-party video frames must never enter a release shot.
await page
.locator('[data-test-id="channel-item"]')
.first()
.waitFor({ state: 'visible', timeout: 20_000 });
await page.waitForTimeout(500);
}
/* ------------------------------------------------------------------ */
/* Live lists */
/* ------------------------------------------------------------------ */
/**
* Opens a portal's live section and selects a category. Deliberately no
* channel click: playback would pull the mock's redirect to a public demo
* stream, and third-party video frames must never enter a published shot.
*/
async function openLiveCategory(
page: Page,
provider: 'xtreams' | 'stalker',
liveSection: 'live' | 'itv',
param: string | null
): Promise<void> {
const playlistId = requirePlaylistId(provider);
await goHome(page);
await clickHrefSuffix(page, `/workspace/${provider}/${playlistId}/vod`);
await clickHrefSuffix(
page,
`/workspace/${provider}/${playlistId}/${liveSection}`
);
const categories = page.locator(
'app-workspace-context-panel .category-item'
);
const category = param
? categories.filter({ hasText: param }).first()
: categories.first();
await category.waitFor({ state: 'visible', timeout: 30_000 });
await category.click();
await page
.locator('app-channel-list-item')
.first()
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForTimeout(700);
}
async function openXtreamLive(page: Page, param: string | null): Promise<void> {
await openLiveCategory(page, 'xtreams', 'live', param);
}
async function openStalkerLive(
page: Page,
param: string | null
): Promise<void> {
await openLiveCategory(page, 'stalker', 'itv', param);
}
async function openXtreamLiveChannel(
page: Page,
param: string | null
): Promise<void> {
// Unlike `open-xtream-live`, this selects a channel: the marketing
// scenario serves live streams from local bytes (`local-media`),
// so playback never reaches a public stream. The player shows a
// format error, which the phone-view shot never frames; what it
// needs is the remote status the selection publishes.
await openXtreamLive(page, param);
const channel = page.locator('app-channel-list-item').first();
await channel.click();
await page.waitForTimeout(1500);
}
/* ------------------------------------------------------------------ */
/* Alternative sources (guide shots) */
/* ------------------------------------------------------------------ */
/**
* Opens a movie of the primary portal whose copy also exists in the secondary
* one (seeded with `secondaryXtream`), and waits for lazy discovery to render
* the Sources chip.
*/
async function openXtreamVodSources(
page: Page,
param: string | null
): Promise<void> {
requirePlaylistId('xtreams-secondary');
await openXtreamVod(page, param ?? 'Action & Mystery');
await page
.locator('app-vod-sources-chip button')
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForTimeout(500);
}
async function openXtreamVodSourcesMenu(
page: Page,
param: string | null
): Promise<void> {
await openXtreamVodSources(page, param);
await page.locator('app-vod-sources-chip button').click();
const menu = page.locator('app-vod-sources-menu');
await menu.waitFor({ state: 'visible', timeout: 15_000 });
// "check all" probes every unchecked copy (one get_vod_info plus a
// HEAD against the mock) so the rows carry verdicts in the frame.
const checkAll = menu.locator('.sources-menu__check-all');
if (await checkAll.isVisible().catch(() => false)) {
await checkAll.click();
await checkAll
.waitFor({ state: 'hidden', timeout: 30_000 })
.catch(() => undefined);
}
await page.waitForTimeout(700);
}
export const PORTAL_ACTIONS: Readonly<Record<string, CaptureAction>> = {
'open-xtream-vod': openXtreamVod,
'open-xtream-series': openXtreamSeries,
'open-m3u-groups': openM3uGroups,
'open-xtream-live': openXtreamLive,
'open-xtream-live-channel': openXtreamLiveChannel,
'open-stalker-live': openStalkerLive,
'open-xtream-vod-sources': openXtreamVodSources,
'open-xtream-vod-sources-menu': openXtreamVodSourcesMenu,
};
@@ -0,0 +1,264 @@
/**
* Setup actions that stage a dialog or a settings page for a shot: the
* add-playlist forms, the settings sections, the remote-control switch.
* Actions here call each other directly rather than through `runAction`,
* which keeps this module free of a dependency on the dispatcher.
*/
import type { Page } from '@playwright/test';
import {
AUTO_DETECT_FIXTURE_MESSAGE,
CAPTURE_REMOTE_CONTROL_PORT,
CAPTURE_REMOTE_CONTROL_URL,
EPG_FIXTURE_URL,
M3U_FIXTURE_PLAYLIST_TITLE,
M3U_FIXTURE_PLAYLIST_URL,
STALKER_FIXTURE_MAC,
STALKER_FIXTURE_PORTAL_URL,
STALKER_FIXTURE_TITLE,
XTREAM_FIXTURE_CREDENTIALS,
XTREAM_FIXTURE_TITLE,
XTREAM_MOCK_ORIGIN,
} from './capture-fixtures';
import {
type CaptureAction,
clickDialogOption,
goHome,
openAddPlaylistDialog,
settleUi,
} from './capture-navigation-helpers';
export async function openSettings(page: Page): Promise<void> {
await page.locator('a[href$="/workspace/settings"]').first().click();
await page.waitForURL(/\/workspace\/settings/, { timeout: 15_000 });
await page
.locator('[data-test-id="settings-container"]')
.waitFor({ state: 'visible', timeout: 15_000 });
}
async function openDashboard(page: Page): Promise<void> {
await page.locator('a.brand[href$="/workspace/dashboard"]').first().click();
await page.waitForURL(/\/workspace\/dashboard/, { timeout: 20_000 });
await page
.locator('[data-test-id="dashboard-hero"]')
.waitFor({ state: 'visible', timeout: 30_000 });
await settleUi(page);
}
/* ------------------------------------------------------------------ */
/* Add-playlist dialog */
/* ------------------------------------------------------------------ */
async function openAddPlaylistXtream(page: Page): Promise<void> {
await goHome(page);
await openAddPlaylistDialog(page);
const dialog = page.locator('mat-dialog-container').last();
await clickDialogOption(dialog, /xtream credentials/i);
await dialog.locator('#title').fill(XTREAM_FIXTURE_TITLE);
await dialog.locator('#serverUrl').fill(XTREAM_MOCK_ORIGIN);
await dialog.locator('#username').fill(XTREAM_FIXTURE_CREDENTIALS.username);
await dialog.locator('#password').fill(XTREAM_FIXTURE_CREDENTIALS.password);
// The status probe only talks to the local mock, so the frame can
// show the successful "portal is active" verdict the guide explains.
await dialog
.getByRole('button', { name: /test connection/i })
.first()
.click();
const status = dialog.locator('.connection-status');
await status.waitFor({ state: 'visible', timeout: 30_000 });
// The dialog body scrolls; bring the verdict the guide explains
// into frame together with the credential fields above it.
await status.scrollIntoViewIfNeeded();
await page.waitForTimeout(500);
}
async function openAddPlaylistAuto(page: Page): Promise<void> {
await goHome(page);
await openAddPlaylistDialog(page);
const dialog = page.locator('mat-dialog-container').last();
await clickDialogOption(dialog, /auto-detect/i);
await dialog
.locator('[data-test-id="auto-detect-textarea"]')
.fill(AUTO_DETECT_FIXTURE_MESSAGE);
const candidate = dialog
.locator('[data-test-id="auto-detect-candidate"]')
.first();
await candidate.waitFor({ state: 'visible', timeout: 15_000 });
await candidate.scrollIntoViewIfNeeded();
await page.waitForTimeout(500);
}
async function openAddPlaylistStalker(page: Page): Promise<void> {
await goHome(page);
await openAddPlaylistDialog(page);
const dialog = page.locator('mat-dialog-container').last();
await clickDialogOption(dialog, /stalker portal/i);
await dialog.locator('#title').fill(STALKER_FIXTURE_TITLE);
await dialog.locator('#portalUrl').fill(STALKER_FIXTURE_PORTAL_URL);
await dialog.locator('#macAddress').fill(STALKER_FIXTURE_MAC);
// Blur runs the MAC normalization the guide describes.
await dialog.locator('#serialNumber').focus();
// The form is long; frame the identity fields and the derive
// toggle rather than the signature fields at the bottom.
await dialog.locator('.derive-device-ids').scrollIntoViewIfNeeded();
await page.waitForTimeout(500);
}
async function openAddPlaylistM3uUrl(page: Page): Promise<void> {
await goHome(page);
await openAddPlaylistDialog(page);
const dialog = page.locator('mat-dialog-container').last();
await clickDialogOption(dialog, /m3u url/i);
// Typed only: the dialog fetches nothing until Add is clicked,
// and the address points at the local mock anyway.
await dialog
.locator('input[formcontrolname="playlistUrl"]')
.fill(M3U_FIXTURE_PLAYLIST_URL);
await dialog
.locator('input[formcontrolname="playlistName"]')
.fill(M3U_FIXTURE_PLAYLIST_TITLE);
await page.waitForTimeout(500);
}
/* ------------------------------------------------------------------ */
/* Settings sections */
/* ------------------------------------------------------------------ */
async function openSettingsEpg(page: Page): Promise<void> {
await openSettings(page);
const sectionLink = page
.locator('[data-test-id="settings-section-epg"]')
.first();
await sectionLink.waitFor({ state: 'visible', timeout: 15_000 });
await sectionLink.click({ timeout: 10_000 });
await page.waitForURL(/\/workspace\/settings\/epg/, { timeout: 15_000 });
const section = page.locator('#epg');
await section.waitFor({ state: 'visible', timeout: 15_000 });
// Show a filled source row instead of the empty state. The value
// is staged in the form only; nothing is saved or fetched. The
// dirty form is discarded by `discardUnsavedSettings` before the
// next action or the app teardown — the settings close guard
// would otherwise hold `app.close()` open forever.
await section
.getByRole('button', { name: /add epg source/i })
.click({ timeout: 10_000 });
const field = section.locator('input[type="url"]').last();
await field.waitFor({ state: 'visible', timeout: 10_000 });
await field.fill(EPG_FIXTURE_URL, { timeout: 10_000 });
await page.waitForTimeout(500);
}
/* ------------------------------------------------------------------ */
/* Remote control (guide shots) */
/* ------------------------------------------------------------------ */
/**
* Opens Settings › Remote control with the feature switched on and the
* capture port in the field. The form is left dirty unless a later
* `enable-remote-control` step saves it; `discardUnsavedSettings` clears it
* before the next action.
*/
async function openRemoteControlSettings(page: Page): Promise<void> {
await openSettings(page);
const sectionLink = page
.locator('[data-test-id="settings-section-remote-control"]')
.first();
await sectionLink.waitFor({ state: 'visible', timeout: 15_000 });
await sectionLink.click({ timeout: 10_000 });
await page.waitForURL(/\/workspace\/settings\/remote-control/, {
timeout: 15_000,
});
const section = page.locator('#remote-control');
await section.waitFor({ state: 'visible', timeout: 15_000 });
const toggle = section.locator(
'[data-test-id="remote-control-enabled"] input[type="checkbox"]'
);
if (!(await toggle.isChecked())) {
await section.locator('[data-test-id="remote-control-enabled"]').click();
}
const port = section.locator('[data-test-id="remote-control-port"]');
await port.waitFor({ state: 'visible', timeout: 10_000 });
if ((await port.inputValue()) !== String(CAPTURE_REMOTE_CONTROL_PORT)) {
await port.fill(String(CAPTURE_REMOTE_CONTROL_PORT));
}
await section
.locator('.remote-control-url')
.first()
.waitFor({ state: 'visible', timeout: 15_000 });
}
async function openSettingsRemoteControl(page: Page): Promise<void> {
await openRemoteControlSettings(page);
const section = page.locator('#remote-control');
const qrButton = section.locator('.url-row button').first();
await qrButton.waitFor({ state: 'visible', timeout: 15_000 });
await qrButton.click();
await section
.locator('qrcode canvas, qrcode img')
.first()
.waitFor({ state: 'visible', timeout: 15_000 });
await page.waitForTimeout(500);
}
async function enableRemoteControl(page: Page): Promise<void> {
await openRemoteControlSettings(page);
const save = page.locator('[data-test-id="save-settings"]').first();
if (await save.isEnabled().catch(() => false)) {
await save.click();
await settleUi(page);
}
await waitForRemoteControlServer();
}
/** Polls the status endpoint the phone view reads until the app's server answers. */
async function waitForRemoteControlServer(): Promise<void> {
const statusUrl = `${CAPTURE_REMOTE_CONTROL_URL}api/remote-control/status`;
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
try {
const response = await fetch(statusUrl);
if (response.ok) {
return;
}
} catch {
// not up yet
}
await new Promise((resolve) => setTimeout(resolve, 300));
}
throw new Error(
`Remote control server did not answer at ${statusUrl} — is port ${CAPTURE_REMOTE_CONTROL_PORT} held by another IPTVnator instance?`
);
}
export const SETUP_ACTIONS: Readonly<Record<string, CaptureAction>> = {
'open-settings': openSettings,
'open-dashboard': openDashboard,
'open-add-playlist-xtream': openAddPlaylistXtream,
'open-add-playlist-auto': openAddPlaylistAuto,
'open-add-playlist-stalker': openAddPlaylistStalker,
'open-add-playlist-m3u-url': openAddPlaylistM3uUrl,
'open-settings-epg': openSettingsEpg,
'open-settings-remote-control': openSettingsRemoteControl,
'enable-remote-control': enableRemoteControl,
};
+37 -706
View File
@@ -2,55 +2,43 @@
* Named setup actions for capture-release-screenshots.ts — the vocabulary
* that screenshots.manifest.json steps refer to — plus theme switching and
* the playlist-id registry the actions navigate with.
*
* The actions themselves live in three modules by subject
* (`capture-navigation-setup-actions.ts`: dialogs, settings, remote
* control; `capture-navigation-portal-actions.ts`: portal browsing and
* alternative sources; `capture-navigation-download-actions.ts`: the
* download manager) over the shared page helpers in
* `capture-navigation-helpers.ts`. This file only dispatches a step name
* and re-exports the API the seeding driver and the capture script use.
*/
import type { Page } from '@playwright/test';
import {
AUTO_DETECT_FIXTURE_MESSAGE,
CAPTURE_DOWNLOAD_FOLDER_NAME,
CAPTURE_REMOTE_CONTROL_PORT,
CAPTURE_REMOTE_CONTROL_URL,
EPG_FIXTURE_URL,
M3U_FIXTURE_PLAYLIST_TITLE,
M3U_FIXTURE_PLAYLIST_URL,
STALKER_FIXTURE_MAC,
STALKER_FIXTURE_PORTAL_URL,
STALKER_FIXTURE_TITLE,
XTREAM_FIXTURE_CREDENTIALS,
XTREAM_FIXTURE_TITLE,
XTREAM_MOCK_ORIGIN,
} from './capture-fixtures';
type CaptureAction,
discardUnsavedSettings,
dismissDialogs,
settleUi,
} from './capture-navigation-helpers';
import { DOWNLOAD_ACTIONS } from './capture-navigation-download-actions';
import { PORTAL_ACTIONS } from './capture-navigation-portal-actions';
import { SETUP_ACTIONS } from './capture-navigation-setup-actions';
/** Route segment of each seeded source, as it appears in `/workspace/<provider>/<id>`. */
export type PlaylistProvider =
| 'playlists'
| 'xtreams'
| 'xtreams-secondary'
| 'stalker';
export {
clickDialogOption,
discardUnsavedSettings,
openAddPlaylistDialog,
registerPlaylistId,
requirePlaylistId,
settleUi,
} from './capture-navigation-helpers';
export type { PlaylistProvider } from './capture-navigation-helpers';
const playlistIds = new Map<PlaylistProvider, string>();
export function registerPlaylistId(
provider: PlaylistProvider,
id: string
): void {
playlistIds.set(provider, id);
}
export function requirePlaylistId(provider: PlaylistProvider): string {
return requireId(provider);
}
function requireId(provider: PlaylistProvider): string {
const id = playlistIds.get(provider);
if (!id) {
throw new Error(`No captured ${provider} playlist id — seeding failed?`);
}
return id;
}
const ACTIONS: Readonly<Record<string, CaptureAction>> = {
...SETUP_ACTIONS,
...PORTAL_ACTIONS,
...DOWNLOAD_ACTIONS,
};
/* ------------------------------------------------------------------ */
/* Theme */
@@ -92,674 +80,17 @@ export async function runAction(
action: string,
param: string | null
): Promise<void> {
const run = ACTIONS[action];
if (!run) {
throw new Error(`Unknown setup action: ${action}`);
}
// Manifest steps are order-independent, and some of them end with a modal
// dialog open or a dirty settings form. Clear whatever the previous step
// left behind before this one starts navigating: a dialog backdrop
// swallows every click, and unsaved settings raise a leave prompt.
await dismissDialogs(page);
await discardUnsavedSettings(page);
switch (action) {
case 'open-settings': {
await page.locator('a[href$="/workspace/settings"]').first().click();
await page.waitForURL(/\/workspace\/settings/, { timeout: 15_000 });
await page
.locator('[data-test-id="settings-container"]')
.waitFor({ state: 'visible', timeout: 15_000 });
return;
}
case 'open-dashboard': {
await page
.locator('a.brand[href$="/workspace/dashboard"]')
.first()
.click();
await page.waitForURL(/\/workspace\/dashboard/, { timeout: 20_000 });
await page
.locator('[data-test-id="dashboard-hero"]')
.waitFor({ state: 'visible', timeout: 30_000 });
await settleUi(page);
return;
}
case 'open-xtream-vod': {
await openXtreamSection(page, 'vod', param ?? 'Action & Mystery');
await page.waitForURL(
/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+/,
{ timeout: 30_000 }
);
await page
.locator('app-content-hero')
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForTimeout(700);
return;
}
case 'open-xtream-series': {
await openXtreamSection(page, 'series', param ?? 'Urban Drama');
await page.waitForURL(
/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+/,
{ timeout: 30_000 }
);
await page
.locator('app-season-container')
.waitFor({ state: 'visible', timeout: 30_000 });
// Season tabs auto-select a season; click the first pill only
// when no episodes rendered on their own.
const episode = page
.locator('.episode-card, .episode-list-item')
.first();
if (!(await episode.isVisible().catch(() => false))) {
await page
.locator('.season-tabs__pill, [data-testid="season-dropdown"]')
.first()
.click();
}
await episode.waitFor({ state: 'visible', timeout: 20_000 });
await page.waitForTimeout(700);
return;
}
case 'open-m3u-groups': {
const playlistId = requireId('playlists');
await goHome(page);
await page
.locator(`a[href*="/workspace/playlists/${playlistId}"]`)
.first()
.click();
await page.waitForURL(
(url) => url.href.includes(`/workspace/playlists/${playlistId}/`),
{ timeout: 20_000 }
);
await clickHrefSuffix(
page,
`/workspace/playlists/${playlistId}/groups`
);
await page
.locator('.group-nav-item')
.first()
.waitFor({ state: 'visible', timeout: 20_000 });
await page.locator('.group-nav-item').first().click();
// Deliberately no channel click: starting playback would pull a
// real HLS stream (the mock redirects to a public demo stream),
// and third-party video frames must never enter a release shot.
await page
.locator('[data-test-id="channel-item"]')
.first()
.waitFor({ state: 'visible', timeout: 20_000 });
await page.waitForTimeout(500);
return;
}
case 'open-add-playlist-xtream': {
await goHome(page);
await openAddPlaylistDialog(page);
const dialog = page.locator('mat-dialog-container').last();
await clickDialogOption(dialog, /xtream credentials/i);
await dialog.locator('#title').fill(XTREAM_FIXTURE_TITLE);
await dialog.locator('#serverUrl').fill(XTREAM_MOCK_ORIGIN);
await dialog
.locator('#username')
.fill(XTREAM_FIXTURE_CREDENTIALS.username);
await dialog
.locator('#password')
.fill(XTREAM_FIXTURE_CREDENTIALS.password);
// The status probe only talks to the local mock, so the frame can
// show the successful "portal is active" verdict the guide explains.
await dialog
.getByRole('button', { name: /test connection/i })
.first()
.click();
const status = dialog.locator('.connection-status');
await status.waitFor({ state: 'visible', timeout: 30_000 });
// The dialog body scrolls; bring the verdict the guide explains
// into frame together with the credential fields above it.
await status.scrollIntoViewIfNeeded();
await page.waitForTimeout(500);
return;
}
case 'open-add-playlist-auto': {
await goHome(page);
await openAddPlaylistDialog(page);
const dialog = page.locator('mat-dialog-container').last();
await clickDialogOption(dialog, /auto-detect/i);
await dialog
.locator('[data-test-id="auto-detect-textarea"]')
.fill(AUTO_DETECT_FIXTURE_MESSAGE);
const candidate = dialog
.locator('[data-test-id="auto-detect-candidate"]')
.first();
await candidate.waitFor({ state: 'visible', timeout: 15_000 });
await candidate.scrollIntoViewIfNeeded();
await page.waitForTimeout(500);
return;
}
case 'open-xtream-live': {
await goHome(page);
await clickHrefSuffix(
page,
`/workspace/xtreams/${requireId('xtreams')}/vod`
);
await clickHrefSuffix(
page,
`/workspace/xtreams/${requireId('xtreams')}/live`
);
const categories = page.locator(
'app-workspace-context-panel .category-item'
);
const category = param
? categories.filter({ hasText: param }).first()
: categories.first();
await category.waitFor({ state: 'visible', timeout: 30_000 });
await category.click();
// Deliberately no channel click: playback would pull the mock's
// redirect to a public demo stream, and third-party video frames
// must never enter a published shot.
await page
.locator('app-channel-list-item')
.first()
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForTimeout(700);
return;
}
case 'open-add-playlist-stalker': {
await goHome(page);
await openAddPlaylistDialog(page);
const dialog = page.locator('mat-dialog-container').last();
await clickDialogOption(dialog, /stalker portal/i);
await dialog.locator('#title').fill(STALKER_FIXTURE_TITLE);
await dialog.locator('#portalUrl').fill(STALKER_FIXTURE_PORTAL_URL);
await dialog.locator('#macAddress').fill(STALKER_FIXTURE_MAC);
// Blur runs the MAC normalization the guide describes.
await dialog.locator('#serialNumber').focus();
// The form is long; frame the identity fields and the derive
// toggle rather than the signature fields at the bottom.
await dialog.locator('.derive-device-ids').scrollIntoViewIfNeeded();
await page.waitForTimeout(500);
return;
}
case 'open-stalker-live': {
await goHome(page);
await clickHrefSuffix(
page,
`/workspace/stalker/${requireId('stalker')}/vod`
);
await clickHrefSuffix(
page,
`/workspace/stalker/${requireId('stalker')}/itv`
);
const categories = page.locator(
'app-workspace-context-panel .category-item'
);
const category = param
? categories.filter({ hasText: param }).first()
: categories.first();
await category.waitFor({ state: 'visible', timeout: 30_000 });
await category.click();
// No channel click: playback would resolve a create_link to a
// public demo stream, and third-party video never enters a shot.
await page
.locator('app-channel-list-item')
.first()
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForTimeout(700);
return;
}
case 'open-add-playlist-m3u-url': {
await goHome(page);
await openAddPlaylistDialog(page);
const dialog = page.locator('mat-dialog-container').last();
await clickDialogOption(dialog, /m3u url/i);
// Typed only: the dialog fetches nothing until Add is clicked,
// and the address points at the local mock anyway.
await dialog
.locator('input[formcontrolname="playlistUrl"]')
.fill(M3U_FIXTURE_PLAYLIST_URL);
await dialog
.locator('input[formcontrolname="playlistName"]')
.fill(M3U_FIXTURE_PLAYLIST_TITLE);
await page.waitForTimeout(500);
return;
}
case 'open-settings-epg': {
await runAction(page, 'open-settings', null);
const sectionLink = page
.locator('[data-test-id="settings-section-epg"]')
.first();
await sectionLink.waitFor({ state: 'visible', timeout: 15_000 });
await sectionLink.click({ timeout: 10_000 });
await page.waitForURL(/\/workspace\/settings\/epg/, {
timeout: 15_000,
});
const section = page.locator('#epg');
await section.waitFor({ state: 'visible', timeout: 15_000 });
// Show a filled source row instead of the empty state. The value
// is staged in the form only; nothing is saved or fetched. The
// dirty form is discarded by `discardUnsavedSettings` before the
// next action or the app teardown — the settings close guard
// would otherwise hold `app.close()` open forever.
await section
.getByRole('button', { name: /add epg source/i })
.click({ timeout: 10_000 });
const field = section.locator('input[type="url"]').last();
await field.waitFor({ state: 'visible', timeout: 10_000 });
await field.fill(EPG_FIXTURE_URL, { timeout: 10_000 });
await page.waitForTimeout(500);
return;
}
case 'open-xtream-live-channel': {
// Unlike `open-xtream-live`, this selects a channel: the marketing
// scenario serves live streams from local bytes (`local-media`),
// so playback never reaches a public stream. The player shows a
// format error, which the phone-view shot never frames; what it
// needs is the remote status the selection publishes.
await runAction(page, 'open-xtream-live', param);
const channel = page.locator('app-channel-list-item').first();
await channel.click();
await page.waitForTimeout(1500);
return;
}
case 'open-settings-remote-control': {
await openRemoteControlSettings(page);
const section = page.locator('#remote-control');
const qrButton = section.locator('.url-row button').first();
await qrButton.waitFor({ state: 'visible', timeout: 15_000 });
await qrButton.click();
await section
.locator('qrcode canvas, qrcode img')
.first()
.waitFor({ state: 'visible', timeout: 15_000 });
await page.waitForTimeout(500);
return;
}
case 'enable-remote-control': {
await openRemoteControlSettings(page);
const save = page.locator('[data-test-id="save-settings"]').first();
if (await save.isEnabled().catch(() => false)) {
await save.click();
await settleUi(page);
}
await waitForRemoteControlServer();
return;
}
case 'open-xtream-vod-sources': {
await openXtreamVodWithSources(page, param ?? 'Action & Mystery');
return;
}
case 'open-xtream-vod-sources-menu': {
await openXtreamVodWithSources(page, param ?? 'Action & Mystery');
await page.locator('app-vod-sources-chip button').click();
const menu = page.locator('app-vod-sources-menu');
await menu.waitFor({ state: 'visible', timeout: 15_000 });
// "check all" probes every unchecked copy (one get_vod_info plus a
// HEAD against the mock) so the rows carry verdicts in the frame.
const checkAll = menu.locator('.sources-menu__check-all');
if (await checkAll.isVisible().catch(() => false)) {
await checkAll.click();
await checkAll
.waitFor({ state: 'hidden', timeout: 30_000 })
.catch(() => undefined);
}
await page.waitForTimeout(700);
return;
}
case 'open-downloads-manager': {
await prepareGuideDownloads(page);
await openDownloadsPage(page);
return;
}
case 'open-downloads-offline-movie': {
await prepareGuideDownloads(page);
await openDownloadsPage(page);
const card = page
.locator('[data-test-id^="download-library-movie-"]')
.first();
await card.waitFor({ state: 'visible', timeout: 30_000 });
await card.locator('.download-library__artwork-button').click();
await page
.locator('[data-testid="offline-play"]')
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForTimeout(700);
return;
}
default:
throw new Error(`Unknown setup action: ${action}`);
}
await run(page, param);
}
/* ------------------------------------------------------------------ */
/* Dialog helpers (shared with the seeding driver) */
/* ------------------------------------------------------------------ */
export async function openAddPlaylistDialog(page: Page): Promise<void> {
await page.getByRole('button', { name: /add playlist/i }).first().click();
await page
.locator('mat-dialog-container')
.last()
.waitFor({ state: 'visible', timeout: 15_000 });
}
export async function clickDialogOption(
dialog: ReturnType<Page['locator']>,
label: RegExp
): Promise<void> {
// The add-playlist dialog has changed shape across releases: source
// methods were tabs, then plain buttons, now a radio group.
for (const role of ['radio', 'tab', 'button'] as const) {
const option = dialog.getByRole(role, { name: label }).first();
if ((await option.count()) > 0) {
await option.click();
return;
}
}
throw new Error(`Dialog option matching ${label} not found`);
}
/**
* Settings edits staged by a shot (the EPG source row) must never persist:
* saving would start a fetch, and a dirty form arms the app's close guard,
* which blocks `app.close()` until someone answers the save/discard prompt.
*/
export async function discardUnsavedSettings(page: Page): Promise<void> {
const discard = page.locator('[data-test-id="discard-settings"]').first();
if ((await discard.count()) === 0 || !(await discard.isVisible())) {
return;
}
await discard.click({ timeout: 10_000 });
await discard
.waitFor({ state: 'hidden', timeout: 10_000 })
.catch(() => undefined);
}
async function dismissDialogs(page: Page): Promise<void> {
const dialogs = page.locator('mat-dialog-container');
if ((await dialogs.count()) === 0) {
return;
}
await page.keyboard.press('Escape');
await dialogs
.first()
.waitFor({ state: 'detached', timeout: 10_000 })
.catch(async () => {
await page.getByRole('button', { name: /^cancel$/i }).last().click();
await dialogs.first().waitFor({ state: 'detached', timeout: 10_000 });
});
}
/** Returns to the dashboard via the always-visible brand link. */
async function goHome(page: Page): Promise<void> {
if (/\/workspace\/dashboard/.test(page.url())) {
return;
}
await page.locator('a.brand[href$="/workspace/dashboard"]').first().click();
await page.waitForURL(/\/workspace\/dashboard/, { timeout: 20_000 });
await settleUi(page);
}
/* ------------------------------------------------------------------ */
/* Remote control (guide shots) */
/* ------------------------------------------------------------------ */
/**
* Opens Settings › Remote control with the feature switched on and the
* capture port in the field. The form is left dirty unless a later
* `enable-remote-control` step saves it; `discardUnsavedSettings` clears it
* before the next action.
*/
async function openRemoteControlSettings(page: Page): Promise<void> {
await runAction(page, 'open-settings', null);
const sectionLink = page
.locator('[data-test-id="settings-section-remote-control"]')
.first();
await sectionLink.waitFor({ state: 'visible', timeout: 15_000 });
await sectionLink.click({ timeout: 10_000 });
await page.waitForURL(/\/workspace\/settings\/remote-control/, {
timeout: 15_000,
});
const section = page.locator('#remote-control');
await section.waitFor({ state: 'visible', timeout: 15_000 });
const toggle = section.locator(
'[data-test-id="remote-control-enabled"] input[type="checkbox"]'
);
if (!(await toggle.isChecked())) {
await section.locator('[data-test-id="remote-control-enabled"]').click();
}
const port = section.locator('[data-test-id="remote-control-port"]');
await port.waitFor({ state: 'visible', timeout: 10_000 });
if ((await port.inputValue()) !== String(CAPTURE_REMOTE_CONTROL_PORT)) {
await port.fill(String(CAPTURE_REMOTE_CONTROL_PORT));
}
await section
.locator('.remote-control-url')
.first()
.waitFor({ state: 'visible', timeout: 15_000 });
}
/** Polls the status endpoint the phone view reads until the app's server answers. */
async function waitForRemoteControlServer(): Promise<void> {
const statusUrl = `${CAPTURE_REMOTE_CONTROL_URL}api/remote-control/status`;
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
try {
const response = await fetch(statusUrl);
if (response.ok) {
return;
}
} catch {
// not up yet
}
await new Promise((resolve) => setTimeout(resolve, 300));
}
throw new Error(
`Remote control server did not answer at ${statusUrl} — is port ${CAPTURE_REMOTE_CONTROL_PORT} held by another IPTVnator instance?`
);
}
/* ------------------------------------------------------------------ */
/* Alternative sources (guide shots) */
/* ------------------------------------------------------------------ */
/**
* Opens a movie of the primary portal whose copy also exists in the secondary
* one (seeded with `secondaryXtream`), and waits for lazy discovery to render
* the Sources chip.
*/
async function openXtreamVodWithSources(
page: Page,
category: string
): Promise<void> {
requireId('xtreams-secondary');
await runAction(page, 'open-xtream-vod', category);
await page
.locator('app-vod-sources-chip button')
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForTimeout(500);
}
/* ------------------------------------------------------------------ */
/* Download manager (guide shots) */
/* ------------------------------------------------------------------ */
/** Header shortcut into `/workspace/downloads`, the same button the e2e suite uses. */
async function openDownloadsPage(page: Page): Promise<void> {
await page.getByRole('button', { name: 'Open downloads' }).click();
await page.waitForURL(/\/workspace\/downloads(?:\?.*)?$/, {
timeout: 20_000,
});
await page
.locator('[data-test-id="downloads-content"]')
.waitFor({ state: 'visible', timeout: 20_000 });
await settleUi(page);
}
/**
* Puts the download manager into the state the offline guide describes: the
* authorized folder inside the isolated data dir, one finished movie in the
* library and two episodes trickling through the queue. Every step
* is idempotent, because the manifest runs each shot once per theme: a movie
* already saved shows the done state instead of the download button, and a
* queued or saved episode no longer offers a download label.
*/
async function prepareGuideDownloads(page: Page): Promise<void> {
await openDownloadsPage(page);
await ensureCaptureDownloadFolder(page);
await openXtreamSection(page, 'vod', 'Action & Mystery');
await page
.locator('app-content-hero')
.waitFor({ state: 'visible', timeout: 30_000 });
const downloadMovie = page.locator('[data-testid="vod-download-start"]');
if (await downloadMovie.isVisible().catch(() => false)) {
await downloadMovie.click();
await page
.locator(
'[data-testid="vod-download-progress"], [data-testid="vod-download-done"]'
)
.first()
.waitFor({ state: 'visible', timeout: 30_000 });
}
await runAction(page, 'open-xtream-series', 'Urban Drama');
// Two single episodes rather than "Download season": a six-episode queue
// fills the whole frame and pushes the finished movie below the fold. An
// episode button whose label no longer starts with "Download" is already
// queued or saved (it would play the local file), so it is left alone.
const episodeButtons = page.locator(
'[data-test-id^="episode-download-"][aria-label^="Download "]:not([disabled])'
);
const toQueue = Math.min(await episodeButtons.count(), 2);
for (let index = 0; index < toQueue; index += 1) {
await episodeButtons.first().click();
await page.waitForTimeout(800);
}
await settleUi(page);
}
/**
* The download manager authorizes the OS Downloads folder by default, which
* would put fixture bytes into the maintainer's real Downloads directory and
* show a personal home path in the frame. The capture stubs the folder dialog
* (`installDownloadFolderDialogStub`), so "Change Folder" lands on the
* isolated folder without any native UI.
*/
async function ensureCaptureDownloadFolder(page: Page): Promise<void> {
const folder = page.locator('[data-test-id="downloads-folder"]');
await folder.waitFor({ state: 'visible', timeout: 20_000 });
if ((await folder.innerText()).includes(CAPTURE_DOWNLOAD_FOLDER_NAME)) {
return;
}
await page.getByRole('button', { name: 'Change Folder' }).click();
await page
.locator('[data-test-id="downloads-folder"]')
.filter({ hasText: CAPTURE_DOWNLOAD_FOLDER_NAME })
.waitFor({ state: 'visible', timeout: 20_000 });
}
async function openXtreamSection(
page: Page,
section: 'vod' | 'series',
category: string
): Promise<void> {
// Manifest steps must be order-independent, so every portal action
// starts from the dashboard, whose sources rail links into the portal.
await goHome(page);
await clickHrefSuffix(
page,
`/workspace/xtreams/${requireId('xtreams')}/vod`
);
if (section !== 'vod') {
await clickHrefSuffix(
page,
`/workspace/xtreams/${requireId('xtreams')}/${section}`
);
}
const item = page
.locator('app-workspace-context-panel .category-item')
.filter({ hasText: category })
.first();
await item.waitFor({ state: 'visible', timeout: 30_000 });
await item.click();
await page.waitForTimeout(600);
const card = page.locator('.category-content-layout mat-card').first();
await card.waitFor({ state: 'visible', timeout: 30_000 });
await card.click();
}
async function clickHrefSuffix(page: Page, suffix: string): Promise<void> {
await page.locator(`a[href$="${suffix}"]`).first().click();
// Predicate rather than a RegExp built from the suffix: the value carries
// playlist ids and path separators, and hand-escaping only some
// metacharacters is how incomplete-sanitization bugs are born.
await page.waitForURL((url) => url.href.includes(suffix), {
timeout: 20_000,
});
}
export async function settleUi(page: Page): Promise<void> {
await page
.locator('.mat-mdc-snack-bar-container')
.first()
.waitFor({ state: 'detached', timeout: 10_000 })
.catch(() => undefined);
// Park the cursor so no nav item keeps its hover tooltip in frame.
await page.mouse.move(640, 700);
await page.evaluate(() => {
document
.querySelectorAll(
'.mat-mdc-snack-bar-container, simple-snack-bar, .mat-mdc-tooltip, .cdk-describedby-message-container'
)
.forEach((element) => {
(element.closest('.cdk-overlay-pane') ?? element).remove();
});
});
await page.waitForTimeout(250);
}
+1 -1
View File
@@ -41,7 +41,7 @@
"{workspaceRoot}/tools/eslint-rules/**/*",
"{workspaceRoot}/tools/eslint/**/*"
],
"command": "eslint \"tools/release/*.mjs\" \"tools/release/capture-release-screenshots.ts\" \"tools/release/capture-app-driver.ts\" \"tools/release/capture-navigation.ts\" \"tools/release/capture-network-gate.ts\" \"tools/release/capture-tmdb-check.ts\""
"command": "eslint \"tools/release/*.mjs\" \"tools/release/capture-release-screenshots.ts\" \"tools/release/capture-app-driver.ts\" \"tools/release/capture-navigation.ts\" \"tools/release/capture-navigation-helpers.ts\" \"tools/release/capture-navigation-setup-actions.ts\" \"tools/release/capture-navigation-portal-actions.ts\" \"tools/release/capture-navigation-download-actions.ts\" \"tools/release/capture-network-gate.ts\" \"tools/release/capture-tmdb-check.ts\""
}
},
"tags": [